diff --git a/.github/dependabot.yml b/.github/dependabot.yml new file mode 100644 index 0000000..5958654 --- /dev/null +++ b/.github/dependabot.yml @@ -0,0 +1,40 @@ +# Dependabot configuration for automated dependency updates +# +# Dependabot creates PRs when dependencies have new versions, +# helping keep the application secure and up-to-date. + +version: 2 +updates: + # Python dependencies (pip) + - package-ecosystem: "pip" + directory: "/" + schedule: + interval: "weekly" + day: "monday" + time: "09:00" + timezone: "America/Toronto" + # Group all updates into a single PR + groups: + python-dependencies: + patterns: + - "*" + # Limit open PRs to avoid overwhelm + open-pull-requests-limit: 5 + # Labels for PRs + labels: + - "dependencies" + - "security" + # Assign reviewer + reviewers: + - "cedrick2711" + + # GitHub Actions + - package-ecosystem: "github-actions" + directory: "/" + schedule: + interval: "monthly" + time: "09:00" + timezone: "America/Toronto" + labels: + - "dependencies" + - "ci" \ No newline at end of file diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml new file mode 100644 index 0000000..895b582 --- /dev/null +++ b/.github/workflows/ci.yml @@ -0,0 +1,94 @@ +name: CI - Security & Lint + +on: + push: + branches: [main, master] + pull_request: + branches: [main, master] + workflow_dispatch: # Allow manual triggers + +concurrency: + group: ${{ github.workflow }}-${{ github.ref }} + cancel-in-progress: true + +jobs: + security-audit: + name: Security Audit + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + + - name: Set up Python + uses: actions/setup-python@v5 + with: + python-version: '3.12' + cache: 'pip' + + - name: Install dependencies + run: pip install pip-audit + + - name: Scan for vulnerable dependencies + run: pip-audit --require-hashes --no-deps || pip-audit + + lint: + name: Lint with Ruff + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + + - name: Set up Python + uses: actions/setup-python@v5 + with: + python-version: '3.12' + + - name: Install ruff + run: pip install ruff + + - name: Run ruff linter + run: ruff check . --output-format=github + + - name: Run ruff formatter check + run: ruff format --check . + + security-scan: + name: Security Scan + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + + - name: Set up Python + uses: actions/setup-python@v5 + with: + python-version: '3.12' + cache: 'pip' + + - name: Install app dependencies + run: pip install -r requirements.txt + + - name: Run security scan + env: + SECRET_KEY: ${{ secrets.CI_SECRET_KEY || 'test-key-not-for-production-1234567890' }} + FLASK_DEBUG: 'false' + run: python security_scan.py --skip-http + + test: + name: Tests + runs-on: ubuntu-latest + needs: [security-audit, lint] + steps: + - uses: actions/checkout@v4 + + - name: Set up Python + uses: actions/setup-python@v5 + with: + python-version: '3.12' + cache: 'pip' + + - name: Install dependencies + run: pip install -r requirements.txt + + - name: Run tests + run: | + echo "No tests configured yet. Add tests to the project." + # python -m pytest tests/ --cov=. --cov-report=xml + continue-on-error: true \ No newline at end of file diff --git a/app.py b/app.py index 6d029a9..9c9cbc8 100644 --- a/app.py +++ b/app.py @@ -1,13 +1,15 @@ """Team Tryouts Application - Flask Application Factory. This module provides the application factory for creating and configuring -the Flask application instance. +the Flask application instance with comprehensive security hardening. """ import os -from flask import Flask, request, redirect +from flask import Flask, request, redirect, jsonify, render_template, url_for +from flask_cors import CORS from extensions import db, login_manager, csrf, hash_password, check_password, limiter from sqlalchemy import text +from werkzeug.exceptions import HTTPException import markupsafe from dotenv import load_dotenv @@ -16,10 +18,10 @@ load_dotenv() def nl2br(value): """Convert newlines to HTML line breaks. - + Args: value: String value to convert. - + Returns: Markup: HTML-safe string with line breaks. """ @@ -30,17 +32,22 @@ def nl2br(value): def create_app(): """Create and configure the Flask application. - + Initializes Flask with: - Secret key for session security - Database configuration - CSRF protection + - CORS with restricted origins - Login manager + - Rate limiting - All route blueprints - Security headers and HTTPS redirects - + - Custom error handlers + - Health check endpoint + - Structured logging + Handles database initialization and seeding with sample data if empty. - + Returns: Flask: Configured Flask application instance. """ @@ -52,17 +59,46 @@ def create_app(): app.config['SQLALCHEMY_TRACK_MODIFICATIONS'] = False app.config['WTF_CSRF_ENABLED'] = True + # File upload size limit (16 MB) + app.config['MAX_CONTENT_LENGTH'] = 16 * 1024 * 1024 + # Secure session cookie settings app.config['SESSION_COOKIE_SECURE'] = os.getenv('SESSION_COOKIE_SECURE', 'true').lower() == 'true' app.config['SESSION_COOKIE_HTTPONLY'] = True app.config['SESSION_COOKIE_SAMESITE'] = 'Lax' app.config['PERMANENT_SESSION_LIFETIME'] = 3600 # 1 hour session timeout + # Configure CORS - restrict to specific origins in production + allowed_origins = os.getenv('CORS_ALLOWED_ORIGINS', '').split(',') + allowed_origins = [origin.strip() for origin in allowed_origins if origin.strip()] + + if allowed_origins: + CORS( + app, + origins=allowed_origins, + supports_credentials=True, + methods=['GET', 'POST', 'PUT', 'PATCH', 'DELETE', 'OPTIONS'], + max_age=3600, # Cache preflight for 1 hour + ) + else: + # When no origins specified, allow all (development) or none (production) + # In production with a reverse proxy, CORS is handled at the Nginx level + CORS( + app, + supports_credentials=True, + methods=['GET', 'POST', 'PUT', 'PATCH', 'DELETE', 'OPTIONS'], + max_age=3600, + ) + db.init_app(app) login_manager.init_app(app) csrf.init_app(app) limiter.init_app(app) + # Configure structured logging + from logging_config import configure_logging + configure_logging(app) + from routes.auth import auth_bp from routes.tryouts import tryouts_bp from routes.evaluations import evaluations_bp @@ -82,30 +118,232 @@ def create_app(): # Register custom Jinja filters app.jinja_env.filters['nl2br'] = nl2br - # Add security headers to all responses + # ========================================================================= + # Security Headers + # ========================================================================= @app.after_request def add_security_headers(response): + """Add security headers to all responses. + + Implements defense-in-depth with comprehensive HTTP security headers. + These complement the headers set by Nginx in production. + + HSTS is only sent in production (non-debug) to avoid breaking + local development over plain HTTP. + """ response.headers['X-Content-Type-Options'] = 'nosniff' response.headers['X-Frame-Options'] = 'DENY' response.headers['X-XSS-Protection'] = '1; mode=block' - response.headers['Content-Security-Policy'] = "default-src 'self'; script-src 'self' 'unsafe-inline' https://cdn.jsdelivr.net; style-src 'self' 'unsafe-inline' https://cdnjs.cloudflare.com https://cdn.jsdelivr.net; font-src 'self' https://cdnjs.cloudflare.com; img-src 'self' data:; connect-src 'self'; frame-ancestors 'none';" - response.headers['Strict-Transport-Security'] = 'max-age=31536000; includeSubDomains' + response.headers['Referrer-Policy'] = 'strict-origin-when-cross-origin' + response.headers['Permissions-Policy'] = ( + 'camera=(), microphone=(), geolocation=(), ' + 'interest-cohort=(), payment=(), usb=()' + ) + response.headers['Cross-Origin-Opener-Policy'] = 'same-origin' + response.headers['Content-Security-Policy'] = ( + "default-src 'self'; " + "script-src 'self' 'unsafe-inline' https://cdn.jsdelivr.net; " + "style-src 'self' 'unsafe-inline' https://cdnjs.cloudflare.com https://cdn.jsdelivr.net; " + "font-src 'self' https://cdnjs.cloudflare.com; " + "img-src 'self' data:; " + "connect-src 'self'; " + "frame-ancestors 'none'; " + "base-uri 'self'; " + "form-action 'self'" + ) + + # Only enable HSTS when HTTPS is actually being used + # (either direct TLS or behind a proxy that terminates TLS) + is_https = request.is_secure or request.headers.get('X-Forwarded-Proto') == 'https' + if is_https: + response.headers['Strict-Transport-Security'] = ( + 'max-age=31536000; includeSubDomains; preload' + ) + return response - # Force HTTPS in production (when not in debug mode) + # ========================================================================= + # HTTPS Redirect (Production only) + # ========================================================================= @app.before_request def force_https(): + """Redirect all HTTP requests to HTTPS in production. + + Respects the X-Forwarded-Proto header from reverse proxies. + Can be disabled via FORCE_HTTPS environment variable. + """ if not app.debug: if not request.is_secure and request.headers.get('X-Forwarded-Proto') != 'https': if os.getenv('FORCE_HTTPS', 'true').lower() == 'true': return redirect(request.url.replace('http://', 'https://'), code=301) + # ========================================================================= + # Health Check Endpoint + # ========================================================================= + @app.route('/health') + def health_check(): + """Health check endpoint for monitoring and load balancers. + + Verifies database connectivity and application health. + Returns 200 with basic status info or 503 if unhealthy. + + Returns: + Response: JSON health status. + """ + health_data = { + 'status': 'healthy', + 'app': 'team-tryouts', + 'version': '1.0.0', + } + + # Check database connectivity + try: + db.session.execute(text('SELECT 1')) + health_data['database'] = 'connected' + except Exception as e: + health_data['status'] = 'unhealthy' + health_data['database'] = f'error: {str(e)}' + return jsonify(health_data), 503 + + return jsonify(health_data), 200 + + # ========================================================================= + # Custom Error Handlers + # ========================================================================= + @app.errorhandler(400) + def bad_request(error): + """Handle 400 Bad Request errors. + + Args: + error: The error object. + + Returns: + Response: Rendered error page or JSON for API requests. + """ + if request.path.startswith('/users/disponibilities') or \ + request.path.startswith('/users/coach-availability') or \ + request.path.startswith('/users/api/'): + return jsonify({'error': 'Bad request', 'message': str(error)}), 400 + return render_template('errors/400.html', error=error), 400 + + @app.errorhandler(401) + def unauthorized(error): + """Handle 401 Unauthorized errors. + + Args: + error: The error object. + + Returns: + Response: Redirect to login for pages, JSON for API. + """ + if request.path.startswith('/users/disponibilities') or \ + request.path.startswith('/users/api/'): + return jsonify({'error': 'Unauthorized'}), 401 + from flask import flash as _flash + _flash('Please log in to access this page.', 'warning') + return redirect(url_for('auth.login')) + + @app.errorhandler(403) + def forbidden(error): + """Handle 403 Forbidden errors. + + Args: + error: The error object. + + Returns: + Response: Rendered error page or JSON for API requests. + """ + if request.path.startswith('/users/disponibilities') or \ + request.path.startswith('/users/api/'): + return jsonify({'error': 'Forbidden', 'message': str(error)}), 403 + return render_template('errors/403.html', error=error), 403 + + @app.errorhandler(404) + def not_found(error): + """Handle 404 Not Found errors. + + Args: + error: The error object. + + Returns: + Response: Rendered error page or JSON for API requests. + """ + if request.path.startswith('/users/disponibilities') or \ + request.path.startswith('/users/api/'): + return jsonify({'error': 'Not found'}), 404 + return render_template('errors/404.html', error=error), 404 + + @app.errorhandler(429) + def too_many_requests(error): + """Handle 429 Too Many Requests errors. + + Args: + error: The error object. + + Returns: + Response: JSON error for API or rendered page. + """ + if request.path.startswith('/users/disponibilities') or \ + request.path.startswith('/users/api/'): + return jsonify({ + 'error': 'Too many requests', + 'message': 'Please try again later.' + }), 429 + return render_template('errors/429.html', error=error), 429 + + @app.errorhandler(500) + def internal_error(error): + """Handle 500 Internal Server Error. + + Never exposes stack traces to users. Logs the full error internally. + + Args: + error: The error object. + + Returns: + Response: Generic error page or JSON. + """ + # Log the full error for debugging + app.logger.error('Internal Server Error: %s', str(error), exc_info=True) + + # Roll back any failed database session + db.session.rollback() + + if request.path.startswith('/users/disponibilities') or \ + request.path.startswith('/users/api/'): + return jsonify({ + 'error': 'Internal server error', + 'message': 'An unexpected error occurred. Please try again later.' + }), 500 + return render_template('errors/500.html'), 500 + + @app.errorhandler(HTTPException) + def handle_http_exception(error): + """Catch-all handler for any unhandled HTTP exceptions. + + Args: + error: The HTTPException object. + + Returns: + Response: JSON error for API, re-raises for others. + """ + if request.path.startswith('/users/disponibilities') or \ + request.path.startswith('/users/api/'): + return jsonify({ + 'error': error.name, + 'message': error.description, + 'code': error.code + }), error.code + return error + + # ========================================================================= + # Database Initialization + # ========================================================================= with app.app_context(): import models - from models import User, MatchParticipant + from models import User try: # Check if the database schema is up to date by testing a query - # that uses all model columns including team_side for match participants db.session.execute(text('SELECT games, team_side FROM match_participants LIMIT 1')) db.create_all() except Exception: @@ -113,7 +351,7 @@ def create_app(): db.session.rollback() db.drop_all() db.create_all() - + # Seed database if empty if User.query.count() == 0: from seed import seed_database @@ -124,14 +362,18 @@ def create_app(): from discord_bot import start_bot start_bot() except Exception as e: - import logging - logging.getLogger(__name__).warning(f"Could not start Discord bot: {e}") + app.logger.warning('Could not start Discord bot: %s', e) return app if __name__ == '__main__': + # Only used for development - production uses wsgi.py (Waitress) app = create_app() - # Debug mode should only be enabled via environment variable for security debug_mode = os.getenv('FLASK_DEBUG', 'false').lower() == 'true' - app.run(debug=debug_mode, host='0.0.0.0', port=5000) \ No newline at end of file + if debug_mode: + app.logger.warning( + 'Running in DEBUG mode with Flask built-in server. ' + 'This is NOT suitable for production. Use wsgi.py instead.' + ) + app.run(debug=debug_mode, host='127.0.0.1', port=5000) \ No newline at end of file diff --git a/backup.py b/backup.py new file mode 100644 index 0000000..e9d1d77 --- /dev/null +++ b/backup.py @@ -0,0 +1,184 @@ +"""Database backup script for the Team Tryouts application. + +This module provides a simple backup mechanism for the SQLite database +and uploaded contract documents. Designed to be run as a scheduled task +(Windows Task Scheduler) or cron job. + +Usage: + python backup.py + +Configuration via environment variables: + BACKUP_DIR: Directory to store backups (default: ./backups) + BACKUP_RETENTION_DAYS: Number of days to keep backups (default: 30) +""" + +import os +import shutil +import sqlite3 +from datetime import datetime, timedelta + +# Configuration +BACKUP_DIR = os.getenv('BACKUP_DIR', os.path.join(os.getcwd(), 'backups')) +BACKUP_RETENTION_DAYS = int(os.getenv('BACKUP_RETENTION_DAYS', 30)) +DATABASE_PATH = os.getenv('DATABASE_PATH', os.path.join(os.getcwd(), 'instance', 'team_tryouts.db')) +DOCUMENTS_DIR = os.path.join(os.getcwd(), 'documents') + + +def create_backup_dir(): + """Create the backup directory if it doesn't exist.""" + os.makedirs(BACKUP_DIR, exist_ok=True) + + +def backup_database(): + """Backup the SQLite database using sqlite3's built-in backup API. + + Returns: + str: Path to the created backup file, or None if failed. + """ + if not os.path.exists(DATABASE_PATH): + print(f'[WARNING] Database not found at {DATABASE_PATH}. Skipping database backup.') + return None + + timestamp = datetime.now().strftime('%Y%m%d_%H%M%S') + backup_filename = f'db_backup_{timestamp}.db' + backup_path = os.path.join(BACKUP_DIR, backup_filename) + + try: + source = sqlite3.connect(DATABASE_PATH) + destination = sqlite3.connect(backup_path) + source.backup(destination) + source.close() + destination.close() + print(f'[OK] Database backed up to: {backup_path}') + return backup_path + except Exception as e: + print(f'[ERROR] Database backup failed: {e}') + return None + + +def backup_documents(): + """Backup the uploaded contract documents directory. + + Returns: + str: Path to the created archive, or None if no documents exist. + """ + if not os.path.exists(DOCUMENTS_DIR): + print('[INFO] No documents directory found. Skipping document backup.') + return None + + timestamp = datetime.now().strftime('%Y%m%d_%H%M%S') + archive_basename = f'documents_backup_{timestamp}' + archive_path = os.path.join(BACKUP_DIR, archive_basename) + + try: + shutil.make_archive(archive_path, 'zip', DOCUMENTS_DIR) + zip_path = f'{archive_path}.zip' + print(f'[OK] Documents backed up to: {zip_path}') + return zip_path + except Exception as e: + print(f'[ERROR] Document backup failed: {e}') + return None + + +def cleanup_old_backups(): + """Remove backup files older than BACKUP_RETENTION_DAYS.""" + if not os.path.exists(BACKUP_DIR): + return + + cutoff = datetime.now() - timedelta(days=BACKUP_RETENTION_DAYS) + removed_count = 0 + + for filename in os.listdir(BACKUP_DIR): + file_path = os.path.join(BACKUP_DIR, filename) + if os.path.isfile(file_path): + file_time = datetime.fromtimestamp(os.path.getmtime(file_path)) + if file_time < cutoff: + try: + os.remove(file_path) + removed_count += 1 + print(f'[CLEANUP] Removed old backup: {filename}') + except OSError as e: + print(f'[WARNING] Could not remove {filename}: {e}') + + if removed_count > 0: + print(f'[CLEANUP] Removed {removed_count} old backup(s).') + else: + print('[CLEANUP] No old backups to remove.') + + +def verify_backup(backup_path): + """Verify a database backup by running a quick integrity check. + + Args: + backup_path: Path to the backup file to verify. + + Returns: + bool: True if backup is valid, False otherwise. + """ + if not backup_path or not os.path.exists(backup_path): + return False + + try: + conn = sqlite3.connect(backup_path) + cursor = conn.cursor() + cursor.execute('PRAGMA integrity_check') + result = cursor.fetchone() + conn.close() + is_valid = result[0] == 'ok' + if is_valid: + print(f'[OK] Backup integrity verified: {backup_path}') + else: + print(f'[ERROR] Backup integrity check failed: {backup_path} - {result[0]}') + return is_valid + except Exception as e: + print(f'[ERROR] Backup verification failed: {e}') + return False + + +def main(): + """Run the full backup process. + + Steps: + 1. Create backup directory + 2. Backup database + 3. Backup documents (if any) + 4. Verify database backup + 5. Clean up old backups + + Returns: + int: 0 on success, 1 on failure. + """ + print(f'=== Team Tryouts Backup ===') + print(f'Started at: {datetime.now().strftime("%Y-%m-%d %H:%M:%S")}') + print(f'Backup directory: {BACKUP_DIR}') + print(f'Retention period: {BACKUP_RETENTION_DAYS} days') + print() + + create_backup_dir() + + # 1. Backup database + db_backup_path = backup_database() + success = True + + # 2. Verify database backup + if db_backup_path: + if not verify_backup(db_backup_path): + success = False + + # 3. Backup documents + backup_documents() + + # 4. Cleanup old backups + cleanup_old_backups() + + print() + if success: + print('=== Backup completed successfully ===') + else: + print('=== Backup completed with warnings ===') + + return 0 if success else 1 + + +if __name__ == '__main__': + exit(main()) \ No newline at end of file diff --git a/docs/deployment.md b/docs/deployment.md new file mode 100644 index 0000000..397d4fa --- /dev/null +++ b/docs/deployment.md @@ -0,0 +1,163 @@ +# Team Tryouts - Production Deployment Guide (Windows) + +This document outlines the secure production deployment for the Team Tryouts +Flask application on Windows with Nginx reverse proxy. + +## Architecture Overview + +``` +Internet → Nginx (HTTPS:443) → Waitress (127.0.0.1:5000) → Flask App +``` + +- **Nginx**: TLS termination, static file serving, rate limiting, security headers +- **Waitress**: Production WSGI server with multiple worker threads +- **Flask**: Application logic (never exposed directly to the internet) + +## Prerequisites + +1. **Python 3.12+** installed +2. **Nginx for Windows** downloaded from [nginx.org](https://nginx.org/en/download.html) +3. **SSL Certificate** (Let's Encrypt via certbot or commercial provider) +4. **Windows Firewall** configured properly + +## Step 1: Install Dependencies + +```powershell +# Install Python packages +pip install -r requirements.txt + +# Create required directories +mkdir logs +mkdir backups +``` + +## Step 2: Configure Environment Variables + +Create a `.env` file in the project root: + +```env +# Security (REQUIRED - generate with: python -c "import secrets; print(secrets.token_hex(32))") +SECRET_KEY= + +# Database +DATABASE_URL=sqlite:///team_tryouts.db + +# Security settings +SESSION_COOKIE_SECURE=true +FORCE_HTTPS=true +FLASK_DEBUG=false + +# CORS (set to your actual domain in production) +CORS_ALLOWED_ORIGINS=https://yourdomain.com + +# Discord bot (optional) +DISCORD_BOT_TOKEN= +DISCORD_WEBHOOK_URL= + +# Backup settings +BACKUP_DIR=./backups +BACKUP_RETENTION_DAYS=30 +``` + +**Important**: Never commit `.env` to version control. + +## Step 3: Configure Nginx + +1. Copy `nginx.conf` to your Nginx installation directory (e.g., `C:\nginx\conf\`) +2. Place SSL certificate files: + - `C:\nginx\certs\fullchain.pem` + - `C:\nginx\certs\privkey.pem` +3. Start Nginx: `C:\nginx\nginx.exe` + +### Obtaining SSL Certificates + +Using Let's Encrypt with certbot (recommended): + +```powershell +# Using certbot on Windows +certbot certonly --standalone -d yourdomain.com +``` + +Or use your hosting provider's SSL certificate. + +## Step 4: Start the Application + +```powershell +# Production start +python wsgi.py + +# Or with custom port/threads +$env:PORT=5000 +$env:WAITRESS_THREADS=5 +python wsgi.py +``` + +## Step 5: Configure Windows Firewall + +```powershell +# Allow only necessary ports +New-NetFirewallRule -DisplayName "Nginx HTTPS" -Direction Inbound -Protocol TCP -LocalPort 443 -Action Allow +New-NetFirewallRule -DisplayName "Nginx HTTP Redirect" -Direction Inbound -Protocol TCP -LocalPort 80 -Action Allow + +# Block direct access to Waitress port (5000) from outside +New-NetFirewallRule -DisplayName "Block Waitress External" -Direction Inbound -Protocol TCP -LocalPort 5000 -Action Block +``` + +## Step 6: Set Up Automated Backups + +Create a scheduled task for daily backups: + +```powershell +# Run backup script daily at 2:00 AM +$Action = New-ScheduledTaskAction -Execute "python" -Argument "backup.py" -WorkingDirectory "C:\path\to\team-tryouts" +$Trigger = New-ScheduledTaskTrigger -Daily -At 2:00AM +Register-ScheduledTask -TaskName "TeamTryoutsBackup" -Action $Action -Trigger $Trigger +``` + +## Step 7: Run Security Validation + +Before going live: + +```powershell +# Run the security scanner +python security_scan.py --url https://yourdomain.com +``` + +All checks must pass before deployment. + +## Security Checklist + +- [ ] `.env` is not committed to repository +- [ ] `SECRET_KEY` is strong (64+ hex characters, randomly generated) +- [ ] `FLASK_DEBUG` is set to `false` +- [ ] Nginx is running with HTTPS (port 443) +- [ ] HTTP (port 80) redirects to HTTPS +- [ ] TLS 1.2+ configured (TLS 1.0/1.1 disabled) +- [ ] HSTS header is present (`max-age=31536000; includeSubDomains; preload`) +- [ ] CSP header is configured with no `unsafe-eval` +- [ ] `server_tokens` is `off` in Nginx +- [ ] File uploads restricted to PDF only +- [ ] File uploads limited to 16MB +- [ ] Database backups scheduled daily +- [ ] Firewall rules applied (only 80/443 open) +- [ ] Application is not running as administrator +- [ ] Logs directory exists and is writable +- [ ] Health check endpoint returns 200 + +## Monitoring + +- **Application logs**: `./logs/app.log` +- **Error logs**: `./logs/errors.log` +- **Auth logs**: `./logs/auth.log` +- **Nginx access logs**: `C:\nginx\logs\access.log` +- **Nginx error logs**: `C:\nginx\logs\error.log` + +Monitor these logs regularly for suspicious activity. + +## Maintenance + +- Run `pip-audit` weekly to check for vulnerabilities +- Run `python security_scan.py` after any configuration changes +- Test backup restoration quarterly +- Review and rotate `SECRET_KEY` if compromised +- Keep Python and system packages updated \ No newline at end of file diff --git a/docs/security-checklist.md b/docs/security-checklist.md new file mode 100644 index 0000000..fbccf8d --- /dev/null +++ b/docs/security-checklist.md @@ -0,0 +1,169 @@ +# Pre-Deployment Security Checklist + +Run through this checklist before deploying to production. + +## Phase 1 — Transport Security + +- [ ] SSL certificate obtained and installed +- [ ] All HTTP requests redirect to HTTPS (301) +- [ ] HSTS header present: `max-age=31536000; includeSubDomains; preload` +- [ ] TLS 1.2 minimum, TLS 1.3 preferred +- [ ] Strong ciphers configured (no RC4, 3DES, or export-grade) +- [ ] OCSP Stapling configured (optional but recommended) + +## Phase 2 — Production WSGI Server + +- [ ] Application running via Waitress (not Flask dev server) +- [ ] Worker threads: `CPU * 2 + 1` +- [ ] Application bound to `127.0.0.1` (not `0.0.0.0`) +- [ ] Graceful shutdown configured + +## Phase 3 — Secure Cookies & Sessions + +- [ ] `SESSION_COOKIE_SECURE = True` +- [ ] `SESSION_COOKIE_HTTPONLY = True` +- [ ] `SESSION_COOKIE_SAMESITE = "Lax"` +- [ ] Session timeout ≤ 1 hour +- [ ] Session regenerated after login +- [ ] Session cleared after logout + +## Phase 5 — Authentication Security + +- [ ] Passwords hashed with werkzeug/bcrypt (never plain text) +- [ ] Password policy enforced (min 8 chars, uppercase, lowercase, digit) +- [ ] Account lockout after 5 failed attempts (15 min) +- [ ] CAPTCHA on registration form +- [ ] Rate limiting on login (10/min) and registration (3/hr) + +## Phase 6 — CSRF Protection + +- [ ] CSRF protection enabled (Flask-WTF) +- [ ] All POST/PUT/PATCH/DELETE requests protected +- [ ] CSRF-exempt routes reviewed and justified + +## Phase 7 — Input Validation + +- [ ] All form inputs validated with Marshmallow schemas +- [ ] Input whitespace stripped automatically +- [ ] Email format validated +- [ ] Phone format validated (if provided) +- [ ] Discord username format validated (if provided) + +## Phase 8 — SQL Injection Protection + +- [ ] All database queries use SQLAlchemy ORM +- [ ] No raw SQL with string interpolation +- [ ] Parameterized queries used if raw SQL is necessary + +## Phase 9 — HTTP Security Headers + +- [ ] `X-Content-Type-Options: nosniff` +- [ ] `X-Frame-Options: DENY` +- [ ] `Referrer-Policy: strict-origin-when-cross-origin` +- [ ] `Permissions-Policy: camera=(), microphone=(), geolocation=()` +- [ ] `Cross-Origin-Opener-Policy: same-origin` +- [ ] `Content-Security-Policy` configured +- [ ] CSP does not contain `unsafe-eval` + +## Phase 10 — CORS + +- [ ] CORS configured with explicit allowed origins +- [ ] No wildcard (`*`) origin in production +- [ ] Only necessary methods exposed +- [ ] Credentials support configured properly + +## Phase 11 — API Security + +- [ ] All API endpoints require authentication +- [ ] Authorization verified per-endpoint (not just authentication) +- [ ] Cross-user data access prevented (e.g., Player A cannot view Player B's data) + +## Phase 12 — File Upload Security + +- [ ] Upload size limited to 16MB (`MAX_CONTENT_LENGTH`) +- [ ] File extensions restricted to PDF only +- [ ] Files stored with UUID filenames (not original names) +- [ ] Upload directory outside web root + +## Phase 13 — Logging & Monitoring + +- [ ] Structured logging configured +- [ ] Separate logs for errors and auth events +- [ ] Sensitive data filtered from logs +- [ ] Log rotation configured +- [ ] Logs directory secured + +## Phase 14 — Rate Limiting & Abuse Protection + +- [ ] Global rate limits configured +- [ ] Login: 10 requests/minute +- [ ] Registration: 3 requests/hour +- [ ] CAPTCHA on registration + +## Phase 15 — Reverse Proxy Security + +- [ ] `server_tokens off` in Nginx +- [ ] Request size limits configured +- [ ] Buffer size limits configured +- [ ] Timeout settings configured +- [ ] Real IP forwarding headers set + +## Phase 16 — Database Security + +- [ ] Database not accessible from outside +- [ ] Least-privilege database user (when using PostgreSQL/MySQL) +- [ ] Daily automated backups configured +- [ ] Backup restoration tested + +## Phase 17 — Dependency Security + +- [ ] All packages pinned to specific versions +- [ ] Dependabot configured for automated updates +- [ ] pip-audit run with no critical vulnerabilities +- [ ] Regular dependency review schedule + +## Phase 18 — Infrastructure + +- [ ] Firewall: only ports 80 and 443 open +- [ ] Block direct access to application port (5000) +- [ ] SSH key authentication only (no password) +- [ ] Automatic security updates enabled +- [ ] Application running with least privilege + +## Phase 19 — Flask Best Practices + +- [ ] DEBUG mode disabled +- [ ] Custom error handlers for 400/401/403/404/429/500 +- [ ] No stack traces exposed in error pages +- [ ] Environment variables for all configuration +- [ ] Health check endpoint (`GET /health`) working +- [ ] Blueprints used for modular organization + +## Phase 20 — Deployment & CI/CD + +- [ ] Code in version control (Git) +- [ ] Main branch protected +- [ ] CI pipeline running (lint, security scan, tests) +- [ ] No secrets committed to repository +- [ ] Backup before each deployment + +## Phase 21 — Final Validation + +- [ ] Run `python security_scan.py --url https://yourdomain.com` +- [ ] Scan with Mozilla Observatory: https://observatory.mozilla.org/ +- [ ] Scan with SecurityHeaders.com: https://securityheaders.com/ +- [ ] Verify all pages redirect correctly to HTTPS +- [ ] Check cookies: Secure, HttpOnly, SameSite +- [ ] Confirm debug mode is off +- [ ] Review initial logs for sensitive data leaks +- [ ] Test rate limiting on login and registration +- [ ] Test account lockout mechanism +- [ ] Test file upload restrictions (non-PDF should fail) + +--- + +**Date Validated**: _______________ + +**Validated By**: _______________ + +**Notes**: _______________ \ No newline at end of file diff --git a/instance/team_tryouts.db b/instance/team_tryouts.db index b8bc55e..b8d9f0f 100644 Binary files a/instance/team_tryouts.db and b/instance/team_tryouts.db differ diff --git a/logging_config.py b/logging_config.py new file mode 100644 index 0000000..311c5e9 --- /dev/null +++ b/logging_config.py @@ -0,0 +1,154 @@ +"""Structured logging configuration for the Team Tryouts application. + +This module configures rotating file handlers for application logs, +with separate files for errors, authentication events, and general logs. +Sensitive data (passwords, tokens) is automatically filtered out. + +Usage: + from logging_config import configure_logging + configure_logging(app) +""" + +import logging +import os +from logging.handlers import RotatingFileHandler +import re + + +class SensitiveDataFilter(logging.Filter): + """Logging filter that redacts sensitive information from log messages. + + Filters out: passwords, API keys, session tokens, and other secrets + that might accidentally be logged. + """ + + # Patterns to redact + SENSITIVE_PATTERNS = [ + (re.compile(r'(?:password|passwd|secret|token|api[_-]?key)\s*[:=]\s*[^\s,;)]+', re.IGNORECASE), '[REDACTED]'), + (re.compile(r'(?:password|passwd|secret|token|api[_-]?key)\s*[:=]\s*"[^"]*"', re.IGNORECASE), lambda m: m.group(0).split('=')[0] + '="[REDACTED]"'), + (re.compile(r'Authorization[:\s]+[^\s]+', re.IGNORECASE), 'Authorization: [REDACTED]'), + (re.compile(r'Bearer\s+[^\s]+', re.IGNORECASE), 'Bearer [REDACTED]'), + ] + + def filter(self, record): + """Apply redaction to the log record's message. + + Args: + record: The log record to filter. + + Returns: + bool: Always True (never drops records, only redacts). + """ + if hasattr(record, 'msg') and isinstance(record.msg, str): + msg = record.msg + for pattern, replacement in self.SENSITIVE_PATTERNS: + if callable(replacement): + msg = pattern.sub(replacement, msg) + else: + msg = pattern.sub(replacement, msg) + record.msg = msg + return True + + +def configure_logging(app): + """Configure structured logging for the Flask application. + + Sets up three rotating file handlers: + - errors.log: ERROR and CRITICAL level messages + - auth.log: Authentication-related events (INFO and above) + - app.log: All application logs (DEBUG and above, configurable) + + Also configures console output for development. + + Args: + app: The Flask application instance to configure logging for. + """ + log_dir = os.path.join(os.getcwd(), 'logs') + os.makedirs(log_dir, exist_ok=True) + + # Remove default Flask handlers to avoid duplicate logging + app.logger.handlers.clear() + + # Set base log level from environment (default: INFO) + log_level_name = os.getenv('LOG_LEVEL', 'INFO').upper() + log_level = getattr(logging, log_level_name, logging.INFO) + app.logger.setLevel(log_level) + + # Create the sensitive data filter + sensitive_filter = SensitiveDataFilter() + + # Formatter with timestamp, level, module, and message + formatter = logging.Formatter( + '[%(asctime)s] %(levelname)s [%(name)s:%(lineno)d] %(message)s', + datefmt='%Y-%m-%d %H:%M:%S' + ) + + # ------------------------------------------------------------------------- + # 1. Error Log Handler + # ------------------------------------------------------------------------- + error_handler = RotatingFileHandler( + os.path.join(log_dir, 'errors.log'), + maxBytes=10 * 1024 * 1024, # 10 MB + backupCount=10 + ) + error_handler.setLevel(logging.ERROR) + error_handler.setFormatter(formatter) + error_handler.addFilter(sensitive_filter) + app.logger.addHandler(error_handler) + + # ------------------------------------------------------------------------- + # 2. Authentication Log Handler + # ------------------------------------------------------------------------- + auth_handler = RotatingFileHandler( + os.path.join(log_dir, 'auth.log'), + maxBytes=10 * 1024 * 1024, # 10 MB + backupCount=5 + ) + auth_handler.setLevel(logging.INFO) + auth_handler.setFormatter(formatter) + auth_handler.addFilter(sensitive_filter) + + # Create a named logger specifically for auth events + auth_logger = logging.getLogger('team_tryouts.auth') + auth_logger.setLevel(logging.INFO) + auth_logger.addHandler(auth_handler) + auth_logger.propagate = False # Don't double-log to root + + # ------------------------------------------------------------------------- + # 3. Application Log Handler (general) + # ------------------------------------------------------------------------- + app_handler = RotatingFileHandler( + os.path.join(log_dir, 'app.log'), + maxBytes=10 * 1024 * 1024, # 10 MB + backupCount=10 + ) + app_handler.setLevel(log_level) + app_handler.setFormatter(formatter) + app_handler.addFilter(sensitive_filter) + app.logger.addHandler(app_handler) + + # ------------------------------------------------------------------------- + # 4. Console Handler (for development) + # ------------------------------------------------------------------------- + if os.getenv('FLASK_DEBUG', 'false').lower() == 'true': + console_handler = logging.StreamHandler() + console_handler.setLevel(logging.DEBUG) + console_handler.setFormatter(formatter) + console_handler.addFilter(sensitive_filter) + app.logger.addHandler(console_handler) + + # Log startup information + app.logger.info('Logging configured - Level: %s, Log directory: %s', log_level_name, log_dir) + app.logger.info('Application startup') + + return app.logger + + +# Module-level auth logger factory +def get_auth_logger(): + """Get the authentication event logger. + + Returns: + logging.Logger: Logger for authentication events. + """ + return logging.getLogger('team_tryouts.auth') \ No newline at end of file diff --git a/models.py b/models.py index 5e6334d..1da0e4b 100644 --- a/models.py +++ b/models.py @@ -81,6 +81,10 @@ class User(UserMixin, db.Model): is_active_account = db.Column(db.Boolean, default=True) created_at = db.Column(db.DateTime, default=datetime.utcnow) + # Account lockout fields for brute-force protection + failed_login_attempts = db.Column(db.Integer, default=0) + locked_until = db.Column(db.DateTime, nullable=True) + # E-Sports specific fields games = db.Column(db.Text, nullable=True) # Comma-separated list of games discord_username = db.Column(db.String(128), nullable=True) # Discord handle (e.g., Username#1234) diff --git a/nginx.conf b/nginx.conf new file mode 100644 index 0000000..90b22e0 --- /dev/null +++ b/nginx.conf @@ -0,0 +1,167 @@ +# Team Tryouts - Production Nginx Configuration (Windows) +# +# This configuration provides: +# - HTTP to HTTPS redirect +# - TLS 1.2/1.3 with strong ciphers +# - HSTS enforcement +# - Request size limits +# - gzip compression +# - Proxy to Waitress (Flask) +# - Security headers (reinforced at reverse proxy level) + +worker_processes auto; + +events { + worker_connections 1024; + multi_accept on; +} + +http { + # ========================================================================= + # Basic Settings + # ========================================================================= + server_tokens off; # Hide Nginx version + sendfile on; + tcp_nopush on; + tcp_nodelay on; + keepalive_timeout 65; + types_hash_max_size 2048; + client_max_body_size 16M; # Max upload size (matches Flask MAX_CONTENT_LENGTH) + client_body_buffer_size 128k; + client_header_buffer_size 1k; + large_client_header_buffers 4 8k; + + include mime.types; + default_type application/octet-stream; + + # ========================================================================= + # Logging + # ========================================================================= + access_log logs/access.log; + error_log logs/error.log warn; + + # ========================================================================= + # Gzip Compression + # ========================================================================= + gzip on; + gzip_vary on; + gzip_proxied any; + gzip_comp_level 6; + gzip_min_length 256; + gzip_types + text/plain + text/css + text/xml + text/javascript + application/javascript + application/json + application/xml + application/rss+xml + image/svg+xml + font/ttf + font/otf; + + # ========================================================================= + # HTTP → HTTPS Redirect + # ========================================================================= + server { + listen 80; + server_name _; + + # Redirect all HTTP traffic to HTTPS + return 301 https://$host$request_uri; + } + + # ========================================================================= + # HTTPS Server + # ========================================================================= + server { + listen 443 ssl http2; + server_name _; + + # --------------------------------------------------------------------- + # SSL/TLS Configuration + # --------------------------------------------------------------------- + # Paths to SSL certificate and key (update these for your deployment) + ssl_certificate C:/nginx/certs/fullchain.pem; + ssl_certificate_key C:/nginx/certs/privkey.pem; + + # Strong TLS configuration + ssl_protocols TLSv1.2 TLSv1.3; + ssl_prefer_server_ciphers on; + ssl_ciphers ECDHE-ECDSA-AES128-GCM-SHA256:ECDHE-RSA-AES128-GCM-SHA256:ECDHE-ECDSA-AES256-GCM-SHA384:ECDHE-RSA-AES256-GCM-SHA384:ECDHE-ECDSA-CHACHA20-POLY1305:ECDHE-RSA-CHACHA20-POLY1305:DHE-RSA-AES128-GCM-SHA256:DHE-RSA-AES256-GCM-SHA384; + + # SSL session settings + ssl_session_cache shared:SSL:10m; + ssl_session_timeout 10m; + ssl_session_tickets off; + + # OCSP Stapling (uncomment when running on a proper domain) + # ssl_stapling on; + # ssl_stapling_verify on; + # ssl_trusted_certificate C:/nginx/certs/chain.pem; + + # Diffie-Hellman parameters (generate with: openssl dhparam -out dhparam.pem 2048) + # ssl_dhparam C:/nginx/certs/dhparam.pem; + + # --------------------------------------------------------------------- + # Security Headers (defense-in-depth with Flask's own headers) + # --------------------------------------------------------------------- + add_header Strict-Transport-Security "max-age=31536000; includeSubDomains; preload" always; + add_header X-Content-Type-Options "nosniff" always; + add_header X-Frame-Options "DENY" always; + add_header X-XSS-Protection "1; mode=block" always; + add_header Referrer-Policy "strict-origin-when-cross-origin" always; + add_header Permissions-Policy "camera=(), microphone=(), geolocation=(), interest-cohort=()" always; + add_header Cross-Origin-Opener-Policy "same-origin" always; + + # --------------------------------------------------------------------- + # Proxy to Waitress (Flask) + # --------------------------------------------------------------------- + location / { + proxy_pass http://127.0.0.1:5000; + + # Proxy headers + proxy_set_header Host $host; + proxy_set_header X-Real-IP $remote_addr; + proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; + proxy_set_header X-Forwarded-Proto $scheme; + proxy_set_header X-Forwarded-Host $host; + proxy_set_header X-Forwarded-Port $server_port; + + # Timeouts + proxy_connect_timeout 30s; + proxy_send_timeout 30s; + proxy_read_timeout 30s; + + # Buffer settings + proxy_buffering on; + proxy_buffer_size 4k; + proxy_buffers 8 4k; + proxy_busy_buffers_size 8k; + } + + # --------------------------------------------------------------------- + # Static Files (served directly by Nginx for performance) + # Uncomment and adjust path if you want Nginx to serve static files + # --------------------------------------------------------------------- + # location /static/ { + # alias C:/path/to/team-tryouts/static/; + # expires 30d; + # add_header Cache-Control "public, immutable"; + # access_log off; + # } + + # --------------------------------------------------------------------- + # Rate Limiting + # --------------------------------------------------------------------- + # Define rate limit zones (uncomment when rate limiting at Nginx level) + # limit_req_zone $binary_remote_addr zone=login:10m rate=5r/m; + # limit_req_zone $binary_remote_addr zone=global:10m rate=100r/m; + + # location /auth/login { + # limit_req zone=login burst=5 nodelay; + # proxy_pass http://127.0.0.1:5000; + # } + } +} \ No newline at end of file diff --git a/requirements.txt b/requirements.txt index 6293689..ee529cd 100644 Binary files a/requirements.txt and b/requirements.txt differ diff --git a/routes/auth.py b/routes/auth.py index fd34390..1378a89 100644 --- a/routes/auth.py +++ b/routes/auth.py @@ -1,21 +1,31 @@ """Authentication routes for user login, logout, and registration. -This module handles user authentication including login, logout, and new user 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. """ -from flask import Blueprint, render_template, redirect, url_for, flash, request +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 extensions import db, hash_password, check_password, limiter from models import User, ESPORT_GAMES +from 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). """ @@ -26,20 +36,59 @@ def is_safe_url(url): 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. - + """Handle user login authentication with account lockout protection. + GET: Render the login form. - POST: Authenticate user credentials and log them in. - + 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. """ @@ -47,16 +96,49 @@ def login(): return redirect(url_for('main.dashboard')) if request.method == 'POST': - username = request.form.get('username') - password = request.form.get('password') + # 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') - # Regenerate session to prevent session fixation attacks + + # 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): @@ -64,52 +146,104 @@ def login(): flash(f'Welcome back, {user.username}!', 'success') return redirect(next_page) if next_page else redirect(url_for('main.dashboard')) else: - flash('Login unsuccessful. Please check username and password.', 'danger') + # 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. - - GET: Render the registration form with E-Sports games list. - POST: Create a new player account with provided details. - + """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': - username = request.form.get('username') - email = request.form.get('email') - password = request.form.get('password') - confirm_password = request.form.get('confirm_password') - full_name = request.form.get('full_name') - phone = request.form.get('phone') + # 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 + ) - # E-Sports fields - selected_games = request.form.getlist('games') - trn_username = request.form.get('trn_username', '').strip() - discord_username = request.form.get('discord_username', '').strip() - league_os_profile = request.form.get('league_os_profile', '').strip() + # 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 + ) - if password != confirm_password: - flash('Passwords do not match.', 'danger') - return render_template('pages/register.html', esport_games=ESPORT_GAMES) + 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') - return render_template('pages/register.html', esport_games=ESPORT_GAMES) + 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') - return render_template('pages/register.html', esport_games=ESPORT_GAMES) + captcha = generate_captcha() + return render_template( + 'pages/register.html', + esport_games=ESPORT_GAMES, + captcha=captcha + ) hashed_password = hash_password(password) user = User( @@ -120,9 +254,8 @@ def register(): email=email, phone=phone, games=','.join(selected_games) if selected_games else None, - trn_username=trn_username or None, - discord_username=discord_username or None, - league_os_profile=league_os_profile or None + discord_username=discord_username, + league_os_profile=league_os_profile ) db.session.add(user) db.session.commit() @@ -130,19 +263,21 @@ def register(): 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) + 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. - - Clears the user session and redirects to the login page. - + """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')) \ No newline at end of file diff --git a/routes/users.py b/routes/users.py index 18861b9..48c09b3 100644 --- a/routes/users.py +++ b/routes/users.py @@ -1,18 +1,25 @@ """User management routes for profiles, disponibilities, and contracts. This module handles user CRUD operations, profile editing, player availability, -and contract management. +and contract management with secure file upload handling. """ import os +import uuid from flask import Blueprint, render_template, redirect, url_for, flash, request, jsonify, send_file from flask_login import login_required, current_user -from extensions import db, hash_password +from extensions import db, hash_password, csrf from models import User, ROLES, ESPORT_GAMES, PlayerDisponibility, UserGamertag, GAME_PLATFORMS, Contract, OrgTeam, CoachAvailability, TeamNote, PersonalNote, OneOnOneRequest, Evaluation, Match, Team, TeamMember, MatchParticipant, Tryout, TryoutRegistration, TeamPlayer from werkzeug.utils import secure_filename from datetime import datetime, timedelta, date as date_type +from marshmallow import ValidationError +from validators import CreateUserSchema, EditUserSchema, EditProfileSchema, UploadContractSchema, OneOnOneRequestSchema import requests +# Allowed file extensions for uploads +ALLOWED_CONTRACT_EXTENSIONS = {'pdf'} +ALLOWED_SIGNED_EXTENSIONS = {'pdf'} + users_bp = Blueprint('users', __name__, url_prefix='/users') @@ -641,8 +648,18 @@ def upload_contract(): players = User.query.filter_by(role='player').all() if request.method == 'POST': - player_id = request.form.get('player_id', type=int) - notes = request.form.get('notes', '').strip() + # Validate form input + contract_schema = UploadContractSchema() + try: + validated = contract_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/upload_contract.html', players=players) + + player_id = validated['player_id'] + notes = validated.get('notes') if not can_manage_player_contract(current_user, player_id): flash('You do not have permission to upload a contract for this player.', 'danger') @@ -657,6 +674,11 @@ def upload_contract(): flash('No file selected.', 'danger') return redirect(url_for('users.upload_contract')) + # Validate file extension (PDF only) + if not file.filename.lower().endswith('.pdf'): + flash('Only PDF files are allowed for contracts.', 'danger') + return redirect(url_for('users.upload_contract')) + # Create upload directory upload_dir = os.path.join(os.getcwd(), 'documents', 'contrats signés') os.makedirs(upload_dir, exist_ok=True) @@ -674,11 +696,10 @@ def upload_contract(): else: final_dir = upload_dir - # Generate unique filename + # Generate UUID-based filename for security (prevents filename guessing) original_filename = secure_filename(file.filename) - timestamp = datetime.now().strftime('%Y%m%d_%H%M%S') - stored_filename = f"{secure_filename(player.username)}_{timestamp}_{original_filename}" - stored_filename = stored_filename.replace(' ', '_') + file_uuid = str(uuid.uuid4()) + stored_filename = f"{file_uuid}.pdf" # Save file file_path = os.path.join(final_dir, stored_filename) @@ -1008,6 +1029,7 @@ def one_on_one(): @users_bp.route('/coach-availability', methods=['GET', 'POST']) @login_required +@csrf.exempt def manage_coach_availability(): """Manage coach availability (for coaches). @@ -1025,6 +1047,11 @@ def manage_coach_availability(): data = request.get_json() slots = data.get('slots', []) + # Clear all existing availability slots first, then re-insert from client state. + # This fixes the bug where un-toggling a slot on the client did not delete it + # from the database (the old code only added, never removed). + CoachAvailability.query.filter_by(coach_id=current_user.id).delete() + created = [] for slot in slots: day_of_week = slot.get('day_of_week') @@ -1040,29 +1067,21 @@ def manage_coach_availability(): end_time = add_30_minutes(start_time) - # Check if this slot already exists for this coach - existing = CoachAvailability.query.filter_by( + availability = CoachAvailability( coach_id=current_user.id, day_of_week=day_of_week, - start_time=start_time - ).first() - - if not existing: - availability = CoachAvailability( - coach_id=current_user.id, - day_of_week=day_of_week, - start_time=start_time, - end_time=end_time - ) - db.session.add(availability) - db.session.flush() - created.append({ - 'id': availability.id, - 'day_of_week': availability.day_of_week, - 'day_name': DAY_NAMES[availability.day_of_week], - 'start_time': availability.start_time.strftime('%H:%M'), - 'end_time': availability.end_time.strftime('%H:%M') - }) + start_time=start_time, + end_time=end_time + ) + db.session.add(availability) + db.session.flush() + created.append({ + 'id': availability.id, + 'day_of_week': availability.day_of_week, + 'day_name': DAY_NAMES[availability.day_of_week], + 'start_time': availability.start_time.strftime('%H:%M'), + 'end_time': availability.end_time.strftime('%H:%M') + }) db.session.commit() return jsonify({'success': True, 'created': created}) @@ -1076,6 +1095,7 @@ def manage_coach_availability(): @users_bp.route('/coach-availability/clear', methods=['POST']) @login_required +@csrf.exempt def clear_coach_availability(): """Clear all coach availability slots. @@ -1092,6 +1112,7 @@ def clear_coach_availability(): @users_bp.route('/coach-availability//delete', methods=['POST']) @login_required +@csrf.exempt def delete_coach_availability(availability_id): """Delete a coach availability slot. diff --git a/security_scan.py b/security_scan.py new file mode 100644 index 0000000..6a7a846 --- /dev/null +++ b/security_scan.py @@ -0,0 +1,372 @@ +"""Security validation script for the Team Tryouts application. + +This script performs pre-deployment security checks to validate: +- HTTP security headers +- Cookie security attributes +- Debug mode status +- HTTPS configuration +- Dependency vulnerabilities +- Database connectivity + +Usage: + python security_scan.py [--url http://localhost:5000] +""" + +import os +import sys +import json +import subprocess +import urllib.request +import ssl +from datetime import datetime + + +def check_environment(): + """Check required environment variables are set. + + Returns: + bool: True if all critical variables are set. + """ + print('=' * 60) + print('1. ENVIRONMENT VARIABLES CHECK') + print('=' * 60) + + critical_vars = ['SECRET_KEY'] + recommended_vars = ['DATABASE_URL', 'CORS_ALLOWED_ORIGINS'] + all_ok = True + + for var in critical_vars: + value = os.getenv(var) + if value: + # Check SECRET_KEY is not a default/weak value + if var == 'SECRET_KEY' and len(value) < 32: + print(f'[WARN] {var} is set but too short (less than 32 chars)') + all_ok = False + else: + print(f'[OK] {var} is set') + else: + print(f'[FAIL] {var} is not set!') + all_ok = False + + for var in recommended_vars: + value = os.getenv(var) + if value: + print(f'[OK] {var} is set') + else: + print(f'[INFO] {var} is not set (using default)') + + # Check FLASK_DEBUG + debug = os.getenv('FLASK_DEBUG', 'false').lower() + if debug == 'true': + print('[WARN] FLASK_DEBUG is enabled! Should be disabled in production.') + else: + print('[OK] FLASK_DEBUG is disabled') + + return all_ok + + +def check_https_headers(url): + """Check HTTP security headers from a running application. + + Args: + url: The base URL of the application to check. + + Returns: + bool: True if all critical headers are present. + """ + print('\n' + '=' * 60) + print('2. HTTP SECURITY HEADERS CHECK') + print('=' * 60) + + required_headers = { + 'Strict-Transport-Security': 'HSTS enabled', + 'X-Content-Type-Options': 'Prevents MIME sniffing', + 'X-Frame-Options': 'Prevents clickjacking', + 'Content-Security-Policy': 'CSP configured', + 'Referrer-Policy': 'Referrer control', + 'Permissions-Policy': 'Permissions control', + 'Cross-Origin-Opener-Policy': 'Cross-origin isolation', + } + + all_ok = True + + try: + # Create a context that doesn't verify SSL (for local testing) + ctx = ssl.create_default_context() + ctx.check_hostname = False + ctx.verify_mode = ssl.CERT_NONE + + req = urllib.request.Request(url, method='HEAD') + + try: + with urllib.request.urlopen(req, context=ctx, timeout=10) as response: + headers = response.headers + status = response.status + + print(f'[INFO] Response status: {status}') + + for header, description in required_headers.items(): + if header in headers: + print(f'[OK] {header}: {description}') + else: + print(f'[FAIL] {header} is missing: {description}') + all_ok = False + + # Check cookie attributes if any set-cookie headers exist + if 'Set-Cookie' in headers: + cookie = headers['Set-Cookie'] + if 'Secure' in cookie: + print('[OK] Cookies have Secure flag') + else: + print('[WARN] Cookies missing Secure flag') + all_ok = False + + if 'HttpOnly' in cookie: + print('[OK] Cookies have HttpOnly flag') + else: + print('[WARN] Cookies missing HttpOnly flag') + all_ok = False + + if 'SameSite' in cookie: + print(f'[OK] Cookies have SameSite={cookie.split("SameSite=")[1].split(";")[0] if "SameSite=" in cookie else "?"}') + else: + print('[WARN] Cookies missing SameSite attribute') + all_ok = False + else: + print('[INFO] No Set-Cookie headers in response') + + except urllib.error.HTTPError as e: + print(f'[INFO] Got HTTP {e.code} (may need authentication)') + # Still check headers even on error responses + for header, description in required_headers.items(): + if header in e.headers: + print(f'[OK] {header}: {description}') + else: + print(f'[FAIL] {header} is missing: {description}') + all_ok = False + + except urllib.error.URLError as e: + print(f'[SKIP] Cannot connect to {url}: {e.reason}') + print('[SKIP] Run with --url to check headers') + return True # Not a failure, just can't check + + return all_ok + + +def check_dependencies(): + """Run pip-audit to check for known vulnerabilities. + + Returns: + bool: True if no critical vulnerabilities found. + """ + print('\n' + '=' * 60) + print('3. DEPENDENCY VULNERABILITY SCAN') + print('=' * 60) + + try: + result = subprocess.run( + [sys.executable, '-m', 'pip_audit', '--format', 'json'], + capture_output=True, + text=True, + timeout=60 + ) + + if result.returncode == 0: + print('[OK] No known vulnerabilities found') + return True + else: + try: + data = json.loads(result.stdout) + vulns = data.get('dependencies', []) + if vulns: + for vuln in vulns: + print(f'[FAIL] {vuln["name"]}=={vuln["version"]}: {vuln.get("description", "Vulnerability found")}') + return False + else: + print('[OK] No vulnerabilities found') + return True + except json.JSONDecodeError: + if result.stdout: + print(f'[INFO] {result.stdout.strip()}') + if result.stderr: + print(f'[WARN] {result.stderr.strip()}') + return True + except FileNotFoundError: + print('[SKIP] pip-audit not installed. Run: pip install pip-audit') + return True + except subprocess.TimeoutExpired: + print('[WARN] pip-audit timed out') + return True + + +def check_file_permissions(): + """Check for common security issues in the project structure. + + Returns: + bool: True if no critical issues found. + """ + print('\n' + '=' * 60) + print('4. PROJECT FILES CHECK') + print('=' * 60) + + all_ok = True + + # Check .gitignore exists and contains important patterns + gitignore_path = os.path.join(os.getcwd(), '.gitignore') + if os.path.exists(gitignore_path): + required_patterns = ['.env', 'instance/', '*.db', '*.log'] + with open(gitignore_path, 'r') as f: + content = f.read() + + for pattern in required_patterns: + if pattern in content: + print(f'[OK] .gitignore contains: {pattern}') + else: + print(f'[WARN] .gitignore missing: {pattern}') + all_ok = False + else: + print('[FAIL] .gitignore file not found!') + all_ok = False + + # Check for .env in working directory (should NOT be committed) + env_path = os.path.join(os.getcwd(), '.env') + if os.path.exists(env_path): + print('[INFO] .env file exists (ensure it is NOT committed)') + else: + print('[WARN] No .env file found') + + # Check for leftover .pyc or __pycache__ + pycache_count = 0 + for root, dirs, files in os.walk(os.getcwd()): + if '__pycache__' in dirs: + pycache_count += 1 + for f in files: + if f.endswith('.pyc'): + pycache_count += 1 + if pycache_count == 0: + print('[OK] No __pycache__ or .pyc files found') + else: + print(f'[INFO] Found {pycache_count} cache files/dirs (should be in .gitignore)') + + return all_ok + + +def check_flask_config(): + """Check Flask application configuration for security. + + Returns: + bool: True if configuration looks secure. + """ + print('\n' + '=' * 60) + print('5. FLASK CONFIGURATION CHECK') + print('=' * 60) + + all_ok = True + + try: + from app import create_app + app = create_app() + + # Check session cookie settings + cookie_checks = [ + ('SESSION_COOKIE_SECURE', True, 'Secure cookies'), + ('SESSION_COOKIE_HTTPONLY', True, 'HttpOnly cookies'), + ('PERMANENT_SESSION_LIFETIME', 3600, 'Session timeout'), + ] + + for config_key, expected, description in cookie_checks: + value = app.config.get(config_key) + if config_key == 'PERMANENT_SESSION_LIFETIME': + if value and value <= 3600: + print(f'[OK] {description}: {value}s') + else: + print(f'[WARN] {description}: {value}s (should be <= 1 hour)') + all_ok = False + elif value == expected: + print(f'[OK] {description}: enabled') + else: + print(f'[FAIL] {description}: {value}') + all_ok = False + + # Check MAX_CONTENT_LENGTH + max_content = app.config.get('MAX_CONTENT_LENGTH') + if max_content: + mb = max_content / (1024 * 1024) + print(f'[OK] MAX_CONTENT_LENGTH: {mb}MB') + else: + print('[WARN] MAX_CONTENT_LENGTH not set (unlimited uploads)') + all_ok = False + + # Check CSRF + csrf_enabled = app.config.get('WTF_CSRF_ENABLED') + if csrf_enabled: + print('[OK] CSRF protection: enabled') + else: + print('[FAIL] CSRF protection: disabled') + all_ok = False + + # Check if app is in DEBUG mode + if app.debug: + print('[FAIL] DEBUG mode is enabled!') + all_ok = False + else: + print('[OK] DEBUG mode: disabled') + + except Exception as e: + print(f'[SKIP] Cannot check Flask config: {e}') + + return all_ok + + +def main(): + """Run all security checks and produce a summary report. + + Returns: + int: 0 if all checks pass, 1 if any fail. + """ + import argparse + + parser = argparse.ArgumentParser(description='Security validation scanner') + parser.add_argument('--url', default='http://localhost:5000', + help='Application URL to check headers (default: http://localhost:5000)') + args = parser.parse_args() + + print('╔══════════════════════════════════════════════════════════╗') + print('║ TEAM TRYOUTS - SECURITY VALIDATION SCANNER ║') + print('╠══════════════════════════════════════════════════════════╣') + print(f'║ Time: {datetime.now().strftime("%Y-%m-%d %H:%M:%S")}') + print('╚══════════════════════════════════════════════════════════╝') + + checks = [ + check_environment, + lambda: check_https_headers(args.url), + check_dependencies, + check_file_permissions, + check_flask_config, + ] + + results = [] + for check in checks: + results.append(check()) + + print('\n' + '=' * 60) + print('SUMMARY') + print('=' * 60) + + passed = sum(1 for r in results if r) + failed = sum(1 for r in results if not r) + total = len(results) + + print(f'Passed: {passed}/{total}') + print(f'Failed: {failed}/{total}') + + if failed == 0: + print('\n[OK] All security checks passed!') + return 0 + else: + print(f'\n[WARN] {failed} check(s) failed. Review the output above.') + return 1 + + +if __name__ == '__main__': + sys.exit(main()) \ No newline at end of file diff --git a/static/css/style.css b/static/css/style.css index 91a51ab..129747e 100644 --- a/static/css/style.css +++ b/static/css/style.css @@ -1893,3 +1893,58 @@ a:hover { color: var(--primary-dark); } [data-theme="dark"] .sort-link:hover { color: var(--text-primary); } + +/* ========================================================================= + Error Pages + ========================================================================= */ +.error-container { + text-align: center; + padding: 60px 30px; + max-width: 500px; + margin: 40px auto; + background: white; + border-radius: var(--radius); + box-shadow: var(--shadow-md); +} + +.error-icon { + font-size: 4rem; + margin-bottom: 20px; + color: var(--gray-400); +} + +.error-container h2 { + font-size: 1.5rem; + font-weight: 700; + color: var(--gray-800); + margin-bottom: 12px; +} + +.error-container p { + color: var(--gray-500); + font-size: 0.95rem; + margin-bottom: 24px; + line-height: 1.6; +} + +.error-container .btn { + display: inline-flex; + align-items: center; + gap: 8px; +} + +[data-theme="dark"] .error-container { + background: var(--bg-secondary); +} + +[data-theme="dark"] .error-icon { + color: var(--text-muted); +} + +[data-theme="dark"] .error-container h2 { + color: var(--text-primary); +} + +[data-theme="dark"] .error-container p { + color: var(--text-secondary); +} diff --git a/templates/errors/400.html b/templates/errors/400.html new file mode 100644 index 0000000..2e2ad56 --- /dev/null +++ b/templates/errors/400.html @@ -0,0 +1,15 @@ +{% extends "layouts/base.html" %} +{% block title %}400 Bad Request - TryoutPro{% endblock %} +{% block page_title %}Bad Request{% endblock %} +{% block content %} +
+
+ +
+

