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:
+178
-43
@@ -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
@@ -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.
|
||||
|
||||
|
||||
Reference in New Issue
Block a user