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
+178 -43
View File
@@ -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'))
+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.