"""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 datetime, timezone from datetime from datetime import datetime, timezone # Import jwt (PyJWT) import jwt from jwt.exceptions import PyJWTError as JWTError # 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, is_token_blacklisted, 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, SetPasswordRequest, SetPasswordTokenValidateOut # 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, ) from app.domain.errors import EntityNotFoundError from app.services.password_setup_service import ( consume_token_and_set_password, validate_token as validate_password_setup_token, ) # 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() # Auto-discover the user's Jira account ID on every login (non-fatal) try: from app.services.jira_service import lookup_user_jira_account_id if lookup_user_jira_account_id(db, user): db.commit() except Exception: pass # 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"} # A token that has *just* expired can still be refreshed within this window. # Without this grace period, `/auth/refresh` decodes the same token with the # same strict expiry check as every other endpoint — so by the time a 401 # triggers a refresh attempt, the refresh call itself has also expired and # the session is unrecoverable. The grace window only widens the refresh # check; a brand-new token is always issued with the full expiry. _REFRESH_GRACE_SECONDS = 15 * 60 @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 or recently expired. Called automatically by the frontend when it detects an expired session while the user is actively using the app. Expiry is checked manually (with `_REFRESH_GRACE_SECONDS` of leeway) instead of relying on PyJWT's built-in check, so a token that expired moments ago can still be refreshed. Revoked (blacklisted) tokens are never refreshable. """ if not aegis_token: raise PermissionViolation("No active session") try: payload = jwt.decode( aegis_token, settings.SECRET_KEY, algorithms=[settings.ALGORITHM], options={"verify_exp": False}, ) except JWTError: raise PermissionViolation("Session expired — please log in again") jti: str | None = payload.get("jti") if jti and is_token_blacklisted(jti): raise PermissionViolation("Session expired — please log in again") exp = payload.get("exp") now = datetime.now(timezone.utc).timestamp() if exp is None or now - exp > _REFRESH_GRACE_SECONDS: 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"} # --------------------------------------------------------------------------- # Set password via a one-time emailed link — public (no auth), rate-limited # --------------------------------------------------------------------------- @router.get("/set-password/validate", response_model=SetPasswordTokenValidateOut) @limiter.limit("20/minute") def validate_set_password_token(request: Request, token: str, db: Session = Depends(get_db)) -> SetPasswordTokenValidateOut: """Check a set-password token before rendering the form. Public endpoint.""" try: user = validate_password_setup_token(db, token) except (EntityNotFoundError, BusinessRuleViolation): return SetPasswordTokenValidateOut(valid=False) return SetPasswordTokenValidateOut(valid=True, full_name=user.full_name) @router.post("/set-password") @limiter.limit("10/minute") def set_password(request: Request, body: SetPasswordRequest, db: Session = Depends(get_db)) -> dict: """Complete a password setup/reset using a one-time token. Public endpoint.""" with UnitOfWork(db) as uow: user = consume_token_and_set_password(db, body.token, body.new_password) log_action( db, user.id, "SET_PASSWORD_VIA_TOKEN", "auth", str(user.id), details={}, ) uow.commit() return {"detail": "Password set successfully — you can now log in"}