diff --git a/CLAUDE.md b/CLAUDE.md index ba3533d..7555a32 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -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) diff --git a/README.md b/README.md index 49e88aa..6a7f9ee 100644 --- a/README.md +++ b/README.md @@ -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 ``` diff --git a/alembic/env.py b/alembic/env.py index 5e0e8de..103dae0 100644 --- a/alembic/env.py +++ b/alembic/env.py @@ -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) diff --git a/alembic/versions/20260602_0001-0001_auth_users.py b/alembic/versions/20260602_0001-0001_auth_users.py new file mode 100644 index 0000000..c5afefb --- /dev/null +++ b/alembic/versions/20260602_0001-0001_auth_users.py @@ -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") diff --git a/app/api/deps.py b/app/api/deps.py index 86872e1..06d7704 100644 --- a/app/api/deps.py +++ b/app/api/deps.py @@ -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)] diff --git a/app/api/schemas/__init__.py b/app/api/schemas/__init__.py new file mode 100644 index 0000000..9026b25 --- /dev/null +++ b/app/api/schemas/__init__.py @@ -0,0 +1 @@ +"""Pydantic request/response models for the native REST API (``/api/v1``).""" diff --git a/app/api/schemas/auth.py b/app/api/schemas/auth.py new file mode 100644 index 0000000..efe41aa --- /dev/null +++ b/app/api/schemas/auth.py @@ -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" diff --git a/app/api/schemas/user.py b/app/api/schemas/user.py new file mode 100644 index 0000000..48f5793 --- /dev/null +++ b/app/api/schemas/user.py @@ -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) diff --git a/app/api/v1/__init__.py b/app/api/v1/__init__.py new file mode 100644 index 0000000..3ff2824 --- /dev/null +++ b/app/api/v1/__init__.py @@ -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"] diff --git a/app/api/v1/admin.py b/app/api/v1/admin.py new file mode 100644 index 0000000..c672e40 --- /dev/null +++ b/app/api/v1/admin.py @@ -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)) diff --git a/app/api/v1/auth.py b/app/api/v1/auth.py new file mode 100644 index 0000000..7af7beb --- /dev/null +++ b/app/api/v1/auth.py @@ -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) diff --git a/app/api/v1/users.py b/app/api/v1/users.py new file mode 100644 index 0000000..8e4dd2e --- /dev/null +++ b/app/api/v1/users.py @@ -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, + ) diff --git a/app/application/auth_service.py b/app/application/auth_service.py new file mode 100644 index 0000000..2cc7dc4 --- /dev/null +++ b/app/application/auth_service.py @@ -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, + ) diff --git a/app/application/user_service.py b/app/application/user_service.py new file mode 100644 index 0000000..4abbba9 --- /dev/null +++ b/app/application/user_service.py @@ -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) diff --git a/app/cli.py b/app/cli.py index 6007e54..30ec830 100644 --- a/app/cli.py +++ b/app/cli.py @@ -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() diff --git a/app/core/logging.py b/app/core/logging.py index 17b5c78..b85ccad 100644 --- a/app/core/logging.py +++ b/app/core/logging.py @@ -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 diff --git a/app/core/security.py b/app/core/security.py new file mode 100644 index 0000000..af14f64 --- /dev/null +++ b/app/core/security.py @@ -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 diff --git a/app/domain/entities/__init__.py b/app/domain/entities/__init__.py new file mode 100644 index 0000000..4b76ded --- /dev/null +++ b/app/domain/entities/__init__.py @@ -0,0 +1,5 @@ +"""Domain entities and value objects — pure, framework-free.""" + +from app.domain.entities.user import Credentials, User + +__all__ = ["Credentials", "User"] diff --git a/app/domain/entities/user.py b/app/domain/entities/user.py new file mode 100644 index 0000000..93a725c --- /dev/null +++ b/app/domain/entities/user.py @@ -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 diff --git a/app/domain/ports.py b/app/domain/ports.py new file mode 100644 index 0000000..6050f67 --- /dev/null +++ b/app/domain/ports.py @@ -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.""" + ... diff --git a/app/domain/tokens.py b/app/domain/tokens.py new file mode 100644 index 0000000..e1dbf3b --- /dev/null +++ b/app/domain/tokens.py @@ -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 diff --git a/app/infrastructure/db/models/__init__.py b/app/infrastructure/db/models/__init__.py new file mode 100644 index 0000000..be7d1ba --- /dev/null +++ b/app/infrastructure/db/models/__init__.py @@ -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"] diff --git a/app/infrastructure/db/models/mixins.py b/app/infrastructure/db/models/mixins.py new file mode 100644 index 0000000..23bebac --- /dev/null +++ b/app/infrastructure/db/models/mixins.py @@ -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, + ) diff --git a/app/infrastructure/db/models/user.py b/app/infrastructure/db/models/user.py new file mode 100644 index 0000000..fbffaf8 --- /dev/null +++ b/app/infrastructure/db/models/user.py @@ -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, + ) diff --git a/app/infrastructure/db/repositories/__init__.py b/app/infrastructure/db/repositories/__init__.py new file mode 100644 index 0000000..511098b --- /dev/null +++ b/app/infrastructure/db/repositories/__init__.py @@ -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"] diff --git a/app/infrastructure/db/repositories/refresh_token_repository.py b/app/infrastructure/db/repositories/refresh_token_repository.py new file mode 100644 index 0000000..b32a25a --- /dev/null +++ b/app/infrastructure/db/repositories/refresh_token_repository.py @@ -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)) + ) diff --git a/app/infrastructure/db/repositories/user_repository.py b/app/infrastructure/db/repositories/user_repository.py new file mode 100644 index 0000000..a3a2d30 --- /dev/null +++ b/app/infrastructure/db/repositories/user_repository.py @@ -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() diff --git a/app/main.py b/app/main.py index be39773..756c434 100644 --- a/app/main.py +++ b/app/main.py @@ -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 diff --git a/docker-compose.yml b/docker-compose.yml deleted file mode 100644 index f45d19b..0000000 --- a/docker-compose.yml +++ /dev/null @@ -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: diff --git a/tests/fakes.py b/tests/fakes.py new file mode 100644 index 0000000..76de7d9 --- /dev/null +++ b/tests/fakes.py @@ -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 diff --git a/tests/test_auth_api.py b/tests/test_auth_api.py new file mode 100644 index 0000000..54b4f91 --- /dev/null +++ b/tests/test_auth_api.py @@ -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 diff --git a/tests/test_auth_service.py b/tests/test_auth_service.py new file mode 100644 index 0000000..74f41d3 --- /dev/null +++ b/tests/test_auth_service.py @@ -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 diff --git a/tests/test_security.py b/tests/test_security.py new file mode 100644 index 0000000..bd4d6d0 --- /dev/null +++ b/tests/test_security.py @@ -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) diff --git a/tests/test_user_service.py b/tests/test_user_service.py new file mode 100644 index 0000000..a3fde3b --- /dev/null +++ b/tests/test_user_service.py @@ -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)