fix: login endpoint - request variable shadowing Starlette Request

The login() function used 'request: LoginRequest' which shadowed
FastAPI's Starlette Request object. Request.client was accessed on
the LoginRequest Pydantic model instead of the HTTP request, causing
AttributeError: 'LoginRequest' object has no attribute 'client'.

Fix: rename the Pydantic parameter to 'body' and add explicit
'request: Request' for IP extraction and rate limiting.
This commit is contained in:
Bruno Charest 2026-05-27 21:16:11 -04:00
parent 17eea0559d
commit 2469026c1d

View File

@ -89,14 +89,14 @@ async def auth_status():
@router.post("/login") @router.post("/login")
async def login(request: LoginRequest, response: Response): async def login(body: LoginRequest, response: Response, request: Request):
"""Authenticate a user. Returns access token and sets refresh cookie. """Authenticate a user. Returns access token and sets refresh cookie.
Implements timing-safe responses to prevent user enumeration: Implements timing-safe responses to prevent user enumeration:
a failed login with an unknown user takes the same time as one a failed login with an unknown user takes the same time as one
with a known user (dummy hash is computed). with a known user (dummy hash is computed).
""" """
user = get_user(request.username) user = get_user(body.username)
if not user: if not user:
# Timing-safe: simulate hash computation to prevent user enumeration # Timing-safe: simulate hash computation to prevent user enumeration
@ -111,11 +111,11 @@ async def login(request: LoginRequest, response: Response):
if is_rate_limited(client_ip): if is_rate_limited(client_ip):
raise HTTPException(429, "Trop de tentatives depuis cette adresse IP (15min)") raise HTTPException(429, "Trop de tentatives depuis cette adresse IP (15min)")
if is_locked(request.username): if is_locked(body.username):
raise HTTPException(429, "Compte temporairement verrouillé (15min)") raise HTTPException(429, "Compte temporairement verrouillé (15min)")
if not verify_password(request.password, user["password_hash"]): if not verify_password(body.password, user["password_hash"]):
attempts = record_login_failure(request.username) attempts = record_login_failure(body.username)
rl_attempts, rl_remaining = rl_record_failure(client_ip) rl_attempts, rl_remaining = rl_record_failure(client_ip)
remaining = max(0, 5 - attempts) remaining = max(0, 5 - attempts)
detail = "Identifiants invalides" detail = "Identifiants invalides"
@ -124,14 +124,14 @@ async def login(request: LoginRequest, response: Response):
raise HTTPException(401, detail) raise HTTPException(401, detail)
# Success — clear rate limits and generate tokens # Success — clear rate limits and generate tokens
record_login_success(request.username) record_login_success(body.username)
rl_record_success(client_ip) rl_record_success(client_ip)
access_token = create_access_token(user) access_token = create_access_token(user)
refresh_token, refresh_jti = create_refresh_token(request.username) refresh_token, refresh_jti = create_refresh_token(body.username)
# Set refresh token as HttpOnly cookie (path-restricted to /api/auth/refresh) # Set refresh token as HttpOnly cookie (path-restricted to /api/auth/refresh)
max_age = 2592000 if request.remember_me else 604800 # 30d or 7d max_age = 2592000 if body.remember_me else 604800 # 30d or 7d
import os import os
secure = os.environ.get("OBSIGATE_SECURE_COOKIES", "false").lower() == "true" secure = os.environ.get("OBSIGATE_SECURE_COOKIES", "false").lower() == "true"
response.set_cookie( response.set_cookie(
@ -144,7 +144,7 @@ async def login(request: LoginRequest, response: Response):
path="/api/auth/refresh", path="/api/auth/refresh",
) )
logger.info(f"User '{request.username}' logged in") logger.info(f"User '{body.username}' logged in")
# Set access token as cookie for same-origin requests (e.g. popout window) # Set access token as cookie for same-origin requests (e.g. popout window)
response.set_cookie( response.set_cookie(