""" Authentication API endpoints. This module handles all authentication-related operations including: - User authentication - Session management - Password management - API key management """ from flask import Blueprint, request, session, jsonify from typing import Dict, List, Any, Optional, Tuple import logging import hashlib import secrets import time from datetime import datetime, timedelta # Import shared utilities try: from src.server.web.controllers.shared.auth_decorators import require_auth, optional_auth from src.server.web.controllers.shared.error_handlers import handle_api_errors from src.server.web.controllers.shared.validators import ( validate_json_input, validate_query_params, is_valid_email, sanitize_string ) from src.server.web.controllers.shared.response_helpers import ( create_success_response, create_error_response, format_user_data ) except ImportError: # Fallback imports for development def require_auth(f): return f def optional_auth(f): return f def handle_api_errors(f): return f def validate_json_input(**kwargs): return lambda f: f def validate_query_params(**kwargs): return lambda f: f def is_valid_email(email): return '@' in email def sanitize_string(s): return str(s).strip() def create_success_response(msg, code=200, data=None): return jsonify({'success': True, 'message': msg, 'data': data}), code def create_error_response(msg, code=400, details=None): return jsonify({'error': msg, 'details': details}), code def format_user_data(data): return data # Import authentication components try: from src.server.data.user_manager import UserManager from src.server.data.session_manager import SessionManager from src.server.data.api_key_manager import APIKeyManager except ImportError: # Fallback for development class UserManager: def authenticate_user(self, username, password): return None def get_user_by_id(self, id): return None def get_user_by_username(self, username): return None def get_user_by_email(self, email): return None def create_user(self, **kwargs): return 1 def update_user(self, id, **kwargs): return True def delete_user(self, id): return True def change_password(self, id, new_password): return True def reset_password(self, email): return 'reset_token' def verify_reset_token(self, token): return None def get_user_sessions(self, user_id): return [] def get_user_activity(self, user_id): return [] class SessionManager: def create_session(self, user_id): return 'session_token' def validate_session(self, token): return None def destroy_session(self, token): return True def destroy_all_sessions(self, user_id): return True def get_session_info(self, token): return None def update_session_activity(self, token): return True class APIKeyManager: def create_api_key(self, user_id, name): return {'id': 1, 'key': 'api_key', 'name': name} def get_user_api_keys(self, user_id): return [] def revoke_api_key(self, key_id): return True def validate_api_key(self, key): return None # Create blueprint auth_bp = Blueprint('auth', __name__) # Initialize managers user_manager = UserManager() session_manager = SessionManager() api_key_manager = APIKeyManager() logger = logging.getLogger(__name__) @auth_bp.route('/auth/login', methods=['POST']) @handle_api_errors @validate_json_input( required_fields=['username', 'password'], optional_fields=['remember_me'], field_types={'username': str, 'password': str, 'remember_me': bool} ) def login() -> Tuple[Any, int]: """ Authenticate user and create session. Request Body: - username: Username or email - password: User password - remember_me: Extend session duration (optional) Returns: JSON response with authentication result """ data = request.get_json() username = sanitize_string(data['username']) password = data['password'] remember_me = data.get('remember_me', False) try: # Authenticate user user = user_manager.authenticate_user(username, password) if not user: logger.warning(f"Failed login attempt for username: {username}") return create_error_response("Invalid username or password", 401) # Create session session_token = session_manager.create_session( user['id'], extended=remember_me ) # Set session data session['user_id'] = user['id'] session['username'] = user['username'] session['session_token'] = session_token session.permanent = remember_me # Format user data (exclude sensitive information) user_data = format_user_data(user, include_sensitive=False) response_data = { 'user': user_data, 'session_token': session_token, 'expires_at': (datetime.now() + timedelta(days=30 if remember_me else 7)).isoformat() } logger.info(f"User {user['username']} (ID: {user['id']}) logged in successfully") return create_success_response("Login successful", 200, response_data) except Exception as e: logger.error(f"Error during login for username {username}: {str(e)}") return create_error_response("Login failed", 500) @auth_bp.route('/auth/logout', methods=['POST']) @require_auth @handle_api_errors def logout() -> Tuple[Any, int]: """ Logout user and destroy session. Returns: JSON response with logout result """ try: # Get session token session_token = session.get('session_token') user_id = session.get('user_id') if session_token: # Destroy session in database session_manager.destroy_session(session_token) # Clear Flask session session.clear() logger.info(f"User ID {user_id} logged out successfully") return create_success_response("Logout successful") except Exception as e: logger.error(f"Error during logout: {str(e)}") return create_error_response("Logout failed", 500) @auth_bp.route('/auth/register', methods=['POST']) @handle_api_errors @validate_json_input( required_fields=['username', 'email', 'password'], optional_fields=['full_name'], field_types={'username': str, 'email': str, 'password': str, 'full_name': str} ) def register() -> Tuple[Any, int]: """ Register new user account. Request Body: - username: Unique username - email: User email address - password: User password - full_name: User's full name (optional) Returns: JSON response with registration result """ data = request.get_json() username = sanitize_string(data['username']) email = sanitize_string(data['email']) password = data['password'] full_name = sanitize_string(data.get('full_name', '')) # Validate input if len(username) < 3: return create_error_response("Username must be at least 3 characters long", 400) if len(password) < 8: return create_error_response("Password must be at least 8 characters long", 400) if not is_valid_email(email): return create_error_response("Invalid email address", 400) try: # Check if username already exists existing_user = user_manager.get_user_by_username(username) if existing_user: return create_error_response("Username already exists", 409) # Check if email already exists existing_email = user_manager.get_user_by_email(email) if existing_email: return create_error_response("Email already registered", 409) # Create user user_id = user_manager.create_user( username=username, email=email, password=password, full_name=full_name ) # Get created user user = user_manager.get_user_by_id(user_id) user_data = format_user_data(user, include_sensitive=False) logger.info(f"New user registered: {username} (ID: {user_id})") return create_success_response("Registration successful", 201, user_data) except Exception as e: logger.error(f"Error during registration for username {username}: {str(e)}") return create_error_response("Registration failed", 500) @auth_bp.route('/auth/me', methods=['GET']) @require_auth @handle_api_errors def get_current_user() -> Tuple[Any, int]: """ Get current user information. Returns: JSON response with current user data """ try: user_id = session.get('user_id') user = user_manager.get_user_by_id(user_id) if not user: return create_error_response("User not found", 404) user_data = format_user_data(user, include_sensitive=False) return create_success_response("User information retrieved", 200, user_data) except Exception as e: logger.error(f"Error getting current user: {str(e)}") return create_error_response("Failed to get user information", 500) @auth_bp.route('/auth/me', methods=['PUT']) @require_auth @handle_api_errors @validate_json_input( optional_fields=['email', 'full_name'], field_types={'email': str, 'full_name': str} ) def update_current_user() -> Tuple[Any, int]: """ Update current user information. Request Body: - email: New email address (optional) - full_name: New full name (optional) Returns: JSON response with update result """ data = request.get_json() user_id = session.get('user_id') # Validate email if provided if 'email' in data and not is_valid_email(data['email']): return create_error_response("Invalid email address", 400) try: # Check if email is already taken by another user if 'email' in data: existing_user = user_manager.get_user_by_email(data['email']) if existing_user and existing_user['id'] != user_id: return create_error_response("Email already registered", 409) # Update user success = user_manager.update_user(user_id, **data) if success: # Get updated user user = user_manager.get_user_by_id(user_id) user_data = format_user_data(user, include_sensitive=False) logger.info(f"User {user_id} updated their profile") return create_success_response("Profile updated successfully", 200, user_data) else: return create_error_response("Failed to update profile", 500) except Exception as e: logger.error(f"Error updating user {user_id}: {str(e)}") return create_error_response("Failed to update profile", 500) @auth_bp.route('/auth/change-password', methods=['PUT']) @require_auth @handle_api_errors @validate_json_input( required_fields=['current_password', 'new_password'], field_types={'current_password': str, 'new_password': str} ) def change_password() -> Tuple[Any, int]: """ Change user password. Request Body: - current_password: Current password - new_password: New password Returns: JSON response with change result """ data = request.get_json() user_id = session.get('user_id') current_password = data['current_password'] new_password = data['new_password'] # Validate new password if len(new_password) < 8: return create_error_response("New password must be at least 8 characters long", 400) try: # Get user user = user_manager.get_user_by_id(user_id) # Verify current password authenticated_user = user_manager.authenticate_user(user['username'], current_password) if not authenticated_user: return create_error_response("Current password is incorrect", 401) # Change password success = user_manager.change_password(user_id, new_password) if success: logger.info(f"User {user_id} changed their password") return create_success_response("Password changed successfully") else: return create_error_response("Failed to change password", 500) except Exception as e: logger.error(f"Error changing password for user {user_id}: {str(e)}") return create_error_response("Failed to change password", 500) @auth_bp.route('/auth/forgot-password', methods=['POST']) @handle_api_errors @validate_json_input( required_fields=['email'], field_types={'email': str} ) def forgot_password() -> Tuple[Any, int]: """ Request password reset. Request Body: - email: User email address Returns: JSON response with reset result """ data = request.get_json() email = sanitize_string(data['email']) if not is_valid_email(email): return create_error_response("Invalid email address", 400) try: # Check if user exists user = user_manager.get_user_by_email(email) if user: # Generate reset token reset_token = user_manager.reset_password(email) # In a real application, you would send this token via email logger.info(f"Password reset requested for user {user['id']} (email: {email})") # For security, always return success even if email doesn't exist return create_success_response("If the email exists, a reset link has been sent") else: # For security, don't reveal that email doesn't exist logger.warning(f"Password reset requested for non-existent email: {email}") return create_success_response("If the email exists, a reset link has been sent") except Exception as e: logger.error(f"Error processing password reset for email {email}: {str(e)}") return create_error_response("Failed to process password reset", 500) @auth_bp.route('/auth/reset-password', methods=['POST']) @handle_api_errors @validate_json_input( required_fields=['token', 'new_password'], field_types={'token': str, 'new_password': str} ) def reset_password() -> Tuple[Any, int]: """ Reset password using token. Request Body: - token: Password reset token - new_password: New password Returns: JSON response with reset result """ data = request.get_json() token = data['token'] new_password = data['new_password'] # Validate new password if len(new_password) < 8: return create_error_response("New password must be at least 8 characters long", 400) try: # Verify reset token user = user_manager.verify_reset_token(token) if not user: return create_error_response("Invalid or expired reset token", 400) # Change password success = user_manager.change_password(user['id'], new_password) if success: logger.info(f"Password reset completed for user {user['id']}") return create_success_response("Password reset successfully") else: return create_error_response("Failed to reset password", 500) except Exception as e: logger.error(f"Error resetting password with token: {str(e)}") return create_error_response("Failed to reset password", 500) @auth_bp.route('/auth/sessions', methods=['GET']) @require_auth @handle_api_errors def get_user_sessions() -> Tuple[Any, int]: """ Get user's active sessions. Returns: JSON response with user sessions """ try: user_id = session.get('user_id') sessions = user_manager.get_user_sessions(user_id) return create_success_response("Sessions retrieved successfully", 200, sessions) except Exception as e: logger.error(f"Error getting user sessions: {str(e)}") return create_error_response("Failed to get sessions", 500) @auth_bp.route('/auth/sessions', methods=['DELETE']) @require_auth @handle_api_errors def destroy_all_sessions() -> Tuple[Any, int]: """ Destroy all user sessions except current one. Returns: JSON response with operation result """ try: user_id = session.get('user_id') current_token = session.get('session_token') # Destroy all sessions except current success = session_manager.destroy_all_sessions(user_id, except_token=current_token) if success: logger.info(f"All sessions destroyed for user {user_id}") return create_success_response("All other sessions destroyed successfully") else: return create_error_response("Failed to destroy sessions", 500) except Exception as e: logger.error(f"Error destroying sessions: {str(e)}") return create_error_response("Failed to destroy sessions", 500) @auth_bp.route('/auth/api-keys', methods=['GET']) @require_auth @handle_api_errors def get_api_keys() -> Tuple[Any, int]: """ Get user's API keys. Returns: JSON response with API keys """ try: user_id = session.get('user_id') api_keys = api_key_manager.get_user_api_keys(user_id) return create_success_response("API keys retrieved successfully", 200, api_keys) except Exception as e: logger.error(f"Error getting API keys: {str(e)}") return create_error_response("Failed to get API keys", 500) @auth_bp.route('/auth/api-keys', methods=['POST']) @require_auth @handle_api_errors @validate_json_input( required_fields=['name'], optional_fields=['description'], field_types={'name': str, 'description': str} ) def create_api_key() -> Tuple[Any, int]: """ Create new API key. Request Body: - name: API key name - description: API key description (optional) Returns: JSON response with created API key """ data = request.get_json() user_id = session.get('user_id') name = sanitize_string(data['name']) description = sanitize_string(data.get('description', '')) try: # Create API key api_key = api_key_manager.create_api_key( user_id=user_id, name=name, description=description ) logger.info(f"API key created for user {user_id}: {name}") return create_success_response("API key created successfully", 201, api_key) except Exception as e: logger.error(f"Error creating API key for user {user_id}: {str(e)}") return create_error_response("Failed to create API key", 500) @auth_bp.route('/auth/api-keys/', methods=['DELETE']) @require_auth @handle_api_errors def revoke_api_key(key_id: int) -> Tuple[Any, int]: """ Revoke API key. Args: key_id: API key ID Returns: JSON response with revocation result """ try: user_id = session.get('user_id') # Verify key belongs to user and revoke success = api_key_manager.revoke_api_key(key_id, user_id) if success: logger.info(f"API key {key_id} revoked by user {user_id}") return create_success_response("API key revoked successfully") else: return create_error_response("API key not found or access denied", 404) except Exception as e: logger.error(f"Error revoking API key {key_id}: {str(e)}") return create_error_response("Failed to revoke API key", 500) @auth_bp.route('/auth/activity', methods=['GET']) @require_auth @handle_api_errors @validate_query_params( allowed_params=['limit', 'offset'], param_types={'limit': int, 'offset': int} ) def get_user_activity() -> Tuple[Any, int]: """ Get user activity log. Query Parameters: - limit: Number of activities to return (default: 50, max: 200) - offset: Number of activities to skip (default: 0) Returns: JSON response with user activity """ limit = min(request.args.get('limit', 50, type=int), 200) offset = request.args.get('offset', 0, type=int) try: user_id = session.get('user_id') activity = user_manager.get_user_activity(user_id, limit=limit, offset=offset) return create_success_response("User activity retrieved successfully", 200, activity) except Exception as e: logger.error(f"Error getting user activity: {str(e)}") return create_error_response("Failed to get user activity", 500)