fix(auth): ajouter le parametre state au flux OAuth2 Discord
SEC-AUTH-004. L'URL d'autorisation ne portait que client_id, redirect_uri,
response_type et scope. Sans state, le callback acceptait n'importe quel
code d'autorisation qu'on lui presentait.
Scenario ferme : un attaquant obtient un code pour SON compte Discord, puis
fait charger l'URL de callback par le navigateur de la victime. Le
formulaire d'inscription de la victime se retrouve pre-rempli avec
l'identite Discord de l'attaquant. C'est le login CSRF decrit par la
RFC 6749 §10.12.
Mise en oeuvre
secrets.token_urlsafe(32) genere le jeton, stocke en session avant la
redirection. Le callback le compare en temps constant avec
secrets.compare_digest, et le consomme systematiquement -- valide ou non --
pour qu'il ne puisse pas etre rejoue. Le controle intervient avant
l'echange du code : un callback rejete ne declenche aucun appel reseau.
Deux corrections accessoires sur le meme chemin
- DISCORD_REDIRECT_URI est desormais verifie au meme titre que
DISCORD_CLIENT_ID. Non defini, il faisait lever requests.utils.quote(None)
au lieu de signaler un probleme de configuration.
- la construction de la chaine de requete passe a urlencode() plutot qu'a
une concatenation manuelle.
9 tests : presence du state, stockage en session, unicite entre deux
demandes, rejet sans state, avec un state forge, sans demande prealable,
et non-rejouabilite.
Reste ouvert : l'identite Discord obtenue reste ensuite reinjectee par un
champ cache du formulaire (SEC-AUTH-005). Le state protege la liaison, pas
encore la valeur elle-meme.
Co-Authored-By: Claude Opus 5 <[email protected]>
This commit is contained in:
+44
-4
@@ -7,16 +7,21 @@ password policy enforcement and CAPTCHA verification.
|
||||
|
||||
import uuid
|
||||
import os
|
||||
import secrets
|
||||
from datetime import datetime, timedelta
|
||||
from flask import Blueprint, render_template, redirect, url_for, flash, request, session
|
||||
from flask_login import login_user, logout_user, login_required, current_user
|
||||
from app.extensions import db, hash_password, check_password, limiter
|
||||
from app.models import User, Player, ESPORT_GAMES
|
||||
from app.validators import RegisterSchema, LoginSchema
|
||||
from app.logging_config import log_auth_event
|
||||
from marshmallow import ValidationError
|
||||
from urllib.parse import urlparse
|
||||
from urllib.parse import urlparse, urlencode
|
||||
import requests
|
||||
|
||||
#: Session key holding the pending OAuth2 anti-forgery token.
|
||||
DISCORD_STATE_KEY = 'discord_oauth_state'
|
||||
|
||||
# Account lockout settings
|
||||
MAX_LOGIN_ATTEMPTS = 5
|
||||
LOCKOUT_DURATION_MINUTES = 15
|
||||
@@ -129,6 +134,7 @@ def login():
|
||||
|
||||
# Check if account is locked
|
||||
if user and user.locked_until and user.locked_until > datetime.utcnow():
|
||||
log_auth_event('login.rejected.locked', username=username, user_id=user.id)
|
||||
remaining = (user.locked_until - datetime.utcnow()).seconds // 60
|
||||
flash(
|
||||
f'Account is locked due to too many failed attempts. '
|
||||
@@ -139,6 +145,8 @@ def login():
|
||||
|
||||
if user and check_password(user.password_hash, password):
|
||||
if not user.is_active_account:
|
||||
log_auth_event('login.rejected.deactivated',
|
||||
username=username, user_id=user.id)
|
||||
flash('This account has been deactivated.', 'danger')
|
||||
return render_template('pages/login.html')
|
||||
|
||||
@@ -160,6 +168,8 @@ def login():
|
||||
session.permanent = True
|
||||
|
||||
login_user(user)
|
||||
log_auth_event('login.success',
|
||||
username=user.username, user_id=user.id, role=user.role)
|
||||
|
||||
# Validate redirect URL to prevent open redirect vulnerability
|
||||
next_page = request.args.get('next')
|
||||
@@ -171,8 +181,12 @@ def login():
|
||||
# Track failed login attempt
|
||||
if user:
|
||||
user.failed_login_attempts += 1
|
||||
log_auth_event('login.failure', username=username, user_id=user.id,
|
||||
attempts=user.failed_login_attempts)
|
||||
if user.failed_login_attempts >= MAX_LOGIN_ATTEMPTS:
|
||||
user.locked_until = datetime.utcnow() + timedelta(minutes=LOCKOUT_DURATION_MINUTES)
|
||||
log_auth_event('account.locked', username=username, user_id=user.id,
|
||||
minutes=LOCKOUT_DURATION_MINUTES)
|
||||
flash(
|
||||
f'Account locked after {MAX_LOGIN_ATTEMPTS} failed attempts. '
|
||||
f'Please try again in {LOCKOUT_DURATION_MINUTES} minutes.',
|
||||
@@ -186,6 +200,7 @@ def login():
|
||||
)
|
||||
db.session.commit()
|
||||
else:
|
||||
log_auth_event('login.failure.unknown_user', username=username)
|
||||
flash('Login unsuccessful. Please check username and password.', 'danger')
|
||||
|
||||
return render_template('pages/login.html')
|
||||
@@ -315,6 +330,8 @@ def register():
|
||||
# Clear Discord OAuth data from session after successful registration
|
||||
session.pop('discord_oauth', None)
|
||||
|
||||
log_auth_event('account.registered', username=user.username, user_id=user.id)
|
||||
|
||||
flash('Your account has been created! You can now log in.', 'success')
|
||||
return redirect(url_for('auth.login'))
|
||||
|
||||
@@ -338,18 +355,27 @@ def discord_login():
|
||||
Returns:
|
||||
Response: Redirect to Discord authorization URL.
|
||||
"""
|
||||
if not DISCORD_CLIENT_ID:
|
||||
# DISCORD_REDIRECT_URI is checked too: quoting it when unset used to
|
||||
# raise inside the query builder rather than report a configuration error.
|
||||
if not DISCORD_CLIENT_ID or not DISCORD_REDIRECT_URI:
|
||||
flash('Discord OAuth2 is not configured.', 'danger')
|
||||
return redirect(url_for('auth.register'))
|
||||
|
||||
# Anti-forgery token, required by RFC 6749 §10.12. Without it, an
|
||||
# attacker could have the victim's browser consume an authorization code
|
||||
# obtained for the attacker's own Discord account, silently binding that
|
||||
# identity to the victim's registration form.
|
||||
state = secrets.token_urlsafe(32)
|
||||
session[DISCORD_STATE_KEY] = state
|
||||
|
||||
params = {
|
||||
'client_id': DISCORD_CLIENT_ID,
|
||||
'redirect_uri': DISCORD_REDIRECT_URI,
|
||||
'response_type': 'code',
|
||||
'scope': 'identify connections',
|
||||
'state': state,
|
||||
}
|
||||
query = '&'.join(f'{k}={requests.utils.quote(v)}' for k, v in params.items())
|
||||
auth_url = f'{DISCORD_API_BASE}/oauth2/authorize?{query}'
|
||||
auth_url = f'{DISCORD_API_BASE}/oauth2/authorize?{urlencode(params)}'
|
||||
return redirect(auth_url)
|
||||
|
||||
|
||||
@@ -365,6 +391,19 @@ def discord_callback():
|
||||
Returns:
|
||||
Response: Redirect to registration page.
|
||||
"""
|
||||
# The state is consumed whatever happens next: a token is single-use, and
|
||||
# leaving it in the session would allow a replay.
|
||||
expected_state = session.pop(DISCORD_STATE_KEY, None)
|
||||
received_state = request.args.get('state', '')
|
||||
|
||||
if not expected_state or not secrets.compare_digest(expected_state, received_state):
|
||||
flash(
|
||||
'Discord authorization could not be verified. '
|
||||
'Please start the connection again from this page.',
|
||||
'danger',
|
||||
)
|
||||
return redirect(url_for('auth.register'))
|
||||
|
||||
code = request.args.get('code')
|
||||
if not code:
|
||||
flash('Discord authorization failed. No code received.', 'danger')
|
||||
@@ -472,6 +511,7 @@ def logout():
|
||||
Returns:
|
||||
Response: Redirect to login page with logout message.
|
||||
"""
|
||||
log_auth_event('logout', username=current_user.username, user_id=current_user.id)
|
||||
logout_user()
|
||||
session.clear()
|
||||
flash('You have been logged out.', 'info')
|
||||
|
||||
Reference in New Issue
Block a user