feat: auth & admin

This commit is contained in:
2026-06-03 10:40:00 +03:00
parent 4bca90a50e
commit 93199a3095
34 changed files with 1634 additions and 119 deletions
+74 -2
View File
@@ -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)]
+1
View File
@@ -0,0 +1 @@
"""Pydantic request/response models for the native REST API (``/api/v1``)."""
+20
View File
@@ -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"
+46
View File
@@ -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)
+14
View File
@@ -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"]
+80
View File
@@ -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))
+39
View File
@@ -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)
+19
View File
@@ -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,
)
+104
View File
@@ -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,
)
+79
View File
@@ -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
View File
@@ -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
View File
@@ -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
+73
View File
@@ -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
+5
View File
@@ -0,0 +1,5 @@
"""Domain entities and value objects — pure, framework-free."""
from app.domain.entities.user import Credentials, User
__all__ = ["Credentials", "User"]
+33
View File
@@ -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
+58
View File
@@ -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."""
...
+45
View File
@@ -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
+10
View File
@@ -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"]
+36
View File
@@ -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,
)
+45
View File
@@ -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
View File
@@ -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