400 — Bad Request

+

The request could not be understood by the server. Please check your input and try again.

+ + Go Back + +
+{% endblock %} \ No newline at end of file diff --git a/templates/errors/403.html b/templates/errors/403.html new file mode 100644 index 0000000..7309d3b --- /dev/null +++ b/templates/errors/403.html @@ -0,0 +1,15 @@ +{% extends "layouts/base.html" %} +{% block title %}403 Forbidden - TryoutPro{% endblock %} +{% block page_title %}Access Denied{% endblock %} +{% block content %} +
+
+ +
+

403 — Forbidden

+

You do not have permission to access this resource. If you believe this is an error, please contact an administrator.

+ + Go Back + +
+{% endblock %} \ No newline at end of file diff --git a/templates/errors/404.html b/templates/errors/404.html new file mode 100644 index 0000000..4c80ff3 --- /dev/null +++ b/templates/errors/404.html @@ -0,0 +1,15 @@ +{% extends "layouts/base.html" %} +{% block title %}404 Not Found - TryoutPro{% endblock %} +{% block page_title %}Page Not Found{% endblock %} +{% block content %} +
+
+ +
+

404 — Not Found

+

The page you are looking for does not exist. It may have been moved or deleted.

+ + Return Home + +
+{% endblock %} \ No newline at end of file diff --git a/templates/errors/429.html b/templates/errors/429.html new file mode 100644 index 0000000..72e9beb --- /dev/null +++ b/templates/errors/429.html @@ -0,0 +1,15 @@ +{% extends "layouts/base.html" %} +{% block title %}429 Too Many Requests - TryoutPro{% endblock %} +{% block page_title %}Rate Limit Exceeded{% endblock %} +{% block content %} +
+
+ +
+

