demander IA de faire tous les modifications pour que le webapp soit pret au déploiement.

Force connection HTTPS, proxy-inversé, WSGI de production, reset cookie de conncection à chaque reconnection, limite sur les mdp, fichiers et One on One par minute, verification d'injection de SQL dans les champs d'entrées. renommage des fichiers lors du téléchargement, fichier de backup quotidien pour la bd et j'ai oublié quelque chose :(
This commit is contained in:
cedrick2711
2026-07-25 18:47:27 -04:00
parent afdf6ded0e
commit 666673fa8f
24 changed files with 2513 additions and 171 deletions
+40
View File
@@ -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"
+94
View File
@@ -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
+254 -12
View File
@@ -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
@@ -35,9 +37,14 @@ def create_app():
- 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.
@@ -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:
@@ -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)
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)
+184
View File
@@ -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())
+163
View File
@@ -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=<your-generated-64-char-hex-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
+169
View File
@@ -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**: _______________
Binary file not shown.
+154
View File
@@ -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')
+4
View File
@@ -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)
+167
View File
@@ -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;
# }
}
}
BIN
View File
Binary file not shown.
+168 -33
View File
@@ -1,14 +1,24 @@
"""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).
@@ -26,16 +36,55 @@ 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.
@@ -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,17 +146,37 @@ 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.
"""Handle new player registration with CAPTCHA and password policy.
GET: Render the registration form with E-Sports games list.
POST: Create a new player account with provided details.
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.
@@ -85,31 +187,63 @@ def register():
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.
"""Log out the current user and clear the session.
Clears the user session and redirects to the login page.
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'))
+50 -29
View File
@@ -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/<int:availability_id>/delete', methods=['POST'])
@login_required
@csrf.exempt
def delete_coach_availability(availability_id):
"""Delete a coach availability slot.
+372
View File
@@ -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 <application_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())
+55
View File
@@ -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);
}
+15
View File
@@ -0,0 +1,15 @@
{% extends "layouts/base.html" %}
{% block title %}400 Bad Request - TryoutPro{% endblock %}
{% block page_title %}Bad Request{% endblock %}
{% block content %}
<div class="error-container">
<div class="error-icon">
<i class="fas fa-exclamation-triangle"></i>
</div>
<h2>400 — Bad Request</h2>
<p>The request could not be understood by the server. Please check your input and try again.</p>
<a href="{{ url_for('main.dashboard') if current_user.is_authenticated else url_for('auth.login') }}" class="btn btn-primary">
<i class="fas fa-arrow-left"></i> Go Back
</a>
</div>
{% endblock %}
+15
View File
@@ -0,0 +1,15 @@
{% extends "layouts/base.html" %}
{% block title %}403 Forbidden - TryoutPro{% endblock %}
{% block page_title %}Access Denied{% endblock %}
{% block content %}
<div class="error-container">
<div class="error-icon">
<i class="fas fa-lock"></i>
</div>
<h2>403 — Forbidden</h2>
<p>You do not have permission to access this resource. If you believe this is an error, please contact an administrator.</p>
<a href="{{ url_for('main.dashboard') if current_user.is_authenticated else url_for('auth.login') }}" class="btn btn-primary">
<i class="fas fa-arrow-left"></i> Go Back
</a>
</div>
{% endblock %}
+15
View File
@@ -0,0 +1,15 @@
{% extends "layouts/base.html" %}
{% block title %}404 Not Found - TryoutPro{% endblock %}
{% block page_title %}Page Not Found{% endblock %}
{% block content %}
<div class="error-container">
<div class="error-icon">
<i class="fas fa-search"></i>
</div>
<h2>404 — Not Found</h2>
<p>The page you are looking for does not exist. It may have been moved or deleted.</p>
<a href="{{ url_for('main.dashboard') if current_user.is_authenticated else url_for('auth.login') }}" class="btn btn-primary">
<i class="fas fa-home"></i> Return Home
</a>
</div>
{% endblock %}
+15
View File
@@ -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 %}
<div class="error-container">
<div class="error-icon">
<i class="fas fa-hourglass-half"></i>
</div>
<h2>429 — Too Many Requests</h2>
<p>You have sent too many requests in a short period. Please wait a moment and try again.</p>
<a href="{{ url_for('main.dashboard') if current_user.is_authenticated else url_for('auth.login') }}" class="btn btn-primary">
<i class="fas fa-arrow-left"></i> Go Back
</a>
</div>
{% endblock %}
+15
View File
@@ -0,0 +1,15 @@
{% extends "layouts/base.html" %}
{% block title %}500 Server Error - TryoutPro{% endblock %}
{% block page_title %}Internal Server Error{% endblock %}
{% block content %}
<div class="error-container">
<div class="error-icon">
<i class="fas fa-cogs"></i>
</div>
<h2>500 — Internal Server Error</h2>
<p>Something went wrong on our end. The error has been logged and will be investigated. Please try again later.</p>
<a href="{{ url_for('main.dashboard') if current_user.is_authenticated else url_for('auth.login') }}" class="btn btn-primary">
<i class="fas fa-redo-alt"></i> Try Again
</a>
</div>
{% endblock %}
+5 -46
View File
@@ -22,39 +22,6 @@
</div>
</div>
<div class="card mt-4">
<div class="card-header">
<h3><i class="fas fa-list"></i> Current Availability</h3>
</div>
<div class="card-body">
{% if existing_availability %}
<table class="table">
<thead>
<tr>
<th>Day</th>
<th>Time</th>
<th>Action</th>
</tr>
</thead>
<tbody>
{% for av in existing_availability %}
<tr>
<td>{{ ['Monday', 'Tuesday', 'Wednesday', 'Thursday', 'Friday', 'Saturday', 'Sunday'][av.day_of_week] }}</td>
<td>{{ av.start_time.strftime('%I:%M %p') }} - {{ av.end_time.strftime('%I:%M %p') }}</td>
<td>
<button type="button" class="btn btn-sm btn-danger" onclick="deleteAvailability({{ av.id }})">
<i class="fas fa-trash"></i> Remove
</button>
</td>
</tr>
{% endfor %}
</tbody>
</table>
{% else %}
<p class="text-muted">No availability slots set. Use the grid above to add your available times.</p>
{% endif %}
</div>
</div>
{% 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');
+34 -35
View File
@@ -70,12 +70,6 @@
<h4 class="section-title"><i class="fas fa-clock"></i> Select Match Time</h4>
<p class="text-muted small">Click time slots consecutively to set match duration. Players available in all selected time blocks are shown below.</p>
<div id="merged-disponibility-selected" class="merged-disponibility-selected-display hidden">
<span>Selected time:</span>
<span class="selected-time-badge" id="selected-time-badge"></span>
<button type="button" class="btn btn-sm btn-outline" onclick="clearTimeSelection()">Change</button>
</div>
<div id="merged-disponibility-grid" class="merged-disponibility-grid">
<p class="text-muted">Loading...</p>
</div>
@@ -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 += '<div class="merged-disponibility-day-label">' + day.name + '</div>';
dayRow += '<div class="merged-disponibility-time-blocks">';
// 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 += '<div class="' + cssClass + '" data-day="' + day.value + '" data-time="' + slot.time + '" ' +
'onclick="toggleTimeSlot(' + day.value + ', \'' + slot.time + '\', this)">' +
slot.display +
dayRow += '<div class="' + cssClass + '" data-day="' + day.value + '" data-time="' + slotData.time + '" ' +
'onclick="toggleTimeSlot(' + day.value + ', \'' + slotData.time + '\', this)">' +
slotData.display +
'<span class="merged-disponibility-count">' + count + '</span>' +
'</div>';
});
@@ -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 = '<p class="text-muted small">No players available</p>';
if (allRegisteredPlayers.length === 0) {
pool.innerHTML = '<p class="text-muted small">No players registered</p>';
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');
});
+472
View File
@@ -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.'
),
)
+37
View File
@@ -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,
)