157 lines
4.6 KiB
Python
157 lines
4.6 KiB
Python
"""Auth API endpoints: register, login, refresh, logout."""
|
|
|
|
from __future__ import annotations
|
|
|
|
from fastapi import APIRouter, Depends, HTTPException, status
|
|
from fastapi.security import OAuth2PasswordRequestForm
|
|
from sqlalchemy.ext.asyncio import AsyncSession
|
|
|
|
from app.core.config import get_settings
|
|
from app.core.deps import get_current_user
|
|
from app.core.db import get_db
|
|
from app.models.user import User
|
|
from app.schemas.auth import (
|
|
LogoutResponse,
|
|
RegisterResponse,
|
|
TokenResponse,
|
|
UserLoginRequest,
|
|
UserRegisterRequest,
|
|
)
|
|
from app.schemas.user import UserOut
|
|
from app.services import auth_service
|
|
|
|
router = APIRouter(prefix="/auth", tags=["auth"])
|
|
|
|
|
|
@router.post(
|
|
"/register",
|
|
response_model=RegisterResponse,
|
|
status_code=status.HTTP_201_CREATED,
|
|
summary="Bootstrap user registration (only allowed if users table is empty)",
|
|
)
|
|
async def register(
|
|
payload: UserRegisterRequest,
|
|
db: AsyncSession = Depends(get_db),
|
|
) -> RegisterResponse:
|
|
"""Create the first user and a default organization.
|
|
|
|
Returns 403 after the first user has been registered.
|
|
"""
|
|
try:
|
|
user, token = await auth_service.register_user(db, payload)
|
|
except auth_service.BootstrapAlreadyCompleted as e:
|
|
raise HTTPException(status_code=403, detail=str(e)) from e
|
|
except auth_service.EmailAlreadyExists as e:
|
|
raise HTTPException(status_code=409, detail=str(e)) from e
|
|
|
|
settings = get_settings()
|
|
return RegisterResponse(
|
|
user=UserOut.model_validate(user),
|
|
access_token=token,
|
|
token_type="bearer",
|
|
expires_in=settings.jwt_expiry_seconds,
|
|
)
|
|
|
|
|
|
@router.post(
|
|
"/login",
|
|
response_model=TokenResponse,
|
|
summary="Login with email + password (form-data or JSON)",
|
|
)
|
|
async def login(
|
|
form_data: OAuth2PasswordRequestForm = Depends(),
|
|
db: AsyncSession = Depends(get_db),
|
|
) -> TokenResponse:
|
|
"""OAuth2-compatible login. `username` field carries the email.
|
|
|
|
Returns 401 on invalid credentials — never leaks whether the email exists.
|
|
"""
|
|
result = await auth_service.authenticate_user(
|
|
db, email=form_data.username, password=form_data.password
|
|
)
|
|
if result is None:
|
|
raise HTTPException(
|
|
status_code=status.HTTP_401_UNAUTHORIZED,
|
|
detail="Invalid email or password",
|
|
headers={"WWW-Authenticate": "Bearer"},
|
|
)
|
|
|
|
_user, token = result
|
|
settings = get_settings()
|
|
return TokenResponse(
|
|
access_token=token,
|
|
token_type="bearer",
|
|
expires_in=settings.jwt_expiry_seconds,
|
|
)
|
|
|
|
|
|
@router.post(
|
|
"/login/json",
|
|
response_model=TokenResponse,
|
|
summary="Login with JSON body (alternative to form-data)",
|
|
)
|
|
async def login_json(
|
|
payload: UserLoginRequest,
|
|
db: AsyncSession = Depends(get_db),
|
|
) -> TokenResponse:
|
|
"""JSON-body login variant for clients that prefer JSON over form-data."""
|
|
result = await auth_service.authenticate_user(
|
|
db, email=payload.email, password=payload.password
|
|
)
|
|
if result is None:
|
|
raise HTTPException(
|
|
status_code=status.HTTP_401_UNAUTHORIZED,
|
|
detail="Invalid email or password",
|
|
headers={"WWW-Authenticate": "Bearer"},
|
|
)
|
|
|
|
_user, token = result
|
|
settings = get_settings()
|
|
return TokenResponse(
|
|
access_token=token,
|
|
token_type="bearer",
|
|
expires_in=settings.jwt_expiry_seconds,
|
|
)
|
|
|
|
|
|
@router.post(
|
|
"/refresh",
|
|
response_model=TokenResponse,
|
|
summary="Issue a new JWT for the current user",
|
|
)
|
|
async def refresh(
|
|
current_user: User = Depends(get_current_user),
|
|
) -> TokenResponse:
|
|
"""Re-issue a fresh token. v1.1 will add refresh-token rotation; v1 re-signs with the same secret."""
|
|
settings = get_settings()
|
|
from app.core.security import create_access_token
|
|
|
|
role_str = (
|
|
current_user.role.value
|
|
if hasattr(current_user.role, "value")
|
|
else str(current_user.role)
|
|
)
|
|
token = create_access_token(current_user.id, current_user.org_id, role_str)
|
|
return TokenResponse(
|
|
access_token=token,
|
|
token_type="bearer",
|
|
expires_in=settings.jwt_expiry_seconds,
|
|
)
|
|
|
|
|
|
@router.post(
|
|
"/logout",
|
|
response_model=LogoutResponse,
|
|
status_code=status.HTTP_200_OK,
|
|
summary="Logout (client-side token discard)",
|
|
)
|
|
async def logout(
|
|
_current_user: User = Depends(get_current_user),
|
|
) -> LogoutResponse:
|
|
"""Stateless logout. Client deletes the token from localStorage.
|
|
|
|
The endpoint validates the token (so a stolen token can be detected on logout)
|
|
but does not maintain a server-side blacklist in v1.
|
|
"""
|
|
return LogoutResponse()
|