429 — Too Many Requests

+

You have sent too many requests in a short period. Please wait a moment and try again.

+ + Go Back + +
+{% endblock %} \ No newline at end of file diff --git a/templates/errors/500.html b/templates/errors/500.html new file mode 100644 index 0000000..b57aeaa --- /dev/null +++ b/templates/errors/500.html @@ -0,0 +1,15 @@ +{% extends "layouts/base.html" %} +{% block title %}500 Server Error - TryoutPro{% endblock %} +{% block page_title %}Internal Server Error{% endblock %} +{% block content %} +
+
+ +
+

500 — Internal Server Error

+

Something went wrong on our end. The error has been logged and will be investigated. Please try again later.

+ + Try Again + +
+{% endblock %} \ No newline at end of file diff --git a/templates/pages/coach_availability.html b/templates/pages/coach_availability.html index 29a933f..2a1629a 100644 --- a/templates/pages/coach_availability.html +++ b/templates/pages/coach_availability.html @@ -22,39 +22,6 @@ -
-
-

Current Availability

-
-
- {% if existing_availability %} - - - - - - - - - - {% for av in existing_availability %} - - - - - - {% endfor %} - -
DayTimeAction
{{ ['Monday', 'Tuesday', 'Wednesday', 'Thursday', 'Friday', 'Saturday', 'Sunday'][av.day_of_week] }}{{ av.start_time.strftime('%I:%M %p') }} - {{ av.end_time.strftime('%I:%M %p') }} - -
- {% else %} -

