Files
team-tryouts/tests/test_auth_session.py
T
GGThedandClaude Opus 5 835a394d3f fix(auth): enumeration, verrou, deconnexion en GET, redirection ouverte
Quatre constats de la liste des gains rapides, tous sur auth.py.

SEC-017 -- enumeration de comptes
Le formulaire repondait "il vous reste 3 tentative(s)" a un compte connu et
"verifiez le nom d utilisateur et le mot de passe" a un inconnu. Le decompte
lui-meme etait la fuite : la meme information, etalee sur cinq requetes.
S y ajoutait un ecart de temps de reponse, check_password n etant appele que
si la ligne existait -- scrypt est cher, l ecart est mesurable.

Un seul message pour tous les echecs, et la verification s execute
desormais sur les deux branches : contre un hachage aleatoire tire une fois
par processus quand l identifiant n existe pas.

SEC-018 -- verrou de compte declenchable par un tiers
Cinq mauvaises reponses mettaient un compte connu hors service pendant
quinze minutes, indefiniment renouvelables. Sur un compte president, c est
toute l administration, et aucun ecran ne permettait de defaire.

Le compteur et la fenetre restent -- ce sont la trace qu un administrateur
lit quand un compte est pilonne, et la fenetre double jusqu a un plafond.
Ce qui change : de bons identifiants passent, fenetre ouverte ou non, et
remettent le compteur a zero. Le proprietaire du compte ne peut plus etre
bloque par un tiers.

Ce que cela coute, dit franchement : un verrou dur n arretait de toute
facon pas un attaquant ayant trouve le mot de passe -- il lui suffisait
d attendre. Le debit de tentatives reste borne par la limite de 10/minute
par IP. Une limite par couple (compte, IP) demanderait un stockage dedie ;
elle attend Alembic.

L evenement account.locked devient account.throttled : "locked" affirmait
plus que ce qui se passe.

SEC-019 -- deconnexion en GET
/auth/logout n avait pas de methods, donc GET, donc hors protection CSRF :
n importe quelle page pouvait deconnecter un visiteur avec une balise img.
La route passe en POST et l entree de navigation devient un formulaire avec
jeton. Le style suit -- les regles .nav-links visaient les liens seuls.

SEC-020 -- validation de redirection
is_safe_url interrogeait urlparse().netloc. urlparse lit /\evil.com comme
un chemin, sans netloc ; plusieurs navigateurs normalisent l antislash en
barre oblique avant de resoudre, ce qui en fait //evil.com. La fonction
refuse maintenant antislash et caracteres de controle, exige un chemin
enracine, et compare l origine explicitement.

Le xfail(strict) qui documentait SEC-017 est leve. 40 tests dans
test_auth_session.py, dont la table des cibles refusees.

Co-Authored-By: Claude Opus 5 <[email protected]>
2026-08-08 15:05:13 -04:00

311 lines
11 KiB
Python

