"""FastAPI dependency implementing the `AuthProvider` contract described in ARCHITECTURE.md: `get_current_user` always returns a `User` row or raises 401, regardless of caller. This is the seam a future Firebase Auth integration would replace. When AUTH_MODE=local (the default), the fixed local-dev user is only returned to a request that's actually from loopback (see `app.core.security.is_localhost`) - anyone reaching the API from a LAN or WAN connection still needs a real bearer token, even with that setting. AUTH_MODE=jwt disables the loopback convenience entirely (required in production, see `Settings._forbid_local_auth_in_production`). """ from __future__ import annotations from fastapi import Depends, Header, HTTPException, Request, status from sqlalchemy.ext.asyncio import AsyncSession from app.core.config import Settings, get_settings from app.core.security import ( InvalidTokenError, TokenType, decode_token, get_client_ip, is_localhost, ) from app.db.session import get_db from app.models.user import User from app.repositories.user_repository import UserRepository from app.services.auth_service import get_or_create_local_user # Everything an account with must_change_password=True can still reach - # just enough to discover the flag (/me), fix it (/change-password), and # bail out (/logout, which doesn't even route through get_current_user but # is listed for clarity). Every other endpoint 403s until they change it - # see app.services.auth_service.require_password_change. _PASSWORD_CHANGE_EXEMPT_PATHS = frozenset( {"/api/v1/auth/me", "/api/v1/auth/change-password", "/api/v1/auth/logout"} ) async def get_current_user( request: Request, authorization: str | None = Header(default=None), settings: Settings = Depends(get_settings), db: AsyncSession = Depends(get_db), ) -> User: if settings.auth_mode == "local" and is_localhost(request, settings): return await get_or_create_local_user(db, get_client_ip(request, settings)) if authorization is None or not authorization.lower().startswith("bearer "): raise HTTPException(status_code=status.HTTP_401_UNAUTHORIZED, detail="Not authenticated") token = authorization.split(" ", 1)[1] try: decoded = decode_token(token, settings, TokenType.ACCESS) except InvalidTokenError as exc: raise HTTPException( status_code=status.HTTP_401_UNAUTHORIZED, detail="Invalid or expired access token", ) from exc repo = UserRepository(db) user = await repo.get_by_id(decoded.user_id) if user is None or not user.is_active: raise HTTPException( status_code=status.HTTP_401_UNAUTHORIZED, detail="Invalid or expired access token", ) if user.must_change_password and request.url.path not in _PASSWORD_CHANGE_EXEMPT_PATHS: raise HTTPException( status_code=status.HTTP_403_FORBIDDEN, detail="Password change required", ) return user async def require_admin(user: User = Depends(get_current_user)) -> User: if not user.is_admin: raise HTTPException( status_code=status.HTTP_403_FORBIDDEN, detail="Admin privileges required" ) return user