Logs a distinct db_viewer_accessed event (not just the earlier session_created "requested" event) when an admin's browser actually completes the hand-off into Adminer. Adds a password-confirmed account-deletion box to Settings, relying on the existing ON DELETE CASCADE foreign keys to clean up everything the account owns. Adds an admin-only "require password change" flag that get_current_user enforces server-side (403 on everything except /auth/me, /auth/change-password, /auth/logout) - meant for handing a demo account to someone with a known sample password. Co-Authored-By: Claude Sonnet 5 <[email protected]>
84 lines
3.2 KiB
Python
84 lines
3.2 KiB
Python
"""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
|