securite: #87 T6-T8 fin dette — deps qualifiées, semgrep, Secure auto, CORS

This commit is contained in:
2026-09-27 12:43:31 -04:00
parent b6e2029770
commit a3973b981c
24 changed files with 390 additions and 73 deletions
+35 -16
View File
@@ -16,7 +16,7 @@ from backend.ratelimit import record_account_failure as rl_record_account_failur
from backend.ratelimit import record_account_success as rl_record_account_success
from backend.ratelimit import record_failure as rl_record_failure
from backend.ratelimit import record_success as rl_record_success
from backend.services.net import get_client_ip
from backend.services.net import get_client_ip, is_trusted_proxy
from .jwt_handler import (
ACCESS_TOKEN_EXPIRE_SECONDS,
@@ -57,15 +57,32 @@ logger = logging.getLogger("obsigate.auth.router")
router = APIRouter(prefix="/api/auth", tags=["auth"])
def is_secure_cookies() -> bool:
"""True when auth cookies must carry the ``Secure`` flag (#87 T3).
def is_secure_cookies(request: Request | None = None) -> bool:
"""True when auth cookies must carry the ``Secure`` flag (#87 T3/T8).
Opt-in via ``OBSIGATE_SECURE_COOKIES=true`` (required behind TLS).
Default stays ``false`` so logins keep working over plain HTTP on
trusted loopback deployments — browsers drop ``Secure`` cookies sent
over HTTP, which would silently break localhost logins.
``OBSIGATE_SECURE_COOKIES=true|false|auto`` (défaut : ``auto``) :
``true``/``false`` forcent le comportement ; ``auto`` met ``Secure``
si la requête arrive en https (production derrière TLS) et l'omet
sinon (dev local en http — les navigateurs jettent les cookies
``Secure`` sur http, ce qui casserait silencieusement les logins
localhost). Derrière un reverse proxy qui termine TLS, le schéma perçu
est http : avec ``OBSIGATE_TRUST_PROXY=true``, ``X-Forwarded-Proto``
est honoré (même garde que ``get_client_ip``, BUG-030).
"""
return os.environ.get("OBSIGATE_SECURE_COOKIES", "false").lower() == "true"
forced = os.environ.get("OBSIGATE_SECURE_COOKIES", "auto").lower()
if forced in ("1", "true", "yes", "on"):
return True
if forced in ("0", "false", "no", "off"):
return False
if request is None:
return False
if request.url.scheme == "https":
return True
if is_trusted_proxy():
proto = request.headers.get("x-forwarded-proto", "").split(",")[0].strip().lower()
if proto == "https":
return True
return False
# ── Pydantic request models ──────────────────────────────────────────
@@ -230,10 +247,11 @@ async def login(body: LoginRequest, response: Response, request: Request):
"remember_me": body.remember_me,
}
return _issue_tokens(user, body.username, body.remember_me, response)
return _issue_tokens(user, body.username, body.remember_me, response, request)
def _issue_tokens(user: dict, username: str, remember_me: bool, response: Response) -> dict:
def _issue_tokens(user: dict, username: str, remember_me: bool, response: Response,
request: Request | None = None) -> dict:
"""Issue JWT tokens after successful authentication (password or MFA verified)."""
record_login_success(username)
rl_record_account_success(username)
@@ -242,7 +260,7 @@ def _issue_tokens(user: dict, username: str, remember_me: bool, response: Respon
refresh_token, refresh_jti = create_refresh_token(username, remember=remember_me)
max_age = 2592000 if remember_me else 604800 # 30d or 7d
secure = is_secure_cookies()
secure = is_secure_cookies(request)
response.set_cookie(
key="refresh_token",
value=refresh_token,
@@ -311,7 +329,7 @@ async def refresh_token_endpoint(request: Request, response: Response):
if stale:
raise HTTPException(401, "Session expirée, veuillez vous reconnecter")
secure = is_secure_cookies()
secure = is_secure_cookies(request)
remember_me = bool(payload.get("remember", False))
# BUG-027: rotate the refresh token — the old one is now single-use.
@@ -437,6 +455,7 @@ async def patch_me(req: UpdateMeRequest, current_user=Depends(require_auth)):
async def change_password(
req: ChangePasswordRequest,
response: Response,
request: Request,
current_user=Depends(require_auth),
):
"""Change own password.
@@ -452,7 +471,7 @@ async def change_password(
updated = get_user(current_user["username"])
result: dict = {"message": "Mot de passe mis à jour"}
if updated is not None:
result.update(_issue_tokens(updated, updated["username"], False, response))
result.update(_issue_tokens(updated, updated["username"], False, response, request))
return result
@@ -815,7 +834,7 @@ async def mfa_webauthn_verify(
rl_record_success(client_ip)
logger.info(f"User '{body.username}' logged in via WebAuthn")
return _issue_tokens(user, body.username, body.remember_me, response)
return _issue_tokens(user, body.username, body.remember_me, response, request)
@router.get("/mfa/status")
@@ -868,7 +887,7 @@ async def mfa_totp_verify(body: MfaVerifyRequest, response: Response, request: R
# Clear IP rate limit on success
rl_record_success(client_ip)
return _issue_tokens(user, body.username, body.remember_me, response)
return _issue_tokens(user, body.username, body.remember_me, response, request)
@router.post("/mfa/recovery")
@@ -906,7 +925,7 @@ async def mfa_recovery_login(body: MfaRecoveryRequest, response: Response, reque
rl_record_success(client_ip)
logger.info(f"User '{body.username}' logged in via recovery code")
return _issue_tokens(user, body.username, False, response)
return _issue_tokens(user, body.username, False, response, request)
# ── Admin endpoints ───────────────────────────────────────────────────