"""Session lifecycle and account state.
These lock in the two fixes from wave 0: sessions now actually expire, and
deactivating an account now closes the sessions it already holds.
"""
import re
import pytest
from app.extensions import db
from app.models import User
#: Matches the flash markup emitted by layouts/base.html.
_FLASH_PATTERN = re.compile(
r'<div class="alert alert-\w+ alert-dismissible">\s*<span>(.*?)</span>',
re.S,
)
def _flash_messages(body):
"""Flash texts rendered in a response, independent of the locale."""
return [m.strip() for m in _FLASH_PATTERN.findall(body)]
def _set_active(app, user_id, active):
with app.app_context():
user = db.session.get(User, user_id)
user.is_active_account = active
db.session.commit()
class TestSessionExpiry:
def test_login_issues_an_expiring_session_cookie(self, app, client, as_role):
"""PERMANENT_SESSION_LIFETIME only applies to permanent sessions.
Before the fix, session.permanent was never set anywhere in app/,
so Flask emitted a browser-session cookie with no Expires attribute
and the configured one-hour lifetime was silently ignored.
"""
as_role('player')
cookie = client.get_cookie('session')
assert cookie is not None, 'no session cookie was issued at login'
assert cookie.expires is not None, (
'session cookie has no expiry: session.permanent was not set, '
'so PERMANENT_SESSION_LIFETIME has no effect'
)
def test_session_lifetime_matches_configuration(self, app):
from datetime import timedelta
assert app.permanent_session_lifetime == timedelta(seconds=3600)
class TestAccountDeactivation:
def test_deactivated_account_cannot_log_in(self, app, client, make_user, login):
user_id = make_user('player')
_set_active(app, user_id, False)
with app.app_context():
username = db.session.get(User, user_id).username
login(username)
response = client.get('/users/profile', follow_redirects=False)
assert response.status_code in (301, 302)
assert '/auth/login' in response.headers.get('Location', '')
def test_deactivated_account_loses_its_existing_session(self, app, client, as_role):
"""The fix that matters: revocation has to reach live sessions.
is_active_account used to be consulted only at login. User did not
override UserMixin.is_active, so Flask-Login treated every account
as active, and disabling someone merely stopped them reconnecting —
their open session kept working.
"""
user_id = as_role('player')
assert client.get('/users/profile').status_code == 200
_set_active(app, user_id, False)
response = client.get('/users/profile', follow_redirects=False)
assert response.status_code in (301, 302), (
'a deactivated account kept access with its existing session'
)
assert '/auth/login' in response.headers.get('Location', '')
def test_is_active_property_tracks_the_column(self, app, make_user):
user_id = make_user('coach')
with app.app_context():
user = db.session.get(User, user_id)
assert user.is_active is True
user.is_active_account = False
assert user.is_active is False
class TestLogout:
def test_logout_ends_the_session(self, client, as_role):
as_role('player')
assert client.get('/users/profile').status_code == 200
client.post('/auth/logout')
response = client.get('/users/profile', follow_redirects=False)
assert response.status_code in (301, 302)
assert '/auth/login' in response.headers.get('Location', '')
def test_a_get_no_longer_logs_anyone_out(self, client, as_role):
"""SEC-019 — a GET route carries no CSRF token, so any page could
sign the user out with <img src=".../auth/logout">."""
as_role('player')
assert client.get('/auth/logout').status_code == 405
assert client.get('/users/profile').status_code == 200
def test_the_logout_control_carries_a_csrf_token(self, app_with_csrf, make_user, login):
"""The nav entry is a form now; without the token it would 400 on
every user, and only in production where CSRF is on."""
client = app_with_csrf.test_client()
with app_with_csrf.app_context():
from app.extensions import hash_password
from app.models import Player
user = Player(username='navtest', password_hash=hash_password('Password123'),
role='player', full_name='Nav Test', email='[email protected]')
db.session.add(user)
db.session.commit()
page = client.get('/auth/login').get_data(as_text=True)
token = re.search(r'name="csrf_token" value="([^"]+)"', page).group(1)
client.post('/auth/login', data={'username': 'navtest',
'password': 'Password123',
'csrf_token': token})
body = client.get('/users/profile').get_data(as_text=True)
form = re.search(
r'<form method="POST" action="/auth/logout".*?</form>', body, re.S)
assert form is not None, 'no logout form in the navigation'
assert 'name="csrf_token"' in form.group(0)
class TestLoginRejection:
def test_wrong_password_is_refused(self, client, make_user, login, app):
user_id = make_user('player')
with app.app_context():
username = db.session.get(User, user_id).username
login(username, password='WrongPassword1')
response = client.get('/users/profile', follow_redirects=False)
assert response.status_code in (301, 302)
def test_login_failure_message_does_not_reveal_account_existence(
self, client, make_user, app
):
"""Compares the rendered flash messages rather than looking for a
known substring: the site is served in French by default, so an
English marker would silently match nothing on both sides and make
the test pass while the flaw is still there."""
user_id = make_user('player')
with app.app_context():
username = db.session.get(User, user_id).username
def failure_message(name):
body = client.post(
'/auth/login',
data={'username': name, 'password': 'WrongPassword1'},
follow_redirects=True,
).get_data(as_text=True)
return _flash_messages(body)
assert failure_message(username) == failure_message('no-such-account')
def test_the_message_stays_the_same_past_the_attempt_threshold(
self, client, make_user, app
):
"""The tally used to be counted out loud — "3 attempt(s) remaining"
— which is the same disclosure, spread over five requests."""
user_id = make_user('player')
with app.app_context():
username = db.session.get(User, user_id).username
seen = set()
for _ in range(7):
body = client.post(
'/auth/login',
data={'username': username, 'password': 'WrongPassword1'},
follow_redirects=True,
).get_data(as_text=True)
seen.update(_flash_messages(body))
assert len(seen) == 1, f'the message varies with the tally: {seen}'
class TestFailedAttemptThrottle:
"""SEC-018 — five wrong guesses used to take a known account out of
service for fifteen minutes, repeatably. On a president's account that
is the whole administration, and nothing in the product could undo it."""
@staticmethod
def _exhaust(client, username, times=6):
for _ in range(times):
client.post('/auth/login',
data={'username': username, 'password': 'WrongPassword1'},
follow_redirects=True)
def test_the_owner_still_gets_in_after_the_threshold(
self, app, client, make_user, login
):
user_id = make_user('player')
with app.app_context():
username = db.session.get(User, user_id).username
self._exhaust(client, username)
login(username)
assert client.get('/users/profile').status_code == 200
def test_a_successful_login_clears_the_tally(self, app, client, make_user, login):
user_id = make_user('player')
with app.app_context():
username = db.session.get(User, user_id).username
self._exhaust(client, username)
login(username)
with app.app_context():
user = db.session.get(User, user_id)
assert user.failed_login_attempts == 0
assert user.locked_until is None
def test_the_window_is_still_recorded(self, app, client, make_user):
"""It no longer gates anything, but it is the trace an administrator
reads when an account is being hammered."""
user_id = make_user('player')
with app.app_context():
username = db.session.get(User, user_id).username
self._exhaust(client, username)
with app.app_context():
user = db.session.get(User, user_id)
assert user.failed_login_attempts >= 5
assert user.locked_until is not None
def test_the_window_grows_with_repetition(self):
from app.routes.auth import MAX_LOCKOUT_MINUTES, cooloff_minutes
assert cooloff_minutes(5) == 15
assert cooloff_minutes(10) == 30
assert cooloff_minutes(15) == 60
assert cooloff_minutes(1000) == MAX_LOCKOUT_MINUTES
class TestRedirectValidation:
"""SEC-020 — is_safe_url() asked urlparse for a netloc, and urlparse
reads '/\evil.com' as a path. Several browsers normalise the backslash
to a slash first, which makes it the protocol-relative '//evil.com'."""
REFUSED = [
'//evil.example',
'/\evil.example',
'\\evil.example',
'https://evil.example/phish',
'javascript:alert(1)',
'dashboard',
'/dash\nboard',
'',
None,
]
ACCEPTED = [
'/dashboard',
'/users/profile?tab=games',
'/teams#roster',
]
@pytest.mark.parametrize('target', REFUSED)
def test_a_hostile_target_is_refused(self, app, target):
from app.routes.auth import is_safe_url
with app.test_request_context('/auth/login'):
assert not is_safe_url(target)
@pytest.mark.parametrize('target', ACCEPTED)
def test_an_internal_path_is_accepted(self, app, target):
from app.routes.auth import is_safe_url
with app.test_request_context('/auth/login'):
assert is_safe_url(target)
def test_the_same_host_spelled_out_is_accepted(self, app):
from app.routes.auth import is_safe_url
with app.test_request_context('/auth/login'):
from flask import request
assert is_safe_url(f'http://{request.host}/dashboard')
def test_the_login_redirect_refuses_to_leave_the_site(self, client, make_user, app):
user_id = make_user('player')
with app.app_context():
username = db.session.get(User, user_id).username
response = client.post(
'/auth/login?next=/%5Cevil.example',
data={'username': username, 'password': 'Password123'},
follow_redirects=False,
)
assert 'evil.example' not in response.headers.get('Location', '')