Files
Code_of_Conquest/api/app/api/auth.py
2025-11-24 23:10:55 -06:00

530 lines
16 KiB
Python

"""
Authentication API Blueprint
This module provides API endpoints for user authentication and management:
- User registration
- User login/logout
- Email verification
- Password reset
All endpoints follow the standard API response format defined in app.utils.response.
"""
import re
from flask import Blueprint, request, make_response, render_template, redirect, url_for, flash
from appwrite.exception import AppwriteException
from app.services.appwrite_service import AppwriteService
from app.utils.response import (
success_response,
created_response,
error_response,
unauthorized_response,
validation_error_response
)
from app.utils.auth import require_auth, get_current_user, extract_session_token
from app.utils.logging import get_logger
from app.config import get_config
# Initialize logger
logger = get_logger(__file__)
# Create blueprint
auth_bp = Blueprint('auth', __name__)
# ===== VALIDATION FUNCTIONS =====
def validate_email(email: str) -> tuple[bool, str]:
"""
Validate email address format.
Args:
email: Email address to validate
Returns:
Tuple of (is_valid, error_message)
"""
if not email:
return False, "Email is required"
config = get_config()
max_length = config.auth.email_max_length
if len(email) > max_length:
return False, f"Email must be no more than {max_length} characters"
# Email regex pattern
pattern = r'^[a-zA-Z0-9._%+-]+@[a-zA-Z0-9.-]+\.[a-zA-Z]{2,}$'
if not re.match(pattern, email):
return False, "Invalid email format"
return True, ""
def validate_password(password: str) -> tuple[bool, str]:
"""
Validate password strength.
Args:
password: Password to validate
Returns:
Tuple of (is_valid, error_message)
"""
if not password:
return False, "Password is required"
config = get_config()
min_length = config.auth.password_min_length
if len(password) < min_length:
return False, f"Password must be at least {min_length} characters long"
if len(password) > 128:
return False, "Password must be no more than 128 characters"
errors = []
if config.auth.password_require_uppercase and not re.search(r'[A-Z]', password):
errors.append("at least one uppercase letter")
if config.auth.password_require_lowercase and not re.search(r'[a-z]', password):
errors.append("at least one lowercase letter")
if config.auth.password_require_number and not re.search(r'[0-9]', password):
errors.append("at least one number")
if config.auth.password_require_special and not re.search(r'[!@#$%^&*()_+\-=\[\]{}|;:,.<>?]', password):
errors.append("at least one special character")
if errors:
return False, f"Password must contain {', '.join(errors)}"
return True, ""
def validate_name(name: str) -> tuple[bool, str]:
"""
Validate user name.
Args:
name: Name to validate
Returns:
Tuple of (is_valid, error_message)
"""
if not name:
return False, "Name is required"
config = get_config()
min_length = config.auth.name_min_length
max_length = config.auth.name_max_length
if len(name) < min_length:
return False, f"Name must be at least {min_length} characters"
if len(name) > max_length:
return False, f"Name must be no more than {max_length} characters"
# Allow letters, spaces, hyphens, apostrophes
if not re.match(r"^[a-zA-Z\s\-']+$", name):
return False, "Name can only contain letters, spaces, hyphens, and apostrophes"
return True, ""
# ===== API ENDPOINTS =====
@auth_bp.route('/api/v1/auth/register', methods=['POST'])
def api_register():
"""
Register a new user account.
Request Body:
{
"email": "user@example.com",
"password": "SecurePass123!",
"name": "Player Name"
}
Returns:
201: User created successfully
400: Validation error or email already exists
500: Internal server error
"""
try:
# Get request data
data = request.get_json()
if not data:
return validation_error_response({"error": "Request body is required"})
email = data.get('email', '').strip().lower()
password = data.get('password', '')
name = data.get('name', '').strip()
# Validate inputs
validation_errors = {}
email_valid, email_error = validate_email(email)
if not email_valid:
validation_errors['email'] = email_error
password_valid, password_error = validate_password(password)
if not password_valid:
validation_errors['password'] = password_error
name_valid, name_error = validate_name(name)
if not name_valid:
validation_errors['name'] = name_error
if validation_errors:
return validation_error_response(validation_errors)
# Register user
appwrite = AppwriteService()
user_data = appwrite.register_user(email=email, password=password, name=name)
logger.info("User registered successfully", user_id=user_data.id, email=email)
return created_response(
result={
"user": user_data.to_dict(),
"message": "Registration successful. Please check your email to verify your account."
}
)
except AppwriteException as e:
logger.error("Registration failed", error=str(e), code=e.code)
# Check for specific error codes
if e.code == 409: # Conflict - user already exists
return validation_error_response({"email": "An account with this email already exists"})
return error_response(message="Registration failed. Please try again.", code="REGISTRATION_ERROR")
except Exception as e:
logger.error("Unexpected error during registration", error=str(e))
return error_response(message="An unexpected error occurred", code="INTERNAL_ERROR")
@auth_bp.route('/api/v1/auth/login', methods=['POST'])
def api_login():
"""
Authenticate a user and create a session.
Request Body:
{
"email": "user@example.com",
"password": "SecurePass123!",
"remember_me": false
}
Returns:
200: Login successful, session cookie set
401: Invalid credentials
400: Validation error
500: Internal server error
"""
try:
# Get request data
data = request.get_json()
if not data:
return validation_error_response({"error": "Request body is required"})
email = data.get('email', '').strip().lower()
password = data.get('password', '')
remember_me = data.get('remember_me', False)
# Validate inputs
if not email:
return validation_error_response({"email": "Email is required"})
if not password:
return validation_error_response({"password": "Password is required"})
# Authenticate user
appwrite = AppwriteService()
session_data, user_data = appwrite.login_user(email=email, password=password)
logger.info("User logged in successfully", user_id=user_data.id, email=email)
# Set session cookie
config = get_config()
duration = config.auth.duration_remember_me if remember_me else config.auth.duration_normal
response = make_response(success_response(
result={
"user": user_data.to_dict(),
"message": "Login successful"
}
))
response.set_cookie(
key=config.auth.cookie_name,
value=session_data.session_id,
max_age=duration,
httponly=config.auth.http_only,
secure=config.auth.secure,
samesite=config.auth.same_site,
path=config.auth.path
)
return response
except AppwriteException as e:
logger.warning("Login failed", email=email if 'email' in locals() else 'unknown', error=str(e), code=e.code)
# Generic error message for security (don't reveal if email exists)
return unauthorized_response(message="Invalid email or password")
except Exception as e:
logger.error("Unexpected error during login", error=str(e))
return error_response(message="An unexpected error occurred", code="INTERNAL_ERROR")
@auth_bp.route('/api/v1/auth/logout', methods=['POST'])
@require_auth
def api_logout():
"""
Log out the current user by deleting their session.
Returns:
200: Logout successful, session cookie cleared
401: Not authenticated
500: Internal server error
"""
try:
# Get session token
token = extract_session_token()
if not token:
return unauthorized_response(message="No active session")
# Logout user
appwrite = AppwriteService()
appwrite.logout_user(session_id=token)
user = get_current_user()
logger.info("User logged out successfully", user_id=user.id if user else 'unknown')
# Clear session cookie
config = get_config()
response = make_response(success_response(
result={"message": "Logout successful"}
))
response.set_cookie(
key=config.auth.cookie_name,
value='',
max_age=0,
httponly=config.auth.http_only,
secure=config.auth.secure,
samesite=config.auth.same_site,
path=config.auth.path
)
return response
except AppwriteException as e:
logger.error("Logout failed", error=str(e), code=e.code)
return error_response(message="Logout failed", code="LOGOUT_ERROR")
except Exception as e:
logger.error("Unexpected error during logout", error=str(e))
return error_response(message="An unexpected error occurred", code="INTERNAL_ERROR")
@auth_bp.route('/api/v1/auth/verify-email', methods=['GET'])
def api_verify_email():
"""
Verify a user's email address.
Query Parameters:
userId: User ID from verification link
secret: Verification secret from verification link
Returns:
Redirects to login page with success/error message
"""
try:
user_id = request.args.get('userId')
secret = request.args.get('secret')
if not user_id or not secret:
flash("Invalid verification link", "error")
return redirect(url_for('auth.login_page'))
# Verify email
appwrite = AppwriteService()
appwrite.verify_email(user_id=user_id, secret=secret)
logger.info("Email verified successfully", user_id=user_id)
flash("Email verified successfully! You can now log in.", "success")
return redirect(url_for('auth.login_page'))
except AppwriteException as e:
logger.error("Email verification failed", error=str(e), code=e.code)
flash("Email verification failed. The link may be invalid or expired.", "error")
return redirect(url_for('auth.login_page'))
except Exception as e:
logger.error("Unexpected error during email verification", error=str(e))
flash("An unexpected error occurred", "error")
return redirect(url_for('auth.login_page'))
@auth_bp.route('/api/v1/auth/forgot-password', methods=['POST'])
def api_forgot_password():
"""
Request a password reset email.
Request Body:
{
"email": "user@example.com"
}
Returns:
200: Always returns success (for security, don't reveal if email exists)
400: Validation error
500: Internal server error
"""
try:
# Get request data
data = request.get_json()
if not data:
return validation_error_response({"error": "Request body is required"})
email = data.get('email', '').strip().lower()
# Validate email
email_valid, email_error = validate_email(email)
if not email_valid:
return validation_error_response({"email": email_error})
# Request password reset
appwrite = AppwriteService()
appwrite.request_password_reset(email=email)
logger.info("Password reset requested", email=email)
# Always return success for security
return success_response(
result={
"message": "If an account exists with this email, you will receive a password reset link shortly."
}
)
except Exception as e:
logger.error("Unexpected error during password reset request", error=str(e))
# Still return success for security
return success_response(
result={
"message": "If an account exists with this email, you will receive a password reset link shortly."
}
)
@auth_bp.route('/api/v1/auth/reset-password', methods=['POST'])
def api_reset_password():
"""
Confirm password reset and update password.
Request Body:
{
"user_id": "user_id_from_link",
"secret": "secret_from_link",
"password": "NewSecurePass123!"
}
Returns:
200: Password reset successful
400: Validation error or invalid/expired link
500: Internal server error
"""
try:
# Get request data
data = request.get_json()
if not data:
return validation_error_response({"error": "Request body is required"})
user_id = data.get('user_id', '').strip()
secret = data.get('secret', '').strip()
password = data.get('password', '')
# Validate inputs
validation_errors = {}
if not user_id:
validation_errors['user_id'] = "User ID is required"
if not secret:
validation_errors['secret'] = "Reset secret is required"
password_valid, password_error = validate_password(password)
if not password_valid:
validation_errors['password'] = password_error
if validation_errors:
return validation_error_response(validation_errors)
# Confirm password reset
appwrite = AppwriteService()
appwrite.confirm_password_reset(user_id=user_id, secret=secret, password=password)
logger.info("Password reset successfully", user_id=user_id)
return success_response(
result={
"message": "Password reset successful. You can now log in with your new password."
}
)
except AppwriteException as e:
logger.error("Password reset failed", error=str(e), code=e.code)
return error_response(
message="Password reset failed. The link may be invalid or expired.",
code="PASSWORD_RESET_ERROR"
)
except Exception as e:
logger.error("Unexpected error during password reset", error=str(e))
return error_response(message="An unexpected error occurred", code="INTERNAL_ERROR")
# ===== TEMPLATE ROUTES (for rendering HTML pages) =====
@auth_bp.route('/login', methods=['GET'])
def login_page():
"""Render the login page."""
return render_template('auth/login.html')
@auth_bp.route('/register', methods=['GET'])
def register_page():
"""Render the registration page."""
return render_template('auth/register.html')
@auth_bp.route('/forgot-password', methods=['GET'])
def forgot_password_page():
"""Render the forgot password page."""
return render_template('auth/forgot_password.html')
@auth_bp.route('/reset-password', methods=['GET'])
def reset_password_page():
"""Render the reset password page."""
user_id = request.args.get('userId', '')
secret = request.args.get('secret', '')
return render_template('auth/reset_password.html', user_id=user_id, secret=secret)