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
+24
View File
@@ -21,10 +21,15 @@ class Settings(BaseSettings):
app_version: str = "0.1.0"
environment: str = "development"
allowed_origins_raw: str = Field(default="http://localhost:3000", validation_alias="PORTFOLIOHUB_ALLOWED_ORIGINS")
bootstrap_username: str = Field(default="", validation_alias="PORTFOLIOHUB_BOOTSTRAP_USERNAME")
bootstrap_email: str = Field(default="", validation_alias="PORTFOLIOHUB_BOOTSTRAP_EMAIL")
bootstrap_password: str = Field(default="", validation_alias="PORTFOLIOHUB_BOOTSTRAP_PASSWORD")
read_tokens_raw: str = Field(default="", validation_alias="PORTFOLIOHUB_READ_TOKENS")
trade_write_tokens_raw: str = Field(default="", validation_alias="PORTFOLIOHUB_TRADE_WRITE_TOKENS")
admin_tokens_raw: str = Field(default="", validation_alias="PORTFOLIOHUB_ADMIN_TOKENS")
db_enabled: bool = Field(default=False, validation_alias="PORTFOLIOHUB_DB_ENABLED")
session_token_ttl_hours: int = Field(default=12, validation_alias="PORTFOLIOHUB_SESSION_TOKEN_TTL_HOURS")
client_token_default_ttl_days: int = Field(default=365, validation_alias="PORTFOLIOHUB_CLIENT_TOKEN_DEFAULT_TTL_DAYS")
# External services configuration
timescaledb_host: str = Field(default="localhost", validation_alias="TIMESCALEDB_HOST")
@@ -49,6 +54,7 @@ class Settings(BaseSettings):
env_prefix="PORTFOLIOHUB_",
case_sensitive=False,
extra="ignore",
populate_by_name=True,
)
@computed_field
@@ -56,6 +62,24 @@ class Settings(BaseSettings):
def allowed_origins(self) -> list[str]:
return [item.strip() for item in self.allowed_origins_raw.split(",") if item.strip()]
@computed_field
@property
def bootstrap_user_username(self) -> str | None:
normalized = self.bootstrap_username.strip()
return normalized or None
@computed_field
@property
def bootstrap_user_email(self) -> str | None:
normalized = self.bootstrap_email.strip()
return normalized or None
@computed_field
@property
def bootstrap_user_password(self) -> str | None:
normalized = self.bootstrap_password.strip()
return normalized or None
@computed_field
@property
def read_tokens(self) -> set[str]:
+50
View File
@@ -0,0 +1,50 @@
"""Password hashing and opaque token helpers."""
from __future__ import annotations
import base64
import hashlib
import hmac
import secrets
def hash_password(password: str) -> str:
salt = secrets.token_bytes(16)
derived = hashlib.scrypt(password.encode("utf-8"), salt=salt, n=16384, r=8, p=1)
return "scrypt$16384$8$1${}${}".format(
base64.b64encode(salt).decode("ascii"),
base64.b64encode(derived).decode("ascii"),
)
def verify_password(password: str, encoded: str | None) -> bool:
if not encoded:
return False
try:
algorithm, n_value, r_value, p_value, salt_encoded, digest_encoded = encoded.split("$", maxsplit=5)
except ValueError:
return False
if algorithm != "scrypt":
return False
salt = base64.b64decode(salt_encoded.encode("ascii"))
expected = base64.b64decode(digest_encoded.encode("ascii"))
actual = hashlib.scrypt(
password.encode("utf-8"),
salt=salt,
n=int(n_value),
r=int(r_value),
p=int(p_value),
)
return hmac.compare_digest(actual, expected)
def generate_token() -> str:
return secrets.token_urlsafe(32)
def hash_token(token: str) -> str:
return hashlib.sha256(token.encode("utf-8")).hexdigest()
def token_prefix(token: str) -> str:
return token[:8]