631 lines
21 KiB
Python
631 lines
21 KiB
Python
"""
|
|
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/<int:key_id>', 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) |