feat: auth & admin
This commit is contained in:
@@ -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