from fastapi import APIRouter, Depends, HTTPException, status, Request from sqlalchemy.orm import Session from app.core.config import settings from app.core.database import get_db from app.core.security import ( create_access_token, create_token_payload, generate_refresh_token, store_refresh_token, validate_refresh_token, invalidate_refresh_token, invalidate_all_user_refresh_tokens, decode_refresh_token_user_id, ) from app.core.redis import get_redis from app.core.rate_limiter import limiter from app.models.user import User from app.models.audit_log import AuditAction from app.schemas.auth import ( LoginRequest, LoginResponse, UserInfo, CSRFTokenResponse, RefreshTokenRequest, RefreshTokenResponse, ) from app.services.auth_client import ( verify_credentials, AuthAPIError, AuthAPIConnectionError, ) from app.services.audit_service import AuditService from app.middleware.auth import get_current_user from app.middleware.csrf import get_csrf_token_for_user router = APIRouter() @router.post("/login", response_model=LoginResponse) @limiter.limit("5/minute") async def login( request: Request, login_request: LoginRequest, db: Session = Depends(get_db), redis_client=Depends(get_redis), ): """ Authenticate user via external API and return JWT token. """ # Prepare metadata for audit logging client_ip = request.client.host if request.client else "unknown" user_agent = request.headers.get("user-agent", "unknown") try: # Verify credentials with external API auth_result = await verify_credentials(login_request.email, login_request.password) except AuthAPIConnectionError: # Log failed login attempt due to service unavailable AuditService.log_event( db=db, event_type="user.login_failed", resource_type="user", action=AuditAction.LOGIN, user_id=None, resource_id=None, changes={"email": login_request.email, "reason": "auth_service_unavailable"}, request_metadata={"ip_address": client_ip, "user_agent": user_agent}, ) db.commit() raise HTTPException( status_code=status.HTTP_503_SERVICE_UNAVAILABLE, detail="Authentication service temporarily unavailable", ) except AuthAPIError as e: # Log failed login attempt due to invalid credentials AuditService.log_event( db=db, event_type="user.login_failed", resource_type="user", action=AuditAction.LOGIN, user_id=None, resource_id=None, changes={"email": login_request.email, "reason": "invalid_credentials"}, request_metadata={"ip_address": client_ip, "user_agent": user_agent}, ) db.commit() raise HTTPException( status_code=status.HTTP_401_UNAUTHORIZED, detail="Invalid credentials", ) # Find or create user in local database user = db.query(User).filter(User.email == login_request.email).first() # Get name from auth API response (nested in data.userInfo.name) user_info = auth_result.get("data", {}).get("userInfo", {}) auth_name = user_info.get("name", login_request.email.split("@")[0]) if not user: # Create new user based on auth API response user = User( email=login_request.email, name=auth_name, is_active=True, ) db.add(user) db.commit() db.refresh(user) else: # Sync user name from external auth system on each login if user.name != auth_name: user.name = auth_name db.commit() db.refresh(user) if not user.is_active: raise HTTPException( status_code=status.HTTP_403_FORBIDDEN, detail="User account is disabled", ) # Get role name role_name = user.role.name if user.role else None # Create token payload token_data = create_token_payload( user_id=user.id, email=user.email, role=role_name, department_id=user.department_id, is_system_admin=user.is_system_admin, ) # Create access token access_token = create_access_token(token_data) # Generate refresh token refresh_token = generate_refresh_token() # Store session in Redis (sync with JWT expiry) redis_client.setex( f"session:{user.id}", settings.JWT_EXPIRE_MINUTES * 60, # Convert to seconds access_token, ) # Store refresh token in Redis with user binding store_refresh_token(redis_client, user.id, refresh_token) # Log successful login AuditService.log_event( db=db, event_type="user.login", resource_type="user", action=AuditAction.LOGIN, user_id=user.id, resource_id=user.id, changes=None, request_metadata={"ip_address": client_ip, "user_agent": user_agent}, ) db.commit() return LoginResponse( access_token=access_token, refresh_token=refresh_token, expires_in=settings.JWT_EXPIRE_MINUTES * 60, user=UserInfo( id=user.id, email=user.email, name=user.name, role=role_name, department_id=user.department_id, is_system_admin=user.is_system_admin, ), ) @router.post("/logout") async def logout( current_user: User = Depends(get_current_user), redis_client=Depends(get_redis), ): """ Logout user and invalidate session and all refresh tokens. """ # Remove session from Redis redis_client.delete(f"session:{current_user.id}") # Invalidate all refresh tokens for this user invalidate_all_user_refresh_tokens(redis_client, current_user.id) return {"detail": "Successfully logged out"} @router.post("/refresh", response_model=RefreshTokenResponse) @limiter.limit("10/minute") async def refresh_access_token( request: Request, refresh_request: RefreshTokenRequest, db: Session = Depends(get_db), redis_client=Depends(get_redis), ): """ Refresh access token using a valid refresh token. This endpoint implements refresh token rotation: - Validates the provided refresh token - Issues a new access token - Issues a new refresh token (rotating the old one) - Invalidates the old refresh token This provides enhanced security by ensuring refresh tokens are single-use. """ old_refresh_token = refresh_request.refresh_token # Find the user ID associated with this refresh token user_id = decode_refresh_token_user_id(old_refresh_token, redis_client) if user_id is None: raise HTTPException( status_code=status.HTTP_401_UNAUTHORIZED, detail="Invalid or expired refresh token", headers={"WWW-Authenticate": "Bearer"}, ) # Validate the refresh token is still valid and bound to this user if not validate_refresh_token(redis_client, user_id, old_refresh_token): raise HTTPException( status_code=status.HTTP_401_UNAUTHORIZED, detail="Invalid or expired refresh token", headers={"WWW-Authenticate": "Bearer"}, ) # Get user from database user = db.query(User).filter(User.id == user_id).first() if user is None: # Invalidate the token since user no longer exists invalidate_refresh_token(redis_client, user_id, old_refresh_token) raise HTTPException( status_code=status.HTTP_401_UNAUTHORIZED, detail="User not found", headers={"WWW-Authenticate": "Bearer"}, ) if not user.is_active: # Invalidate all tokens for disabled user invalidate_all_user_refresh_tokens(redis_client, user_id) raise HTTPException( status_code=status.HTTP_403_FORBIDDEN, detail="User account is disabled", ) # Invalidate the old refresh token (rotation) invalidate_refresh_token(redis_client, user_id, old_refresh_token) # Get role name role_name = user.role.name if user.role else None # Create new token payload token_data = create_token_payload( user_id=user.id, email=user.email, role=role_name, department_id=user.department_id, is_system_admin=user.is_system_admin, ) # Create new access token new_access_token = create_access_token(token_data) # Generate new refresh token (rotation) new_refresh_token = generate_refresh_token() # Store new session in Redis redis_client.setex( f"session:{user.id}", settings.JWT_EXPIRE_MINUTES * 60, new_access_token, ) # Store new refresh token store_refresh_token(redis_client, user.id, new_refresh_token) return RefreshTokenResponse( access_token=new_access_token, refresh_token=new_refresh_token, expires_in=settings.JWT_EXPIRE_MINUTES * 60, ) @router.get("/me", response_model=UserInfo) async def get_current_user_info( current_user: User = Depends(get_current_user), ): """ Get current authenticated user information. """ role_name = current_user.role.name if current_user.role else None return UserInfo( id=current_user.id, email=current_user.email, name=current_user.name, role=role_name, department_id=current_user.department_id, is_system_admin=current_user.is_system_admin, ) @router.get("/csrf-token", response_model=CSRFTokenResponse) async def get_csrf_token( current_user: User = Depends(get_current_user), ): """ Get a CSRF token for the current user. The CSRF token should be included in the X-CSRF-Token header for all sensitive state-changing operations (DELETE, PUT, PATCH). Token expires after 1 hour and should be refreshed. """ csrf_token = get_csrf_token_for_user(current_user.id) return CSRFTokenResponse( csrf_token=csrf_token, expires_in=3600, # 1 hour )