"""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 import os # Import APIRouter, Cookie, Depends, Request, Response from fastapi from fastapi import APIRouter, Cookie, Depends, Request, Response # Import OAuth2PasswordRequestForm from fastapi.security from fastapi.security import OAuth2PasswordRequestForm # Import jwt (PyJWT) import jwt # Import Session from sqlalchemy.orm from sqlalchemy.orm import Session # Import blacklist_token, create_access_token, verify_pa... from app.auth from app.auth import blacklist_token, create_access_token, verify_password # Import settings from app.config from app.config import settings # Import get_db from app.database from app.database import get_db # Import get_current_user from app.dependencies.auth from app.dependencies.auth import get_current_user # Import BusinessRuleViolation, PermissionViolation from app.domain.errors from app.domain.errors import BusinessRuleViolation, PermissionViolation # Import UnitOfWork from app.domain.unit_of_work from app.domain.unit_of_work import UnitOfWork # Import limiter from app.limiter from app.limiter import limiter # Import resolve_client_ip from app.middleware.request_context from app.middleware.request_context import resolve_client_ip # Import User from app.models.user from app.models.user import User # Import TokenResponse, UserOut from app.schemas.auth from app.schemas.auth import TokenResponse, UserOut # Import PasswordChange from app.schemas.user from app.schemas.user import PasswordChange # Import log_action from app.services.audit_service from app.services.audit_service import log_action # Import from app.services.auth_service from app.services.auth_service import ( _DUMMY_HASH, ) # Import from app.services.auth_service from app.services.auth_service import ( change_password as auth_change_password, ) # Assign router = APIRouter(prefix="/auth", tags=["auth"]) 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" # Apply the @router.post decorator @router.post("/login", response_model=TokenResponse) # Apply the @limiter.limit decorator @limiter.limit("5/minute") # Define function login def login( # Entry: request request: Request, # Entry: response response: Response, # Entry: form_data form_data: OAuth2PasswordRequestForm = Depends(), # Entry: db db: Session = Depends(get_db), ) -> TokenResponse: """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). """ # Assign user = db.query(User).filter(User.username == form_data.username).first() user = db.query(User).filter(User.username == form_data.username).first() # Assign target_hash = user.hashed_password if user else _DUMMY_HASH target_hash = user.hashed_password if user else _DUMMY_HASH # Assign password_valid = verify_password(form_data.password, target_hash) password_valid = verify_password(form_data.password, target_hash) # Assign ip = resolve_client_ip(request) ip = resolve_client_ip(request) # Check: user is None or not password_valid if user is None or not password_valid: # Open context manager with UnitOfWork(db) as uow: # Call log_action() log_action( db, user.id if user else None, # Literal argument value "LOGIN_FAILED", # Literal argument value "auth", # Literal argument value None, # Keyword argument: details details={ # Literal argument value "username": form_data.username, # Literal argument value "ip": ip, # Literal argument value "reason": "invalid_credentials", }, # Keyword argument: ip_address ip_address=ip, ) # Call uow.commit() uow.commit() # Raise BusinessRuleViolation raise BusinessRuleViolation("Incorrect username or password") # Check: not user.is_active if not user.is_active: # Raise PermissionViolation raise PermissionViolation("Account is disabled. Contact an administrator.") # Assign access_token = create_access_token(data={"sub": user.username}) access_token = create_access_token(data={"sub": user.username}) # Open context manager with UnitOfWork(db) as uow: # Call log_action() log_action( db, user.id, # Literal argument value "LOGIN_SUCCESS", # Literal argument value "auth", str(user.id), # Keyword argument: details details={"username": user.username, "ip": ip}, # Keyword argument: ip_address ip_address=ip, ) # Call uow.commit() uow.commit() # Call response.set_cookie() response.set_cookie( # Keyword argument: key key=_COOKIE_NAME, # Keyword argument: value value=access_token, # Keyword argument: httponly httponly=True, # Keyword argument: secure secure=_IS_HTTPS, # Keyword argument: samesite samesite="strict", # Keyword argument: max_age max_age=settings.ACCESS_TOKEN_EXPIRE_MINUTES * 60, # Keyword argument: path path="/", ) # Return TokenResponse(access_token=access_token) return TokenResponse(access_token=access_token) # Apply the @router.post decorator @router.post("/logout") # Define function logout def logout( # Entry: request request: Request, # Entry: response response: Response, # Entry: aegis_token aegis_token: str | None = Cookie(None), ) -> dict: """Clear the authentication cookie and revoke the current token.""" # Assign bearer = ( bearer = ( request.headers.get("Authorization") or request.headers.get("authorization") or "" ) # Assign bearer = bearer.removeprefix("Bearer ").removeprefix("bearer ").strip() bearer = bearer.removeprefix("Bearer ").removeprefix("bearer ").strip() # Assign seen = set() seen: set[str] = set() # Iterate over (aegis_token, bearer) for raw in (aegis_token, bearer): # Check: not raw or raw in seen if not raw or raw in seen: # Skip to the next loop iteration continue # Call seen.add() seen.add(raw) # Attempt the following; catch errors below try: # Assign payload = jwt.decode( payload = jwt.decode( raw, settings.SECRET_KEY, # Keyword argument: algorithms algorithms=[settings.ALGORITHM], ) # Assign jti = payload.get("jti") jti = payload.get("jti") # Assign exp = payload.get("exp", 0) exp = payload.get("exp", 0) # Check: jti if jti: # Call blacklist_token() blacklist_token(jti, float(exp)) # Handle any JWT validation error during logout (token may be expired or malformed) except jwt.exceptions.InvalidTokenError: # Intentional no-op placeholder pass # Call response.delete_cookie() response.delete_cookie( # Keyword argument: key key=_COOKIE_NAME, # Keyword argument: httponly httponly=True, # Keyword argument: secure secure=_IS_HTTPS, # Keyword argument: samesite samesite="strict", # Keyword argument: path path="/", ) # Return {"detail": "Logged out"} 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) # Define function read_current_user def read_current_user(current_user: User = Depends(get_current_user)) -> UserOut: """Return the profile of the currently authenticated user.""" # Return current_user return current_user # Apply the @router.post decorator @router.post("/change-password") # Define function change_password def change_password( # Entry: body body: PasswordChange, # Entry: db db: Session = Depends(get_db), # Entry: current_user current_user: User = Depends(get_current_user), ) -> dict: """Change the current user's password.""" # Call auth_change_password() auth_change_password( db, current_user, # Keyword argument: current_password current_password=body.current_password, # Keyword argument: new_password new_password=body.new_password, ) # Open context manager with UnitOfWork(db) as uow: # Call uow.commit() uow.commit() # Return {"detail": "Password changed successfully"} return {"detail": "Password changed successfully"}