Files
crm-system/app/api/v1/auth.py
T

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.db import get_db
from app.core.deps import get_current_user
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()