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:
GGThed
2026-08-07 20:01:24 -04:00
co-authored by Claude Opus 5
parent d05e9cde32
commit 983b7a1f49
2 changed files with 167 additions and 4 deletions
+44 -4
View File
@@ -7,16 +7,21 @@ password policy enforcement and CAPTCHA verification.
import uuid import uuid
import os import os
import secrets
from datetime import datetime, timedelta from datetime import datetime, timedelta
from flask import Blueprint, render_template, redirect, url_for, flash, request, session 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 flask_login import login_user, logout_user, login_required, current_user
from app.extensions import db, hash_password, check_password, limiter from app.extensions import db, hash_password, check_password, limiter
from app.models import User, Player, ESPORT_GAMES from app.models import User, Player, ESPORT_GAMES
from app.validators import RegisterSchema, LoginSchema from app.validators import RegisterSchema, LoginSchema
from app.logging_config import log_auth_event
from marshmallow import ValidationError from marshmallow import ValidationError
from urllib.parse import urlparse from urllib.parse import urlparse, urlencode
import requests import requests
#: Session key holding the pending OAuth2 anti-forgery token.
DISCORD_STATE_KEY = 'discord_oauth_state'
# Account lockout settings # Account lockout settings
MAX_LOGIN_ATTEMPTS = 5 MAX_LOGIN_ATTEMPTS = 5
LOCKOUT_DURATION_MINUTES = 15 LOCKOUT_DURATION_MINUTES = 15
@@ -129,6 +134,7 @@ def login():
# Check if account is locked # Check if account is locked
if user and user.locked_until and user.locked_until > datetime.utcnow(): 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 remaining = (user.locked_until - datetime.utcnow()).seconds // 60
flash( flash(
f'Account is locked due to too many failed attempts. ' 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 user and check_password(user.password_hash, password):
if not user.is_active_account: 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') flash('This account has been deactivated.', 'danger')
return render_template('pages/login.html') return render_template('pages/login.html')
@@ -160,6 +168,8 @@ def login():
session.permanent = True session.permanent = True
login_user(user) 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 # Validate redirect URL to prevent open redirect vulnerability
next_page = request.args.get('next') next_page = request.args.get('next')
@@ -171,8 +181,12 @@ def login():
# Track failed login attempt # Track failed login attempt
if user: if user:
user.failed_login_attempts += 1 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: if user.failed_login_attempts >= MAX_LOGIN_ATTEMPTS:
user.locked_until = datetime.utcnow() + timedelta(minutes=LOCKOUT_DURATION_MINUTES) 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( flash(
f'Account locked after {MAX_LOGIN_ATTEMPTS} failed attempts. ' f'Account locked after {MAX_LOGIN_ATTEMPTS} failed attempts. '
f'Please try again in {LOCKOUT_DURATION_MINUTES} minutes.', f'Please try again in {LOCKOUT_DURATION_MINUTES} minutes.',
@@ -186,6 +200,7 @@ def login():
) )
db.session.commit() db.session.commit()
else: else:
log_auth_event('login.failure.unknown_user', username=username)
flash('Login unsuccessful. Please check username and password.', 'danger') flash('Login unsuccessful. Please check username and password.', 'danger')
return render_template('pages/login.html') return render_template('pages/login.html')
@@ -315,6 +330,8 @@ def register():
# Clear Discord OAuth data from session after successful registration # Clear Discord OAuth data from session after successful registration
session.pop('discord_oauth', None) 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') flash('Your account has been created! You can now log in.', 'success')
return redirect(url_for('auth.login')) return redirect(url_for('auth.login'))
@@ -338,18 +355,27 @@ def discord_login():
Returns: Returns:
Response: Redirect to Discord authorization URL. 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') flash('Discord OAuth2 is not configured.', 'danger')
return redirect(url_for('auth.register')) 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 = { params = {
'client_id': DISCORD_CLIENT_ID, 'client_id': DISCORD_CLIENT_ID,
'redirect_uri': DISCORD_REDIRECT_URI, 'redirect_uri': DISCORD_REDIRECT_URI,
'response_type': 'code', 'response_type': 'code',
'scope': 'identify connections', '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?{urlencode(params)}'
auth_url = f'{DISCORD_API_BASE}/oauth2/authorize?{query}'
return redirect(auth_url) return redirect(auth_url)
@@ -365,6 +391,19 @@ def discord_callback():
Returns: Returns:
Response: Redirect to registration page. 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') code = request.args.get('code')
if not code: if not code:
flash('Discord authorization failed. No code received.', 'danger') flash('Discord authorization failed. No code received.', 'danger')
@@ -472,6 +511,7 @@ def logout():
Returns: Returns:
Response: Redirect to login page with logout message. Response: Redirect to login page with logout message.
""" """
log_auth_event('logout', username=current_user.username, user_id=current_user.id)
logout_user() logout_user()
session.clear() session.clear()
flash('You have been logged out.', 'info') flash('You have been logged out.', 'info')
+123
View File
@@ -0,0 +1,123 @@
"""Discord OAuth2 flow.
SEC-AUTH-004. The authorization URL carried client_id, redirect_uri,
response_type and scope — and nothing else. With no `state`, the callback
accepted any authorization code presented to it, so an attacker could have
a victim's browser consume a code issued for the attacker's own Discord
account (RFC 6749 §10.12, "login CSRF").
"""
from urllib.parse import parse_qs, urlparse
import pytest
from app.routes import auth as auth_module
@pytest.fixture
def discord_configured(monkeypatch):
"""Pretend the Discord application credentials are present.
They are read into module-level constants at import time, so they are
patched on the module rather than in the environment.
"""
monkeypatch.setattr(auth_module, 'DISCORD_CLIENT_ID', '123456789012345678')
monkeypatch.setattr(auth_module, 'DISCORD_CLIENT_SECRET', 'not-a-real-secret')
monkeypatch.setattr(
auth_module, 'DISCORD_REDIRECT_URI',
'https://example.test/auth/discord/callback',
)
def _authorize_params(response):
return parse_qs(urlparse(response.headers['Location']).query)
class TestAuthorizationRequest:
def test_the_request_carries_a_state(self, client, discord_configured):
response = client.get('/auth/discord/login', follow_redirects=False)
assert response.status_code in (301, 302)
params = _authorize_params(response)
assert 'state' in params
assert len(params['state'][0]) >= 32
def test_the_state_is_stored_in_the_session(self, client, discord_configured):
response = client.get('/auth/discord/login', follow_redirects=False)
sent = _authorize_params(response)['state'][0]
with client.session_transaction() as sess:
assert sess[auth_module.DISCORD_STATE_KEY] == sent
def test_two_requests_get_different_states(self, client, discord_configured):
first = _authorize_params(
client.get('/auth/discord/login', follow_redirects=False))['state'][0]
second = _authorize_params(
client.get('/auth/discord/login', follow_redirects=False))['state'][0]
assert first != second
def test_scopes_and_redirect_are_preserved(self, client, discord_configured):
params = _authorize_params(
client.get('/auth/discord/login', follow_redirects=False))
assert params['scope'][0] == 'identify connections'
assert params['response_type'][0] == 'code'
assert params['redirect_uri'][0] == 'https://example.test/auth/discord/callback'
def test_a_missing_redirect_uri_is_reported_not_raised(self, client, monkeypatch):
"""Quoting an unset redirect_uri used to raise inside the query
builder instead of reporting a configuration problem."""
monkeypatch.setattr(auth_module, 'DISCORD_CLIENT_ID', '123456789012345678')
monkeypatch.setattr(auth_module, 'DISCORD_REDIRECT_URI', None)
response = client.get('/auth/discord/login', follow_redirects=False)
assert response.status_code in (301, 302)
assert '/auth/register' in response.headers['Location']
class TestCallbackStateValidation:
"""The callback must reject anything it did not itself initiate.
None of these reach Discord: the state check happens before the token
exchange, so a rejected callback performs no network call.
"""
def test_a_callback_without_state_is_rejected(self, client, discord_configured):
client.get('/auth/discord/login', follow_redirects=False)
response = client.get(
'/auth/discord/callback?code=attacker-code', follow_redirects=False)
assert '/auth/register' in response.headers['Location']
def test_a_callback_with_a_wrong_state_is_rejected(self, client, discord_configured):
client.get('/auth/discord/login', follow_redirects=False)
response = client.get(
'/auth/discord/callback?code=attacker-code&state=forged',
follow_redirects=False)
assert '/auth/register' in response.headers['Location']
with client.session_transaction() as sess:
assert 'discord_oauth' not in sess
def test_a_callback_without_a_prior_request_is_rejected(self, client, discord_configured):
"""No /discord/login beforehand: nothing to match against."""
response = client.get(
'/auth/discord/callback?code=x&state=anything', follow_redirects=False)
assert '/auth/register' in response.headers['Location']
def test_the_state_is_single_use(self, client, discord_configured):
"""Consumed on the first callback, valid or not, so it cannot be
replayed."""
state = _authorize_params(
client.get('/auth/discord/login', follow_redirects=False))['state'][0]
client.get(f'/auth/discord/callback?code=x&state={state}',
follow_redirects=False)
with client.session_transaction() as sess:
assert auth_module.DISCORD_STATE_KEY not in sess