diff --git a/app/api/v1/auth.py b/app/api/v1/auth.py new file mode 100644 index 0000000..8989901 --- /dev/null +++ b/app/api/v1/auth.py @@ -0,0 +1,156 @@ +"""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()