from __future__ import annotations import logging from fastapi import APIRouter, Depends, HTTPException, Request, Response, status from sqlalchemy.orm import Session from app.api.routes.api.deps import require_csrf, require_session from app.config import Settings from app.dependencies import get_app_settings, get_db from app.schemas.session import ( LoginRequest, PasswordChangeRequest, SessionResponse, SessionUser, ) from app.services.auth import ( AuthPasswordChangeError, AuthenticatedSession, authenticate_user, change_password, create_session, revoke_session, ) from app.services import login_throttle logger = logging.getLogger(__name__) router = APIRouter(prefix="/api", tags=["api-session"]) def _build_session_response(auth: AuthenticatedSession) -> SessionResponse: return SessionResponse( user=SessionUser( username=auth.user.username, force_password_change=auth.user.force_password_change, ), csrf_token=auth.session.csrf_token, ) @router.get("/session", response_model=SessionResponse) def get_session( auth: AuthenticatedSession = Depends(require_session), ) -> SessionResponse: """Return the current session user and CSRF token. Returns 401 if not authenticated.""" return _build_session_response(auth) def _get_client_ip(request: Request, *, trust_forwarded_for: bool) -> str: """Extract the client IP address from the request. When ``trust_forwarded_for`` is True (reverse-proxy deployments) the left-most value from the ``X-Forwarded-For`` header is used. Otherwise the direct socket IP (``request.client.host``) is used. """ if trust_forwarded_for: xff = request.headers.get("X-Forwarded-For", "") if xff: return xff.split(",")[0].strip() if request.client is not None: return request.client.host return "unknown" @router.post("/auth/login", response_model=SessionResponse) def post_login( body: LoginRequest, request: Request, response: Response, db: Session = Depends(get_db), settings: Settings = Depends(get_app_settings), ) -> SessionResponse: """ Authenticate with username and password. On success, sets an HttpOnly session cookie and returns the session user + CSRF token. On failure, returns 401 with no cookie set. Repeated failures trigger exponential back-off (429 + Retry-After). No X-CSRF-Token required (unauthenticated endpoint). """ client_ip = _get_client_ip(request, trust_forwarded_for=settings.auth_trust_forwarded_for) # --- Throttle check (before any password verification) --- if settings.auth_login_throttle_enabled: wait_seconds = login_throttle.check_and_get_wait( db, ip=client_ip, username=body.username ) if wait_seconds > 0: raise HTTPException( status_code=status.HTTP_429_TOO_MANY_REQUESTS, detail="too many failed login attempts; please try again later", headers={"Retry-After": str(wait_seconds)}, ) # --- Password verification --- user = authenticate_user(db, username=body.username, password=body.password) if user is None: if settings.auth_login_throttle_enabled: login_throttle.register_failure(db, ip=client_ip, username=body.username) raise HTTPException( status_code=status.HTTP_401_UNAUTHORIZED, detail="invalid username or password", ) # --- Success: clear back-off state and issue session --- if settings.auth_login_throttle_enabled: login_throttle.clear(db, ip=client_ip, username=body.username) auth_session, raw_token = create_session(db, user=user, settings=settings) logger.info("Created API authenticated session for user '%s'", user.username) response.set_cookie( key=settings.auth_session_cookie_name, value=raw_token, max_age=settings.auth_session_ttl_hours * 3600, httponly=True, secure=settings.auth_cookie_secure, samesite="lax", path="/", ) auth = AuthenticatedSession(user=user, session=auth_session) return _build_session_response(auth) @router.post("/auth/logout") def post_logout( response: Response, db: Session = Depends(get_db), settings: Settings = Depends(get_app_settings), auth: AuthenticatedSession = Depends(require_session), _csrf: None = Depends(require_csrf), ) -> Response: """ Revoke the current session and clear the session cookie. Requires authentication and X-CSRF-Token header. Returns 204 No Content. """ revoke_session(db, auth_session=auth.session) logger.info("Revoked API authenticated session for user '%s'", auth.user.username) no_content = Response(status_code=status.HTTP_204_NO_CONTENT) no_content.delete_cookie(settings.auth_session_cookie_name, path="/") return no_content @router.post("/auth/password") def post_change_password( body: PasswordChangeRequest, db: Session = Depends(get_db), auth: AuthenticatedSession = Depends(require_session), _csrf: None = Depends(require_csrf), ) -> Response: """ Change the current user's password. Requires authentication and X-CSRF-Token header. On AuthPasswordChangeError returns 400 with a generic message. On success, force_password_change becomes False (handled by the service). Returns 204 No Content. """ try: change_password( db, user=auth.user, current_password=body.current_password, new_password=body.new_password, confirm_password=body.confirm_password, ) except AuthPasswordChangeError as exc: logger.info( "Rejected password change for user '%s': %s", auth.user.username, exc, ) raise HTTPException( status_code=status.HTTP_400_BAD_REQUEST, detail="password change failed", ) from exc logger.info("Password updated for user '%s'", auth.user.username) return Response(status_code=status.HTTP_204_NO_CONTENT)