| """
|
| Authentication API endpoints
|
| Handles signup, login, logout, session management, and password reset
|
| """
|
| from flask import Blueprint, request, session, jsonify, current_app, make_response
|
| import re
|
| import random
|
| import secrets
|
| import logging
|
| from datetime import datetime, timedelta
|
| from bcrypt import hashpw, gensalt
|
|
|
| from ...utils.helpers import verify_turnstile
|
| from ...utils.mailer import send_reset_code_email
|
| from ...models.user import (
|
| create_user, get_user, user_exists, email_exists, get_user_by_id,
|
| get_user_by_email, change_password,
|
| store_reset_code, verify_reset_code, clear_reset_code, reset_password,
|
| delete_user
|
| )
|
| from ...core.caching import clear_user_cache
|
| from ...core.config import Config
|
| from ...core.extensions import limiter
|
|
|
| auth_api_bp = Blueprint('auth_api', __name__)
|
| logger = logging.getLogger(__name__)
|
|
|
|
|
| @auth_api_bp.route('/signup', methods=['POST'])
|
| @limiter.limit("3 per minute")
|
| def signup():
|
| """User registration endpoint"""
|
| data = request.get_json()
|
| if not data:
|
| return jsonify({'success': False, 'message': 'Invalid JSON request payload.'}), 400
|
|
|
| username = data.get('username', '').strip()
|
| email = data.get('email', '').strip().lower()
|
| password = data.get('password', '')
|
| turnstile_token = data.get('cf_turnstile_response')
|
|
|
| if not verify_turnstile(turnstile_token, Config.CLOUDFLARE_SECRET, request.remote_addr):
|
| return jsonify({'success': False, 'message': 'Please verify you are not a robot.'}), 403
|
|
|
|
|
| if not username or not email or not password:
|
| return jsonify({'success': False, 'message': 'All fields are required.'}), 400
|
|
|
| if len(username) < 3 or len(username) > 20:
|
| return jsonify({'success': False, 'message': 'Username must be between 3 and 20 characters long.'}), 400
|
|
|
| if ' ' in username:
|
| return jsonify({'success': False, 'message': 'Username cannot contain spaces.'}), 400
|
|
|
| if len(password) < 6 or len(password) > 30:
|
| return jsonify({'success': False, 'message': 'Password must be between 6 and 30 characters long.'}), 400
|
|
|
| if len(email) > 50:
|
| return jsonify({'success': False, 'message': 'Email address is too long.'}), 400
|
|
|
| if user_exists(username):
|
| return jsonify({'success': False, 'message': 'Username already exists. Please choose a different one.'}), 409
|
|
|
| if email_exists(email):
|
| return jsonify({'success': False, 'message': 'Email already registered. Please use a different email or try logging in.'}), 409
|
|
|
| email_pattern = r'^[a-zA-Z0-9._%+-]+@[a-zA-Z0-9.-]+\.[a-zA-Z]{2,}$'
|
| if not re.match(email_pattern, email):
|
| return jsonify({'success': False, 'message': 'Please enter a valid email address.'}), 400
|
|
|
| try:
|
| user_id = create_user(username, password, email)
|
|
|
| session.clear()
|
| session['username'] = username
|
| session['_id'] = user_id
|
| session['avatar'] = None
|
| session['password_version'] = 0
|
| session['role'] = 'user'
|
| session.permanent = True
|
|
|
| current_app.logger.info(f"User {username} signed up successfully with ID {user_id}")
|
|
|
| return jsonify({
|
| 'success': True,
|
| 'message': 'Account created successfully!',
|
| 'user': {
|
| 'username': username,
|
| '_id': str(user_id)
|
| }
|
| }), 201
|
|
|
| except Exception as e:
|
| current_app.logger.error(f"Error creating user: {e}")
|
| return jsonify({'success': False, 'message': 'Failed to create account. Please try again.'}), 500
|
|
|
|
|
| @auth_api_bp.route('/check-username', methods=['GET'])
|
| @limiter.limit("60 per minute")
|
| def check_username():
|
| """Check if a username is available in real-time"""
|
| username = request.args.get('username', '').strip()
|
| if not username:
|
| return jsonify({'available': False, 'message': 'Username is required.'}), 400
|
|
|
| if len(username) < 3 or len(username) > 20:
|
| return jsonify({'available': False, 'message': 'Username must be between 3 and 20 characters.'}), 200
|
|
|
| if ' ' in username:
|
| return jsonify({'available': False, 'message': 'Username cannot contain spaces.'}), 200
|
|
|
| if user_exists(username):
|
| return jsonify({'available': False, 'message': 'Username is already taken.'}), 200
|
|
|
| return jsonify({'available': True, 'message': 'Username is available!'}), 200
|
|
|
|
|
| @auth_api_bp.route('/login', methods=['POST'])
|
| @limiter.limit("5 per minute")
|
| def login():
|
| """User login endpoint"""
|
| data = request.get_json()
|
| if not data:
|
| return jsonify({'success': False, 'message': 'Invalid JSON request payload.'}), 400
|
|
|
| username = data.get('username', '').strip()
|
| password = data.get('password', '')
|
| turnstile_token = data.get('cf_turnstile_response')
|
| client_ip = request.remote_addr
|
|
|
| current_app.logger.info(f"Login attempt for user '{username}' from IP: {client_ip}")
|
|
|
| if not verify_turnstile(turnstile_token, Config.CLOUDFLARE_SECRET, client_ip):
|
| current_app.logger.warning(f"Failed captcha for user '{username}' from IP: {client_ip}")
|
| return jsonify({'success': False, 'message': 'Please verify you are not a robot.'}), 403
|
|
|
| if not username or not password:
|
| return jsonify({'success': False, 'message': 'Username and password are required.'}), 400
|
|
|
| if len(password) < 6 or len(password) > 30 or len(username) > 20:
|
| return jsonify({'success': False, 'message': 'Invalid username or password.'}), 401
|
|
|
| try:
|
| user = get_user(username, password)
|
| if user:
|
| session.clear()
|
| session['username'] = username
|
| session['_id'] = user['_id']
|
| session['password_version'] = user.get('password_version', 0)
|
| session['avatar'] = user.get('avatar')
|
| session['role'] = user.get('role', 'user')
|
| session['anilist_authenticated'] = bool(user.get('anilist_id'))
|
| if user.get('anilist_id'):
|
| session['anilist_id'] = user.get('anilist_id')
|
| session.permanent = True
|
|
|
| current_app.logger.info(f"User {username} logged in successfully from IP: {client_ip}")
|
|
|
| return jsonify({
|
| 'success': True,
|
| 'message': 'Login successful!',
|
| 'user': {
|
| 'username': username,
|
| '_id': str(user['_id'])
|
| }
|
| }), 200
|
| else:
|
| current_app.logger.warning(f"Failed login for user '{username}' from IP: {client_ip} (Invalid credentials)")
|
| return jsonify({'success': False, 'message': 'Invalid username or password.'}), 401
|
|
|
| except Exception as e:
|
| current_app.logger.error(f"Error during login: {e}")
|
| return jsonify({'success': False, 'message': 'Login failed. Please try again.'}), 500
|
|
|
|
|
| @auth_api_bp.route('/logout', methods=['POST'])
|
| def logout():
|
| """User logout endpoint"""
|
| try:
|
| user_id = session.get('_id')
|
| if user_id:
|
| clear_user_cache(int(user_id))
|
|
|
| username = session.get('username', 'Unknown')
|
| session.clear()
|
|
|
|
|
| response = make_response(jsonify({'success': True, 'message': 'Logged out successfully.'}))
|
| response.delete_cookie('session')
|
|
|
| current_app.logger.info(f"User {username} logged out successfully via API")
|
| return response, 200
|
| except Exception as e:
|
| current_app.logger.error(f"Error during logout: {e}")
|
|
|
| session.clear()
|
| response = make_response(jsonify({'success': True, 'message': 'Logged out successfully.'}))
|
| response.delete_cookie('session')
|
| return response, 200
|
|
|
|
|
| @auth_api_bp.route('/me', methods=['GET'])
|
| def me():
|
| """Get current user session info"""
|
| try:
|
| username = session.get('username')
|
| user_id = session.get('_id')
|
|
|
| current_app.logger.debug(f"Checking session: username={username}, user_id={user_id}")
|
|
|
| if username and user_id:
|
| user = get_user_by_id(user_id)
|
| if user:
|
| from ...models.user import get_anilist_connection_info
|
| anilist_info = get_anilist_connection_info(user_id)
|
|
|
| return jsonify({
|
| 'username': username,
|
| '_id': str(user['_id']),
|
| 'anilist_authenticated': anilist_info.get('connected', False),
|
| 'avatar': user.get('avatar'),
|
| 'anilist_id': user.get('anilist_id'),
|
| 'anilist_stats': user.get('anilist_stats', {}),
|
| 'auth_method': user.get('auth_method', 'local')
|
| }), 200
|
|
|
| return jsonify(None), 401
|
|
|
| except Exception as e:
|
| current_app.logger.error(f"Error checking session: {e}")
|
| return jsonify(None), 401
|
|
|
|
|
| @auth_api_bp.route('/change-password', methods=['POST'])
|
| def change_password_route():
|
| """Change user password"""
|
| if 'username' not in session or '_id' not in session:
|
| return jsonify({'success': False, 'message': 'Not logged in.'}), 401
|
|
|
| data = request.get_json()
|
| current_password = data.get('current_password', '')
|
| new_password = data.get('new_password', '')
|
|
|
| if not current_password or not new_password:
|
| return jsonify({'success': False, 'message': 'Current and new passwords are required.'}), 400
|
|
|
| if len(new_password) < 6 or len(new_password) > 30:
|
| return jsonify({'success': False, 'message': 'New password must be between 6 and 30 characters long.'}), 400
|
|
|
| try:
|
| user_id = session.get('_id')
|
|
|
| result = change_password(user_id, current_password, new_password)
|
|
|
| if result:
|
| user = get_user_by_id(user_id)
|
| if user:
|
| session['password_version'] = user.get('password_version', 0)
|
| current_app.logger.info(f"Password changed successfully for user {session.get('username')}")
|
| return jsonify({'success': True, 'message': 'Password changed successfully!'}), 200
|
| else:
|
| return jsonify({'success': False, 'message': 'Current password is incorrect.'}), 400
|
|
|
| except Exception as e:
|
| current_app.logger.error(f"Error changing password: {e}")
|
| return jsonify({'success': False, 'message': 'Failed to change password. Please try again.'}), 500
|
|
|
|
|
|
|
|
|
|
|
|
|
| @auth_api_bp.route('/forgot-password', methods=['POST'])
|
| @limiter.limit("3 per minute")
|
| def forgot_password():
|
| """Send a 6-digit reset code to the user's email."""
|
| data = request.get_json()
|
| email = (data.get('email') or '').strip().lower()
|
|
|
| if not email:
|
| return jsonify({'success': False, 'message': 'Email is required.'}), 400
|
|
|
| if len(email) > 50:
|
| return jsonify({'success': False, 'message': 'Email address is too long.'}), 400
|
|
|
| email_pattern = r'^[a-zA-Z0-9._%+-]+@[a-zA-Z0-9.-]+\.[a-zA-Z]{2,}$'
|
| if not re.match(email_pattern, email):
|
| return jsonify({'success': False, 'message': 'Please enter a valid email address.'}), 400
|
|
|
|
|
| user = get_user_by_email(email)
|
| if not user:
|
| return jsonify({'success': False, 'message': 'No account found with that email address.'}), 404
|
|
|
|
|
| code = f"{secrets.SystemRandom().randint(0, 999999):06d}"
|
| hashed_code = hashpw(code.encode('utf-8'), gensalt())
|
| expires_at = datetime.utcnow() + timedelta(minutes=5)
|
|
|
| stored = store_reset_code(email, hashed_code, expires_at)
|
| if not stored:
|
| logger.error(f"Failed to store reset code for {email}")
|
| return jsonify({'success': False, 'message': 'Internal database error. Please try again.'}), 500
|
|
|
| sent = send_reset_code_email(email, code)
|
| if not sent:
|
| logger.error(f"Failed to send reset email to {email}")
|
| return jsonify({'success': False, 'message': 'Failed to send email. Please try again later.'}), 500
|
|
|
| logger.info(f"Reset code sent to {email}")
|
| return jsonify({'success': True, 'message': 'Reset code sent successfully!'}), 200
|
|
|
|
|
| @auth_api_bp.route('/verify-reset-code', methods=['POST'])
|
| @limiter.limit("5 per minute")
|
| def verify_reset_code_endpoint():
|
| """Verify the 6-digit reset code."""
|
| data = request.get_json()
|
| email = (data.get('email') or '').strip().lower()
|
| code = (data.get('code') or '').strip()
|
|
|
| if not email or not code:
|
| return jsonify({'success': False, 'message': 'Email and code are required.'}), 400
|
|
|
| if len(email) > 50:
|
| return jsonify({'success': False, 'message': 'Email address is too long.'}), 400
|
|
|
| if not re.fullmatch(r'\d{6}', code):
|
| return jsonify({'success': False, 'message': 'Code must be exactly 6 digits.'}), 400
|
|
|
| if verify_reset_code(email, code):
|
|
|
|
|
| token = secrets.token_urlsafe(32)
|
| session['reset_token'] = token
|
| session['reset_email'] = email
|
| return jsonify({'success': True, 'message': 'Code verified!', 'reset_token': token}), 200
|
|
|
| return jsonify({'success': False, 'message': 'Invalid or expired code.'}), 400
|
|
|
|
|
| @auth_api_bp.route('/reset-password', methods=['POST'])
|
| @limiter.limit("3 per minute")
|
| def reset_password_endpoint():
|
| """Set a new password after code verification."""
|
| data = request.get_json()
|
| email = (data.get('email') or '').strip().lower()
|
| code = (data.get('code') or '').strip()
|
| new_password = data.get('new_password', '')
|
| token = data.get('reset_token', '')
|
|
|
| if not email or not code or not new_password:
|
| return jsonify({'success': False, 'message': 'All fields are required.'}), 400
|
|
|
| if len(email) > 50:
|
| return jsonify({'success': False, 'message': 'Email address is too long.'}), 400
|
|
|
| if len(new_password) < 6 or len(new_password) > 30:
|
| return jsonify({'success': False, 'message': 'Password must be between 6 and 30 characters long.'}), 400
|
|
|
|
|
| if not token or token != session.get('reset_token') or email != session.get('reset_email'):
|
| return jsonify({'success': False, 'message': 'Invalid reset session. Please start over.'}), 403
|
|
|
|
|
| if not verify_reset_code(email, code):
|
| return jsonify({'success': False, 'message': 'Code expired. Please request a new one.'}), 400
|
|
|
| if reset_password(email, new_password):
|
|
|
| session.pop('reset_token', None)
|
| session.pop('reset_email', None)
|
| logger.info(f"Password reset successful for {email}")
|
| return jsonify({'success': True, 'message': 'Password reset successful! You can now sign in.'}), 200
|
|
|
| return jsonify({'success': False, 'message': 'Failed to reset password. Please try again.'}), 500
|
|
|
|
|
| @auth_api_bp.route('/delete-account', methods=['POST'])
|
| def delete_account():
|
| """Delete current user account"""
|
| if 'username' not in session or '_id' not in session:
|
| return jsonify({'success': False, 'message': 'Not logged in.'}), 401
|
|
|
| try:
|
| user_id = session.get('_id')
|
|
|
|
|
| success = delete_user(user_id)
|
| if success:
|
|
|
| clear_user_cache(int(user_id))
|
|
|
|
|
| username = session.get('username')
|
| session.clear()
|
|
|
|
|
| response = make_response(jsonify({'success': True, 'message': 'Account deleted successfully.'}))
|
| response.delete_cookie('session')
|
|
|
| current_app.logger.info(f"User {username} deleted their account successfully (ID: {user_id})")
|
| return response, 200
|
| else:
|
| return jsonify({'success': False, 'message': 'Failed to delete account. User not found.'}), 404
|
|
|
| except Exception as e:
|
| current_app.logger.error(f"Error deleting account: {e}")
|
| return jsonify({'success': False, 'message': 'An error occurred while deleting your account.'}), 500
|
|
|