feat: auth & admin

This commit is contained in:
2026-06-03 10:40:00 +03:00
parent 4bca90a50e
commit 93199a3095
34 changed files with 1634 additions and 119 deletions
+74 -2
View File
@@ -1,17 +1,31 @@
"""Shared FastAPI dependencies — the composition root for request-scoped wiring.
Concrete adapters are bound to ports here so routers and services stay
decoupled from infrastructure. Repository/service providers are added in
later steps as the domain grows.
decoupled from infrastructure. Each request gets its own repositories/services
bound to the request-scoped DB session; stateless adapters (hasher, token
service) are process-cached.
"""
from collections.abc import AsyncIterator
from functools import lru_cache
from typing import Annotated
from fastapi import Depends
from fastapi.security import HTTPAuthorizationCredentials, HTTPBearer
from sqlalchemy.ext.asyncio import AsyncSession
from app.application.auth_service import AuthService
from app.application.user_service import UserService
from app.core.config import get_settings
from app.core.security import Argon2PasswordHasher, JwtTokenService
from app.domain.entities import User
from app.domain.errors import AuthenticationError, PermissionDeniedError
from app.domain.ports import PasswordHasher, TokenService
from app.infrastructure.db import get_sessionmaker
from app.infrastructure.db.repositories import (
SqlAlchemyRefreshTokenRepository,
SqlAlchemyUserRepository,
)
async def get_session() -> AsyncIterator[AsyncSession]:
@@ -28,3 +42,61 @@ async def get_session() -> AsyncIterator[AsyncSession]:
SessionDep = Annotated[AsyncSession, Depends(get_session)]
# -- stateless adapters (process-cached) ---------------------------------------
@lru_cache
def get_password_hasher() -> PasswordHasher:
return Argon2PasswordHasher()
@lru_cache
def get_token_service() -> TokenService:
return JwtTokenService(get_settings())
# -- request-scoped services ---------------------------------------------------
def get_auth_service(session: SessionDep) -> AuthService:
return AuthService(
users=SqlAlchemyUserRepository(session),
refresh_tokens=SqlAlchemyRefreshTokenRepository(session),
hasher=get_password_hasher(),
tokens=get_token_service(),
)
def get_user_service(session: SessionDep) -> UserService:
return UserService(
users=SqlAlchemyUserRepository(session),
refresh_tokens=SqlAlchemyRefreshTokenRepository(session),
hasher=get_password_hasher(),
)
AuthServiceDep = Annotated[AuthService, Depends(get_auth_service)]
UserServiceDep = Annotated[UserService, Depends(get_user_service)]
# -- current user / authorization ----------------------------------------------
# auto_error=False: we raise domain AuthenticationError (mapped to 401) so the
# error envelope stays consistent with the rest of the API.
_bearer = HTTPBearer(auto_error=False)
BearerDep = Annotated[HTTPAuthorizationCredentials | None, Depends(_bearer)]
async def get_current_user(credentials: BearerDep, auth: AuthServiceDep) -> User:
if credentials is None:
raise AuthenticationError("Missing bearer token.")
return await auth.authenticate_access(credentials.credentials)
CurrentUser = Annotated[User, Depends(get_current_user)]
async def get_current_superuser(user: CurrentUser) -> User:
if not user.is_superuser:
raise PermissionDeniedError("Administrator privileges required.")
return user
SuperUser = Annotated[User, Depends(get_current_superuser)]
+1
View File
@@ -0,0 +1 @@
"""Pydantic request/response models for the native REST API (``/api/v1``)."""
+20
View File
@@ -0,0 +1,20 @@
"""Auth request/response schemas. Tokens are returned in the body (the client
stores them); refresh is presented back in the body too (offline-first clients
manage their own token store, not cookies)."""
from pydantic import BaseModel, Field
class LoginRequest(BaseModel):
username: str = Field(min_length=1, max_length=64)
password: str = Field(min_length=1)
class RefreshRequest(BaseModel):
refresh_token: str
class TokenResponse(BaseModel):
access_token: str
refresh_token: str
token_type: str = "bearer"
+46
View File
@@ -0,0 +1,46 @@
"""User schemas. ``password_hash`` is never exposed — only ``UserResponse``
fields leave the service."""
import datetime as dt
import uuid
from pydantic import BaseModel, ConfigDict, Field
from app.domain.entities import User
class UserResponse(BaseModel):
model_config = ConfigDict(from_attributes=True)
id: uuid.UUID
username: str
is_superuser: bool
is_active: bool
created_at: dt.datetime
updated_at: dt.datetime
@classmethod
def from_entity(cls, user: User) -> UserResponse:
return cls.model_validate(user)
class CreateUserRequest(BaseModel):
username: str = Field(min_length=1, max_length=64)
password: str = Field(min_length=8)
is_superuser: bool = False
class UpdateUserRequest(BaseModel):
"""Admin patch — every field optional; only provided ones change."""
is_superuser: bool | None = None
is_active: bool | None = None
class ResetPasswordRequest(BaseModel):
new_password: str = Field(min_length=8)
class ChangePasswordRequest(BaseModel):
current_password: str = Field(min_length=1)
new_password: str = Field(min_length=8)
+14
View File
@@ -0,0 +1,14 @@
"""Native REST API, version 1. Aggregates feature routers under ``/api/v1``."""
from fastapi import APIRouter
from app.api.v1.admin import router as admin_router
from app.api.v1.auth import router as auth_router
from app.api.v1.users import router as users_router
api_v1_router = APIRouter(prefix="/api/v1")
api_v1_router.include_router(auth_router)
api_v1_router.include_router(users_router)
api_v1_router.include_router(admin_router)
__all__ = ["api_v1_router"]
+80
View File
@@ -0,0 +1,80 @@
"""Admin user-management endpoints. Every route requires a superuser.
Registration is admin-only — this is a private instance, there is no public
sign-up (plan §6.4).
"""
import uuid
from fastapi import APIRouter, Query, status
from app.api.deps import SuperUser, UserServiceDep
from app.api.schemas.user import (
CreateUserRequest,
ResetPasswordRequest,
UpdateUserRequest,
UserResponse,
)
router = APIRouter(prefix="/admin/users", tags=["admin"])
@router.get("", response_model=list[UserResponse])
async def list_users(
_admin: SuperUser,
users: UserServiceDep,
limit: int = Query(default=50, ge=1, le=200),
offset: int = Query(default=0, ge=0),
) -> list[UserResponse]:
result = await users.list_users(limit=limit, offset=offset)
return [UserResponse.from_entity(u) for u in result]
@router.post("", response_model=UserResponse, status_code=status.HTTP_201_CREATED)
async def create_user(
body: CreateUserRequest, _admin: SuperUser, users: UserServiceDep
) -> UserResponse:
user = await users.create_user(
username=body.username,
password=body.password,
is_superuser=body.is_superuser,
)
return UserResponse.from_entity(user)
@router.get("/{user_id}", response_model=UserResponse)
async def get_user(user_id: uuid.UUID, _admin: SuperUser, users: UserServiceDep) -> UserResponse:
return UserResponse.from_entity(await users.get_user(user_id))
@router.patch("/{user_id}", response_model=UserResponse)
async def update_user(
user_id: uuid.UUID,
body: UpdateUserRequest,
_admin: SuperUser,
users: UserServiceDep,
) -> UserResponse:
user = await users.get_user(user_id)
if body.is_superuser is not None:
user = await users.set_superuser(user_id, is_superuser=body.is_superuser)
if body.is_active is not None:
user = await users.set_active(user_id, is_active=body.is_active)
return UserResponse.from_entity(user)
@router.post("/{user_id}/reset-password", status_code=status.HTTP_204_NO_CONTENT)
async def reset_password(
user_id: uuid.UUID,
body: ResetPasswordRequest,
_admin: SuperUser,
users: UserServiceDep,
) -> None:
await users.reset_password(user_id, new_password=body.new_password)
@router.delete("/{user_id}", response_model=UserResponse)
async def deactivate_user(
user_id: uuid.UUID, _admin: SuperUser, users: UserServiceDep
) -> UserResponse:
"""Soft delete — deactivates the account and revokes its sessions."""
return UserResponse.from_entity(await users.deactivate(user_id))
+39
View File
@@ -0,0 +1,39 @@
"""Auth endpoints: login, refresh (rotation), logout, and current-user."""
from fastapi import APIRouter, status
from app.api.deps import AuthServiceDep, CurrentUser
from app.api.schemas.auth import LoginRequest, RefreshRequest, TokenResponse
from app.api.schemas.user import UserResponse
from app.domain.tokens import TokenPair
router = APIRouter(prefix="/auth", tags=["auth"])
def _to_token_response(pair: TokenPair) -> TokenResponse:
return TokenResponse(
access_token=pair.access.encoded,
refresh_token=pair.refresh.encoded,
)
@router.post("/login", response_model=TokenResponse)
async def login(body: LoginRequest, auth: AuthServiceDep) -> TokenResponse:
pair = await auth.login(body.username, body.password)
return _to_token_response(pair)
@router.post("/refresh", response_model=TokenResponse)
async def refresh(body: RefreshRequest, auth: AuthServiceDep) -> TokenResponse:
pair = await auth.refresh(body.refresh_token)
return _to_token_response(pair)
@router.post("/logout", status_code=status.HTTP_204_NO_CONTENT)
async def logout(body: RefreshRequest, auth: AuthServiceDep) -> None:
await auth.logout(body.refresh_token)
@router.get("/me", response_model=UserResponse)
async def me(user: CurrentUser) -> UserResponse:
return UserResponse.from_entity(user)
+19
View File
@@ -0,0 +1,19 @@
"""Self-service user endpoints (the authenticated caller acts on themselves)."""
from fastapi import APIRouter, status
from app.api.deps import CurrentUser, UserServiceDep
from app.api.schemas.user import ChangePasswordRequest
router = APIRouter(prefix="/users", tags=["users"])
@router.patch("/me/password", status_code=status.HTTP_204_NO_CONTENT)
async def change_my_password(
body: ChangePasswordRequest, user: CurrentUser, users: UserServiceDep
) -> None:
await users.change_password(
user.id,
current_password=body.current_password,
new_password=body.new_password,
)