from fastapi import APIRouter, Depends, status from jwt import InvalidTokenError from sqlalchemy import select from sqlalchemy.orm import Session from app.api.deps import get_current_user from app.core.errors import raise_api_error from app.core.security import ( create_access_token, create_refresh_token, decode_refresh_token, hash_password, verify_password, ) from app.db.session import get_db from app.models.user import User from app.schemas.auth import ( AccessTokenResponse, BootstrapRequest, LoginRequest, LogoutResponse, RefreshRequest, TokenResponse, ) from app.schemas.user import UserRead router = APIRouter() @router.post("/bootstrap", response_model=UserRead, status_code=status.HTTP_201_CREATED) def bootstrap_admin( payload: BootstrapRequest, db: Session = Depends(get_db), ) -> User: existing_user = db.scalar(select(User.id).limit(1)) if existing_user is not None: raise_api_error( status_code=status.HTTP_409_CONFLICT, code="bootstrap_unavailable", message="Bootstrap is only available before the first user is created.", ) user = User( username=payload.username, password_hash=hash_password(payload.password), role="admin", is_active=True, ) db.add(user) db.commit() db.refresh(user) return user @router.post("/login", response_model=TokenResponse) def login( payload: LoginRequest, db: Session = Depends(get_db), ) -> TokenResponse: user = db.scalar(select(User).where(User.username == payload.username)) if user is None or not verify_password(payload.password, user.password_hash): raise_api_error( status_code=status.HTTP_401_UNAUTHORIZED, code="invalid_credentials", message="Username or password is incorrect.", ) if not user.is_active: raise_api_error( status_code=status.HTTP_403_FORBIDDEN, code="user_inactive", message="User account is inactive.", ) return TokenResponse( access_token=create_access_token(user.id, user.token_version), refresh_token=create_refresh_token(user.id, user.token_version), user=UserRead.model_validate(user), ) @router.post("/refresh", response_model=AccessTokenResponse) def refresh_token( payload: RefreshRequest, db: Session = Depends(get_db), ) -> AccessTokenResponse: try: decoded = decode_refresh_token(payload.refresh_token) user_id = int(decoded["sub"]) token_version = int(decoded["ver"]) except (InvalidTokenError, ValueError, KeyError): raise_api_error( status_code=status.HTTP_401_UNAUTHORIZED, code="invalid_refresh_token", message="Refresh token is invalid.", ) user = db.get(User, user_id) if user is None or not user.is_active: raise_api_error( status_code=status.HTTP_401_UNAUTHORIZED, code="invalid_refresh_token", message="Refresh token is invalid.", ) if user.token_version != token_version: raise_api_error( status_code=status.HTTP_401_UNAUTHORIZED, code="invalid_refresh_token", message="Refresh token is invalid.", ) return AccessTokenResponse( access_token=create_access_token(user.id, user.token_version) ) @router.post("/logout", response_model=LogoutResponse) def logout( current_user: User = Depends(get_current_user), db: Session = Depends(get_db), ) -> LogoutResponse: # Bumping token_version invalidates both access and refresh tokens that were # issued before logout without tracking individual token IDs. current_user.token_version += 1 db.add(current_user) db.commit() return LogoutResponse() @router.get("/me", response_model=UserRead) def me(current_user: User = Depends(get_current_user)) -> User: return current_user