"""Authentication routes for user login, logout, and registration. This module handles user authentication including login with account lockout protection, logout with session clearing, and new user registration with password policy enforcement and CAPTCHA verification. """ import uuid from datetime import datetime, timedelta from flask import Blueprint, render_template, redirect, url_for, flash, request, session from flask_login import login_user, logout_user, login_required, current_user from app.extensions import db, hash_password, check_password, limiter from app.models import User, Player, ESPORT_GAMES from app.validators import RegisterSchema, LoginSchema from marshmallow import ValidationError from urllib.parse import urlparse # Account lockout settings MAX_LOGIN_ATTEMPTS = 5 LOCKOUT_DURATION_MINUTES = 15 def is_safe_url(url): """Validate that a URL is safe for redirection (same origin). Args: url: The URL to validate. Returns: bool: True if the URL is safe (relative or same origin). """ if not url: return False parsed = urlparse(url) # Allow relative URLs (no netloc) or same-origin URLs return not parsed.netloc or parsed.netloc == request.host def generate_captcha(): """Generate a simple math CAPTCHA challenge. Creates a random addition problem and stores the answer in the session. Returns: dict: A dictionary with 'question' (e.g., '3 + 7') and 'id' keys. """ import random a = random.randint(1, 10) b = random.randint(1, 10) captcha_id = str(uuid.uuid4()) session['captcha_id'] = captcha_id session['captcha_answer'] = a + b return {'question': f'{a} + {b} = ?', 'id': captcha_id} def verify_captcha(user_answer): """Verify the CAPTCHA answer from the session. Args: user_answer: The user's submitted answer (string or int). Returns: bool: True if the answer matches the stored CAPTCHA, False otherwise. """ try: expected = session.pop('captcha_answer', None) session.pop('captcha_id', None) if expected is None: return False return int(user_answer) == expected except (ValueError, TypeError): return False auth_bp = Blueprint('auth', __name__, url_prefix='/auth') @auth_bp.route('/login', methods=['GET', 'POST']) @limiter.limit("10 per minute") def login(): """Handle user login authentication with account lockout protection. GET: Render the login form. POST: Authenticate user credentials with lockout check and audit logging. Account lockout: After 5 consecutive failed attempts, the account is locked for 15 minutes. Successful login resets the counter. Redirects authenticated users to dashboard. Validates credentials and checks account status before login. Returns: Response: Login form or redirect to dashboard/next page. """ if current_user.is_authenticated: return redirect(url_for('main.dashboard')) if request.method == 'POST': # Validate input with marshmallow schema login_schema = LoginSchema() try: validated = login_schema.load(request.form) except ValidationError as err: for field, messages in err.messages.items(): for msg in messages: flash(f'{field}: {msg}', 'danger') return render_template('pages/login.html') username = validated['username'] password = validated['password'] user = User.query.filter_by(username=username).first() # Check if account is locked if user and user.locked_until and user.locked_until > datetime.utcnow(): remaining = (user.locked_until - datetime.utcnow()).seconds // 60 flash( f'Account is locked due to too many failed attempts. ' f'Please try again in {remaining} minute(s).', 'danger' ) return render_template('pages/login.html') if user and check_password(user.password_hash, password): if not user.is_active_account: flash('This account has been deactivated.', 'danger') return render_template('pages/login.html') # Reset failed login attempts on successful login user.failed_login_attempts = 0 user.locked_until = None db.session.commit() # Clear old session data and preserve CSRF token to prevent # session fixation attacks (Flask-Login rotates the session ID) _csrf_token = session.get('csrf_token') session.clear() if _csrf_token: session['csrf_token'] = _csrf_token login_user(user) # Validate redirect URL to prevent open redirect vulnerability next_page = request.args.get('next') if next_page and not is_safe_url(next_page): next_page = None flash(f'Welcome back, {user.username}!', 'success') return redirect(next_page) if next_page else redirect(url_for('main.dashboard')) else: # Track failed login attempt if user: user.failed_login_attempts += 1 if user.failed_login_attempts >= MAX_LOGIN_ATTEMPTS: user.locked_until = datetime.utcnow() + timedelta(minutes=LOCKOUT_DURATION_MINUTES) flash( f'Account locked after {MAX_LOGIN_ATTEMPTS} failed attempts. ' f'Please try again in {LOCKOUT_DURATION_MINUTES} minutes.', 'danger' ) else: remaining = MAX_LOGIN_ATTEMPTS - user.failed_login_attempts flash( f'Login unsuccessful. {remaining} attempt(s) remaining before lockout.', 'danger' ) db.session.commit() else: flash('Login unsuccessful. Please check username and password.', 'danger') return render_template('pages/login.html') @auth_bp.route('/register', methods=['GET', 'POST']) @limiter.limit("3 per hour") def register(): """Handle new player registration with CAPTCHA and password policy. GET: Render the registration form with E-Sports games list and CAPTCHA. POST: Validate all inputs, verify CAPTCHA, enforce password policy, and create a new player account. Only players can register through this form. Validates username/email uniqueness and password confirmation. Returns: Response: Registration form or redirect to login. """ if current_user.is_authenticated: return redirect(url_for('main.dashboard')) # Generate CAPTCHA for GET requests captcha = generate_captcha() if request.method == 'POST': # Validate CAPTCHA first captcha_answer = request.form.get('captcha_answer', '') if not verify_captcha(captcha_answer): flash('Incorrect CAPTCHA answer. Please try again.', 'danger') captcha = generate_captcha() # Generate new captcha return render_template( 'pages/register.html', esport_games=ESPORT_GAMES, captcha=captcha ) # Validate input with marshmallow schema register_schema = RegisterSchema() try: validated = register_schema.load(request.form) except ValidationError as err: for field, messages in err.messages.items(): for msg in messages: flash(f'{field}: {msg}', 'danger') captcha = generate_captcha() return render_template( 'pages/register.html', esport_games=ESPORT_GAMES, captcha=captcha ) username = validated['username'] email = validated['email'] password = validated['password'] full_name = validated['full_name'] phone = validated.get('phone') selected_games = validated.get('games', []) trn_username = request.form.get('trn_username', '').strip() or None discord_username = validated.get('discord_username') league_os_profile = validated.get('league_os_profile') if User.query.filter_by(username=username).first(): flash('Username already exists.', 'danger') captcha = generate_captcha() return render_template( 'pages/register.html', esport_games=ESPORT_GAMES, captcha=captcha ) if User.query.filter_by(email=email).first(): flash('Email already registered.', 'danger') captcha = generate_captcha() return render_template( 'pages/register.html', esport_games=ESPORT_GAMES, captcha=captcha ) hashed_password = hash_password(password) user = Player( username=username, password_hash=hashed_password, role='player', full_name=full_name, email=email, phone=phone, games=','.join(selected_games) if selected_games else None, discord_username=discord_username, league_os_profile=league_os_profile, ) db.session.add(user) db.session.commit() flash('Your account has been created! You can now log in.', 'success') return redirect(url_for('auth.login')) return render_template('pages/register.html', esport_games=ESPORT_GAMES, captcha=captcha) @auth_bp.route('/logout') @login_required def logout(): """Log out the current user and clear the session. Clears the user session and regenerates session ID to prevent session fixation/replay after logout. Returns: Response: Redirect to login page with logout message. """ logout_user() session.clear() flash('You have been logged out.', 'info') return redirect(url_for('auth.login'))