""" Authentication router — username/password with DB-backed sessions, account lockout, role-based access control, and multi-user registration. Session flow: POST /setup → create admin User + Settings + calendars → issue session cookie POST /login → verify credentials → check lockout → MFA/enforce checks → issue session POST /register → create standard user (when registration enabled) POST /logout → mark session revoked in DB → delete cookie GET /status → verify user exists + session valid + role + registration_open Security layers: 1. Nginx limit_req_zone (real-IP, 10 req/min burst 5) — outer guard on auth endpoints 2. DB-backed account lockout (10 failures → 30-min lock, HTTP 423) 3. Session revocation stored in DB (survives container restarts) 4. bcrypt→Argon2id transparent upgrade on first login 5. Role-based authorization via require_role() dependency factory """ import uuid from datetime import datetime, timedelta from typing import Optional from fastapi import APIRouter, Depends, HTTPException, Request, Response, Cookie from sqlalchemy.ext.asyncio import AsyncSession from sqlalchemy import select, func from app.database import get_db from app.models.user import User from app.models.session import UserSession from app.models.settings import Settings from app.models.system_config import SystemConfig from app.models.calendar import Calendar from app.schemas.auth import ( SetupRequest, LoginRequest, RegisterRequest, ChangePasswordRequest, VerifyPasswordRequest, ) from app.services.auth import ( hash_password, verify_password, verify_password_with_upgrade, create_session_token, verify_session_token, create_mfa_token, create_mfa_enforce_token, ) from app.services.audit import log_audit_event from app.config import settings as app_settings router = APIRouter() # Pre-computed dummy hash for timing equalization (M-02). # When a login attempt targets a non-existent username, we still run # Argon2id verification against this dummy hash so the response time # is indistinguishable from a wrong-password attempt. _DUMMY_HASH = hash_password("timing-equalization-dummy") # --------------------------------------------------------------------------- # Cookie helper # --------------------------------------------------------------------------- def _set_session_cookie(response: Response, token: str) -> None: response.set_cookie( key="session", value=token, httponly=True, secure=app_settings.COOKIE_SECURE, max_age=app_settings.SESSION_MAX_AGE_DAYS * 86400, samesite="lax", ) # --------------------------------------------------------------------------- # Auth dependencies — export get_current_user and get_current_settings # --------------------------------------------------------------------------- async def get_current_user( request: Request, response: Response, session_cookie: Optional[str] = Cookie(None, alias="session"), db: AsyncSession = Depends(get_db), ) -> User: """ Dependency that verifies the session cookie and returns the authenticated User. L-03 sliding window: if the session has less than (SESSION_MAX_AGE_DAYS - 1) days remaining, silently extend expires_at and re-issue the cookie so active users never hit expiration. """ if not session_cookie: raise HTTPException(status_code=401, detail="Not authenticated") payload = verify_session_token(session_cookie) if payload is None: raise HTTPException(status_code=401, detail="Invalid or expired session") user_id: int = payload.get("uid") session_id: str = payload.get("sid") if user_id is None or session_id is None: raise HTTPException(status_code=401, detail="Malformed session token") # Verify session is active in DB (covers revocation + expiry) session_result = await db.execute( select(UserSession).where( UserSession.id == session_id, UserSession.user_id == user_id, UserSession.revoked == False, UserSession.expires_at > datetime.now(), ) ) db_session = session_result.scalar_one_or_none() if not db_session: raise HTTPException(status_code=401, detail="Session has been revoked or expired") user_result = await db.execute( select(User).where(User.id == user_id, User.is_active == True) ) user = user_result.scalar_one_or_none() if not user: raise HTTPException(status_code=401, detail="User not found or inactive") # L-03: Sliding window renewal — extend session if >1 day has elapsed since # last renewal (i.e. remaining time < SESSION_MAX_AGE_DAYS - 1 day). now = datetime.now() renewal_threshold = timedelta(days=app_settings.SESSION_MAX_AGE_DAYS - 1) if db_session.expires_at - now < renewal_threshold: db_session.expires_at = now + timedelta(days=app_settings.SESSION_MAX_AGE_DAYS) await db.flush() # Re-issue cookie with fresh signed token to reset browser max_age timer fresh_token = create_session_token(user_id, session_id) _set_session_cookie(response, fresh_token) return user async def get_current_settings( current_user: User = Depends(get_current_user), db: AsyncSession = Depends(get_db), ) -> Settings: """ Convenience dependency for routers that need Settings access. Always chain after get_current_user — never use standalone. """ result = await db.execute( select(Settings).where(Settings.user_id == current_user.id) ) settings_obj = result.scalar_one_or_none() if not settings_obj: raise HTTPException(status_code=500, detail="Settings not found for user") return settings_obj # --------------------------------------------------------------------------- # Role-based authorization dependencies # --------------------------------------------------------------------------- def require_role(*allowed_roles: str): """Factory: returns a dependency that enforces role membership.""" async def _check( current_user: User = Depends(get_current_user), ) -> User: if current_user.role not in allowed_roles: raise HTTPException(status_code=403, detail="Insufficient permissions") return current_user return _check # Convenience aliases require_admin = require_role("admin") # --------------------------------------------------------------------------- # Account lockout helpers # --------------------------------------------------------------------------- async def _check_account_lockout(user: User) -> None: """Raise HTTP 423 if the account is currently locked.""" if user.locked_until and datetime.now() < user.locked_until: remaining = int((user.locked_until - datetime.now()).total_seconds() / 60) + 1 raise HTTPException( status_code=423, detail=f"Account locked. Try again in {remaining} minutes.", ) async def _record_failed_login(db: AsyncSession, user: User) -> None: """Increment failure counter; lock account after 10 failures.""" user.failed_login_count += 1 if user.failed_login_count >= 10: user.locked_until = datetime.now() + timedelta(minutes=30) await db.commit() async def _record_successful_login(db: AsyncSession, user: User) -> None: """Reset failure counter and update last_login_at.""" user.failed_login_count = 0 user.locked_until = None user.last_login_at = datetime.now() await db.commit() # --------------------------------------------------------------------------- # Session creation helper # --------------------------------------------------------------------------- async def _create_db_session( db: AsyncSession, user: User, ip: str, user_agent: str | None, ) -> tuple[str, str]: """Insert a UserSession row and return (session_id, signed_cookie_token).""" session_id = uuid.uuid4().hex expires_at = datetime.now() + timedelta(days=app_settings.SESSION_MAX_AGE_DAYS) db_session = UserSession( id=session_id, user_id=user.id, expires_at=expires_at, ip_address=ip[:45] if ip else None, user_agent=(user_agent or "")[:255] if user_agent else None, ) db.add(db_session) await db.flush() token = create_session_token(user.id, session_id) return session_id, token # --------------------------------------------------------------------------- # User bootstrapping helper (Settings + default calendars) # --------------------------------------------------------------------------- async def _create_user_defaults( db: AsyncSession, user_id: int, *, preferred_name: str | None = None, ) -> None: """Create Settings row and default calendars for a new user.""" db.add(Settings(user_id=user_id, preferred_name=preferred_name)) db.add(Calendar( name="Personal", color="#3b82f6", is_default=True, is_system=False, is_visible=True, user_id=user_id, )) db.add(Calendar( name="Birthdays", color="#f59e0b", is_default=False, is_system=True, is_visible=True, user_id=user_id, )) # --------------------------------------------------------------------------- # Routes # --------------------------------------------------------------------------- @router.post("/setup") async def setup( data: SetupRequest, response: Response, request: Request, db: AsyncSession = Depends(get_db), ): """ First-time setup: create the admin User + Settings + default calendars. Only works when no users exist (i.e., fresh install). """ user_count = await db.execute(select(func.count()).select_from(User)) if user_count.scalar_one() > 0: raise HTTPException(status_code=400, detail="Setup already completed") password_hash = hash_password(data.password) new_user = User( username=data.username, password_hash=password_hash, role="admin", last_password_change_at=datetime.now(), ) db.add(new_user) await db.flush() await _create_user_defaults(db, new_user.id) ip = request.client.host if request.client else "unknown" user_agent = request.headers.get("user-agent") _, token = await _create_db_session(db, new_user, ip, user_agent) _set_session_cookie(response, token) await log_audit_event( db, action="auth.setup_complete", actor_id=new_user.id, ip=ip, ) await db.commit() return {"message": "Setup completed successfully", "authenticated": True} @router.post("/login") async def login( data: LoginRequest, request: Request, response: Response, db: AsyncSession = Depends(get_db), ): """ Authenticate with username + password. Returns: { authenticated: true } — on success (no TOTP, no enforcement) { authenticated: false, totp_required: true, mfa_token: "..." } — TOTP pending { authenticated: false, mfa_setup_required: true, mfa_token: "..." } — MFA enforcement { authenticated: false, must_change_password: true } — forced password change after admin reset HTTP 401 — wrong credentials HTTP 403 — account disabled (is_active=False) HTTP 423 — account locked """ client_ip = request.client.host if request.client else "unknown" result = await db.execute(select(User).where(User.username == data.username)) user = result.scalar_one_or_none() if not user: # M-02: Run Argon2id against a dummy hash so the response time is # indistinguishable from a wrong-password attempt (prevents username enumeration). verify_password("x", _DUMMY_HASH) raise HTTPException(status_code=401, detail="Invalid username or password") # M-02: Run password verification BEFORE lockout check so Argon2id always # executes — prevents distinguishing "locked" from "wrong password" via timing. valid, new_hash = verify_password_with_upgrade(data.password, user.password_hash) await _check_account_lockout(user) if not valid: await _record_failed_login(db, user) await log_audit_event( db, action="auth.login_failed", actor_id=user.id, detail={"reason": "invalid_password"}, ip=client_ip, ) await db.commit() raise HTTPException(status_code=401, detail="Invalid username or password") # Block disabled accounts — checked AFTER password verification to avoid # leaking account-state info, and BEFORE _record_successful_login so # last_login_at and lockout counters are not reset for inactive users. if not user.is_active: await log_audit_event( db, action="auth.login_blocked_inactive", actor_id=user.id, detail={"reason": "account_disabled"}, ip=client_ip, ) await db.commit() raise HTTPException(status_code=403, detail="Account is disabled. Contact an administrator.") if new_hash: user.password_hash = new_hash await _record_successful_login(db, user) # SEC-03: MFA enforcement — block login entirely until MFA setup completes if user.mfa_enforce_pending and not user.totp_enabled: enforce_token = create_mfa_enforce_token(user.id) await log_audit_event( db, action="auth.mfa_enforce_prompted", actor_id=user.id, ip=client_ip, ) await db.commit() return { "authenticated": False, "mfa_setup_required": True, "mfa_token": enforce_token, } # If TOTP is enabled, issue a short-lived MFA challenge token if user.totp_enabled: mfa_token = create_mfa_token(user.id) return { "authenticated": False, "totp_required": True, "mfa_token": mfa_token, } # SEC-12: Forced password change after admin reset if user.must_change_password: # Issue a session but flag the frontend to show password change user_agent = request.headers.get("user-agent") _, token = await _create_db_session(db, user, client_ip, user_agent) _set_session_cookie(response, token) await db.commit() return { "authenticated": True, "must_change_password": True, } user_agent = request.headers.get("user-agent") _, token = await _create_db_session(db, user, client_ip, user_agent) _set_session_cookie(response, token) await log_audit_event( db, action="auth.login_success", actor_id=user.id, ip=client_ip, ) await db.commit() return {"authenticated": True} @router.post("/register") async def register( data: RegisterRequest, response: Response, request: Request, db: AsyncSession = Depends(get_db), ): """ Create a new standard user account. Only available when system_config.allow_registration is True. """ config_result = await db.execute( select(SystemConfig).where(SystemConfig.id == 1) ) config = config_result.scalar_one_or_none() if not config or not config.allow_registration: raise HTTPException(status_code=403, detail="Registration is not available") # Check username availability (generic error to prevent enumeration) existing = await db.execute( select(User).where(User.username == data.username) ) if existing.scalar_one_or_none(): raise HTTPException(status_code=400, detail="Registration could not be completed. Please try a different username.") password_hash = hash_password(data.password) # SEC-01: Explicit field assignment — never **data.model_dump() new_user = User( username=data.username, password_hash=password_hash, role="standard", last_password_change_at=datetime.now(), ) # Check if MFA enforcement is enabled for new users if config.enforce_mfa_new_users: new_user.mfa_enforce_pending = True db.add(new_user) await db.flush() await _create_user_defaults(db, new_user.id) ip = request.client.host if request.client else "unknown" user_agent = request.headers.get("user-agent") await log_audit_event( db, action="auth.registration", actor_id=new_user.id, ip=ip, ) await db.commit() # If MFA enforcement is pending, don't issue a session — require MFA setup first if new_user.mfa_enforce_pending: enforce_token = create_mfa_enforce_token(new_user.id) return { "message": "Registration successful", "authenticated": False, "mfa_setup_required": True, "mfa_token": enforce_token, } _, token = await _create_db_session(db, new_user, ip, user_agent) _set_session_cookie(response, token) await db.commit() return {"message": "Registration successful", "authenticated": True} @router.post("/logout") async def logout( response: Response, session_cookie: Optional[str] = Cookie(None, alias="session"), db: AsyncSession = Depends(get_db), ): """Revoke the current session in DB and clear the cookie.""" if session_cookie: payload = verify_session_token(session_cookie) if payload: session_id = payload.get("sid") if session_id: result = await db.execute( select(UserSession).where(UserSession.id == session_id) ) db_session = result.scalar_one_or_none() if db_session: db_session.revoked = True await db.commit() response.delete_cookie( key="session", httponly=True, secure=app_settings.COOKIE_SECURE, samesite="lax", ) return {"message": "Logout successful"} @router.get("/status") async def auth_status( session_cookie: Optional[str] = Cookie(None, alias="session"), db: AsyncSession = Depends(get_db), ): """ Check authentication status, role, and whether initial setup/registration is available. """ user_count_result = await db.execute( select(func.count()).select_from(User) ) setup_required = user_count_result.scalar_one() == 0 authenticated = False role = None if not setup_required and session_cookie: payload = verify_session_token(session_cookie) if payload: user_id = payload.get("uid") session_id = payload.get("sid") if user_id and session_id: session_result = await db.execute( select(UserSession).where( UserSession.id == session_id, UserSession.user_id == user_id, UserSession.revoked == False, UserSession.expires_at > datetime.now(), ) ) if session_result.scalar_one_or_none() is not None: authenticated = True user_obj_result = await db.execute( select(User).where(User.id == user_id, User.is_active == True) ) u = user_obj_result.scalar_one_or_none() if u: role = u.role else: authenticated = False # Check registration availability registration_open = False if not setup_required: config_result = await db.execute( select(SystemConfig).where(SystemConfig.id == 1) ) config = config_result.scalar_one_or_none() registration_open = config.allow_registration if config else False return { "authenticated": authenticated, "setup_required": setup_required, "role": role, "username": u.username if authenticated and u else None, "registration_open": registration_open, } @router.post("/verify-password") async def verify_password( data: VerifyPasswordRequest, db: AsyncSession = Depends(get_db), current_user: User = Depends(get_current_user), ): """ Verify the current user's password without changing anything. Used by the frontend lock screen to re-authenticate without a full login. """ await _check_account_lockout(current_user) valid, new_hash = verify_password_with_upgrade(data.password, current_user.password_hash) if not valid: await _record_failed_login(db, current_user) raise HTTPException(status_code=401, detail="Invalid password") if new_hash: current_user.password_hash = new_hash await db.commit() return {"verified": True} @router.post("/change-password") async def change_password( data: ChangePasswordRequest, db: AsyncSession = Depends(get_db), current_user: User = Depends(get_current_user), ): """Change the current user's password. Requires old password verification.""" await _check_account_lockout(current_user) valid, _ = verify_password_with_upgrade(data.old_password, current_user.password_hash) if not valid: await _record_failed_login(db, current_user) raise HTTPException(status_code=401, detail="Invalid current password") if data.new_password == data.old_password: raise HTTPException(status_code=400, detail="New password must be different from your current password") current_user.password_hash = hash_password(data.new_password) current_user.last_password_change_at = datetime.now() # Clear forced password change flag if set (SEC-12) if current_user.must_change_password: current_user.must_change_password = False await db.commit() return {"message": "Password changed successfully"}