"""API v2 authentication endpoints with enhanced security.""" from typing import Annotated, Optional from uuid import UUID from fastapi import APIRouter, Depends, HTTPException, status, Request, Header from fastapi.security import HTTPBearer, HTTPAuthorizationCredentials from sqlalchemy.ext.asyncio import AsyncSession from src.api.deps import get_db from src.api.v2.rate_limiter import TieredRateLimit from src.core.security import ( verify_access_token, verify_refresh_token, create_access_token, create_refresh_token, ) from src.core.config import settings from src.core.audit_logger import ( audit_logger, AuditEventType, log_login, log_password_change, ) from src.core.monitoring import metrics from src.schemas.user import ( UserCreate, UserLogin, UserResponse, AuthResponse, TokenRefresh, TokenResponse, PasswordChange, ) from src.services.auth_service import ( register_user, authenticate_user, change_password, get_user_by_id, create_tokens_for_user, EmailAlreadyExistsError, InvalidCredentialsError, InvalidPasswordError, ) router = APIRouter() security = HTTPBearer() rate_limiter = TieredRateLimit() async def get_current_user_v2( credentials: Annotated[HTTPAuthorizationCredentials, Depends(security)], session: AsyncSession = Depends(get_db), ) -> UserResponse: """Get current authenticated user from JWT token (v2). Enhanced version with better error handling. """ token = credentials.credentials payload = verify_access_token(token) if not payload: raise HTTPException( status_code=status.HTTP_401_UNAUTHORIZED, detail="Invalid or expired token", headers={"WWW-Authenticate": "Bearer"}, ) user_id = payload.get("sub") if not user_id: raise HTTPException( status_code=status.HTTP_401_UNAUTHORIZED, detail="Invalid token payload", headers={"WWW-Authenticate": "Bearer"}, ) user = await get_user_by_id(session, UUID(user_id)) if not user: raise HTTPException( status_code=status.HTTP_401_UNAUTHORIZED, detail="User not found", headers={"WWW-Authenticate": "Bearer"}, ) if not user.is_active: raise HTTPException( status_code=status.HTTP_401_UNAUTHORIZED, detail="User account is disabled", headers={"WWW-Authenticate": "Bearer"}, ) return UserResponse.model_validate(user) @router.post( "/register", response_model=AuthResponse, status_code=status.HTTP_201_CREATED, summary="Register new user", description="Register a new user account.", responses={ 201: {"description": "User registered successfully"}, 400: {"description": "Email already exists or validation error"}, 429: {"description": "Rate limit exceeded"}, }, ) async def register( request: Request, user_data: UserCreate, session: AsyncSession = Depends(get_db), ): """Register a new user. Creates a new user account with email and password. """ # Rate limiting (strict for registration) await rate_limiter.check_rate_limit(request, None, tier="free", burst=3) try: user = await register_user( session=session, email=user_data.email, password=user_data.password, full_name=user_data.full_name, ) # Track metrics metrics.increment_counter("users_registered_total") metrics.increment_counter( "auth_attempts_total", labels={"type": "register", "success": "true"}, ) # Audit log audit_logger.log_auth_event( event_type=AuditEventType.USER_REGISTERED, user_id=user.id, user_email=user.email, ip_address=request.client.host if request.client else None, user_agent=request.headers.get("user-agent"), ) # Create tokens access_token, refresh_token = create_tokens_for_user(user) return AuthResponse( user=UserResponse.model_validate(user), access_token=access_token, refresh_token=refresh_token, ) except EmailAlreadyExistsError: metrics.increment_counter( "auth_attempts_total", labels={"type": "register", "success": "false"}, ) raise HTTPException( status_code=status.HTTP_400_BAD_REQUEST, detail="Email already registered", ) except ValueError as e: raise HTTPException( status_code=status.HTTP_422_UNPROCESSABLE_ENTITY, detail=str(e), ) @router.post( "/login", response_model=TokenResponse, summary="User login", description="Authenticate user and get access tokens.", responses={ 200: {"description": "Login successful"}, 401: {"description": "Invalid credentials"}, 429: {"description": "Rate limit exceeded"}, }, ) async def login( request: Request, credentials: UserLogin, session: AsyncSession = Depends(get_db), ): """Login with email and password. Returns access and refresh tokens on success. """ # Rate limiting (strict for login) await rate_limiter.check_rate_limit(request, None, tier="free", burst=5) try: user = await authenticate_user( session=session, email=credentials.email, password=credentials.password, ) if not user: # Track failed attempt metrics.increment_counter( "auth_attempts_total", labels={"type": "login", "success": "false"}, ) # Audit log log_login( user_id=None, user_email=credentials.email, ip_address=request.client.host if request.client else None, user_agent=request.headers.get("user-agent"), success=False, failure_reason="Invalid credentials", ) raise HTTPException( status_code=status.HTTP_401_UNAUTHORIZED, detail="Invalid email or password", headers={"WWW-Authenticate": "Bearer"}, ) # Track success metrics.increment_counter( "auth_attempts_total", labels={"type": "login", "success": "true"}, ) # Audit log log_login( user_id=user.id, user_email=user.email, ip_address=request.client.host if request.client else None, user_agent=request.headers.get("user-agent"), success=True, ) access_token, refresh_token = create_tokens_for_user(user) return TokenResponse( access_token=access_token, refresh_token=refresh_token, ) except InvalidCredentialsError: metrics.increment_counter( "auth_attempts_total", labels={"type": "login", "success": "false"}, ) raise HTTPException( status_code=status.HTTP_401_UNAUTHORIZED, detail="Invalid email or password", headers={"WWW-Authenticate": "Bearer"}, ) @router.post( "/refresh", response_model=TokenResponse, summary="Refresh token", description="Get new access token using refresh token.", responses={ 200: {"description": "Token refreshed successfully"}, 401: {"description": "Invalid refresh token"}, }, ) async def refresh_token( request: Request, token_data: TokenRefresh, session: AsyncSession = Depends(get_db), ): """Refresh access token using refresh token.""" payload = verify_refresh_token(token_data.refresh_token) if not payload: raise HTTPException( status_code=status.HTTP_401_UNAUTHORIZED, detail="Invalid or expired refresh token", headers={"WWW-Authenticate": "Bearer"}, ) user_id = payload.get("sub") user = await get_user_by_id(session, UUID(user_id)) if not user or not user.is_active: raise HTTPException( status_code=status.HTTP_401_UNAUTHORIZED, detail="User not found or inactive", headers={"WWW-Authenticate": "Bearer"}, ) # Audit log audit_logger.log_auth_event( event_type=AuditEventType.TOKEN_REFRESH, user_id=user.id, user_email=user.email, ip_address=request.client.host if request.client else None, ) access_token, refresh_token = create_tokens_for_user(user) return TokenResponse( access_token=access_token, refresh_token=refresh_token, ) @router.get( "/me", response_model=UserResponse, summary="Get current user", description="Get information about the currently authenticated user.", ) async def get_me( current_user: Annotated[UserResponse, Depends(get_current_user_v2)], ): """Get current user information.""" return current_user @router.post( "/change-password", status_code=status.HTTP_200_OK, summary="Change password", description="Change current user password.", responses={ 200: {"description": "Password changed successfully"}, 400: {"description": "Current password incorrect"}, 401: {"description": "Not authenticated"}, }, ) async def change_user_password( request: Request, password_data: PasswordChange, current_user: Annotated[UserResponse, Depends(get_current_user_v2)], session: AsyncSession = Depends(get_db), ): """Change current user password.""" try: await change_password( session=session, user_id=UUID(current_user.id), old_password=password_data.old_password, new_password=password_data.new_password, ) # Audit log log_password_change( user_id=UUID(current_user.id), user_email=current_user.email, ip_address=request.client.host if request.client else None, ) return {"message": "Password changed successfully"} except InvalidPasswordError: raise HTTPException( status_code=status.HTTP_400_BAD_REQUEST, detail="Current password is incorrect", ) @router.post( "/logout", status_code=status.HTTP_200_OK, summary="Logout", description="Logout current user (client should discard tokens).", ) async def logout( request: Request, current_user: Annotated[UserResponse, Depends(get_current_user_v2)], ): """Logout current user. Note: This endpoint is for client convenience. Actual logout is handled by discarding tokens on the client side. """ # Audit log audit_logger.log_auth_event( event_type=AuditEventType.LOGOUT, user_id=UUID(current_user.id), user_email=current_user.email, ip_address=request.client.host if request.client else None, user_agent=request.headers.get("user-agent"), ) return {"message": "Logged out successfully"}