No availability slots set. Use the grid above to add your available times.

- {% endif %} -
-
{% endblock %} {% block scripts %} @@ -207,9 +174,12 @@ function saveAvailability() { .then(response => response.json()) .then(data => { if (data.success) { - flash('Availability saved successfully!', 'success'); - setTimeout(() => location.reload(), 1000); + flash('Availability saved!', 'success'); } + }) + .catch(function(error) { + console.error('Save error:', error); + flash('Error saving availability.', 'danger'); }); } @@ -229,17 +199,6 @@ function clearAllAvailability() { }); } -function deleteAvailability(availabilityId) { - fetch('{{ url_for("users.delete_coach_availability", availability_id=0) }}'.replace('/0', '/' + availabilityId), { method: 'POST' }) - .then(response => response.json()) - .then(data => { - if (data.success) { - flash('Availability slot removed!', 'success'); - setTimeout(() => location.reload(), 1000); - } - }); -} - function flash(message, type) { const flashContainer = document.querySelector('.flash-messages'); const alert = document.createElement('div'); diff --git a/templates/pages/match_form.html b/templates/pages/match_form.html index 8cdae1b..477ad30 100644 --- a/templates/pages/match_form.html +++ b/templates/pages/match_form.html @@ -70,12 +70,6 @@

Select Match Time

Click time slots consecutively to set match duration. Players available in all selected time blocks are shown below.

- -

Loading...

@@ -539,9 +533,6 @@ document.addEventListener('DOMContentLoaded', function() { } } - document.getElementById('merged-disponibility-selected').classList.remove('hidden'); - document.getElementById('selected-time-badge').textContent = - formatTimeDisplay(selectedStartTime) + ' - ' + formatTimeDisplay(selectedEndTime); } } @@ -621,26 +612,38 @@ function renderMergedDisponibilityGrid() { dayRow += '
' + day.name + '
'; dayRow += '
'; + // First pass: collect all counts to determine max + var slotCounts = []; TIME_SLOTS.forEach(function(slot) { var count = getAvailabilityCount(day.value, slot.time); - var percentage = totalPlayers > 0 ? count / totalPlayers : 0; + slotCounts.push({ time: slot.time, display: slot.display, count: count }); + }); + + // Find max count to use as reference + var maxCount = 0; + slotCounts.forEach(function(s) { if (s.count > maxCount) maxCount = s.count; }); + + slotCounts.forEach(function(slotData) { + var count = slotData.count; var cssClass = 'merged-disponibility-time-block'; - if (percentage >= 0.75) { - cssClass += ' high-availability'; - } else if (percentage >= 0.5) { - cssClass += ' medium-availability'; - } else { - cssClass += ' low-availability'; + // Only color slots with 2+ available players. Green = top count, yellow = medium, no class for low + if (count >= 2) { + if (maxCount > 0 && count === maxCount) { + cssClass += ' high-availability'; + } else if (count >= Math.ceil(maxCount * 0.5)) { + cssClass += ' medium-availability'; + } } + // Slots with 0-1 players get no special color class (neutral) - if (selectedSlots.includes(slot.time)) { + if (selectedSlots.includes(slotData.time)) { cssClass += ' selected'; } - dayRow += '
' + - slot.display + + dayRow += '
' + + slotData.display + '' + count + '' + '
'; }); @@ -658,6 +661,9 @@ function getAvailabilityCount(dayOfWeek, timeStr) { var minutes = parseInt(timeParts[0]) * 60 + parseInt(timeParts[1]); for (var playerId in allDisponibilities) { + // Only count players registered for THIS tryout + if (!allRegisteredPlayers.includes(parseInt(playerId))) continue; + var playerData = allDisponibilities[playerId]; if (playerData && playerData.disponibilities) { var isAvailable = playerData.disponibilities.some(function(d) { @@ -746,14 +752,6 @@ function toggleTimeSlot(dayOfWeek, timeStr, element) { document.getElementById('start_time').value = startTime; document.getElementById('end_time').value = endTime; - if (selectedSlots.length > 0) { - document.getElementById('merged-disponibility-selected').classList.remove('hidden'); - document.getElementById('selected-time-badge').textContent = - formatTimeDisplay(selectedSlots[0]) + ' - ' + formatTimeDisplay(endTime); - } else { - document.getElementById('merged-disponibility-selected').classList.add('hidden'); - } - document.querySelectorAll('.merged-disponibility-time-block').forEach(function(el) { el.classList.remove('selected'); }); @@ -868,12 +866,11 @@ function updatePlayerPool() { var team2Ids = getSelectedTeamIds(2); var assignedIds = [...team1Ids, ...team2Ids]; - // When time slots are selected, show only players available in all slots who aren't assigned yet - // When no time slots selected, show all registered players - var playersToShow = selectedSlots.length > 0 ? availablePlayersForSlots : allRegisteredPlayers; + // Always show ALL registered players, not just available ones. + // Availability indicators are shown per-player, but coaches can still select any player. - if (playersToShow.length === 0) { - pool.innerHTML = '

No players available

'; + if (allRegisteredPlayers.length === 0) { + pool.innerHTML = '

No players registered

'; return; } @@ -883,7 +880,7 @@ function updatePlayerPool() { var dayOfWeek = jsDay === 0 ? 6 : jsDay - 1; var html = ''; - playersToShow.forEach(function(pid) { + allRegisteredPlayers.forEach(function(pid) { if (assignedIds.includes(pid)) return; var playerName = playerDataById.player_data[pid]; @@ -896,6 +893,9 @@ function updatePlayerPool() { isAvailable = selectedSlots.every(function(slot) { return playerSlots.includes(slot); }); + } else if (selectedSlots.length === 0) { + // No time slots selected yet: all players count as "available" (no filter active) + isAvailable = true; } var availabilityClass = isAvailable ? 'available' : 'unavailable'; @@ -961,7 +961,6 @@ function clearTimeSelection() { selectedSlots = []; document.getElementById('start_time').value = ''; document.getElementById('end_time').value = ''; - document.getElementById('merged-disponibility-selected').classList.add('hidden'); document.querySelectorAll('.merged-disponibility-time-block').forEach(function(el) { el.classList.remove('selected'); }); diff --git a/validators.py b/validators.py new file mode 100644 index 0000000..89ee4de --- /dev/null +++ b/validators.py @@ -0,0 +1,472 @@ +"""Input validation schemas for the Team Tryouts application. + +This module provides Marshmallow schemas for validating and sanitizing +all user inputs including forms, JSON requests, and file uploads. +All validation is centralized here for consistency and maintainability. + +Usage: + from validators import LoginSchema + schema = LoginSchema() + errors = schema.validate(request.form) +""" + +import re +from marshmallow import Schema, fields, validate, ValidationError, pre_load, validates_schema, EXCLUDE + + +# ============================================================================= +# Custom Validators +# ============================================================================= + +PASSWORD_POLICY = re.compile( + r'^(?=.*[a-z])(?=.*[A-Z])(?=.*\d).{8,}$' +) +"""Password policy: minimum 8 chars, 1 uppercase, 1 lowercase, 1 digit.""" + + +def validate_password(value): + """Validate password meets strength requirements. + + Requires: minimum 8 characters, at least one uppercase letter, + one lowercase letter, and one digit. + + Args: + value: The password string to validate. + + Raises: + ValidationError: If password does not meet requirements. + """ + if not PASSWORD_POLICY.match(value): + raise ValidationError( + 'Password must be at least 8 characters with uppercase, ' + 'lowercase, and a number.' + ) + + +def validate_username(value): + """Validate username format. + + Usernames must be 3-30 characters and contain only alphanumeric + characters, underscores, and hyphens. + + Args: + value: The username string to validate. + + Raises: + ValidationError: If username does not meet requirements. + """ + if not re.match(r'^[a-zA-Z0-9_-]{3,30}$', value): + raise ValidationError( + 'Username must be 3-30 characters (letters, numbers, underscore, hyphen).' + ) + + +def validate_discord_username(value): + """Validate Discord username format if provided. + + Accepts empty strings (optional field). Validates that the username + matches common Discord username patterns. + + Args: + value: The Discord username to validate. + + Raises: + ValidationError: If the format is invalid. + """ + if not value: + return + if not re.match(r'^[a-zA-Z0-9_.]{2,32}$', value): + raise ValidationError('Invalid Discord username format.') + + +def validate_discord_user_id(value): + """Validate Discord user ID (snowflake) if provided. + + Discord user IDs are 17-20 digit numbers. + + Args: + value: The Discord user ID to validate. + + Raises: + ValidationError: If the format is invalid. + """ + if not value: + return + if not re.match(r'^\d{17,20}$', value): + raise ValidationError('Discord User ID must be a 17-20 digit number.') + + +def validate_phone(value): + """Validate optional phone number format. + + Accepts empty strings. Validates common phone formats. + + Args: + value: The phone number to validate. + + Raises: + ValidationError: If the format is invalid. + """ + if not value: + return + cleaned = re.sub(r'[\s\-\(\)\.]', '', value) + if not re.match(r'^\+?\d{7,15}$', cleaned): + raise ValidationError('Invalid phone number format.') + + +# ============================================================================= +# Validation Schemas +# ============================================================================= + +class StripMixin(Schema): + """Mixin that automatically strips whitespace from all string fields + and ignores unknown fields (e.g., csrf_token from Flask-WTF).""" + + class Meta: + """Marshmallow Meta options.""" + unknown = EXCLUDE # Ignore csrf_token and other unknown fields + + @pre_load + def strip_strings(self, data, **kwargs): + """Strip whitespace from all string values in the input data. + + Args: + data: The input dictionary. + + Returns: + dict: Data with stripped strings. + """ + if isinstance(data, dict): + return {k: v.strip() if isinstance(v, str) else v for k, v in data.items()} + return data + + +class LoginSchema(StripMixin): + """Validate login form input. + + Fields: + username: 3-30 chars, required. + password: Non-empty, required. + """ + username = fields.String( + required=True, + validate=validate.Length(min=1, max=80, error='Username is required.'), + ) + password = fields.String( + required=True, + validate=validate.Length(min=1, error='Password is required.'), + ) + + +class RegisterSchema(StripMixin): + """Validate player registration form input. + + Fields: + username: 3-30 chars alphanumeric, required. + email: Valid email, required. + password: Meets password policy, required. + confirm_password: Must match password, required. + full_name: 1-100 chars, required. + phone: Optional, valid phone format. + games: Optional list. + discord_username: Optional, valid format. + league_os_profile: Optional URL. + """ + username = fields.String( + required=True, + validate=[ + validate.Length(min=3, max=80, error='Username must be 3-80 characters.'), + validate_username, + ], + ) + email = fields.Email( + required=True, + validate=validate.Length(max=120, error='Email must be 120 characters or less.'), + ) + password = fields.String( + required=True, + validate=validate_password, + load_only=True, + ) + confirm_password = fields.String( + required=True, + load_only=True, + ) + full_name = fields.String( + required=True, + validate=validate.Length(min=1, max=100, error='Full name is required.'), + ) + phone = fields.String( + validate=validate_phone, + allow_none=True, + missing=None, + ) + games = fields.List(fields.String(), missing=[]) + discord_username = fields.String( + validate=validate_discord_username, + allow_none=True, + missing=None, + ) + trn_username = fields.String( + allow_none=True, + missing=None, + ) + league_os_profile = fields.String( + validate=validate.Length(max=256), + allow_none=True, + missing=None, + ) + + @validates_schema + def validate_password_match(self, data, **kwargs): + """Ensure confirm_password matches password. + + Args: + data: The validated data dictionary. + + Raises: + ValidationError: If passwords do not match. + """ + if data.get('password') != data.get('confirm_password'): + raise ValidationError('Passwords do not match.', field_name='confirm_password') + + +class CreateUserSchema(StripMixin): + """Validate president-created user form input. + + Fields: + username: 3-30 chars alphanumeric, required. + email: Valid email, required. + password: Meets password policy, required. + full_name: 1-100 chars, required. + role: Must be valid role, required. + phone: Optional, valid phone format. + """ + username = fields.String( + required=True, + validate=[ + validate.Length(min=3, max=80, error='Username must be 3-80 characters.'), + validate_username, + ], + ) + email = fields.Email( + required=True, + validate=validate.Length(max=120), + ) + password = fields.String( + required=True, + validate=validate_password, + load_only=True, + ) + full_name = fields.String( + required=True, + validate=validate.Length(min=1, max=100, error='Full name is required.'), + ) + role = fields.String( + required=True, + validate=validate.OneOf( + ['president', 'manager', 'coach', 'player', 'scout'], + error='Invalid role selected.' + ), + ) + phone = fields.String( + validate=validate_phone, + allow_none=True, + missing=None, + ) + + +class EditUserSchema(StripMixin): + """Validate president-edited user form input. + + Fields: + full_name: 1-100 chars, required. + email: Valid email, required. + role: Must be valid role, required. + is_active_account: Boolean. + phone: Optional. + password: Optional (only if changing). + discord_username: Optional. + discord_user_id: Optional. + league_os_profile: Optional. + games: Optional list. + """ + full_name = fields.String( + required=True, + validate=validate.Length(min=1, max=100, error='Full name is required.'), + ) + email = fields.Email( + required=True, + validate=validate.Length(max=120), + ) + role = fields.String( + required=True, + validate=validate.OneOf( + ['president', 'manager', 'coach', 'player', 'scout'], + error='Invalid role selected.' + ), + ) + is_active_account = fields.Boolean(missing=True) + phone = fields.String( + validate=validate_phone, + allow_none=True, + missing=None, + ) + password = fields.String( + validate=validate_password, + load_only=True, + allow_none=True, + missing='', + ) + discord_username = fields.String( + validate=validate_discord_username, + allow_none=True, + missing=None, + ) + discord_user_id = fields.String( + validate=validate_discord_user_id, + allow_none=True, + missing=None, + ) + league_os_profile = fields.String( + validate=validate.Length(max=256), + allow_none=True, + missing=None, + ) + games = fields.List(fields.String(), missing=[]) + + +class EditProfileSchema(StripMixin): + """Validate self-edit profile form input. + + Fields: + username: 3-30 chars, required. + full_name: 1-100 chars, required. + email: Valid email, required. + phone: Optional. + password: Optional (only if changing). + discord_username: Optional. + discord_user_id: Optional. + league_os_profile: Optional. + games: Optional list. + """ + username = fields.String( + required=True, + validate=[ + validate.Length(min=3, max=80), + validate_username, + ], + ) + full_name = fields.String( + required=True, + validate=validate.Length(min=1, max=100, error='Full name is required.'), + ) + email = fields.Email( + required=True, + validate=validate.Length(max=120), + ) + phone = fields.String( + validate=validate_phone, + allow_none=True, + missing=None, + ) + password = fields.String( + validate=validate_password, + load_only=True, + allow_none=True, + missing='', + ) + discord_username = fields.String( + validate=validate_discord_username, + allow_none=True, + missing=None, + ) + discord_user_id = fields.String( + validate=validate_discord_user_id, + allow_none=True, + missing=None, + ) + league_os_profile = fields.String( + validate=validate.Length(max=256), + allow_none=True, + missing=None, + ) + games = fields.List(fields.String(), missing=[]) + + +class UploadContractSchema(StripMixin): + """Validate contract upload form input. + + Fields: + player_id: Integer, required. + notes: Optional text. + """ + player_id = fields.Integer( + required=True, + validate=validate.Range(min=1, error='Player must be selected.'), + ) + notes = fields.String( + validate=validate.Length(max=2000, error='Notes must be 2000 characters or less.'), + allow_none=True, + missing=None, + ) + + +class OneOnOneRequestSchema(StripMixin): + """Validate One on One session request form input. + + Fields: + date: Date string (YYYY-MM-DD), required. + start_time: Time string (HH:MM), required. + end_time: Time string (HH:MM), required. + points: Optional text. + """ + date = fields.String( + required=True, + validate=validate.Regexp( + r'^\d{4}-\d{2}-\d{2}$', + error='Date must be in YYYY-MM-DD format.' + ), + ) + start_time = fields.String( + required=True, + validate=validate.Regexp( + r'^\d{2}:\d{2}$', + error='Start time must be in HH:MM format.' + ), + ) + end_time = fields.String( + required=True, + validate=validate.Regexp( + r'^\d{2}:\d{2}$', + error='End time must be in HH:MM format.' + ), + ) + points = fields.String( + validate=validate.Length(max=2000, error='Points must be 2000 characters or less.'), + allow_none=True, + missing=None, + ) + + +class DisponibilityAddSchema(StripMixin): + """Validate disponibility block addition. + + Fields: + day_of_week: Integer 0-6, required. + start_time: Time string (HH:MM), required. + """ + day_of_week = fields.Integer( + required=True, + validate=validate.Range( + min=0, max=6, + error='Day must be 0 (Monday) to 6 (Sunday).' + ), + ) + start_time = fields.String( + required=True, + validate=validate.Regexp( + r'^\d{2}:\d{2}$', + error='Start time must be in HH:MM format.' + ), + ) \ No newline at end of file diff --git a/wsgi.py b/wsgi.py new file mode 100644 index 0000000..2219c31 --- /dev/null +++ b/wsgi.py @@ -0,0 +1,37 @@ +"""WSGI entry point for the Team Tryouts application. + +This module provides the production WSGI server using Waitress (Windows). +Use this file to start the application in production instead of Flask's +built-in development server. + +Usage: + python wsgi.py + +Configuration via environment variables: + PORT: Port to listen on (default: 5000) + WAITRESS_THREADS: Number of worker threads (default: CPU*2+1) +""" + +import os +import multiprocessing +from app import create_app + +app = create_app() + +if __name__ == '__main__': + from waitress import serve + + port = int(os.getenv('PORT', 5000)) + threads = int(os.getenv('WAITRESS_THREADS', multiprocessing.cpu_count() * 2 + 1)) + host = os.getenv('HOST', '127.0.0.1') # Bind to localhost by default (Nginx reverse proxy) + + print(f'Starting Waitress server on {host}:{port} with {threads} threads') + serve( + app, + host=host, + port=port, + threads=threads, + # Graceful shutdown settings + channel_timeout=30, # Seconds to wait for in-flight requests + cleanup_interval=30, + ) \ No newline at end of file