feat(auth): add DB-backed login, token management, and idempotent auth migration

This commit is contained in:
2026-04-03 12:55:34 +00:00
parent 5168e52e4b
commit 06b1805b92
28 changed files with 1609 additions and 140 deletions
+68 -12
View File
@@ -4,11 +4,14 @@ from __future__ import annotations
from dataclasses import dataclass
from enum import StrEnum
from typing import Generator
from fastapi import Depends, HTTPException, status
from fastapi.security import HTTPAuthorizationCredentials, HTTPBearer
from fastapi import Depends, HTTPException, Request, status
from app.core.config import Settings, get_settings
from app.core.security import hash_token
from app.repositories.factory import get_portfolio_repository_from_settings
from app.repositories.portfolio_repository import PortfolioRepository
class Permission(StrEnum):
@@ -21,9 +24,17 @@ class Permission(StrEnum):
class AuthContext:
token: str
permission: Permission
user_id: str | None = None
token_id: str | None = None
token_source: str = "env"
security = HTTPBearer(auto_error=False)
def get_auth_repository(settings: Settings = Depends(get_settings)) -> Generator[PortfolioRepository, None, None]:
repository = get_portfolio_repository_from_settings(settings)
try:
yield repository
finally:
if callable(getattr(type(repository), "close", None)):
repository.close()
def _resolve_permission(token: str, settings: Settings) -> Permission | None:
@@ -36,6 +47,26 @@ def _resolve_permission(token: str, settings: Settings) -> Permission | None:
return None
def _resolve_auth_context(token: str, settings: Settings, repository: PortfolioRepository) -> AuthContext | None:
permission = _resolve_permission(token, settings)
if permission is not None:
return AuthContext(token=token, permission=permission, token_source="env")
if not settings.db_enabled:
return None
principal = repository.get_token_principal(hash_token(token))
if principal is None:
return None
return AuthContext(
token=token,
permission=Permission(principal.permission),
user_id=principal.user_id,
token_id=principal.token_id,
token_source="db",
)
def _has_permission(current: Permission, required: Permission) -> bool:
if current == Permission.ADMIN:
return True
@@ -48,28 +79,53 @@ def _has_permission(current: Permission, required: Permission) -> bool:
def require_permission(
required: Permission,
credentials: HTTPAuthorizationCredentials | None = Depends(security),
request: Request,
settings: Settings = Depends(get_settings),
repository: PortfolioRepository = Depends(get_auth_repository),
) -> AuthContext:
if credentials is None or credentials.scheme.lower() != "bearer":
return authorize_request(required, request, settings, repository=repository)
def authorize_request(
required: Permission,
request: Request,
settings: Settings,
*,
repository: PortfolioRepository | None = None,
) -> AuthContext:
authorization = request.headers.get("Authorization", "").strip()
if not authorization:
raise HTTPException(status_code=status.HTTP_401_UNAUTHORIZED, detail="Missing bearer token")
scheme, _, token = authorization.partition(" ")
if scheme.lower() != "bearer" or not token:
raise HTTPException(status_code=status.HTTP_401_UNAUTHORIZED, detail="Missing bearer token")
permission = _resolve_permission(credentials.credentials, settings)
if permission is None:
managed_repository = repository is None
if repository is None:
repository = get_portfolio_repository_from_settings(settings)
try:
auth_context = _resolve_auth_context(token, settings, repository)
finally:
if managed_repository and callable(getattr(type(repository), "close", None)):
repository.close()
if auth_context is None:
raise HTTPException(status_code=status.HTTP_401_UNAUTHORIZED, detail="Invalid token")
if not _has_permission(permission, required):
if not _has_permission(auth_context.permission, required):
raise HTTPException(status_code=status.HTTP_403_FORBIDDEN, detail="Insufficient permission")
return AuthContext(token=credentials.credentials, permission=permission)
return auth_context
def permission_dependency(required: Permission):
def dependency(
credentials: HTTPAuthorizationCredentials | None = Depends(security),
request: Request,
settings: Settings = Depends(get_settings),
repository: PortfolioRepository = Depends(get_auth_repository),
) -> AuthContext:
return require_permission(required, credentials=credentials, settings=settings)
return require_permission(required, request=request, settings=settings, repository=repository)
return dependency