""" 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.services.connection import sync_birthday_to_contacts 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, ProfileUpdate, ProfileResponse, ) from app.services.auth import ( ahash_password, averify_password, averify_password_with_upgrade, 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 get_client_ip, 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", path="/", ) # --------------------------------------------------------------------------- # 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") # AC-1: Single JOIN query for session + user (was 2 sequential queries) result = await db.execute( select(UserSession, User) .join(User, UserSession.user_id == User.id) .where( UserSession.id == session_id, UserSession.user_id == user_id, UserSession.revoked == False, UserSession.expires_at > datetime.now(), User.is_active == True, ) ) row = result.one_or_none() if not row: raise HTTPException(status_code=401, detail="Session expired or user inactive") db_session, user = row.tuple() # 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) # Stash session on request so lock/unlock endpoints can access it request.state.db_session = db_session # Defense-in-depth: block API access while session is locked. # Exempt endpoints needed for unlocking, locking, checking status, and logout. if db_session.is_locked: lock_exempt = { "/api/auth/lock", "/api/auth/verify-password", "/api/auth/status", "/api/auth/logout", } if request.url.path not in lock_exempt: raise HTTPException(status_code=423, detail="Session is locked") return user async def get_current_settings( request: Request, 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. AC-3: Cache in request.state so multiple dependencies don't re-query. """ cached = getattr(request.state, "settings", None) if cached is not None: return cached 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") request.state.settings = settings_obj 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() # Enforce concurrent session limit: revoke oldest sessions beyond the cap active_sessions = ( await db.execute( select(UserSession) .where( UserSession.user_id == user.id, UserSession.revoked == False, # noqa: E712 UserSession.expires_at > datetime.now(), ) .order_by(UserSession.created_at.asc()) ) ).scalars().all() max_sessions = app_settings.MAX_SESSIONS_PER_USER if len(active_sessions) > max_sessions: for old_session in active_sessions[: len(active_sessions) - max_sessions]: old_session.revoked = True 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 = await ahash_password(data.password) new_user = User( username=data.username, umbral_name=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 = get_client_ip(request) 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 = get_client_ip(request) 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). await averify_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 = await averify_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 check your details and try again.") # Check email uniqueness (generic error to prevent enumeration) if data.email: existing_email = await db.execute( select(User).where(User.email == data.email) ) if existing_email.scalar_one_or_none(): raise HTTPException(status_code=400, detail="Registration could not be completed. Please check your details and try again.") password_hash = await ahash_password(data.password) # SEC-01: Explicit field assignment — never **data.model_dump() new_user = User( username=data.username, umbral_name=data.username, password_hash=password_hash, role="standard", email=data.email, date_of_birth=data.date_of_birth, 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, preferred_name=data.preferred_name) ip = get_client_ip(request) 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 is_locked = False 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(), ) ) db_sess = session_result.scalar_one_or_none() if db_sess is not None: authenticated = True is_locked = db_sess.is_locked 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, "is_locked": is_locked, } @router.post("/lock") async def lock_session( request: Request, db: AsyncSession = Depends(get_db), current_user: User = Depends(get_current_user), ): """Mark the current session as locked. Frontend must verify password to unlock.""" db_session: UserSession = request.state.db_session db_session.is_locked = True db_session.locked_at = datetime.now() await db.commit() return {"locked": True} @router.post("/verify-password") async def verify_password( data: VerifyPasswordRequest, request: Request, 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 = await averify_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 # Clear session lock on successful password verification db_session: UserSession = request.state.db_session db_session.is_locked = False db_session.locked_at = None 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, _ = await averify_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 = await ahash_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"} @router.get("/profile", response_model=ProfileResponse) async def get_profile( current_user: User = Depends(get_current_user), ): """Return the current user's profile fields.""" return ProfileResponse.model_validate(current_user) @router.put("/profile", response_model=ProfileResponse) async def update_profile( data: ProfileUpdate, request: Request, db: AsyncSession = Depends(get_db), current_user: User = Depends(get_current_user), ): """Update the current user's profile fields (first_name, last_name, email, date_of_birth).""" update_data = data.model_dump(exclude_unset=True) if not update_data: return ProfileResponse.model_validate(current_user) # Email uniqueness check if email is changing if "email" in update_data and update_data["email"] != current_user.email: new_email = update_data["email"] if new_email: existing = await db.execute( select(User).where(User.email == new_email, User.id != current_user.id) ) if existing.scalar_one_or_none(): raise HTTPException(status_code=400, detail="Email is already in use") # Umbral name uniqueness check if changing if "umbral_name" in update_data and update_data["umbral_name"] != current_user.umbral_name: new_name = update_data["umbral_name"] existing = await db.execute( select(User).where(User.umbral_name == new_name, User.id != current_user.id) ) if existing.scalar_one_or_none(): raise HTTPException(status_code=400, detail="Umbral name is already taken") # SEC-01: Explicit field assignment — only allowed profile fields if "first_name" in update_data: current_user.first_name = update_data["first_name"] if "last_name" in update_data: current_user.last_name = update_data["last_name"] if "email" in update_data: current_user.email = update_data["email"] if "date_of_birth" in update_data: current_user.date_of_birth = update_data["date_of_birth"] settings_result = await db.execute( select(Settings).where(Settings.user_id == current_user.id) ) user_settings = settings_result.scalar_one_or_none() share = user_settings.share_birthday if user_settings else False await sync_birthday_to_contacts(db, current_user.id, share_birthday=share, date_of_birth=update_data["date_of_birth"]) if "umbral_name" in update_data: current_user.umbral_name = update_data["umbral_name"] await log_audit_event( db, action="auth.profile_updated", actor_id=current_user.id, detail={"fields": list(update_data.keys())}, ip=get_client_ip(request), ) await db.commit() await db.refresh(current_user) return ProfileResponse.model_validate(current_user)