225 lines
7.1 KiB
Python
225 lines
7.1 KiB
Python
"""Auth endpoints: register, register/admin, login, refresh, logout, me."""
|
|
|
|
from __future__ import annotations
|
|
|
|
import uuid
|
|
from datetime import datetime, timezone
|
|
|
|
from fastapi import APIRouter, Depends, HTTPException, Response, status
|
|
from sqlalchemy import func, or_, select
|
|
from sqlalchemy.ext.asyncio import AsyncSession
|
|
|
|
from app.api.deps import get_current_user
|
|
from app.core.security import (
|
|
create_access_token,
|
|
create_refresh_token,
|
|
decode_token,
|
|
hash_password,
|
|
validate_password_strength,
|
|
verify_password,
|
|
)
|
|
from app.core.settings_service import get_admin_setup_token
|
|
from app.db import get_db
|
|
from app.models import User
|
|
from app.schemas import (
|
|
AdminRegisterRequest,
|
|
LoginRequest,
|
|
RegisterRequest,
|
|
TokenResponse,
|
|
UserPublic,
|
|
)
|
|
|
|
router = APIRouter(prefix="/api", tags=["auth"])
|
|
|
|
|
|
async def _check_first_admin(db: AsyncSession) -> bool:
|
|
"""Return True if at least one admin exists."""
|
|
cnt = (
|
|
await db.execute(select(func.count(User.id)).where(User.is_admin.is_(True)))
|
|
).scalar_one()
|
|
return cnt > 0
|
|
|
|
|
|
@router.post("/register", response_model=UserPublic, status_code=status.HTTP_201_CREATED)
|
|
async def register(
|
|
body: RegisterRequest,
|
|
db: AsyncSession = Depends(get_db),
|
|
) -> User:
|
|
"""Register a regular user. Only allowed if at least one admin already exists."""
|
|
has_admin = await _check_first_admin(db)
|
|
if not has_admin:
|
|
raise HTTPException(
|
|
status.HTTP_403_FORBIDDEN,
|
|
"no_admin_yet_use_admin_register",
|
|
)
|
|
|
|
existing = (
|
|
await db.execute(
|
|
select(User).where(
|
|
or_(User.email == body.email, User.username == body.username)
|
|
)
|
|
)
|
|
).scalar_one_or_none()
|
|
if existing is not None:
|
|
if existing.email == body.email:
|
|
raise HTTPException(status.HTTP_400_BAD_REQUEST, "email_already_exists")
|
|
raise HTTPException(status.HTTP_400_BAD_REQUEST, "username_already_exists")
|
|
|
|
errors = validate_password_strength(body.password)
|
|
if errors:
|
|
raise HTTPException(status.HTTP_400_BAD_REQUEST, detail=errors[0])
|
|
|
|
user = User(
|
|
email=body.email,
|
|
username=body.username,
|
|
password_hash=hash_password(body.password),
|
|
is_admin=False,
|
|
is_active=True,
|
|
)
|
|
db.add(user)
|
|
await db.commit()
|
|
await db.refresh(user)
|
|
return user
|
|
|
|
|
|
@router.post(
|
|
"/register/admin",
|
|
response_model=UserPublic,
|
|
status_code=status.HTTP_201_CREATED,
|
|
)
|
|
async def register_admin(
|
|
body: AdminRegisterRequest,
|
|
db: AsyncSession = Depends(get_db),
|
|
) -> User:
|
|
"""Register the first admin user. Requires a valid setup token."""
|
|
has_admin = await _check_first_admin(db)
|
|
if has_admin:
|
|
raise HTTPException(status.HTTP_403_FORBIDDEN, "admin_already_exists")
|
|
|
|
expected_token = await get_admin_setup_token(db)
|
|
if body.token != expected_token:
|
|
raise HTTPException(status.HTTP_403_FORBIDDEN, "invalid_admin_token")
|
|
|
|
existing = (
|
|
await db.execute(
|
|
select(User).where(
|
|
or_(User.email == body.email, User.username == body.username)
|
|
)
|
|
)
|
|
).scalar_one_or_none()
|
|
if existing is not None:
|
|
if existing.email == body.email:
|
|
raise HTTPException(status.HTTP_400_BAD_REQUEST, "email_already_exists")
|
|
raise HTTPException(status.HTTP_400_BAD_REQUEST, "username_already_exists")
|
|
|
|
errors = validate_password_strength(body.password)
|
|
if errors:
|
|
raise HTTPException(status.HTTP_400_BAD_REQUEST, detail=errors[0])
|
|
|
|
user = User(
|
|
email=body.email,
|
|
username=body.username,
|
|
password_hash=hash_password(body.password),
|
|
is_admin=True,
|
|
is_active=True,
|
|
)
|
|
db.add(user)
|
|
await db.commit()
|
|
await db.refresh(user)
|
|
return user
|
|
|
|
|
|
@router.post("/auth/login", response_model=TokenResponse)
|
|
async def login(
|
|
body: LoginRequest,
|
|
db: AsyncSession = Depends(get_db),
|
|
) -> TokenResponse:
|
|
"""Login by email OR username. Returns access + refresh JWTs."""
|
|
stmt = select(User).where(
|
|
or_(User.email == body.login, User.username == body.login)
|
|
)
|
|
user = (await db.execute(stmt)).scalar_one_or_none()
|
|
if user is None:
|
|
raise HTTPException(status.HTTP_401_UNAUTHORIZED, "invalid_credentials")
|
|
if not verify_password(body.password, user.password_hash):
|
|
raise HTTPException(status.HTTP_401_UNAUTHORIZED, "invalid_credentials")
|
|
if not user.is_active:
|
|
raise HTTPException(status.HTTP_403_FORBIDDEN, "account_disabled")
|
|
|
|
user.last_login_at = datetime.now(timezone.utc)
|
|
await db.commit()
|
|
|
|
access = create_access_token(user.id, extra_claims={"is_admin": user.is_admin})
|
|
refresh = create_refresh_token(user.id)
|
|
return TokenResponse(
|
|
access_token=access,
|
|
refresh_token=refresh,
|
|
token_type="bearer",
|
|
expires_in=60 * 24,
|
|
user=UserPublic.model_validate(user),
|
|
)
|
|
|
|
|
|
@router.post("/auth/refresh", response_model=TokenResponse)
|
|
async def refresh_token(
|
|
db: AsyncSession = Depends(get_db),
|
|
token: str = ...,
|
|
) -> TokenResponse:
|
|
"""Exchange a refresh token for a new access + refresh pair.
|
|
|
|
The token is passed in the request body as `{refresh_token: "..."}`.
|
|
"""
|
|
raise NotImplementedError("Implemented below via RefreshRequest body")
|
|
|
|
|
|
from pydantic import BaseModel # noqa: E402
|
|
|
|
|
|
class RefreshRequest(BaseModel):
|
|
refresh_token: str
|
|
|
|
|
|
@router.post("/auth/refresh", response_model=TokenResponse, name="refresh_real")
|
|
async def refresh_real(
|
|
body: RefreshRequest,
|
|
db: AsyncSession = Depends(get_db),
|
|
) -> TokenResponse:
|
|
try:
|
|
payload = decode_token(body.refresh_token)
|
|
except Exception as e: # noqa: BLE001
|
|
raise HTTPException(status.HTTP_401_UNAUTHORIZED, f"Invalid refresh token: {e}")
|
|
if payload.get("type") != "refresh":
|
|
raise HTTPException(status.HTTP_401_UNAUTHORIZED, "Not a refresh token")
|
|
user_id = uuid.UUID(payload["sub"])
|
|
user = (await db.execute(select(User).where(User.id == user_id))).scalar_one_or_none()
|
|
if user is None or not user.is_active:
|
|
raise HTTPException(status.HTTP_401_UNAUTHORIZED, "User not found or disabled")
|
|
|
|
access = create_access_token(user.id, extra_claims={"is_admin": user.is_admin})
|
|
new_refresh = create_refresh_token(user.id)
|
|
return TokenResponse(
|
|
access_token=access,
|
|
refresh_token=new_refresh,
|
|
token_type="bearer",
|
|
expires_in=60 * 24,
|
|
user=UserPublic.model_validate(user),
|
|
)
|
|
|
|
|
|
# Remove the placeholder earlier /auth/refresh route so only the real one stays.
|
|
_refresh_routes = [r for r in router.routes if getattr(r, "path", "") == "/api/auth/refresh"]
|
|
if len(_refresh_routes) > 1:
|
|
router.routes.remove(_refresh_routes[0])
|
|
|
|
|
|
@router.post("/auth/logout", status_code=status.HTTP_204_NO_CONTENT, response_class=Response)
|
|
async def logout() -> Response:
|
|
"""Stateless logout — client drops the tokens."""
|
|
return Response(status_code=status.HTTP_204_NO_CONTENT)
|
|
|
|
|
|
@router.get("/auth/me", response_model=UserPublic)
|
|
async def me(current: User = Depends(get_current_user)) -> User:
|
|
"""Return the current user's profile."""
|
|
return current
|