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