fix(auth): retirer un CAPTCHA qui ne protegeait rien, filtrer autrement
SEC-AUTH-008. L addition a deux operandes entre 1 et 10 avait dix-neuf reponses possibles et se resolvait en lisant la question comme une chaine. Elle n arretait aucune inscription automatisee. Elle ajoutait en revanche une etape a chaque personne, lecteur d ecran compris, contre une apparence de protection — ce qui est pire que rien, puisque ca se compte comme une protection. L autre branche proposee par l audit etait un vrai service de CAPTCHA : un tiers, une cle d API, une requete a chaque affichage, et un script etranger remis dans script-src, defaisant le travail qui a ferme SEC-WEB-001. Disproportionne pour le site d un club. A la place, deux verifications invisibles pour un visiteur : un champ piege, cache par la feuille de style et hors du parcours clavier, qu un robot remplisseur complete et qu une personne ne voit jamais ; et un delai minimal entre la remise du formulaire et son retour, l horodatage etant dans la session signee et non dans un champ. Le plafond est dit dans le code plutot que sous-entendu : ceci arrete le pourriel de masse, pas quelqu un qui lit la page. Ce qui filtrerait vraiment les inscriptions serait l activation des comptes par le staff — is_active_account vaut True par defaut. C est une decision de produit. L angle « session forgeable » du constat tombe : avec SECRET_KEY compromise (SEC-001), on forge une session connectee sur n importe quel compte et on n a aucune raison de s inscrire. Au passage, ARCH-005 en partie : le bloc « regenerer, purger les mots de passe, re-rendre » etait recopie quatre fois. Un seul helper, et la purge des mots de passe ne peut plus etre oubliee dans la cinquieme copie. Les refus sont journalises avec leur motif — c est le seul endroit ou un abus du formulaire devient visible — mais restent indistinguables pour l expediteur : nommer la regle indique comment la contourner. 429 tests.
This commit is contained in:
+106
-85
@@ -2,12 +2,12 @@
|
||||
|
||||
This module handles user authentication including login with account lockout
|
||||
protection, logout with session clearing, and new user registration with
|
||||
password policy enforcement and CAPTCHA verification.
|
||||
password policy enforcement and sign-up screening.
|
||||
"""
|
||||
|
||||
import os
|
||||
import secrets
|
||||
import uuid
|
||||
import time
|
||||
from datetime import datetime, timedelta
|
||||
from urllib.parse import urlencode, urlparse
|
||||
|
||||
@@ -38,6 +38,18 @@ MAX_LOCKOUT_MINUTES = 240
|
||||
#: response time stops telling a caller which usernames exist (SEC-017).
|
||||
_ABSENT_USER_HASH = None
|
||||
|
||||
#: Session key recording when the registration form was handed out.
|
||||
REGISTRATION_ISSUED_KEY = 'registration_form_issued_at'
|
||||
|
||||
#: Name of the honeypot input. Plausible enough that a form-filler wants it,
|
||||
#: absent from the visible form. Hidden by .honeypot in style.css — not by an
|
||||
#: inline style, so that the rule survives a tightening of style-src.
|
||||
REGISTRATION_HONEYPOT_FIELD = 'website'
|
||||
|
||||
#: Floor on how long a genuine registration takes. Eleven fields and a
|
||||
#: password typed twice; three seconds is generous.
|
||||
MIN_REGISTRATION_SECONDS = 3
|
||||
|
||||
# Discord OAuth2 configuration
|
||||
DISCORD_CLIENT_ID = os.getenv('DISCORD_CLIENT_ID')
|
||||
DISCORD_CLIENT_SECRET = os.getenv('DISCORD_CLIENT_SECRET')
|
||||
@@ -116,41 +128,65 @@ def cooloff_minutes(failed_attempts):
|
||||
return min(LOCKOUT_DURATION_MINUTES * (2**steps), MAX_LOCKOUT_MINUTES)
|
||||
|
||||
|
||||
def generate_captcha():
|
||||
"""Generate a simple math CAPTCHA challenge.
|
||||
def issue_registration_challenge():
|
||||
"""Mark that the registration form has been handed out, and when.
|
||||
|
||||
Creates a random addition problem and stores the answer in the session.
|
||||
Kept in the signed session rather than in a form field, so that the
|
||||
timestamp is not something the submitter can choose. Left in place across
|
||||
a failed submission: someone correcting a typo should not be told to slow
|
||||
down, and a robot has already paid for the round trip by then.
|
||||
"""
|
||||
session.setdefault(REGISTRATION_ISSUED_KEY, time.time())
|
||||
|
||||
|
||||
def check_registration_challenge(form):
|
||||
"""Say why this registration should be refused, or None to accept.
|
||||
|
||||
What replaced the arithmetic CAPTCHA, and why (SEC-AUTH-008).
|
||||
|
||||
`a + b = ?` with both operands between 1 and 10 has nineteen possible
|
||||
answers and is solvable by reading the string. It stopped no automated
|
||||
registration whatsoever. What it did do was add a step for every human,
|
||||
including anyone using a screen reader, in exchange for an appearance of
|
||||
protection — which is worse than no protection, because it gets counted
|
||||
as one.
|
||||
|
||||
The audit's alternative was a real CAPTCHA service. That means a third
|
||||
party, an API key, a request on every page load, and putting a foreign
|
||||
script back into script-src — undoing the CSP work that closed
|
||||
SEC-WEB-001. Disproportionate for a club site.
|
||||
|
||||
So: two checks that cost the visitor nothing.
|
||||
|
||||
- a honeypot field, hidden in the stylesheet, that a form-filling
|
||||
robot completes and a person never sees;
|
||||
- a minimum dwell time between being handed the form and sending it
|
||||
back. Eleven fields and a password typed twice do not get filled in
|
||||
under three seconds, and a POST with no issued form at all never
|
||||
fetched the page.
|
||||
|
||||
Be clear about the ceiling: this stops commodity spam, not somebody who
|
||||
looks at the form for five minutes. The thing that would actually gate
|
||||
registration is staff activation of new accounts, which does not exist —
|
||||
`is_active_account` defaults to True. That is a product decision, not one
|
||||
to slip in here.
|
||||
|
||||
The session-forgery angle in the constat is moot: with SECRET_KEY
|
||||
compromised (SEC-001) an attacker forges a logged-in session for any
|
||||
account and has no reason to register at all.
|
||||
|
||||
Returns:
|
||||
dict: A dictionary with 'question' (e.g., '3 + 7') and 'id' keys.
|
||||
str | None: a short reason for the log, or None to let it through.
|
||||
"""
|
||||
import random
|
||||
if form.get(REGISTRATION_HONEYPOT_FIELD, '').strip():
|
||||
return 'honeypot'
|
||||
|
||||
a = random.randint(1, 10)
|
||||
b = random.randint(1, 10)
|
||||
captcha_id = str(uuid.uuid4())
|
||||
session['captcha_id'] = captcha_id
|
||||
session['captcha_answer'] = a + b
|
||||
return {'question': f'{a} + {b} = ?', 'id': captcha_id}
|
||||
|
||||
|
||||
def verify_captcha(user_answer):
|
||||
"""Verify the CAPTCHA answer from the session.
|
||||
|
||||
Args:
|
||||
user_answer: The user's submitted answer (string or int).
|
||||
|
||||
Returns:
|
||||
bool: True if the answer matches the stored CAPTCHA, False otherwise.
|
||||
"""
|
||||
try:
|
||||
expected = session.pop('captcha_answer', None)
|
||||
session.pop('captcha_id', None)
|
||||
if expected is None:
|
||||
return False
|
||||
return int(user_answer) == expected
|
||||
except (ValueError, TypeError):
|
||||
return False
|
||||
issued_at = session.get(REGISTRATION_ISSUED_KEY)
|
||||
if issued_at is None:
|
||||
return 'no-form-issued'
|
||||
if time.time() - issued_at < MIN_REGISTRATION_SECONDS:
|
||||
return 'too-fast'
|
||||
return None
|
||||
|
||||
|
||||
auth_bp = Blueprint('auth', __name__, url_prefix='/auth')
|
||||
@@ -276,14 +312,33 @@ def login():
|
||||
return render_template('pages/login.html')
|
||||
|
||||
|
||||
def _rerender_registration(form_data):
|
||||
"""Re-render the registration form after a refusal.
|
||||
|
||||
Was copied out four times, near-identically (ARCH-005). Dropping the two
|
||||
password fields is the part that must not be forgotten in the fifth copy:
|
||||
echoing a password back into the HTML puts it in the browser's cache and
|
||||
in any proxy log along the way.
|
||||
"""
|
||||
form_data = dict(form_data)
|
||||
form_data.pop('password', None)
|
||||
form_data.pop('confirm_password', None)
|
||||
return render_template(
|
||||
'pages/register.html',
|
||||
esport_games=ESPORT_GAMES,
|
||||
honeypot_field=REGISTRATION_HONEYPOT_FIELD,
|
||||
form_data=form_data,
|
||||
)
|
||||
|
||||
|
||||
@auth_bp.route('/register', methods=['GET', 'POST'])
|
||||
@limiter.limit("20 per hour")
|
||||
def register():
|
||||
"""Handle new player registration with CAPTCHA and password policy.
|
||||
"""Handle new player registration.
|
||||
|
||||
GET: Render the registration form with E-Sports games list and CAPTCHA.
|
||||
POST: Validate all inputs, verify CAPTCHA, enforce password policy,
|
||||
and create a new player account.
|
||||
GET: Render the registration form with the E-Sports games list.
|
||||
POST: Screen the submission (see check_registration_challenge), validate
|
||||
every input against RegisterSchema, and create a new player account.
|
||||
|
||||
Only players can register through this form. Validates username/email
|
||||
uniqueness and password confirmation.
|
||||
@@ -299,20 +354,15 @@ def register():
|
||||
form_data = dict(request.form)
|
||||
form_data['games'] = request.form.getlist('games')
|
||||
|
||||
# Validate CAPTCHA first
|
||||
captcha_answer = request.form.get('captcha_answer', '')
|
||||
if not verify_captcha(captcha_answer):
|
||||
flash(_('Incorrect CAPTCHA answer. Please try again.'), 'danger')
|
||||
captcha = generate_captcha()
|
||||
# Clear password fields only on CAPTCHA failure
|
||||
form_data.pop('password', None)
|
||||
form_data.pop('confirm_password', None)
|
||||
return render_template(
|
||||
'pages/register.html',
|
||||
esport_games=ESPORT_GAMES,
|
||||
captcha=captcha,
|
||||
form_data=form_data,
|
||||
)
|
||||
refusal = check_registration_challenge(request.form)
|
||||
if refusal is not None:
|
||||
# Logged, because this is the only place abuse of the sign-up
|
||||
# form becomes visible at all. Deliberately vague to the sender:
|
||||
# naming the honeypot tells whoever tripped it how to avoid it.
|
||||
log_auth_event('account.registration_refused', reason=refusal)
|
||||
flash(_('Your registration could not be processed. Please try again.'), 'danger')
|
||||
issue_registration_challenge()
|
||||
return _rerender_registration(form_data)
|
||||
|
||||
# Validate input with marshmallow schema
|
||||
register_schema = RegisterSchema()
|
||||
@@ -322,16 +372,7 @@ def register():
|
||||
for field, messages in err.messages.items():
|
||||
for msg in messages:
|
||||
flash(_('%(field)s: %(msg)s', field=field, msg=msg), 'danger')
|
||||
captcha = generate_captcha()
|
||||
# Clear password fields on validation failure
|
||||
form_data.pop('password', None)
|
||||
form_data.pop('confirm_password', None)
|
||||
return render_template(
|
||||
'pages/register.html',
|
||||
esport_games=ESPORT_GAMES,
|
||||
captcha=captcha,
|
||||
form_data=form_data,
|
||||
)
|
||||
return _rerender_registration(form_data)
|
||||
|
||||
username = validated['username']
|
||||
email = validated['email']
|
||||
@@ -345,27 +386,11 @@ def register():
|
||||
|
||||
if User.query.filter_by(username=username).first():
|
||||
flash(_('Username already exists.'), 'danger')
|
||||
captcha = generate_captcha()
|
||||
form_data.pop('password', None)
|
||||
form_data.pop('confirm_password', None)
|
||||
return render_template(
|
||||
'pages/register.html',
|
||||
esport_games=ESPORT_GAMES,
|
||||
captcha=captcha,
|
||||
form_data=form_data,
|
||||
)
|
||||
return _rerender_registration(form_data)
|
||||
|
||||
if User.query.filter_by(email=email).first():
|
||||
flash(_('Email already registered.'), 'danger')
|
||||
captcha = generate_captcha()
|
||||
form_data.pop('password', None)
|
||||
form_data.pop('confirm_password', None)
|
||||
return render_template(
|
||||
'pages/register.html',
|
||||
esport_games=ESPORT_GAMES,
|
||||
captcha=captcha,
|
||||
form_data=form_data,
|
||||
)
|
||||
return _rerender_registration(form_data)
|
||||
|
||||
hashed_password = hash_password(password)
|
||||
user = Player(
|
||||
@@ -404,6 +429,7 @@ def register():
|
||||
|
||||
# Clear Discord OAuth data from session after successful registration
|
||||
session.pop('discord_oauth', None)
|
||||
session.pop(REGISTRATION_ISSUED_KEY, None)
|
||||
|
||||
log_auth_event('account.registered', username=user.username, user_id=user.id)
|
||||
|
||||
@@ -411,13 +437,8 @@ def register():
|
||||
return redirect(url_for('auth.login'))
|
||||
|
||||
# GET request — render empty form
|
||||
captcha = generate_captcha()
|
||||
return render_template(
|
||||
'pages/register.html',
|
||||
esport_games=ESPORT_GAMES,
|
||||
captcha=captcha,
|
||||
form_data={},
|
||||
)
|
||||
issue_registration_challenge()
|
||||
return _rerender_registration({})
|
||||
|
||||
|
||||
@auth_bp.route('/discord/login')
|
||||
|
||||
Reference in New Issue
Block a user