"""Authentication router: login, logout and current-user endpoints. The JWT access token is delivered as an **HttpOnly** cookie (``aegis_token``) so it is inaccessible to client-side JavaScript, mitigating XSS token-theft attacks. The JSON response also includes the token in the body for backwards compatibility and for clients that cannot use cookies (e.g. Swagger UI). """ import os from fastapi import APIRouter, Cookie, Depends, Request, Response from fastapi.security import OAuth2PasswordRequestForm from sqlalchemy.orm import Session from jose import jwt, JWTError from app.auth import create_access_token, blacklist_token, verify_password from app.config import settings from app.database import get_db from app.dependencies.auth import get_current_user from app.domain.errors import BusinessRuleViolation, PermissionViolation from app.domain.unit_of_work import UnitOfWork from app.limiter import limiter from app.middleware.request_context import resolve_client_ip from app.models.user import User from app.services.auth_service import ( _DUMMY_HASH, change_password as auth_change_password, ) from app.services.audit_service import log_action from app.schemas.auth import TokenResponse, UserOut from app.schemas.user import PasswordChange router = APIRouter(prefix="/auth", tags=["auth"]) # SECURE_COOKIES desacopla la seguridad de la cookie del entorno de ejecucion. # Por defecto activo en produccion; ponlo en "false" para servidores HTTP. _aegis_env = os.environ.get("AEGIS_ENV", "development").lower() _secure_cookie_env = os.environ.get("SECURE_COOKIES", "auto").lower() if _secure_cookie_env == "false": _IS_HTTPS = False elif _secure_cookie_env == "true": _IS_HTTPS = True else: # "auto" — activo solo si AEGIS_ENV=production _IS_HTTPS = _aegis_env == "production" _COOKIE_NAME = "aegis_token" @router.post("/login", response_model=TokenResponse) @limiter.limit("5/minute") def login( request: Request, response: Response, form_data: OAuth2PasswordRequestForm = Depends(), db: Session = Depends(get_db), ): """Authenticate a user and return a JWT access token. Rate-limited to **5 attempts per minute per IP**. Failed and successful logins are recorded in the audit log (SEC-009). """ user = db.query(User).filter(User.username == form_data.username).first() target_hash = user.hashed_password if user else _DUMMY_HASH password_valid = verify_password(form_data.password, target_hash) ip = resolve_client_ip(request) if user is None or not password_valid: with UnitOfWork(db) as uow: log_action( db, user.id if user else None, "LOGIN_FAILED", "auth", None, details={ "username": form_data.username, "ip": ip, "reason": "invalid_credentials", }, ip_address=ip, ) uow.commit() raise BusinessRuleViolation("Incorrect username or password") if not user.is_active: raise PermissionViolation("Account is disabled. Contact an administrator.") access_token = create_access_token(data={"sub": user.username}) with UnitOfWork(db) as uow: log_action( db, user.id, "LOGIN_SUCCESS", "auth", str(user.id), details={"username": user.username, "ip": ip}, ip_address=ip, ) uow.commit() response.set_cookie( key=_COOKIE_NAME, value=access_token, httponly=True, secure=_IS_HTTPS, samesite="strict", max_age=settings.ACCESS_TOKEN_EXPIRE_MINUTES * 60, path="/", ) return TokenResponse(access_token=access_token) @router.post("/logout") def logout( request: Request, response: Response, aegis_token: str | None = Cookie(None), ): """Clear the authentication cookie and revoke the current token.""" bearer = ( request.headers.get("Authorization") or request.headers.get("authorization") or "" ) bearer = bearer.removeprefix("Bearer ").removeprefix("bearer ").strip() seen: set[str] = set() for raw in (aegis_token, bearer): if not raw or raw in seen: continue seen.add(raw) try: payload = jwt.decode( raw, settings.SECRET_KEY, algorithms=[settings.ALGORITHM], ) jti = payload.get("jti") exp = payload.get("exp", 0) if jti: blacklist_token(jti, float(exp)) except JWTError: pass response.delete_cookie( key=_COOKIE_NAME, httponly=True, secure=_IS_HTTPS, samesite="strict", path="/", ) return {"detail": "Logged out"} @router.post("/refresh", response_model=TokenResponse) def refresh_token( response: Response, aegis_token: str | None = Cookie(None), db: Session = Depends(get_db), ): """Issue a new access token if the current one is valid. Called automatically by the frontend when it detects an expired session while the user is actively using the app. If the current cookie token is still valid (not blacklisted, not expired), a fresh token is issued and the cookie is renewed — keeping the session alive without requiring re-authentication. """ if not aegis_token: raise PermissionViolation("No active session") try: payload = jwt.decode( aegis_token, settings.SECRET_KEY, algorithms=[settings.ALGORITHM], ) except JWTError: raise PermissionViolation("Session expired — please log in again") username: str | None = payload.get("sub") if not username: raise PermissionViolation("Invalid session") user = db.query(User).filter(User.username == username).first() if user is None or not user.is_active: raise PermissionViolation("Account not found or disabled") if getattr(user, "must_change_password", False): raise PermissionViolation("Password change required before refreshing session") # Issue a fresh token with a new expiry new_token = create_access_token(data={"sub": user.username}) response.set_cookie( key=_COOKIE_NAME, value=new_token, httponly=True, secure=_IS_HTTPS, samesite="strict", max_age=settings.ACCESS_TOKEN_EXPIRE_MINUTES * 60, path="/", ) return TokenResponse(access_token=new_token) @router.get("/me", response_model=UserOut) def read_current_user(current_user: User = Depends(get_current_user)): """Return the profile of the currently authenticated user.""" return current_user @router.post("/change-password") def change_password( body: PasswordChange, db: Session = Depends(get_db), current_user: User = Depends(get_current_user), ): """Change the current user's password.""" auth_change_password( db, current_user, current_password=body.current_password, new_password=body.new_password, ) with UnitOfWork(db) as uow: uow.commit() return {"detail": "Password changed successfully"}