feat: auth & admin
This commit is contained in:
+74
-2
@@ -1,17 +1,31 @@
|
||||
"""Shared FastAPI dependencies — the composition root for request-scoped wiring.
|
||||
|
||||
Concrete adapters are bound to ports here so routers and services stay
|
||||
decoupled from infrastructure. Repository/service providers are added in
|
||||
later steps as the domain grows.
|
||||
decoupled from infrastructure. Each request gets its own repositories/services
|
||||
bound to the request-scoped DB session; stateless adapters (hasher, token
|
||||
service) are process-cached.
|
||||
"""
|
||||
|
||||
from collections.abc import AsyncIterator
|
||||
from functools import lru_cache
|
||||
from typing import Annotated
|
||||
|
||||
from fastapi import Depends
|
||||
from fastapi.security import HTTPAuthorizationCredentials, HTTPBearer
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
|
||||
from app.application.auth_service import AuthService
|
||||
from app.application.user_service import UserService
|
||||
from app.core.config import get_settings
|
||||
from app.core.security import Argon2PasswordHasher, JwtTokenService
|
||||
from app.domain.entities import User
|
||||
from app.domain.errors import AuthenticationError, PermissionDeniedError
|
||||
from app.domain.ports import PasswordHasher, TokenService
|
||||
from app.infrastructure.db import get_sessionmaker
|
||||
from app.infrastructure.db.repositories import (
|
||||
SqlAlchemyRefreshTokenRepository,
|
||||
SqlAlchemyUserRepository,
|
||||
)
|
||||
|
||||
|
||||
async def get_session() -> AsyncIterator[AsyncSession]:
|
||||
@@ -28,3 +42,61 @@ async def get_session() -> AsyncIterator[AsyncSession]:
|
||||
|
||||
|
||||
SessionDep = Annotated[AsyncSession, Depends(get_session)]
|
||||
|
||||
|
||||
# -- stateless adapters (process-cached) ---------------------------------------
|
||||
@lru_cache
|
||||
def get_password_hasher() -> PasswordHasher:
|
||||
return Argon2PasswordHasher()
|
||||
|
||||
|
||||
@lru_cache
|
||||
def get_token_service() -> TokenService:
|
||||
return JwtTokenService(get_settings())
|
||||
|
||||
|
||||
# -- request-scoped services ---------------------------------------------------
|
||||
def get_auth_service(session: SessionDep) -> AuthService:
|
||||
return AuthService(
|
||||
users=SqlAlchemyUserRepository(session),
|
||||
refresh_tokens=SqlAlchemyRefreshTokenRepository(session),
|
||||
hasher=get_password_hasher(),
|
||||
tokens=get_token_service(),
|
||||
)
|
||||
|
||||
|
||||
def get_user_service(session: SessionDep) -> UserService:
|
||||
return UserService(
|
||||
users=SqlAlchemyUserRepository(session),
|
||||
refresh_tokens=SqlAlchemyRefreshTokenRepository(session),
|
||||
hasher=get_password_hasher(),
|
||||
)
|
||||
|
||||
|
||||
AuthServiceDep = Annotated[AuthService, Depends(get_auth_service)]
|
||||
UserServiceDep = Annotated[UserService, Depends(get_user_service)]
|
||||
|
||||
|
||||
# -- current user / authorization ----------------------------------------------
|
||||
# auto_error=False: we raise domain AuthenticationError (mapped to 401) so the
|
||||
# error envelope stays consistent with the rest of the API.
|
||||
_bearer = HTTPBearer(auto_error=False)
|
||||
BearerDep = Annotated[HTTPAuthorizationCredentials | None, Depends(_bearer)]
|
||||
|
||||
|
||||
async def get_current_user(credentials: BearerDep, auth: AuthServiceDep) -> User:
|
||||
if credentials is None:
|
||||
raise AuthenticationError("Missing bearer token.")
|
||||
return await auth.authenticate_access(credentials.credentials)
|
||||
|
||||
|
||||
CurrentUser = Annotated[User, Depends(get_current_user)]
|
||||
|
||||
|
||||
async def get_current_superuser(user: CurrentUser) -> User:
|
||||
if not user.is_superuser:
|
||||
raise PermissionDeniedError("Administrator privileges required.")
|
||||
return user
|
||||
|
||||
|
||||
SuperUser = Annotated[User, Depends(get_current_superuser)]
|
||||
|
||||
@@ -0,0 +1 @@
|
||||
"""Pydantic request/response models for the native REST API (``/api/v1``)."""
|
||||
@@ -0,0 +1,20 @@
|
||||
"""Auth request/response schemas. Tokens are returned in the body (the client
|
||||
stores them); refresh is presented back in the body too (offline-first clients
|
||||
manage their own token store, not cookies)."""
|
||||
|
||||
from pydantic import BaseModel, Field
|
||||
|
||||
|
||||
class LoginRequest(BaseModel):
|
||||
username: str = Field(min_length=1, max_length=64)
|
||||
password: str = Field(min_length=1)
|
||||
|
||||
|
||||
class RefreshRequest(BaseModel):
|
||||
refresh_token: str
|
||||
|
||||
|
||||
class TokenResponse(BaseModel):
|
||||
access_token: str
|
||||
refresh_token: str
|
||||
token_type: str = "bearer"
|
||||
@@ -0,0 +1,46 @@
|
||||
"""User schemas. ``password_hash`` is never exposed — only ``UserResponse``
|
||||
fields leave the service."""
|
||||
|
||||
import datetime as dt
|
||||
import uuid
|
||||
|
||||
from pydantic import BaseModel, ConfigDict, Field
|
||||
|
||||
from app.domain.entities import User
|
||||
|
||||
|
||||
class UserResponse(BaseModel):
|
||||
model_config = ConfigDict(from_attributes=True)
|
||||
|
||||
id: uuid.UUID
|
||||
username: str
|
||||
is_superuser: bool
|
||||
is_active: bool
|
||||
created_at: dt.datetime
|
||||
updated_at: dt.datetime
|
||||
|
||||
@classmethod
|
||||
def from_entity(cls, user: User) -> UserResponse:
|
||||
return cls.model_validate(user)
|
||||
|
||||
|
||||
class CreateUserRequest(BaseModel):
|
||||
username: str = Field(min_length=1, max_length=64)
|
||||
password: str = Field(min_length=8)
|
||||
is_superuser: bool = False
|
||||
|
||||
|
||||
class UpdateUserRequest(BaseModel):
|
||||
"""Admin patch — every field optional; only provided ones change."""
|
||||
|
||||
is_superuser: bool | None = None
|
||||
is_active: bool | None = None
|
||||
|
||||
|
||||
class ResetPasswordRequest(BaseModel):
|
||||
new_password: str = Field(min_length=8)
|
||||
|
||||
|
||||
class ChangePasswordRequest(BaseModel):
|
||||
current_password: str = Field(min_length=1)
|
||||
new_password: str = Field(min_length=8)
|
||||
@@ -0,0 +1,14 @@
|
||||
"""Native REST API, version 1. Aggregates feature routers under ``/api/v1``."""
|
||||
|
||||
from fastapi import APIRouter
|
||||
|
||||
from app.api.v1.admin import router as admin_router
|
||||
from app.api.v1.auth import router as auth_router
|
||||
from app.api.v1.users import router as users_router
|
||||
|
||||
api_v1_router = APIRouter(prefix="/api/v1")
|
||||
api_v1_router.include_router(auth_router)
|
||||
api_v1_router.include_router(users_router)
|
||||
api_v1_router.include_router(admin_router)
|
||||
|
||||
__all__ = ["api_v1_router"]
|
||||
@@ -0,0 +1,80 @@
|
||||
"""Admin user-management endpoints. Every route requires a superuser.
|
||||
|
||||
Registration is admin-only — this is a private instance, there is no public
|
||||
sign-up (plan §6.4).
|
||||
"""
|
||||
|
||||
import uuid
|
||||
|
||||
from fastapi import APIRouter, Query, status
|
||||
|
||||
from app.api.deps import SuperUser, UserServiceDep
|
||||
from app.api.schemas.user import (
|
||||
CreateUserRequest,
|
||||
ResetPasswordRequest,
|
||||
UpdateUserRequest,
|
||||
UserResponse,
|
||||
)
|
||||
|
||||
router = APIRouter(prefix="/admin/users", tags=["admin"])
|
||||
|
||||
|
||||
@router.get("", response_model=list[UserResponse])
|
||||
async def list_users(
|
||||
_admin: SuperUser,
|
||||
users: UserServiceDep,
|
||||
limit: int = Query(default=50, ge=1, le=200),
|
||||
offset: int = Query(default=0, ge=0),
|
||||
) -> list[UserResponse]:
|
||||
result = await users.list_users(limit=limit, offset=offset)
|
||||
return [UserResponse.from_entity(u) for u in result]
|
||||
|
||||
|
||||
@router.post("", response_model=UserResponse, status_code=status.HTTP_201_CREATED)
|
||||
async def create_user(
|
||||
body: CreateUserRequest, _admin: SuperUser, users: UserServiceDep
|
||||
) -> UserResponse:
|
||||
user = await users.create_user(
|
||||
username=body.username,
|
||||
password=body.password,
|
||||
is_superuser=body.is_superuser,
|
||||
)
|
||||
return UserResponse.from_entity(user)
|
||||
|
||||
|
||||
@router.get("/{user_id}", response_model=UserResponse)
|
||||
async def get_user(user_id: uuid.UUID, _admin: SuperUser, users: UserServiceDep) -> UserResponse:
|
||||
return UserResponse.from_entity(await users.get_user(user_id))
|
||||
|
||||
|
||||
@router.patch("/{user_id}", response_model=UserResponse)
|
||||
async def update_user(
|
||||
user_id: uuid.UUID,
|
||||
body: UpdateUserRequest,
|
||||
_admin: SuperUser,
|
||||
users: UserServiceDep,
|
||||
) -> UserResponse:
|
||||
user = await users.get_user(user_id)
|
||||
if body.is_superuser is not None:
|
||||
user = await users.set_superuser(user_id, is_superuser=body.is_superuser)
|
||||
if body.is_active is not None:
|
||||
user = await users.set_active(user_id, is_active=body.is_active)
|
||||
return UserResponse.from_entity(user)
|
||||
|
||||
|
||||
@router.post("/{user_id}/reset-password", status_code=status.HTTP_204_NO_CONTENT)
|
||||
async def reset_password(
|
||||
user_id: uuid.UUID,
|
||||
body: ResetPasswordRequest,
|
||||
_admin: SuperUser,
|
||||
users: UserServiceDep,
|
||||
) -> None:
|
||||
await users.reset_password(user_id, new_password=body.new_password)
|
||||
|
||||
|
||||
@router.delete("/{user_id}", response_model=UserResponse)
|
||||
async def deactivate_user(
|
||||
user_id: uuid.UUID, _admin: SuperUser, users: UserServiceDep
|
||||
) -> UserResponse:
|
||||
"""Soft delete — deactivates the account and revokes its sessions."""
|
||||
return UserResponse.from_entity(await users.deactivate(user_id))
|
||||
@@ -0,0 +1,39 @@
|
||||
"""Auth endpoints: login, refresh (rotation), logout, and current-user."""
|
||||
|
||||
from fastapi import APIRouter, status
|
||||
|
||||
from app.api.deps import AuthServiceDep, CurrentUser
|
||||
from app.api.schemas.auth import LoginRequest, RefreshRequest, TokenResponse
|
||||
from app.api.schemas.user import UserResponse
|
||||
from app.domain.tokens import TokenPair
|
||||
|
||||
router = APIRouter(prefix="/auth", tags=["auth"])
|
||||
|
||||
|
||||
def _to_token_response(pair: TokenPair) -> TokenResponse:
|
||||
return TokenResponse(
|
||||
access_token=pair.access.encoded,
|
||||
refresh_token=pair.refresh.encoded,
|
||||
)
|
||||
|
||||
|
||||
@router.post("/login", response_model=TokenResponse)
|
||||
async def login(body: LoginRequest, auth: AuthServiceDep) -> TokenResponse:
|
||||
pair = await auth.login(body.username, body.password)
|
||||
return _to_token_response(pair)
|
||||
|
||||
|
||||
@router.post("/refresh", response_model=TokenResponse)
|
||||
async def refresh(body: RefreshRequest, auth: AuthServiceDep) -> TokenResponse:
|
||||
pair = await auth.refresh(body.refresh_token)
|
||||
return _to_token_response(pair)
|
||||
|
||||
|
||||
@router.post("/logout", status_code=status.HTTP_204_NO_CONTENT)
|
||||
async def logout(body: RefreshRequest, auth: AuthServiceDep) -> None:
|
||||
await auth.logout(body.refresh_token)
|
||||
|
||||
|
||||
@router.get("/me", response_model=UserResponse)
|
||||
async def me(user: CurrentUser) -> UserResponse:
|
||||
return UserResponse.from_entity(user)
|
||||
@@ -0,0 +1,19 @@
|
||||
"""Self-service user endpoints (the authenticated caller acts on themselves)."""
|
||||
|
||||
from fastapi import APIRouter, status
|
||||
|
||||
from app.api.deps import CurrentUser, UserServiceDep
|
||||
from app.api.schemas.user import ChangePasswordRequest
|
||||
|
||||
router = APIRouter(prefix="/users", tags=["users"])
|
||||
|
||||
|
||||
@router.patch("/me/password", status_code=status.HTTP_204_NO_CONTENT)
|
||||
async def change_my_password(
|
||||
body: ChangePasswordRequest, user: CurrentUser, users: UserServiceDep
|
||||
) -> None:
|
||||
await users.change_password(
|
||||
user.id,
|
||||
current_password=body.current_password,
|
||||
new_password=body.new_password,
|
||||
)
|
||||
@@ -0,0 +1,104 @@
|
||||
"""Authentication use cases: login, token refresh (rotation), logout, and
|
||||
access-token verification.
|
||||
|
||||
Depends only on domain ports. Wired with concrete adapters at the composition
|
||||
root (``app.api.deps``).
|
||||
"""
|
||||
|
||||
import hashlib
|
||||
import uuid
|
||||
|
||||
from app.domain.entities import User
|
||||
from app.domain.errors import AuthenticationError
|
||||
from app.domain.ports import (
|
||||
PasswordHasher,
|
||||
RefreshTokenRepository,
|
||||
TokenService,
|
||||
UserRepository,
|
||||
)
|
||||
from app.domain.tokens import IssuedToken, TokenPair, TokenType
|
||||
|
||||
|
||||
def _hash_token(encoded: str) -> str:
|
||||
"""At-rest hash of a refresh token. A signed JWT is high-entropy, so a fast
|
||||
SHA-256 suffices (no slow KDF needed) — we never store the raw token."""
|
||||
return hashlib.sha256(encoded.encode("utf-8")).hexdigest()
|
||||
|
||||
|
||||
class AuthService:
|
||||
def __init__(
|
||||
self,
|
||||
*,
|
||||
users: UserRepository,
|
||||
refresh_tokens: RefreshTokenRepository,
|
||||
hasher: PasswordHasher,
|
||||
tokens: TokenService,
|
||||
) -> None:
|
||||
self._users = users
|
||||
self._refresh_tokens = refresh_tokens
|
||||
self._hasher = hasher
|
||||
self._tokens = tokens
|
||||
|
||||
async def login(self, username: str, password: str) -> TokenPair:
|
||||
credentials = await self._users.get_credentials_by_username(username)
|
||||
# Same error whether the user is missing or the password is wrong —
|
||||
# don't leak which usernames exist.
|
||||
if credentials is None:
|
||||
raise AuthenticationError("Invalid username or password.")
|
||||
|
||||
valid, updated_hash = self._hasher.verify_and_update(password, credentials.password_hash)
|
||||
if not valid:
|
||||
raise AuthenticationError("Invalid username or password.")
|
||||
if not credentials.user.is_active:
|
||||
raise AuthenticationError("Account is disabled.")
|
||||
if updated_hash is not None:
|
||||
await self._users.set_password_hash(credentials.user.id, updated_hash)
|
||||
|
||||
return await self._issue_pair(credentials.user.id)
|
||||
|
||||
async def refresh(self, encoded_refresh: str) -> TokenPair:
|
||||
claims = self._tokens.decode(encoded_refresh)
|
||||
if claims.token_type is not TokenType.REFRESH:
|
||||
raise AuthenticationError("Not a refresh token.")
|
||||
if not await self._refresh_tokens.is_valid(claims.jti):
|
||||
raise AuthenticationError("Refresh token is revoked or expired.")
|
||||
|
||||
user = await self._users.get_by_id(claims.subject)
|
||||
if user is None or not user.is_active:
|
||||
raise AuthenticationError("Account is unavailable.")
|
||||
|
||||
# Rotation: invalidate the presented token before issuing a new pair.
|
||||
await self._refresh_tokens.revoke(claims.jti)
|
||||
return await self._issue_pair(user.id)
|
||||
|
||||
async def logout(self, encoded_refresh: str) -> None:
|
||||
# Best-effort: a malformed/expired token simply has nothing to revoke.
|
||||
try:
|
||||
claims = self._tokens.decode(encoded_refresh)
|
||||
except AuthenticationError:
|
||||
return
|
||||
if claims.token_type is TokenType.REFRESH:
|
||||
await self._refresh_tokens.revoke(claims.jti)
|
||||
|
||||
async def authenticate_access(self, encoded_access: str) -> User:
|
||||
claims = self._tokens.decode(encoded_access)
|
||||
if claims.token_type is not TokenType.ACCESS:
|
||||
raise AuthenticationError("Not an access token.")
|
||||
user = await self._users.get_by_id(claims.subject)
|
||||
if user is None or not user.is_active:
|
||||
raise AuthenticationError("Account is unavailable.")
|
||||
return user
|
||||
|
||||
async def _issue_pair(self, user_id: uuid.UUID) -> TokenPair:
|
||||
access = self._tokens.issue(subject=user_id, token_type=TokenType.ACCESS)
|
||||
refresh = self._tokens.issue(subject=user_id, token_type=TokenType.REFRESH)
|
||||
await self._persist_refresh(user_id, refresh)
|
||||
return TokenPair(access=access, refresh=refresh)
|
||||
|
||||
async def _persist_refresh(self, user_id: uuid.UUID, refresh: IssuedToken) -> None:
|
||||
await self._refresh_tokens.add(
|
||||
jti=refresh.jti,
|
||||
user_id=user_id,
|
||||
token_hash=_hash_token(refresh.encoded),
|
||||
expires_at=refresh.expires_at,
|
||||
)
|
||||
@@ -0,0 +1,79 @@
|
||||
"""User-management use cases: admin CRUD plus self-service password change.
|
||||
|
||||
Deletion is *soft* (deactivate) — likes, play history and playlists reference
|
||||
users via append-only event-logs, so rows must not vanish (plan §4 invariants).
|
||||
"""
|
||||
|
||||
import uuid
|
||||
|
||||
from app.domain.entities import User
|
||||
from app.domain.errors import AlreadyExistsError, AuthenticationError, NotFoundError
|
||||
from app.domain.ports import PasswordHasher, RefreshTokenRepository, UserRepository
|
||||
|
||||
|
||||
class UserService:
|
||||
def __init__(
|
||||
self,
|
||||
*,
|
||||
users: UserRepository,
|
||||
refresh_tokens: RefreshTokenRepository,
|
||||
hasher: PasswordHasher,
|
||||
) -> None:
|
||||
self._users = users
|
||||
self._refresh_tokens = refresh_tokens
|
||||
self._hasher = hasher
|
||||
|
||||
async def create_user(
|
||||
self, *, username: str, password: str, is_superuser: bool = False
|
||||
) -> User:
|
||||
if await self._users.get_credentials_by_username(username) is not None:
|
||||
raise AlreadyExistsError(f"Username {username!r} is taken.")
|
||||
return await self._users.add(
|
||||
username=username,
|
||||
password_hash=self._hasher.hash(password),
|
||||
is_superuser=is_superuser,
|
||||
)
|
||||
|
||||
async def list_users(self, *, limit: int = 50, offset: int = 0) -> list[User]:
|
||||
return await self._users.list(limit=limit, offset=offset)
|
||||
|
||||
async def get_user(self, user_id: uuid.UUID) -> User:
|
||||
user = await self._users.get_by_id(user_id)
|
||||
if user is None:
|
||||
raise NotFoundError("User not found.")
|
||||
return user
|
||||
|
||||
async def set_superuser(self, user_id: uuid.UUID, *, is_superuser: bool) -> User:
|
||||
await self.get_user(user_id)
|
||||
return await self._users.set_superuser(user_id, is_superuser)
|
||||
|
||||
async def set_active(self, user_id: uuid.UUID, *, is_active: bool) -> User:
|
||||
await self.get_user(user_id)
|
||||
user = await self._users.set_active(user_id, is_active)
|
||||
if not is_active:
|
||||
# Deactivating a user kills their sessions immediately.
|
||||
await self._refresh_tokens.revoke_all_for_user(user_id)
|
||||
return user
|
||||
|
||||
async def reset_password(self, user_id: uuid.UUID, *, new_password: str) -> None:
|
||||
"""Admin-driven password reset. Revokes all sessions."""
|
||||
await self.get_user(user_id)
|
||||
await self._users.set_password_hash(user_id, self._hasher.hash(new_password))
|
||||
await self._refresh_tokens.revoke_all_for_user(user_id)
|
||||
|
||||
async def deactivate(self, user_id: uuid.UUID) -> User:
|
||||
"""Soft delete: disable the account, keep the row for referential history."""
|
||||
return await self.set_active(user_id, is_active=False)
|
||||
|
||||
async def change_password(
|
||||
self, user_id: uuid.UUID, *, current_password: str, new_password: str
|
||||
) -> None:
|
||||
"""Self-service change: verify the current password first."""
|
||||
user = await self.get_user(user_id)
|
||||
credentials = await self._users.get_credentials_by_username(user.username)
|
||||
assert credentials is not None # user exists; fetched by id above
|
||||
valid, _ = self._hasher.verify_and_update(current_password, credentials.password_hash)
|
||||
if not valid:
|
||||
raise AuthenticationError("Current password is incorrect.")
|
||||
await self._users.set_password_hash(user_id, self._hasher.hash(new_password))
|
||||
await self._refresh_tokens.revoke_all_for_user(user_id)
|
||||
+55
-3
@@ -1,22 +1,74 @@
|
||||
"""Management CLI (``mcma``). Admin/seed commands land here in later steps.
|
||||
"""Management CLI (``mcma``).
|
||||
|
||||
For now it exposes ``mcma version``. ``mcma create-admin`` arrives with auth
|
||||
(plan §11 step 3).
|
||||
Commands:
|
||||
* ``mcma version`` — print the backend version.
|
||||
* ``mcma create-admin`` — create the first (or another) superuser. Private
|
||||
instance, so there is no public sign-up — bootstrap
|
||||
admins here (plan §11 step 3).
|
||||
"""
|
||||
|
||||
import argparse
|
||||
import asyncio
|
||||
import getpass
|
||||
|
||||
from app import __version__
|
||||
|
||||
|
||||
async def _create_admin(username: str, password: str) -> None:
|
||||
from app.application.user_service import UserService
|
||||
from app.core.security import Argon2PasswordHasher
|
||||
from app.infrastructure.db import session_scope
|
||||
from app.infrastructure.db.repositories import (
|
||||
SqlAlchemyRefreshTokenRepository,
|
||||
SqlAlchemyUserRepository,
|
||||
)
|
||||
|
||||
async with session_scope() as session:
|
||||
service = UserService(
|
||||
users=SqlAlchemyUserRepository(session),
|
||||
refresh_tokens=SqlAlchemyRefreshTokenRepository(session),
|
||||
hasher=Argon2PasswordHasher(),
|
||||
)
|
||||
user = await service.create_user(username=username, password=password, is_superuser=True)
|
||||
print(f"Created admin {user.username!r} ({user.id}).")
|
||||
|
||||
|
||||
def _cmd_create_admin(args: argparse.Namespace) -> None:
|
||||
username: str = args.username or input("Username: ").strip()
|
||||
if not username:
|
||||
raise SystemExit("Username is required.")
|
||||
password: str = args.password or getpass.getpass("Password: ")
|
||||
if len(password) < 8:
|
||||
raise SystemExit("Password must be at least 8 characters.")
|
||||
if args.password is None and getpass.getpass("Confirm password: ") != password:
|
||||
raise SystemExit("Passwords do not match.")
|
||||
|
||||
from app.domain.errors import AlreadyExistsError
|
||||
|
||||
try:
|
||||
asyncio.run(_create_admin(username, password))
|
||||
except AlreadyExistsError as exc:
|
||||
raise SystemExit(str(exc)) from exc
|
||||
|
||||
|
||||
def main() -> None:
|
||||
parser = argparse.ArgumentParser(prog="mcma", description="mcma-backend management CLI")
|
||||
sub = parser.add_subparsers(dest="command")
|
||||
|
||||
sub.add_parser("version", help="Print the backend version")
|
||||
|
||||
admin = sub.add_parser("create-admin", help="Create a superuser")
|
||||
admin.add_argument("username", nargs="?", help="Username (prompted if omitted)")
|
||||
admin.add_argument(
|
||||
"--password",
|
||||
help="Password (prompted securely if omitted; avoid on shared shells)",
|
||||
)
|
||||
|
||||
args = parser.parse_args()
|
||||
if args.command == "version":
|
||||
print(__version__)
|
||||
elif args.command == "create-admin":
|
||||
_cmd_create_admin(args)
|
||||
else:
|
||||
parser.print_help()
|
||||
|
||||
|
||||
+1
-3
@@ -16,9 +16,7 @@ from structlog.typing import EventDict, FilteringBoundLogger, Processor, Wrapped
|
||||
correlation_id: ContextVar[str | None] = ContextVar("correlation_id", default=None)
|
||||
|
||||
|
||||
def _add_correlation_id(
|
||||
_logger: WrappedLogger, _method: str, event_dict: EventDict
|
||||
) -> EventDict:
|
||||
def _add_correlation_id(_logger: WrappedLogger, _method: str, event_dict: EventDict) -> EventDict:
|
||||
cid = correlation_id.get()
|
||||
if cid is not None:
|
||||
event_dict["correlation_id"] = cid
|
||||
|
||||
@@ -0,0 +1,73 @@
|
||||
"""Security adapters: password hashing (argon2 via pwdlib) and JWT (pyjwt).
|
||||
|
||||
These are the concrete implementations of the domain ports
|
||||
``PasswordHasher`` and ``TokenService``. Vendor crypto libraries are confined
|
||||
to this module (CLAUDE.md: security is a cross-cutting concern in ``core``).
|
||||
Higher layers depend only on the Protocols, never on pwdlib/pyjwt directly.
|
||||
"""
|
||||
|
||||
import datetime as dt
|
||||
import uuid
|
||||
|
||||
import jwt
|
||||
from pwdlib import PasswordHash
|
||||
|
||||
from app.core.config import Settings
|
||||
from app.domain.errors import AuthenticationError
|
||||
from app.domain.tokens import IssuedToken, TokenClaims, TokenType
|
||||
|
||||
|
||||
class Argon2PasswordHasher:
|
||||
"""argon2id hasher with sensible defaults from pwdlib."""
|
||||
|
||||
def __init__(self) -> None:
|
||||
self._ph = PasswordHash.recommended()
|
||||
|
||||
def hash(self, password: str) -> str:
|
||||
return self._ph.hash(password)
|
||||
|
||||
def verify_and_update(self, password: str, password_hash: str) -> tuple[bool, str | None]:
|
||||
return self._ph.verify_and_update(password, password_hash)
|
||||
|
||||
|
||||
class JwtTokenService:
|
||||
"""Issues and verifies HS256 JWTs for access + refresh tokens.
|
||||
|
||||
TTLs come from settings (access: short; refresh: long, offline-first).
|
||||
Every token carries a unique ``jti`` so refresh tokens can be tracked and
|
||||
revoked server-side.
|
||||
"""
|
||||
|
||||
def __init__(self, settings: Settings) -> None:
|
||||
self._secret = settings.jwt_secret.get_secret_value()
|
||||
self._algorithm = settings.jwt_algorithm
|
||||
self._ttl = {
|
||||
TokenType.ACCESS: settings.access_token_ttl_seconds,
|
||||
TokenType.REFRESH: settings.refresh_token_ttl_seconds,
|
||||
}
|
||||
|
||||
def issue(self, *, subject: uuid.UUID, token_type: TokenType) -> IssuedToken:
|
||||
now = dt.datetime.now(dt.UTC)
|
||||
expires_at = now + dt.timedelta(seconds=self._ttl[token_type])
|
||||
jti = uuid.uuid4()
|
||||
payload = {
|
||||
"sub": str(subject),
|
||||
"type": token_type.value,
|
||||
"jti": str(jti),
|
||||
"iat": int(now.timestamp()),
|
||||
"exp": int(expires_at.timestamp()),
|
||||
}
|
||||
encoded = jwt.encode(payload, self._secret, algorithm=self._algorithm)
|
||||
return IssuedToken(encoded=encoded, jti=jti, expires_at=expires_at)
|
||||
|
||||
def decode(self, encoded: str) -> TokenClaims:
|
||||
try:
|
||||
payload = jwt.decode(encoded, self._secret, algorithms=[self._algorithm])
|
||||
return TokenClaims(
|
||||
subject=uuid.UUID(payload["sub"]),
|
||||
token_type=TokenType(payload["type"]),
|
||||
jti=uuid.UUID(payload["jti"]),
|
||||
expires_at=dt.datetime.fromtimestamp(payload["exp"], tz=dt.UTC),
|
||||
)
|
||||
except (jwt.InvalidTokenError, KeyError, ValueError) as exc:
|
||||
raise AuthenticationError("Invalid or expired token.") from exc
|
||||
@@ -0,0 +1,5 @@
|
||||
"""Domain entities and value objects — pure, framework-free."""
|
||||
|
||||
from app.domain.entities.user import Credentials, User
|
||||
|
||||
__all__ = ["Credentials", "User"]
|
||||
@@ -0,0 +1,33 @@
|
||||
"""User entity.
|
||||
|
||||
Admin is a single ``is_superuser`` flag — no role system in Phase 1 (kept
|
||||
deliberately minimal; granular permissions are deferred, see plan §3.5).
|
||||
``User`` is the outward-facing entity and never carries the password hash;
|
||||
the hash lives on :class:`Credentials`, used only inside the auth service.
|
||||
"""
|
||||
|
||||
import datetime as dt
|
||||
import uuid
|
||||
from dataclasses import dataclass
|
||||
|
||||
|
||||
@dataclass(frozen=True, slots=True)
|
||||
class User:
|
||||
"""A person with access to the instance. The password hash is intentionally
|
||||
absent — see :class:`Credentials`."""
|
||||
|
||||
id: uuid.UUID
|
||||
username: str
|
||||
is_superuser: bool
|
||||
is_active: bool
|
||||
created_at: dt.datetime
|
||||
updated_at: dt.datetime
|
||||
|
||||
|
||||
@dataclass(frozen=True, slots=True)
|
||||
class Credentials:
|
||||
"""A user paired with their stored password hash. Stays inside the
|
||||
application layer — never serialized to clients."""
|
||||
|
||||
user: User
|
||||
password_hash: str
|
||||
@@ -0,0 +1,58 @@
|
||||
"""Ports — the contracts the application layer depends on.
|
||||
|
||||
These are Protocols, not implementations. Concrete adapters live in
|
||||
``app.infrastructure`` (repositories) and ``app.core.security`` (crypto) and
|
||||
are bound to these ports at the composition root (``app.api.deps``).
|
||||
"""
|
||||
|
||||
import datetime as dt
|
||||
import uuid
|
||||
from typing import Protocol
|
||||
|
||||
from app.domain.entities import Credentials, User
|
||||
from app.domain.tokens import IssuedToken, TokenClaims, TokenType
|
||||
|
||||
|
||||
class UserRepository(Protocol):
|
||||
async def get_by_id(self, user_id: uuid.UUID) -> User | None: ...
|
||||
async def get_credentials_by_username(self, username: str) -> Credentials | None: ...
|
||||
async def add(self, *, username: str, password_hash: str, is_superuser: bool) -> User: ...
|
||||
async def list(self, *, limit: int, offset: int) -> list[User]: ...
|
||||
async def set_password_hash(self, user_id: uuid.UUID, password_hash: str) -> None: ...
|
||||
async def set_superuser(self, user_id: uuid.UUID, is_superuser: bool) -> User: ...
|
||||
async def set_active(self, user_id: uuid.UUID, is_active: bool) -> User: ...
|
||||
async def count(self) -> int: ...
|
||||
|
||||
|
||||
class RefreshTokenRepository(Protocol):
|
||||
async def add(
|
||||
self,
|
||||
*,
|
||||
jti: uuid.UUID,
|
||||
user_id: uuid.UUID,
|
||||
token_hash: str,
|
||||
expires_at: dt.datetime,
|
||||
) -> None: ...
|
||||
async def is_valid(self, jti: uuid.UUID) -> bool:
|
||||
"""True iff a row exists for ``jti`` that is neither revoked nor expired."""
|
||||
...
|
||||
|
||||
async def revoke(self, jti: uuid.UUID) -> None: ...
|
||||
async def revoke_all_for_user(self, user_id: uuid.UUID) -> None: ...
|
||||
|
||||
|
||||
class PasswordHasher(Protocol):
|
||||
def hash(self, password: str) -> str: ...
|
||||
def verify_and_update(self, password: str, password_hash: str) -> tuple[bool, str | None]:
|
||||
"""Verify ``password`` against ``password_hash``. Returns
|
||||
``(is_valid, updated_hash)`` where ``updated_hash`` is a fresh hash to
|
||||
persist when the stored one uses outdated parameters, else ``None``."""
|
||||
...
|
||||
|
||||
|
||||
class TokenService(Protocol):
|
||||
def issue(self, *, subject: uuid.UUID, token_type: TokenType) -> IssuedToken: ...
|
||||
def decode(self, encoded: str) -> TokenClaims:
|
||||
"""Verify signature + expiry and return claims. Raises
|
||||
:class:`~app.domain.errors.AuthenticationError` on any failure."""
|
||||
...
|
||||
@@ -0,0 +1,45 @@
|
||||
"""Token value objects — framework-free.
|
||||
|
||||
The auth flow issues a short-lived *access* token and a long-lived *refresh*
|
||||
token (offline-first: clients may stay disconnected for weeks). Refresh tokens
|
||||
are persisted and revocable (see :class:`~app.domain.ports.RefreshTokenRepository`);
|
||||
access tokens are stateless and verified by signature + expiry alone.
|
||||
"""
|
||||
|
||||
import datetime as dt
|
||||
import enum
|
||||
import uuid
|
||||
from dataclasses import dataclass
|
||||
|
||||
|
||||
class TokenType(enum.StrEnum):
|
||||
ACCESS = "access"
|
||||
REFRESH = "refresh"
|
||||
|
||||
|
||||
@dataclass(frozen=True, slots=True)
|
||||
class TokenClaims:
|
||||
"""Decoded, verified claims from a JWT."""
|
||||
|
||||
subject: uuid.UUID # user id (``sub``)
|
||||
token_type: TokenType
|
||||
jti: uuid.UUID
|
||||
expires_at: dt.datetime
|
||||
|
||||
|
||||
@dataclass(frozen=True, slots=True)
|
||||
class IssuedToken:
|
||||
"""A freshly minted token: the encoded string plus the metadata needed to
|
||||
persist/track it (jti, expiry)."""
|
||||
|
||||
encoded: str
|
||||
jti: uuid.UUID
|
||||
expires_at: dt.datetime
|
||||
|
||||
|
||||
@dataclass(frozen=True, slots=True)
|
||||
class TokenPair:
|
||||
"""The access + refresh pair returned to clients on login/refresh."""
|
||||
|
||||
access: IssuedToken
|
||||
refresh: IssuedToken
|
||||
@@ -0,0 +1,10 @@
|
||||
"""ORM models package.
|
||||
|
||||
Importing this package registers every model on ``Base.metadata`` so Alembic
|
||||
autogenerate and ``create_all`` (tests) see the full schema. ``alembic/env.py``
|
||||
imports it for exactly this side effect.
|
||||
"""
|
||||
|
||||
from app.infrastructure.db.models.user import RefreshTokenModel, UserModel
|
||||
|
||||
__all__ = ["RefreshTokenModel", "UserModel"]
|
||||
@@ -0,0 +1,36 @@
|
||||
"""Reusable mapped-column mixins for ORM models."""
|
||||
|
||||
import datetime as dt
|
||||
import uuid
|
||||
|
||||
from sqlalchemy import DateTime, func
|
||||
from sqlalchemy.orm import Mapped, mapped_column
|
||||
|
||||
|
||||
class UUIDPrimaryKeyMixin:
|
||||
"""``id`` UUID primary key, generated application-side.
|
||||
|
||||
Generating in Python (not a DB default) keeps ids available before flush —
|
||||
important because ``track.id`` is the client-facing ``content_id``.
|
||||
"""
|
||||
|
||||
id: Mapped[uuid.UUID] = mapped_column(primary_key=True, default=uuid.uuid4)
|
||||
|
||||
|
||||
class TimestampMixin:
|
||||
"""``created_at`` / ``updated_at``, server-managed.
|
||||
|
||||
Present on every user-mutable entity for future delta-sync (plan §4).
|
||||
"""
|
||||
|
||||
created_at: Mapped[dt.datetime] = mapped_column(
|
||||
DateTime(timezone=True),
|
||||
server_default=func.now(),
|
||||
nullable=False,
|
||||
)
|
||||
updated_at: Mapped[dt.datetime] = mapped_column(
|
||||
DateTime(timezone=True),
|
||||
server_default=func.now(),
|
||||
onupdate=func.now(),
|
||||
nullable=False,
|
||||
)
|
||||
@@ -0,0 +1,45 @@
|
||||
"""ORM models for users and refresh tokens."""
|
||||
|
||||
import datetime as dt
|
||||
import uuid
|
||||
|
||||
from sqlalchemy import Boolean, DateTime, ForeignKey, String
|
||||
from sqlalchemy.orm import Mapped, mapped_column
|
||||
|
||||
from app.infrastructure.db.base import Base
|
||||
from app.infrastructure.db.models.mixins import TimestampMixin, UUIDPrimaryKeyMixin
|
||||
|
||||
|
||||
class UserModel(UUIDPrimaryKeyMixin, TimestampMixin, Base):
|
||||
__tablename__ = "users"
|
||||
|
||||
username: Mapped[str] = mapped_column(String(64), unique=True, index=True, nullable=False)
|
||||
password_hash: Mapped[str] = mapped_column(String(255), nullable=False)
|
||||
# Admin is a single flag in Phase 1 — no role system (plan §3.5).
|
||||
is_superuser: Mapped[bool] = mapped_column(Boolean, default=False, nullable=False)
|
||||
is_active: Mapped[bool] = mapped_column(Boolean, default=True, nullable=False)
|
||||
|
||||
|
||||
class RefreshTokenModel(UUIDPrimaryKeyMixin, Base):
|
||||
"""A persisted, revocable refresh token (offline-first sessions).
|
||||
|
||||
Stores only a *hash* of the token, never the raw JWT. Rotated on every
|
||||
refresh (old jti revoked, new row added); logout revokes the current jti.
|
||||
"""
|
||||
|
||||
__tablename__ = "refresh_tokens"
|
||||
|
||||
user_id: Mapped[uuid.UUID] = mapped_column(
|
||||
ForeignKey("users.id", ondelete="CASCADE"),
|
||||
index=True,
|
||||
nullable=False,
|
||||
)
|
||||
jti: Mapped[uuid.UUID] = mapped_column(unique=True, index=True, nullable=False)
|
||||
token_hash: Mapped[str] = mapped_column(String(64), nullable=False)
|
||||
expires_at: Mapped[dt.datetime] = mapped_column(DateTime(timezone=True), nullable=False)
|
||||
revoked_at: Mapped[dt.datetime | None] = mapped_column(DateTime(timezone=True), nullable=True)
|
||||
created_at: Mapped[dt.datetime] = mapped_column(
|
||||
DateTime(timezone=True),
|
||||
default=lambda: dt.datetime.now(dt.UTC),
|
||||
nullable=False,
|
||||
)
|
||||
@@ -0,0 +1,8 @@
|
||||
"""SQLAlchemy repository adapters implementing the domain ports."""
|
||||
|
||||
from app.infrastructure.db.repositories.refresh_token_repository import (
|
||||
SqlAlchemyRefreshTokenRepository,
|
||||
)
|
||||
from app.infrastructure.db.repositories.user_repository import SqlAlchemyUserRepository
|
||||
|
||||
__all__ = ["SqlAlchemyRefreshTokenRepository", "SqlAlchemyUserRepository"]
|
||||
@@ -0,0 +1,61 @@
|
||||
"""Refresh-token repository — adapter implementing
|
||||
``app.domain.ports.RefreshTokenRepository``.
|
||||
"""
|
||||
|
||||
import datetime as dt
|
||||
import uuid
|
||||
|
||||
from sqlalchemy import select, update
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
|
||||
from app.infrastructure.db.models import RefreshTokenModel
|
||||
|
||||
|
||||
class SqlAlchemyRefreshTokenRepository:
|
||||
def __init__(self, session: AsyncSession) -> None:
|
||||
self._session = session
|
||||
|
||||
async def add(
|
||||
self,
|
||||
*,
|
||||
jti: uuid.UUID,
|
||||
user_id: uuid.UUID,
|
||||
token_hash: str,
|
||||
expires_at: dt.datetime,
|
||||
) -> None:
|
||||
self._session.add(
|
||||
RefreshTokenModel(
|
||||
jti=jti,
|
||||
user_id=user_id,
|
||||
token_hash=token_hash,
|
||||
expires_at=expires_at,
|
||||
)
|
||||
)
|
||||
await self._session.flush()
|
||||
|
||||
async def is_valid(self, jti: uuid.UUID) -> bool:
|
||||
row = (
|
||||
await self._session.execute(
|
||||
select(RefreshTokenModel).where(RefreshTokenModel.jti == jti)
|
||||
)
|
||||
).scalar_one_or_none()
|
||||
if row is None or row.revoked_at is not None:
|
||||
return False
|
||||
return row.expires_at > dt.datetime.now(dt.UTC)
|
||||
|
||||
async def revoke(self, jti: uuid.UUID) -> None:
|
||||
await self._session.execute(
|
||||
update(RefreshTokenModel)
|
||||
.where(RefreshTokenModel.jti == jti, RefreshTokenModel.revoked_at.is_(None))
|
||||
.values(revoked_at=dt.datetime.now(dt.UTC))
|
||||
)
|
||||
|
||||
async def revoke_all_for_user(self, user_id: uuid.UUID) -> None:
|
||||
await self._session.execute(
|
||||
update(RefreshTokenModel)
|
||||
.where(
|
||||
RefreshTokenModel.user_id == user_id,
|
||||
RefreshTokenModel.revoked_at.is_(None),
|
||||
)
|
||||
.values(revoked_at=dt.datetime.now(dt.UTC))
|
||||
)
|
||||
@@ -0,0 +1,93 @@
|
||||
"""User repository — adapter over ``AsyncSession`` implementing
|
||||
``app.domain.ports.UserRepository``.
|
||||
|
||||
Translates between ORM rows (``UserModel``) and domain entities (``User`` /
|
||||
``Credentials``). The domain never sees ORM objects.
|
||||
"""
|
||||
|
||||
import uuid
|
||||
|
||||
from sqlalchemy import func, select
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
|
||||
from app.domain.entities import Credentials, User
|
||||
from app.domain.errors import NotFoundError
|
||||
from app.infrastructure.db.models import UserModel
|
||||
|
||||
|
||||
def _to_entity(row: UserModel) -> User:
|
||||
return User(
|
||||
id=row.id,
|
||||
username=row.username,
|
||||
is_superuser=row.is_superuser,
|
||||
is_active=row.is_active,
|
||||
created_at=row.created_at,
|
||||
updated_at=row.updated_at,
|
||||
)
|
||||
|
||||
|
||||
class SqlAlchemyUserRepository:
|
||||
def __init__(self, session: AsyncSession) -> None:
|
||||
self._session = session
|
||||
|
||||
async def _get_row(self, user_id: uuid.UUID) -> UserModel:
|
||||
row = await self._session.get(UserModel, user_id)
|
||||
if row is None:
|
||||
raise NotFoundError("User not found.")
|
||||
return row
|
||||
|
||||
async def get_by_id(self, user_id: uuid.UUID) -> User | None:
|
||||
row = await self._session.get(UserModel, user_id)
|
||||
return _to_entity(row) if row is not None else None
|
||||
|
||||
async def get_credentials_by_username(self, username: str) -> Credentials | None:
|
||||
row = (
|
||||
await self._session.execute(select(UserModel).where(UserModel.username == username))
|
||||
).scalar_one_or_none()
|
||||
if row is None:
|
||||
return None
|
||||
return Credentials(user=_to_entity(row), password_hash=row.password_hash)
|
||||
|
||||
async def add(self, *, username: str, password_hash: str, is_superuser: bool) -> User:
|
||||
row = UserModel(
|
||||
username=username,
|
||||
password_hash=password_hash,
|
||||
is_superuser=is_superuser,
|
||||
is_active=True,
|
||||
)
|
||||
self._session.add(row)
|
||||
await self._session.flush()
|
||||
await self._session.refresh(row)
|
||||
return _to_entity(row)
|
||||
|
||||
async def list(self, *, limit: int, offset: int) -> list[User]:
|
||||
rows = (
|
||||
await self._session.execute(
|
||||
select(UserModel).order_by(UserModel.created_at).limit(limit).offset(offset)
|
||||
)
|
||||
).scalars()
|
||||
return [_to_entity(row) for row in rows]
|
||||
|
||||
async def set_password_hash(self, user_id: uuid.UUID, password_hash: str) -> None:
|
||||
row = await self._get_row(user_id)
|
||||
row.password_hash = password_hash
|
||||
await self._session.flush()
|
||||
|
||||
async def set_superuser(self, user_id: uuid.UUID, is_superuser: bool) -> User:
|
||||
row = await self._get_row(user_id)
|
||||
row.is_superuser = is_superuser
|
||||
await self._session.flush()
|
||||
await self._session.refresh(row)
|
||||
return _to_entity(row)
|
||||
|
||||
async def set_active(self, user_id: uuid.UUID, is_active: bool) -> User:
|
||||
row = await self._get_row(user_id)
|
||||
row.is_active = is_active
|
||||
await self._session.flush()
|
||||
await self._session.refresh(row)
|
||||
return _to_entity(row)
|
||||
|
||||
async def count(self) -> int:
|
||||
return (
|
||||
await self._session.execute(select(func.count()).select_from(UserModel))
|
||||
).scalar_one()
|
||||
+3
-2
@@ -8,6 +8,7 @@ from fastapi import FastAPI
|
||||
from app.api.errors import register_exception_handlers
|
||||
from app.api.health import router as health_router
|
||||
from app.api.middleware import CorrelationIdMiddleware
|
||||
from app.api.v1 import api_v1_router
|
||||
from app.core.config import get_settings
|
||||
from app.core.logging import configure_logging, get_logger
|
||||
from app.infrastructure.cache import close_redis
|
||||
@@ -41,8 +42,8 @@ def create_app() -> FastAPI:
|
||||
register_exception_handlers(app)
|
||||
|
||||
app.include_router(health_router)
|
||||
# Versioned API routers (auth, library, …) are mounted in later steps:
|
||||
# app.include_router(api_v1_router, prefix="/api/v1")
|
||||
app.include_router(api_v1_router)
|
||||
# Subsonic-compatible layer is mounted in a later step:
|
||||
# app.include_router(subsonic_router, prefix="/rest")
|
||||
|
||||
return app
|
||||
|
||||
Reference in New Issue
Block a user