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 revision --autogenerate -m "msg" # new migration (after model changes)
|
||||||
uv run alembic upgrade head # apply migrations
|
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.
|
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)
|
## Architecture — hexagonal (ports & adapters)
|
||||||
|
|||||||
@@ -25,21 +25,25 @@ app/
|
|||||||
`application` depends on domain ports; `infrastructure`/`api` are the outer ring
|
`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`).
|
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
|
```bash
|
||||||
cp .env.example .env # then set a real JWT_SECRET
|
docker build -t mcma-backend . # build just this service
|
||||||
docker compose up --build # api on :8000, worker, postgres, redis
|
# or, from the workspace root, the whole stack:
|
||||||
curl localhost:8000/health # {"status":"ok"}
|
docker compose --profile app up --build # db, redis, api, worker, webui
|
||||||
curl localhost:8000/health/ready
|
|
||||||
```
|
```
|
||||||
|
|
||||||
## Local dev (without Docker)
|
## Local dev (without Docker)
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
uv sync # install deps (uses managed Python 3.14)
|
uv sync # install deps (uses managed Python 3.14)
|
||||||
# start Postgres + Redis (e.g. `docker compose up db redis`)
|
# start backing services from the workspace root: `docker compose up -d` (db + redis)
|
||||||
cp .env.example .env
|
cp .env.example .env # then set a real JWT_SECRET
|
||||||
uv run uvicorn app.main:app --reload
|
uv run uvicorn app.main:app --reload
|
||||||
```
|
```
|
||||||
|
|
||||||
|
|||||||
+3
-4
@@ -8,16 +8,15 @@ registers every model so autogenerate sees the full schema.
|
|||||||
import asyncio
|
import asyncio
|
||||||
from logging.config import fileConfig
|
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 alembic import context
|
||||||
from app.core.config import get_settings
|
from app.core.config import get_settings
|
||||||
from app.infrastructure.db import Base
|
from app.infrastructure.db import Base
|
||||||
from sqlalchemy.engine import Connection
|
from sqlalchemy.engine import Connection
|
||||||
from sqlalchemy.ext.asyncio import async_engine_from_config
|
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
|
config = context.config
|
||||||
if config.config_file_name is not None:
|
if config.config_file_name is not None:
|
||||||
fileConfig(config.config_file_name)
|
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.
|
"""Shared FastAPI dependencies — the composition root for request-scoped wiring.
|
||||||
|
|
||||||
Concrete adapters are bound to ports here so routers and services stay
|
Concrete adapters are bound to ports here so routers and services stay
|
||||||
decoupled from infrastructure. Repository/service providers are added in
|
decoupled from infrastructure. Each request gets its own repositories/services
|
||||||
later steps as the domain grows.
|
bound to the request-scoped DB session; stateless adapters (hasher, token
|
||||||
|
service) are process-cached.
|
||||||
"""
|
"""
|
||||||
|
|
||||||
from collections.abc import AsyncIterator
|
from collections.abc import AsyncIterator
|
||||||
|
from functools import lru_cache
|
||||||
from typing import Annotated
|
from typing import Annotated
|
||||||
|
|
||||||
from fastapi import Depends
|
from fastapi import Depends
|
||||||
|
from fastapi.security import HTTPAuthorizationCredentials, HTTPBearer
|
||||||
from sqlalchemy.ext.asyncio import AsyncSession
|
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 import get_sessionmaker
|
||||||
|
from app.infrastructure.db.repositories import (
|
||||||
|
SqlAlchemyRefreshTokenRepository,
|
||||||
|
SqlAlchemyUserRepository,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
async def get_session() -> AsyncIterator[AsyncSession]:
|
async def get_session() -> AsyncIterator[AsyncSession]:
|
||||||
@@ -28,3 +42,61 @@ async def get_session() -> AsyncIterator[AsyncSession]:
|
|||||||
|
|
||||||
|
|
||||||
SessionDep = Annotated[AsyncSession, Depends(get_session)]
|
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
|
Commands:
|
||||||
(plan §11 step 3).
|
* ``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 argparse
|
||||||
|
import asyncio
|
||||||
|
import getpass
|
||||||
|
|
||||||
from app import __version__
|
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:
|
def main() -> None:
|
||||||
parser = argparse.ArgumentParser(prog="mcma", description="mcma-backend management CLI")
|
parser = argparse.ArgumentParser(prog="mcma", description="mcma-backend management CLI")
|
||||||
sub = parser.add_subparsers(dest="command")
|
sub = parser.add_subparsers(dest="command")
|
||||||
|
|
||||||
sub.add_parser("version", help="Print the backend version")
|
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()
|
args = parser.parse_args()
|
||||||
if args.command == "version":
|
if args.command == "version":
|
||||||
print(__version__)
|
print(__version__)
|
||||||
|
elif args.command == "create-admin":
|
||||||
|
_cmd_create_admin(args)
|
||||||
else:
|
else:
|
||||||
parser.print_help()
|
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)
|
correlation_id: ContextVar[str | None] = ContextVar("correlation_id", default=None)
|
||||||
|
|
||||||
|
|
||||||
def _add_correlation_id(
|
def _add_correlation_id(_logger: WrappedLogger, _method: str, event_dict: EventDict) -> EventDict:
|
||||||
_logger: WrappedLogger, _method: str, event_dict: EventDict
|
|
||||||
) -> EventDict:
|
|
||||||
cid = correlation_id.get()
|
cid = correlation_id.get()
|
||||||
if cid is not None:
|
if cid is not None:
|
||||||
event_dict["correlation_id"] = cid
|
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.errors import register_exception_handlers
|
||||||
from app.api.health import router as health_router
|
from app.api.health import router as health_router
|
||||||
from app.api.middleware import CorrelationIdMiddleware
|
from app.api.middleware import CorrelationIdMiddleware
|
||||||
|
from app.api.v1 import api_v1_router
|
||||||
from app.core.config import get_settings
|
from app.core.config import get_settings
|
||||||
from app.core.logging import configure_logging, get_logger
|
from app.core.logging import configure_logging, get_logger
|
||||||
from app.infrastructure.cache import close_redis
|
from app.infrastructure.cache import close_redis
|
||||||
@@ -41,8 +42,8 @@ def create_app() -> FastAPI:
|
|||||||
register_exception_handlers(app)
|
register_exception_handlers(app)
|
||||||
|
|
||||||
app.include_router(health_router)
|
app.include_router(health_router)
|
||||||
# Versioned API routers (auth, library, …) are mounted in later steps:
|
app.include_router(api_v1_router)
|
||||||
# app.include_router(api_v1_router, prefix="/api/v1")
|
# Subsonic-compatible layer is mounted in a later step:
|
||||||
# app.include_router(subsonic_router, prefix="/rest")
|
# app.include_router(subsonic_router, prefix="/rest")
|
||||||
|
|
||||||
return app
|
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