from __future__ import annotations from datetime import UTC, datetime, timedelta from fastapi import APIRouter, Depends, HTTPException, status from sqlalchemy.orm import Session from app.core.config import settings from app.core.database import get_db from app.core.dependencies import CurrentUser from app.core.security import ( create_access_token, create_refresh_token, decode_token, hash_password, verify_password, InvalidTokenError, ) from app.repositories.user_repository import RefreshTokenRepository, UserRepository from app.schemas.auth import ( ChangePasswordRequest, LoginRequest, RefreshTokenRequest, RegisterRequest, TokenResponse, ) from app.schemas.common import SuccessResponse from app.schemas.user import UserResponse router = APIRouter(prefix="/auth", tags=["Authentication"]) @router.post( "/register", response_model=UserResponse, status_code=status.HTTP_201_CREATED, summary="Register User", description="Register a new user account.", ) def register( payload: RegisterRequest, db: Session = Depends(get_db), ) -> UserResponse: """Register a new user.""" user_repo = UserRepository(db) # Check for existing user if user_repo.get_by_username(payload.username): raise HTTPException( status_code=status.HTTP_409_CONFLICT, detail=f"Username '{payload.username}' is already taken", ) if user_repo.get_by_email(payload.email): raise HTTPException( status_code=status.HTTP_409_CONFLICT, detail=f"Email '{payload.email}' is already registered", ) hashed = hash_password(payload.password) user = user_repo.create_user( username=payload.username, email=payload.email, hashed_password=hashed, full_name=payload.full_name, role_names=["user"], ) db.commit() db.refresh(user) return UserResponse.model_validate(user) @router.post( "/login", response_model=TokenResponse, summary="Login", description="Authenticate with username and password to obtain JWT tokens.", ) def login( payload: LoginRequest, db: Session = Depends(get_db), ) -> TokenResponse: """Authenticate user and return JWT tokens.""" user_repo = UserRepository(db) refresh_repo = RefreshTokenRepository(db) user = user_repo.get_by_username(payload.username) if not user or not verify_password(payload.password, user.hashed_password): raise HTTPException( status_code=status.HTTP_401_UNAUTHORIZED, detail="Invalid username or password", headers={"WWW-Authenticate": "Bearer"}, ) if not user.is_active: raise HTTPException( status_code=status.HTTP_403_FORBIDDEN, detail="User account is deactivated", ) # Generate tokens access_token = create_access_token(data={"sub": str(user.id), "username": user.username}) refresh_token_str = create_refresh_token(data={"sub": str(user.id)}) # Store refresh token expires_at = datetime.now(UTC) + timedelta(days=settings.jwt_refresh_token_expire_days) refresh_repo.create_token( user_id=user.id, token=refresh_token_str, expires_at=expires_at, ) # Update last login user_repo.update_last_login(user) db.commit() return TokenResponse( access_token=access_token, refresh_token=refresh_token_str, token_type="bearer", expires_in=settings.jwt_access_token_expire_minutes * 60, ) @router.post( "/refresh", response_model=TokenResponse, summary="Refresh Token", description="Obtain a new access token using a valid refresh token.", ) def refresh_token( payload: RefreshTokenRequest, db: Session = Depends(get_db), ) -> TokenResponse: """Refresh access token using a refresh token.""" refresh_repo = RefreshTokenRepository(db) user_repo = UserRepository(db) # Validate the refresh token try: token_payload = decode_token(payload.refresh_token) except InvalidTokenError: raise HTTPException( status_code=status.HTTP_401_UNAUTHORIZED, detail="Invalid or expired refresh token", ) if token_payload.get("type") != "refresh": raise HTTPException( status_code=status.HTTP_401_UNAUTHORIZED, detail="Invalid token type", ) # Check if token exists in database and is not revoked stored_token = refresh_repo.get_by_token(payload.refresh_token) if not stored_token: raise HTTPException( status_code=status.HTTP_401_UNAUTHORIZED, detail="Refresh token not found or revoked", ) user = user_repo.get_by_id(token_payload["sub"]) if not user or not user.is_active: raise HTTPException( status_code=status.HTTP_401_UNAUTHORIZED, detail="User not found or deactivated", ) # Revoke old refresh token refresh_repo.revoke_token(payload.refresh_token) # Generate new tokens new_access_token = create_access_token(data={"sub": str(user.id), "username": user.username}) new_refresh_token = create_refresh_token(data={"sub": str(user.id)}) expires_at = datetime.now(UTC) + timedelta(days=settings.jwt_refresh_token_expire_days) refresh_repo.create_token( user_id=user.id, token=new_refresh_token, expires_at=expires_at, ) db.commit() return TokenResponse( access_token=new_access_token, refresh_token=new_refresh_token, token_type="bearer", expires_in=settings.jwt_access_token_expire_minutes * 60, ) @router.post( "/logout", response_model=SuccessResponse, summary="Logout", description="Revoke the current refresh token.", ) def logout( payload: RefreshTokenRequest, current_user: CurrentUser, db: Session = Depends(get_db), ) -> SuccessResponse: """Logout by revoking the refresh token.""" refresh_repo = RefreshTokenRepository(db) refresh_repo.revoke_token(payload.refresh_token) db.commit() return SuccessResponse(message="Successfully logged out") @router.post( "/change-password", response_model=SuccessResponse, summary="Change Password", description="Change the current user's password.", ) def change_password( payload: ChangePasswordRequest, current_user: CurrentUser, db: Session = Depends(get_db), ) -> SuccessResponse: """Change user password.""" if not verify_password(payload.current_password, current_user.hashed_password): raise HTTPException( status_code=status.HTTP_400_BAD_REQUEST, detail="Current password is incorrect", ) current_user.hashed_password = hash_password(payload.new_password) # Revoke all refresh tokens for security refresh_repo = RefreshTokenRepository(db) refresh_repo.revoke_all_user_tokens(current_user.id) db.commit() return SuccessResponse(message="Password changed successfully") @router.get( "/me", response_model=UserResponse, summary="Get Current User", description="Get the currently authenticated user's profile.", ) def get_me(current_user: CurrentUser) -> UserResponse: """Get current authenticated user profile.""" return UserResponse.model_validate(current_user)