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
+7 -1
View File
@@ -26,9 +26,15 @@ uv run pytest tests/test_health.py::test_liveness_ok # single test
uv run alembic revision --autogenerate -m "msg" # new migration (after model changes)
uv run alembic upgrade head # apply migrations
docker compose up --build # full stack: api, worker, db, redis
docker build -t mcma-backend . # build this service's image (repo is infra-free)
```
Orchestration is **not** in this repo. The workspace compose (`../docker-compose.yml`,
shared with `mcma-webui`) wires the stack: `docker compose up -d` runs backing
services only (db + redis) for local dev; `docker compose --profile app up --build`
runs the full stack. The image takes every peer from env (`DATABASE_URL`, `REDIS_URL`,
`MEDIA_PATH`, …) and hardcodes nothing.
Tests run in-process against the ASGI app (httpx + asgi-lifespan) — no server, no network. They do **not** require a running DB/Redis: dependency-backed checks degrade rather than fail.
## Architecture — hexagonal (ports & adapters)
+11 -7
View File
@@ -25,21 +25,25 @@ app/
`application` depends on domain ports; `infrastructure`/`api` are the outer ring
and are wired together at the composition root (`app/main.py`, `app/api/deps.py`).
## Quick start (Docker)
## Build (Docker)
This repo ships only its own `Dockerfile` — the image runs anywhere and gets
every peer (Postgres, Redis, media path) from env. It carries **no**
orchestration. Full-stack wiring lives in the workspace compose one level up
(`../docker-compose.yml`, alongside `mcma-webui`):
```bash
cp .env.example .env # then set a real JWT_SECRET
docker compose up --build # api on :8000, worker, postgres, redis
curl localhost:8000/health # {"status":"ok"}
curl localhost:8000/health/ready
docker build -t mcma-backend . # build just this service
# or, from the workspace root, the whole stack:
docker compose --profile app up --build # db, redis, api, worker, webui
```
## Local dev (without Docker)
```bash
uv sync # install deps (uses managed Python 3.14)
# start Postgres + Redis (e.g. `docker compose up db redis`)
cp .env.example .env
# start backing services from the workspace root: `docker compose up -d` (db + redis)
cp .env.example .env # then set a real JWT_SECRET
uv run uvicorn app.main:app --reload
```
+3 -4
View File
@@ -8,16 +8,15 @@ registers every model so autogenerate sees the full schema.
import asyncio
from logging.config import fileConfig
# Import side-effect: registers all ORM models on Base.metadata so autogenerate
# sees the full schema.
import app.infrastructure.db.models # noqa: F401
from alembic import context
from app.core.config import get_settings
from app.infrastructure.db import Base
from sqlalchemy.engine import Connection
from sqlalchemy.ext.asyncio import async_engine_from_config
# Import side-effect: registers all ORM models on Base.metadata.
# Uncomment once models exist (plan §11 step 2):
# import app.infrastructure.db.models
config = context.config
if config.config_file_name is not None:
fileConfig(config.config_file_name)
@@ -0,0 +1,77 @@
"""auth: users and refresh_tokens
Revision ID: 0001_auth_users
Revises:
Create Date: 2026-06-02
First schema migration: authentication & user management (plan §11 step 3).
Constraint/index names follow the metadata naming convention in
``app.infrastructure.db.base`` so a later ``--autogenerate`` stays a no-op.
"""
from __future__ import annotations
from collections.abc import Sequence
import sqlalchemy as sa
from alembic import op
# revision identifiers, used by Alembic.
revision: str = "0001_auth_users"
down_revision: str | None = None
branch_labels: str | Sequence[str] | None = None
depends_on: str | Sequence[str] | None = None
def upgrade() -> None:
op.create_table(
"users",
sa.Column("id", sa.Uuid(), nullable=False),
sa.Column("username", sa.String(length=64), nullable=False),
sa.Column("password_hash", sa.String(length=255), nullable=False),
sa.Column("is_superuser", sa.Boolean(), nullable=False),
sa.Column("is_active", sa.Boolean(), nullable=False),
sa.Column(
"created_at",
sa.DateTime(timezone=True),
server_default=sa.func.now(),
nullable=False,
),
sa.Column(
"updated_at",
sa.DateTime(timezone=True),
server_default=sa.func.now(),
nullable=False,
),
sa.PrimaryKeyConstraint("id", name=op.f("pk_users")),
)
# username is unique=True + index=True -> a single unique index.
op.create_index(op.f("ix_users_username"), "users", ["username"], unique=True)
op.create_table(
"refresh_tokens",
sa.Column("id", sa.Uuid(), nullable=False),
sa.Column("user_id", sa.Uuid(), nullable=False),
sa.Column("jti", sa.Uuid(), nullable=False),
sa.Column("token_hash", sa.String(length=64), nullable=False),
sa.Column("expires_at", sa.DateTime(timezone=True), nullable=False),
sa.Column("revoked_at", sa.DateTime(timezone=True), nullable=True),
sa.Column("created_at", sa.DateTime(timezone=True), nullable=False),
sa.ForeignKeyConstraint(
["user_id"],
["users.id"],
name=op.f("fk_refresh_tokens_user_id_users"),
ondelete="CASCADE",
),
sa.PrimaryKeyConstraint("id", name=op.f("pk_refresh_tokens")),
)
op.create_index(op.f("ix_refresh_tokens_user_id"), "refresh_tokens", ["user_id"], unique=False)
op.create_index(op.f("ix_refresh_tokens_jti"), "refresh_tokens", ["jti"], unique=True)
def downgrade() -> None:
op.drop_index(op.f("ix_refresh_tokens_jti"), table_name="refresh_tokens")
op.drop_index(op.f("ix_refresh_tokens_user_id"), table_name="refresh_tokens")
op.drop_table("refresh_tokens")
op.drop_index(op.f("ix_users_username"), table_name="users")
op.drop_table("users")
+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
-97
View File
@@ -1,97 +0,0 @@
# Dev/prod stack. Worker reuses the api image with a different command.
# The ML service is intentionally a commented placeholder — it is optional
# (graceful degradation) and lands in a later phase.
services:
api:
build: .
command: uvicorn app.main:app --host 0.0.0.0 --port 8000
env_file: .env
environment:
DATABASE_URL: postgresql+asyncpg://mcma:mcma@db:5432/mcma
REDIS_URL: redis://redis:6379/0
ports:
- "8000:8000"
volumes:
- media:/data/media
- transcode_cache:/data/transcode-cache
depends_on:
db:
condition: service_healthy
redis:
condition: service_healthy
healthcheck:
test: ["CMD", "python", "-c", "import urllib.request,sys; sys.exit(0 if urllib.request.urlopen('http://localhost:8000/health').status==200 else 1)"]
interval: 30s
timeout: 5s
retries: 3
restart: unless-stopped
worker:
build: .
command: arq app.workers.arq_worker.WorkerSettings
env_file: .env
environment:
DATABASE_URL: postgresql+asyncpg://mcma:mcma@db:5432/mcma
REDIS_URL: redis://redis:6379/0
volumes:
- media:/data/media
- transcode_cache:/data/transcode-cache
depends_on:
db:
condition: service_healthy
redis:
condition: service_healthy
restart: unless-stopped
db:
image: postgres:16-alpine
environment:
POSTGRES_USER: mcma
POSTGRES_PASSWORD: mcma
POSTGRES_DB: mcma
volumes:
- pgdata:/var/lib/postgresql/data
healthcheck:
test: ["CMD-SHELL", "pg_isready -U mcma"]
interval: 10s
timeout: 5s
retries: 5
restart: unless-stopped
redis:
image: redis:7-alpine
command: redis-server --save 60 1 --loglevel warning
volumes:
- redisdata:/data
healthcheck:
test: ["CMD", "redis-cli", "ping"]
interval: 10s
timeout: 5s
retries: 5
restart: unless-stopped
# Reverse proxy + auto-HTTPS (enable on prod with a real domain).
# caddy:
# image: caddy:2-alpine
# ports: ["80:80", "443:443"]
# volumes:
# - ./Caddyfile:/etc/caddy/Caddyfile
# - caddy_data:/data
# depends_on: [api]
# restart: unless-stopped
# ML recommendation service — OPTIONAL, added in a later phase.
# The backend must run fully without it (set ML_SERVICE_URL to enable).
# ml:
# image: mcma-ml:latest
# environment:
# MODEL_PATH: /models
# restart: unless-stopped
volumes:
pgdata:
redisdata:
media:
transcode_cache:
# caddy_data:
+102
View File
@@ -0,0 +1,102 @@
"""In-memory port implementations for fast, DB-free unit tests."""
import datetime as dt
import uuid
from dataclasses import dataclass, replace
from app.domain.entities import Credentials, User
@dataclass
class _Stored:
user: User
password_hash: str
class InMemoryUserRepository:
def __init__(self) -> None:
self._by_id: dict[uuid.UUID, _Stored] = {}
async def get_by_id(self, user_id: uuid.UUID) -> User | None:
stored = self._by_id.get(user_id)
return stored.user if stored else None
async def get_credentials_by_username(self, username: str) -> Credentials | None:
for stored in self._by_id.values():
if stored.user.username == username:
return Credentials(user=stored.user, password_hash=stored.password_hash)
return None
async def add(self, *, username: str, password_hash: str, is_superuser: bool) -> User:
now = dt.datetime.now(dt.UTC)
user = User(
id=uuid.uuid4(),
username=username,
is_superuser=is_superuser,
is_active=True,
created_at=now,
updated_at=now,
)
self._by_id[user.id] = _Stored(user=user, password_hash=password_hash)
return user
async def list(self, *, limit: int, offset: int) -> list[User]:
users = [s.user for s in self._by_id.values()]
users.sort(key=lambda u: u.created_at)
return users[offset : offset + limit]
async def set_password_hash(self, user_id: uuid.UUID, password_hash: str) -> None:
self._by_id[user_id].password_hash = password_hash
async def set_superuser(self, user_id: uuid.UUID, is_superuser: bool) -> User:
stored = self._by_id[user_id]
stored.user = replace(stored.user, is_superuser=is_superuser)
return stored.user
async def set_active(self, user_id: uuid.UUID, is_active: bool) -> User:
stored = self._by_id[user_id]
stored.user = replace(stored.user, is_active=is_active)
return stored.user
async def count(self) -> int:
return len(self._by_id)
@dataclass
class _Token:
user_id: uuid.UUID
token_hash: str
expires_at: dt.datetime
revoked_at: dt.datetime | None = None
class InMemoryRefreshTokenRepository:
def __init__(self) -> None:
self._by_jti: dict[uuid.UUID, _Token] = {}
async def add(
self,
*,
jti: uuid.UUID,
user_id: uuid.UUID,
token_hash: str,
expires_at: dt.datetime,
) -> None:
self._by_jti[jti] = _Token(user_id=user_id, token_hash=token_hash, expires_at=expires_at)
async def is_valid(self, jti: uuid.UUID) -> bool:
token = self._by_jti.get(jti)
if token is None or token.revoked_at is not None:
return False
return token.expires_at > dt.datetime.now(dt.UTC)
async def revoke(self, jti: uuid.UUID) -> None:
token = self._by_jti.get(jti)
if token and token.revoked_at is None:
token.revoked_at = dt.datetime.now(dt.UTC)
async def revoke_all_for_user(self, user_id: uuid.UUID) -> None:
now = dt.datetime.now(dt.UTC)
for token in self._by_jti.values():
if token.user_id == user_id and token.revoked_at is None:
token.revoked_at = now
+169
View File
@@ -0,0 +1,169 @@
"""Integration tests for the auth + admin HTTP surface.
These require a reachable Postgres (the schema is created via metadata). When
no DB is available they *skip* — preserving the project rule that the test
suite never hard-requires a running database.
"""
import asyncio
from collections.abc import AsyncIterator
import pytest
from app.core.security import Argon2PasswordHasher
from app.infrastructure.db import Base, get_engine, session_scope
from app.infrastructure.db.repositories import (
SqlAlchemyRefreshTokenRepository,
SqlAlchemyUserRepository,
)
from httpx import ASGITransport, AsyncClient
pytestmark = pytest.mark.asyncio
_db_reachable_cache: bool | None = None
async def _db_reachable() -> bool:
# Probe once per session (cached): bounded so the suite never hangs when
# nothing (or a half-open socket) is on the DB port — mirrors the
# readiness-probe rule (never hang).
global _db_reachable_cache
if _db_reachable_cache is not None:
return _db_reachable_cache
from sqlalchemy import text
try:
async with asyncio.timeout(3):
async with get_engine().connect() as conn:
await conn.execute(text("SELECT 1"))
_db_reachable_cache = True
except Exception:
_db_reachable_cache = False
return _db_reachable_cache
@pytest.fixture
async def api() -> AsyncIterator[AsyncClient]:
if not await _db_reachable():
pytest.skip("Postgres not reachable — integration test skipped.")
# Fresh schema for the test run.
async with get_engine().begin() as conn:
await conn.run_sync(Base.metadata.drop_all)
await conn.run_sync(Base.metadata.create_all)
# Seed an admin directly through the service layer.
from app.application.user_service import UserService
async with session_scope() as session:
await UserService(
users=SqlAlchemyUserRepository(session),
refresh_tokens=SqlAlchemyRefreshTokenRepository(session),
hasher=Argon2PasswordHasher(),
).create_user(username="admin", password="adminpass1", is_superuser=True)
from app.main import create_app
from asgi_lifespan import LifespanManager
app = create_app()
async with LifespanManager(app):
transport = ASGITransport(app=app)
async with AsyncClient(transport=transport, base_url="http://test") as client:
yield client
async with get_engine().begin() as conn:
await conn.run_sync(Base.metadata.drop_all)
async def _login(api: AsyncClient, username: str, password: str) -> tuple[str, str]:
resp = await api.post("/api/v1/auth/login", json={"username": username, "password": password})
assert resp.status_code == 200, resp.text
body = resp.json()
return body["access_token"], body["refresh_token"]
async def test_login_and_me(api: AsyncClient) -> None:
access, _ = await _login(api, "admin", "adminpass1")
resp = await api.get("/api/v1/auth/me", headers={"Authorization": f"Bearer {access}"})
assert resp.status_code == 200
assert resp.json()["username"] == "admin"
assert resp.json()["is_superuser"] is True
async def test_login_bad_credentials(api: AsyncClient) -> None:
resp = await api.post("/api/v1/auth/login", json={"username": "admin", "password": "wrong"})
assert resp.status_code == 401
async def test_me_requires_token(api: AsyncClient) -> None:
resp = await api.get("/api/v1/auth/me")
assert resp.status_code == 401
async def test_refresh_rotation(api: AsyncClient) -> None:
_, refresh = await _login(api, "admin", "adminpass1")
resp = await api.post("/api/v1/auth/refresh", json={"refresh_token": refresh})
assert resp.status_code == 200
new_refresh = resp.json()["refresh_token"]
assert new_refresh != refresh
# Old refresh is revoked after rotation.
reuse = await api.post("/api/v1/auth/refresh", json={"refresh_token": refresh})
assert reuse.status_code == 401
async def test_logout_revokes(api: AsyncClient) -> None:
_, refresh = await _login(api, "admin", "adminpass1")
out = await api.post("/api/v1/auth/logout", json={"refresh_token": refresh})
assert out.status_code == 204
reuse = await api.post("/api/v1/auth/refresh", json={"refresh_token": refresh})
assert reuse.status_code == 401
async def test_admin_creates_user_and_nonadmin_forbidden(api: AsyncClient) -> None:
admin_access, _ = await _login(api, "admin", "adminpass1")
admin_headers = {"Authorization": f"Bearer {admin_access}"}
created = await api.post(
"/api/v1/admin/users",
headers=admin_headers,
json={"username": "carol", "password": "carolpass1"},
)
assert created.status_code == 201, created.text
assert created.json()["is_superuser"] is False
# Non-admin cannot reach admin routes.
user_access, _ = await _login(api, "carol", "carolpass1")
forbidden = await api.get(
"/api/v1/admin/users", headers={"Authorization": f"Bearer {user_access}"}
)
assert forbidden.status_code == 403
async def test_admin_create_duplicate_conflicts(api: AsyncClient) -> None:
admin_access, _ = await _login(api, "admin", "adminpass1")
headers = {"Authorization": f"Bearer {admin_access}"}
payload = {"username": "dave", "password": "davepass12"}
first = await api.post("/api/v1/admin/users", headers=headers, json=payload)
assert first.status_code == 201
dup = await api.post("/api/v1/admin/users", headers=headers, json=payload)
assert dup.status_code == 409
async def test_deactivated_user_cannot_login(api: AsyncClient) -> None:
admin_access, _ = await _login(api, "admin", "adminpass1")
headers = {"Authorization": f"Bearer {admin_access}"}
created = await api.post(
"/api/v1/admin/users",
headers=headers,
json={"username": "erin", "password": "erinpass12"},
)
user_id = created.json()["id"]
deactivate = await api.delete(f"/api/v1/admin/users/{user_id}", headers=headers)
assert deactivate.status_code == 200
assert deactivate.json()["is_active"] is False
resp = await api.post("/api/v1/auth/login", json={"username": "erin", "password": "erinpass12"})
assert resp.status_code == 401
+97
View File
@@ -0,0 +1,97 @@
"""Unit tests for AuthService using in-memory ports."""
import pytest
from app.application.auth_service import AuthService
from app.application.user_service import UserService
from app.core.config import Settings
from app.core.security import Argon2PasswordHasher, JwtTokenService
from app.domain.errors import AuthenticationError
from tests.fakes import InMemoryRefreshTokenRepository, InMemoryUserRepository
@pytest.fixture
def env() -> tuple[AuthService, UserService, InMemoryUserRepository]:
users = InMemoryUserRepository()
refresh = InMemoryRefreshTokenRepository()
hasher = Argon2PasswordHasher()
tokens = JwtTokenService(Settings(jwt_secret="svc-test-secret"))
auth = AuthService(users=users, refresh_tokens=refresh, hasher=hasher, tokens=tokens)
user_svc = UserService(users=users, refresh_tokens=refresh, hasher=hasher)
return auth, user_svc, users
async def test_login_success_then_authenticate(
env: tuple[AuthService, UserService, object],
) -> None:
auth, user_svc, _ = env
created = await user_svc.create_user(username="alice", password="password123")
pair = await auth.login("alice", "password123")
user = await auth.authenticate_access(pair.access.encoded)
assert user.id == created.id
assert user.username == "alice"
async def test_login_wrong_password(env: tuple[AuthService, UserService, object]) -> None:
auth, user_svc, _ = env
await user_svc.create_user(username="alice", password="password123")
with pytest.raises(AuthenticationError):
await auth.login("alice", "nope")
async def test_login_unknown_user(env: tuple[AuthService, UserService, object]) -> None:
auth, _, _ = env
with pytest.raises(AuthenticationError):
await auth.login("ghost", "whatever")
async def test_login_inactive_user(env: tuple[AuthService, UserService, object]) -> None:
auth, user_svc, _ = env
user = await user_svc.create_user(username="alice", password="password123")
await user_svc.set_active(user.id, is_active=False)
with pytest.raises(AuthenticationError):
await auth.login("alice", "password123")
async def test_refresh_rotates_and_invalidates_old(
env: tuple[AuthService, UserService, object],
) -> None:
auth, user_svc, _ = env
await user_svc.create_user(username="alice", password="password123")
pair = await auth.login("alice", "password123")
new_pair = await auth.refresh(pair.refresh.encoded)
assert new_pair.refresh.encoded != pair.refresh.encoded
# Old refresh token is now revoked (rotation) — reuse must fail.
with pytest.raises(AuthenticationError):
await auth.refresh(pair.refresh.encoded)
# New one still works.
await auth.refresh(new_pair.refresh.encoded)
async def test_access_token_not_accepted_as_refresh(
env: tuple[AuthService, UserService, object],
) -> None:
auth, user_svc, _ = env
await user_svc.create_user(username="alice", password="password123")
pair = await auth.login("alice", "password123")
with pytest.raises(AuthenticationError):
await auth.refresh(pair.access.encoded)
async def test_logout_revokes_refresh(env: tuple[AuthService, UserService, object]) -> None:
auth, user_svc, _ = env
await user_svc.create_user(username="alice", password="password123")
pair = await auth.login("alice", "password123")
await auth.logout(pair.refresh.encoded)
with pytest.raises(AuthenticationError):
await auth.refresh(pair.refresh.encoded)
async def test_logout_ignores_garbage(env: tuple[AuthService, UserService, object]) -> None:
auth, _, _ = env
await auth.logout("not-a-jwt") # must not raise
+78
View File
@@ -0,0 +1,78 @@
"""Unit tests for the security adapters (no DB, no network)."""
import datetime as dt
import uuid
import jwt
import pytest
from app.core.config import Settings
from app.core.security import Argon2PasswordHasher, JwtTokenService
from app.domain.errors import AuthenticationError
from app.domain.tokens import TokenType
def _settings(**overrides: object) -> Settings:
base: dict[str, object] = {"jwt_secret": "unit-test-secret", "access_token_ttl_seconds": 900}
base.update(overrides)
return Settings(**base) # type: ignore[arg-type]
def test_password_hash_roundtrip() -> None:
hasher = Argon2PasswordHasher()
hashed = hasher.hash("correct horse battery staple")
assert hashed != "correct horse battery staple"
valid, updated = hasher.verify_and_update("correct horse battery staple", hashed)
assert valid is True
assert updated is None # fresh hash, no rehash needed
wrong, _ = hasher.verify_and_update("wrong password", hashed)
assert wrong is False
def test_jwt_issue_and_decode_roundtrip() -> None:
svc = JwtTokenService(_settings())
subject = uuid.uuid4()
issued = svc.issue(subject=subject, token_type=TokenType.ACCESS)
claims = svc.decode(issued.encoded)
assert claims.subject == subject
assert claims.token_type is TokenType.ACCESS
assert claims.jti == issued.jti
def test_jwt_rejects_tampered_token() -> None:
svc = JwtTokenService(_settings())
issued = svc.issue(subject=uuid.uuid4(), token_type=TokenType.ACCESS)
tampered = issued.encoded[:-2] + ("aa" if issued.encoded[-2:] != "aa" else "bb")
with pytest.raises(AuthenticationError):
svc.decode(tampered)
def test_jwt_rejects_wrong_secret() -> None:
issuer = JwtTokenService(_settings(jwt_secret="secret-a"))
verifier = JwtTokenService(_settings(jwt_secret="secret-b"))
issued = issuer.issue(subject=uuid.uuid4(), token_type=TokenType.ACCESS)
with pytest.raises(AuthenticationError):
verifier.decode(issued.encoded)
def test_jwt_rejects_expired_token() -> None:
settings = _settings()
secret = settings.jwt_secret.get_secret_value()
expired = jwt.encode(
{
"sub": str(uuid.uuid4()),
"type": "access",
"jti": str(uuid.uuid4()),
"iat": int((dt.datetime.now(dt.UTC) - dt.timedelta(hours=2)).timestamp()),
"exp": int((dt.datetime.now(dt.UTC) - dt.timedelta(hours=1)).timestamp()),
},
secret,
algorithm=settings.jwt_algorithm,
)
with pytest.raises(AuthenticationError):
JwtTokenService(settings).decode(expired)
+88
View File
@@ -0,0 +1,88 @@
"""Unit tests for UserService using in-memory ports."""
import pytest
from app.application.auth_service import AuthService
from app.application.user_service import UserService
from app.core.config import Settings
from app.core.security import Argon2PasswordHasher, JwtTokenService
from app.domain.errors import AlreadyExistsError, AuthenticationError, NotFoundError
from tests.fakes import InMemoryRefreshTokenRepository, InMemoryUserRepository
@pytest.fixture
def env() -> tuple[UserService, AuthService]:
users = InMemoryUserRepository()
refresh = InMemoryRefreshTokenRepository()
hasher = Argon2PasswordHasher()
tokens = JwtTokenService(Settings(jwt_secret="u-test-secret"))
user_svc = UserService(users=users, refresh_tokens=refresh, hasher=hasher)
auth = AuthService(users=users, refresh_tokens=refresh, hasher=hasher, tokens=tokens)
return user_svc, auth
async def test_create_user_duplicate_username(env: tuple[UserService, AuthService]) -> None:
user_svc, _ = env
await user_svc.create_user(username="bob", password="password123")
with pytest.raises(AlreadyExistsError):
await user_svc.create_user(username="bob", password="another-one")
async def test_get_unknown_user_raises(env: tuple[UserService, AuthService]) -> None:
import uuid
user_svc, _ = env
with pytest.raises(NotFoundError):
await user_svc.get_user(uuid.uuid4())
async def test_change_password_requires_current(env: tuple[UserService, AuthService]) -> None:
user_svc, auth = env
user = await user_svc.create_user(username="bob", password="password123")
with pytest.raises(AuthenticationError):
await user_svc.change_password(
user.id, current_password="wrong", new_password="newpassword1"
)
await user_svc.change_password(
user.id, current_password="password123", new_password="newpassword1"
)
# New password works, old one no longer.
await auth.login("bob", "newpassword1")
with pytest.raises(AuthenticationError):
await auth.login("bob", "password123")
async def test_change_password_revokes_sessions(env: tuple[UserService, AuthService]) -> None:
user_svc, auth = env
user = await user_svc.create_user(username="bob", password="password123")
pair = await auth.login("bob", "password123")
await user_svc.change_password(
user.id, current_password="password123", new_password="newpassword1"
)
with pytest.raises(AuthenticationError):
await auth.refresh(pair.refresh.encoded)
async def test_reset_password_revokes_sessions(env: tuple[UserService, AuthService]) -> None:
user_svc, auth = env
user = await user_svc.create_user(username="bob", password="password123")
pair = await auth.login("bob", "password123")
await user_svc.reset_password(user.id, new_password="adminset12")
with pytest.raises(AuthenticationError):
await auth.refresh(pair.refresh.encoded)
await auth.login("bob", "adminset12")
async def test_deactivate_revokes_sessions(env: tuple[UserService, AuthService]) -> None:
user_svc, auth = env
user = await user_svc.create_user(username="bob", password="password123")
pair = await auth.login("bob", "password123")
deactivated = await user_svc.deactivate(user.id)
assert deactivated.is_active is False
with pytest.raises(AuthenticationError):
await auth.refresh(pair.refresh.encoded)