""" Authentication Router Handles all authentication-related endpoints: - Login/Logout - User info - Password changes - User preferences """ import sqlite3 from typing import Dict from fastapi import APIRouter, Depends, HTTPException, Body, Request from fastapi.responses import JSONResponse from slowapi import Limiter from slowapi.util import get_remote_address from ..core.dependencies import get_current_user, get_app_state from ..core.config import settings from ..core.exceptions import AuthError, handle_exceptions from ..core.responses import to_iso8601, now_iso8601 from ..models.api_models import LoginRequest, ChangePasswordRequest, UserPreferences from modules.universal_logger import get_logger logger = get_logger('API') router = APIRouter(prefix="/api/auth", tags=["Authentication"]) # Rate limiter - will be set from main app limiter = Limiter(key_func=get_remote_address) @router.post("/login") @limiter.limit("5/minute") @handle_exceptions async def login(login_data: LoginRequest, request: Request): """ Authenticate user with username and password. Returns JWT token or 2FA challenge if 2FA is enabled. """ app_state = get_app_state() if not app_state.auth: raise HTTPException(status_code=500, detail="Authentication not initialized") # Query user from database with sqlite3.connect(app_state.auth.db_path) as conn: cursor = conn.cursor() cursor.execute(""" SELECT password_hash, role, is_active, totp_enabled, duo_enabled, passkey_enabled FROM users WHERE username = ? """, (login_data.username,)) row = cursor.fetchone() if not row: raise HTTPException(status_code=401, detail="Invalid credentials") password_hash, role, is_active, totp_enabled, duo_enabled, passkey_enabled = row if not is_active: raise HTTPException(status_code=401, detail="Account is inactive") if not app_state.auth.verify_password(login_data.password, password_hash): app_state.auth._record_login_attempt(login_data.username, False) raise HTTPException(status_code=401, detail="Invalid credentials") # Check if user has any 2FA methods enabled available_methods = [] if totp_enabled: available_methods.append('totp') if passkey_enabled: available_methods.append('passkey') if duo_enabled: available_methods.append('duo') # If user has 2FA enabled, return require2FA flag if available_methods: return { 'success': True, 'require2FA': True, 'availableMethods': available_methods, 'username': login_data.username } # No 2FA - proceed with normal login result = app_state.auth._create_session( username=login_data.username, role=role, ip_address=request.client.host if request.client else None, remember_me=login_data.rememberMe ) # Create response with cookie response = JSONResponse(content=result) # Set auth cookie (secure, httponly for security) max_age = 30 * 24 * 60 * 60 if login_data.rememberMe else None response.set_cookie( key="auth_token", value=result.get('token'), max_age=max_age, httponly=True, secure=settings.SECURE_COOKIES, samesite="lax", path="/" ) logger.info(f"User {login_data.username} logged in successfully", module="Auth") return response @router.post("/logout") @limiter.limit("10/minute") @handle_exceptions async def logout(request: Request, current_user: Dict = Depends(get_current_user)): """Logout and invalidate session""" username = current_user.get('sub', 'unknown') response = JSONResponse(content={ "success": True, "message": "Logged out successfully", "timestamp": now_iso8601() }) # Clear auth cookie response.set_cookie( key="auth_token", value="", max_age=0, httponly=True, secure=settings.SECURE_COOKIES, samesite="lax", path="/" ) logger.info(f"User {username} logged out", module="Auth") return response @router.get("/me") @limiter.limit("30/minute") @handle_exceptions async def get_me(request: Request, current_user: Dict = Depends(get_current_user)): """Get current user information""" app_state = get_app_state() username = current_user.get('sub') user_info = app_state.auth.get_user(username) if not user_info: raise HTTPException(status_code=404, detail="User not found") return user_info @router.post("/change-password") @limiter.limit("5/minute") @handle_exceptions async def change_password( request: Request, current_password: str = Body(..., embed=True), new_password: str = Body(..., embed=True), current_user: Dict = Depends(get_current_user) ): """Change user password""" app_state = get_app_state() username = current_user.get('sub') ip_address = request.client.host if request.client else None # Validate new password if len(new_password) < 8: raise HTTPException(status_code=400, detail="Password must be at least 8 characters") result = app_state.auth.change_password(username, current_password, new_password, ip_address) if not result['success']: raise HTTPException(status_code=400, detail=result.get('error', 'Password change failed')) logger.info(f"Password changed for user {username}", module="Auth") return { "success": True, "message": "Password changed successfully", "timestamp": now_iso8601() } @router.post("/preferences") @limiter.limit("10/minute") @handle_exceptions async def update_preferences( request: Request, preferences: dict = Body(...), current_user: Dict = Depends(get_current_user) ): """Update user preferences (theme, notifications, etc.)""" app_state = get_app_state() username = current_user.get('sub') # Validate theme if provided if 'theme' in preferences: if preferences['theme'] not in ('light', 'dark', 'system'): raise HTTPException(status_code=400, detail="Invalid theme value") result = app_state.auth.update_preferences(username, preferences) if not result['success']: raise HTTPException(status_code=400, detail=result.get('error', 'Failed to update preferences')) return { "success": True, "message": "Preferences updated", "preferences": preferences, "timestamp": now_iso8601() }