Fix auth session persistence with HttpOnly cookies and CSRF
This commit is contained in:
@@ -1,11 +1,18 @@
|
||||
"""Authentication endpoints for credential login, session introspection, and logout."""
|
||||
|
||||
import logging
|
||||
import secrets
|
||||
from datetime import UTC, datetime
|
||||
|
||||
from fastapi import APIRouter, Depends, HTTPException, Request, status
|
||||
from sqlalchemy.orm import Session
|
||||
|
||||
from app.api.auth import AuthContext, require_user_or_admin
|
||||
from app.api.auth import (
|
||||
AuthContext,
|
||||
SESSION_COOKIE_NAME,
|
||||
CSRF_COOKIE_NAME,
|
||||
require_user_or_admin,
|
||||
)
|
||||
from app.db.base import get_session
|
||||
from app.schemas.auth import (
|
||||
AuthLoginRequest,
|
||||
@@ -21,6 +28,11 @@ from app.services.auth_login_throttle import (
|
||||
)
|
||||
from app.services.authentication import authenticate_user, issue_user_session, revoke_auth_session
|
||||
|
||||
try:
|
||||
from fastapi import Response
|
||||
except (ImportError, AttributeError):
|
||||
from fastapi.responses import Response
|
||||
|
||||
|
||||
router = APIRouter(prefix="/auth", tags=["auth"])
|
||||
logger = logging.getLogger(__name__)
|
||||
@@ -48,13 +60,82 @@ def _retry_after_headers(retry_after_seconds: int) -> dict[str, str]:
|
||||
return {"Retry-After": str(max(1, int(retry_after_seconds)))}
|
||||
|
||||
|
||||
def _is_https_request(request: Request) -> bool:
|
||||
"""Returns whether the incoming request should be treated as HTTPS for cookie flags."""
|
||||
|
||||
forwarded_protocol = request.headers.get("x-forwarded-proto", "").strip().lower().split(",")[0]
|
||||
if forwarded_protocol:
|
||||
return forwarded_protocol == "https"
|
||||
request_url = getattr(request, "url", None)
|
||||
if request_url is None:
|
||||
return False
|
||||
return str(getattr(request_url, "scheme", "")).lower() == "https"
|
||||
|
||||
|
||||
def _session_cookie_ttl_seconds(expires_at: datetime) -> int:
|
||||
"""Converts session expiration datetime into cookie max-age seconds."""
|
||||
|
||||
now = datetime.now(UTC)
|
||||
ttl = int((expires_at - now).total_seconds())
|
||||
return max(1, ttl)
|
||||
|
||||
|
||||
def _set_session_cookie(response: Response, session_token: str, *, expires_at: datetime, secure: bool) -> None:
|
||||
"""Stores the issued session token in a browser HttpOnly auth cookie."""
|
||||
|
||||
if response is None or not hasattr(response, "set_cookie"):
|
||||
return
|
||||
expires_seconds = _session_cookie_ttl_seconds(expires_at)
|
||||
response.set_cookie(
|
||||
SESSION_COOKIE_NAME,
|
||||
value=session_token,
|
||||
max_age=expires_seconds,
|
||||
httponly=True,
|
||||
secure=secure,
|
||||
samesite="strict",
|
||||
path="/",
|
||||
)
|
||||
|
||||
|
||||
def _set_csrf_cookie(
|
||||
response: Response,
|
||||
csrf_token: str,
|
||||
*,
|
||||
expires_at: datetime,
|
||||
secure: bool,
|
||||
) -> None:
|
||||
"""Stores an anti-CSRF token in a browser cookie for JavaScript-safe extraction."""
|
||||
|
||||
if response is None or not hasattr(response, "set_cookie"):
|
||||
return
|
||||
response.set_cookie(
|
||||
CSRF_COOKIE_NAME,
|
||||
value=csrf_token,
|
||||
max_age=_session_cookie_ttl_seconds(expires_at),
|
||||
httponly=False,
|
||||
secure=secure,
|
||||
samesite="strict",
|
||||
path="/",
|
||||
)
|
||||
|
||||
|
||||
def _clear_session_cookies(response: Response) -> None:
|
||||
"""Clears auth cookies returned by login responses."""
|
||||
|
||||
if response is None or not hasattr(response, "delete_cookie"):
|
||||
return
|
||||
response.delete_cookie(SESSION_COOKIE_NAME, path="/")
|
||||
response.delete_cookie(CSRF_COOKIE_NAME, path="/")
|
||||
|
||||
|
||||
@router.post("/login", response_model=AuthLoginResponse)
|
||||
def login(
|
||||
payload: AuthLoginRequest,
|
||||
request: Request,
|
||||
response: Response | None = None,
|
||||
session: Session = Depends(get_session),
|
||||
) -> AuthLoginResponse:
|
||||
"""Authenticates credentials with throttle protection and returns an issued bearer session token."""
|
||||
"""Authenticates credentials with throttle protection and returns issued session metadata."""
|
||||
|
||||
ip_address = _request_ip_address(request)
|
||||
try:
|
||||
@@ -120,10 +201,27 @@ def login(
|
||||
ip_address=ip_address,
|
||||
)
|
||||
session.commit()
|
||||
return AuthLoginResponse(
|
||||
access_token=issued_session.token,
|
||||
|
||||
csrf_token = secrets.token_urlsafe(32)
|
||||
secure_cookie = _is_https_request(request)
|
||||
_set_session_cookie(
|
||||
response,
|
||||
issued_session.token,
|
||||
expires_at=issued_session.expires_at,
|
||||
secure=secure_cookie,
|
||||
)
|
||||
_set_csrf_cookie(
|
||||
response,
|
||||
csrf_token,
|
||||
expires_at=issued_session.expires_at,
|
||||
secure=secure_cookie,
|
||||
)
|
||||
|
||||
return AuthLoginResponse(
|
||||
user=AuthUserResponse.model_validate(user),
|
||||
expires_at=issued_session.expires_at,
|
||||
access_token=issued_session.token,
|
||||
csrf_token=csrf_token,
|
||||
)
|
||||
|
||||
|
||||
@@ -143,10 +241,11 @@ def me(context: AuthContext = Depends(require_user_or_admin)) -> AuthSessionResp
|
||||
|
||||
@router.post("/logout", response_model=AuthLogoutResponse)
|
||||
def logout(
|
||||
response: Response | None = None,
|
||||
context: AuthContext = Depends(require_user_or_admin),
|
||||
session: Session = Depends(get_session),
|
||||
) -> AuthLogoutResponse:
|
||||
"""Revokes current bearer session token and confirms logout state."""
|
||||
"""Revokes current session token and clears client auth cookies."""
|
||||
|
||||
revoked = revoke_auth_session(
|
||||
session,
|
||||
@@ -154,4 +253,6 @@ def logout(
|
||||
)
|
||||
if revoked:
|
||||
session.commit()
|
||||
|
||||
_clear_session_cookies(response)
|
||||
return AuthLogoutResponse(revoked=revoked)
|
||||
|
||||
Reference in New Issue
Block a user