""" Tool_OCR - External Authentication Router (V2) Handles authentication via external Microsoft Azure AD API """ from datetime import datetime, timedelta import logging from typing import Optional from fastapi import APIRouter, Depends, HTTPException, status, Request from sqlalchemy.orm import Session from app.core.config import settings from app.core.deps import get_db, get_current_user from app.core.security import create_access_token from app.models.user import User from app.models.session import Session as UserSession from app.schemas.auth import LoginRequest, Token, UserResponse from app.services.external_auth_service import external_auth_service from app.services.audit_service import audit_service logger = logging.getLogger(__name__) router = APIRouter(prefix="/api/v2/auth", tags=["Authentication V2"]) def get_client_ip(request: Request) -> str: """Extract client IP address from request""" # Check X-Forwarded-For header (for proxies) forwarded = request.headers.get("X-Forwarded-For") if forwarded: return forwarded.split(",")[0].strip() # Check X-Real-IP header real_ip = request.headers.get("X-Real-IP") if real_ip: return real_ip # Fallback to direct client return request.client.host if request.client else "unknown" def get_user_agent(request: Request) -> str: """Extract user agent from request""" return request.headers.get("User-Agent", "unknown")[:500] @router.post("/login", response_model=Token, summary="External API login") async def login( login_data: LoginRequest, request: Request, db: Session = Depends(get_db) ): """ User login via external Microsoft Azure AD API Returns JWT access token and stores session information - **username**: User's email address - **password**: User's password """ # Call external authentication API success, auth_response, error_msg = await external_auth_service.authenticate_user( username=login_data.username, password=login_data.password ) if not success or not auth_response: logger.warning( f"External auth failed for user {login_data.username}: {error_msg}" ) # Log failed login attempt audit_service.log_event( db=db, event_type="auth_login", event_category="authentication", description=f"Login failed for {login_data.username}: {error_msg}", ip_address=get_client_ip(request), user_agent=get_user_agent(request), success=False, error_message=error_msg ) raise HTTPException( status_code=status.HTTP_401_UNAUTHORIZED, detail=error_msg or "Authentication failed", headers={"WWW-Authenticate": "Bearer"}, ) # Extract user info from external API response user_info = auth_response.user_info email = user_info.email display_name = user_info.name # Find or create user in database user = db.query(User).filter(User.email == email).first() if not user: # Create new user user = User( email=email, display_name=display_name, is_active=True, last_login=datetime.utcnow() ) db.add(user) db.commit() db.refresh(user) logger.info(f"Created new user: {email} (ID: {user.id})") else: # Update existing user user.display_name = display_name user.last_login = datetime.utcnow() # Check if user is active if not user.is_active: logger.warning(f"Inactive user login attempt: {email}") raise HTTPException( status_code=status.HTTP_403_FORBIDDEN, detail="User account is inactive" ) db.commit() db.refresh(user) logger.info(f"Updated existing user: {email} (ID: {user.id})") # Parse token expiration try: expires_at = datetime.fromisoformat(auth_response.expires_at.replace('Z', '+00:00')) issued_at = datetime.fromisoformat(auth_response.issued_at.replace('Z', '+00:00')) except Exception as e: logger.error(f"Failed to parse token timestamps: {e}") expires_at = datetime.utcnow() + timedelta(seconds=auth_response.expires_in) issued_at = datetime.utcnow() # Create session in database # TODO: Implement token encryption before storing session = UserSession( user_id=user.id, access_token=auth_response.access_token, # Should be encrypted id_token=auth_response.id_token, # Should be encrypted token_type=auth_response.token_type, expires_at=expires_at, issued_at=issued_at, ip_address=get_client_ip(request), user_agent=get_user_agent(request) ) db.add(session) db.commit() db.refresh(session) logger.info( f"Created session {session.id} for user {user.email} " f"(expires: {expires_at})" ) # Create internal JWT token for API access # This token contains user ID and session ID internal_token_expires = timedelta(minutes=settings.access_token_expire_minutes) internal_access_token = create_access_token( data={ "sub": str(user.id), "email": user.email, "session_id": session.id }, expires_delta=internal_token_expires ) # Log successful login audit_service.log_event( db=db, event_type="auth_login", event_category="authentication", description=f"User logged in successfully", user_id=user.id, ip_address=get_client_ip(request), user_agent=get_user_agent(request), resource_type="session", resource_id=str(session.id), success=True ) return { "access_token": internal_access_token, "token_type": "bearer", "expires_in": int(internal_token_expires.total_seconds()), "user": { "id": user.id, "email": user.email, "display_name": user.display_name } } @router.post("/logout", summary="User logout") async def logout( request: Request, session_id: Optional[int] = None, db: Session = Depends(get_db), current_user: User = Depends(get_current_user) ): """ User logout - invalidates session - **session_id**: Session ID to logout (optional, logs out all if not provided) """ if session_id: # Logout specific session session = db.query(UserSession).filter( UserSession.id == session_id, UserSession.user_id == current_user.id ).first() if session: db.delete(session) db.commit() logger.info(f"Logged out session {session_id} for user {current_user.email}") # Log logout event audit_service.log_event( db=db, event_type="auth_logout", event_category="authentication", description=f"User logged out session {session_id}", user_id=current_user.id, ip_address=get_client_ip(request), user_agent=get_user_agent(request), resource_type="session", resource_id=str(session_id), success=True ) return {"message": "Logged out successfully"} else: raise HTTPException( status_code=status.HTTP_404_NOT_FOUND, detail="Session not found" ) else: # Logout all sessions sessions = db.query(UserSession).filter( UserSession.user_id == current_user.id ).all() count = len(sessions) for session in sessions: db.delete(session) db.commit() logger.info(f"Logged out all {count} sessions for user {current_user.email}") # Log logout event audit_service.log_event( db=db, event_type="auth_logout", event_category="authentication", description=f"User logged out all {count} sessions", user_id=current_user.id, ip_address=get_client_ip(request), user_agent=get_user_agent(request), success=True, metadata={"sessions_count": count} ) return {"message": f"Logged out {count} sessions"} @router.get("/me", response_model=UserResponse, summary="Get current user") async def get_me( current_user: User = Depends(get_current_user) ): """ Get current authenticated user information """ # TODO: Implement proper current_user dependency from JWT token return { "id": current_user.id, "email": current_user.email, "display_name": current_user.display_name, "created_at": current_user.created_at, "last_login": current_user.last_login, "is_active": current_user.is_active } @router.get("/sessions", summary="List user sessions") async def list_sessions( db: Session = Depends(get_db), current_user: User = Depends(get_current_user) ): """ List all active sessions for current user """ sessions = db.query(UserSession).filter( UserSession.user_id == current_user.id ).order_by(UserSession.created_at.desc()).all() return { "sessions": [ { "id": s.id, "token_type": s.token_type, "expires_at": s.expires_at, "issued_at": s.issued_at, "ip_address": s.ip_address, "user_agent": s.user_agent, "created_at": s.created_at, "last_accessed_at": s.last_accessed_at, "is_expired": s.is_expired, "time_until_expiry": s.time_until_expiry } for s in sessions ] } @router.post("/refresh", response_model=Token, summary="Refresh access token") async def refresh_token( request: Request, db: Session = Depends(get_db), current_user: User = Depends(get_current_user) ): """ Refresh access token before expiration Re-authenticates with external API using stored session. Note: Since external API doesn't provide refresh tokens, we re-issue internal JWT tokens with extended expiry. """ try: # Find user's most recent session session = db.query(UserSession).filter( UserSession.user_id == current_user.id ).order_by(UserSession.created_at.desc()).first() if not session: raise HTTPException( status_code=status.HTTP_401_UNAUTHORIZED, detail="No active session found" ) # Check if token is expiring soon (within TOKEN_REFRESH_BUFFER) if not external_auth_service.is_token_expiring_soon(session.expires_at): # Token still valid for a while, just issue new internal JWT internal_token_expires = timedelta(minutes=settings.access_token_expire_minutes) internal_access_token = create_access_token( data={ "sub": str(current_user.id), "email": current_user.email, "session_id": session.id }, expires_delta=internal_token_expires ) logger.info(f"Refreshed internal token for user {current_user.email}") return { "access_token": internal_access_token, "token_type": "bearer", "expires_in": int(internal_token_expires.total_seconds()), "user": { "id": current_user.id, "email": current_user.email, "display_name": current_user.display_name } } # External token expiring soon - would need re-authentication # For now, we extend internal token and log a warning logger.warning( f"External token expiring soon for user {current_user.email}. " "User should re-authenticate." ) internal_token_expires = timedelta(minutes=settings.access_token_expire_minutes) internal_access_token = create_access_token( data={ "sub": str(current_user.id), "email": current_user.email, "session_id": session.id }, expires_delta=internal_token_expires ) return { "access_token": internal_access_token, "token_type": "bearer", "expires_in": int(internal_token_expires.total_seconds()), "user": { "id": current_user.id, "email": current_user.email, "display_name": current_user.display_name } } except HTTPException: raise except Exception as e: logger.exception(f"Token refresh failed for user {current_user.id}") raise HTTPException( status_code=status.HTTP_500_INTERNAL_SERVER_ERROR, detail=f"Token refresh failed: {str(e)}" )