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