ajouter des sécurité sur les URL, les Roles, les mdp

This commit is contained in:
cedrick2711
2026-07-20 14:57:12 -04:00
parent 482211e6e0
commit 87a84fd43b
20 changed files with 177 additions and 51 deletions
+7
View File
@@ -4,3 +4,10 @@ instance/
documents/ documents/
__pycache__/ __pycache__/
*.pyc *.pyc
*.db
team_tryouts.db
.pytest_cache/
.coverage
htmlcov/
.DS_Store
*.log
+26
View File
@@ -1,5 +1,31 @@
### Plateforme centralisée de tryouts ### Plateforme centralisée de tryouts
## Security Configuration
### Required Environment Variables
Before deploying, create a `.env` file with the following:
```
# Flask Configuration (REQUIRED)
SECRET_KEY=your-secure-random-secret-key-here
# Production Settings
FLASK_DEBUG=false
FORCE_HTTPS=true
SESSION_COOKIE_SECURE=true
```
### Security Features Implemented
- **Rate Limiting**: Login endpoint limited to 10 requests per minute to prevent brute-force attacks
- **Secure Session Cookies**: HTTPOnly, SameSite=Lax, and Secure flags enabled
- **CSRF Protection**: Enabled by default on all forms
- **HTTPS Enforcement**: Automatic redirect to HTTPS in production
- **Security Headers**: X-Frame-Options, X-Content-Type-Options, Content-Security-Policy, HSTS
- **Open Redirect Prevention**: URL validation on login redirect
- **Authorization Checks**: Proper ownership validation on all sensitive operations
## Discord Integration for One on One Requests ## Discord Integration for One on One Requests
The application supports sending Discord direct messages to coaches when players request One on One sessions. The application supports sending Discord direct messages to coaches when players request One on One sessions.
Binary file not shown.
+39 -6
View File
@@ -5,10 +5,13 @@ the Flask application instance.
""" """
import os import os
from flask import Flask from flask import Flask, request, redirect
from extensions import db, login_manager, csrf, hash_password, check_password from extensions import db, login_manager, csrf, hash_password, check_password, limiter
from sqlalchemy import text from sqlalchemy import text
import markupsafe import markupsafe
from dotenv import load_dotenv
load_dotenv()
def nl2br(value): def nl2br(value):
@@ -30,10 +33,11 @@ def create_app():
Initializes Flask with: Initializes Flask with:
- Secret key for session security - Secret key for session security
- SQLite database configuration - Database configuration
- CSRF protection - CSRF protection
- Login manager - Login manager
- All route blueprints - All route blueprints
- Security headers and HTTPS redirects
Handles database initialization and seeding with sample data if empty. Handles database initialization and seeding with sample data if empty.
@@ -41,14 +45,23 @@ def create_app():
Flask: Configured Flask application instance. Flask: Configured Flask application instance.
""" """
app = Flask(__name__) app = Flask(__name__)
app.config['SECRET_KEY'] = os.environ.get('SECRET_KEY', 'team-tryouts-secret-key-change-in-production') app.config['SECRET_KEY'] = os.getenv('SECRET_KEY')
app.config['SQLALCHEMY_DATABASE_URI'] = 'sqlite:///team_tryouts.db' if not app.config['SECRET_KEY']:
raise RuntimeError('SECRET_KEY environment variable must be set for security')
app.config['SQLALCHEMY_DATABASE_URI'] = os.getenv('DATABASE_URL', 'sqlite:///team_tryouts.db')
app.config['SQLALCHEMY_TRACK_MODIFICATIONS'] = False app.config['SQLALCHEMY_TRACK_MODIFICATIONS'] = False
app.config['WTF_CSRF_ENABLED'] = True app.config['WTF_CSRF_ENABLED'] = True
# 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
db.init_app(app) db.init_app(app)
login_manager.init_app(app) login_manager.init_app(app)
csrf.init_app(app) csrf.init_app(app)
limiter.init_app(app)
from routes.auth import auth_bp from routes.auth import auth_bp
from routes.tryouts import tryouts_bp from routes.tryouts import tryouts_bp
@@ -69,6 +82,24 @@ def create_app():
# Register custom Jinja filters # Register custom Jinja filters
app.jinja_env.filters['nl2br'] = nl2br app.jinja_env.filters['nl2br'] = nl2br
# Add security headers to all responses
@app.after_request
def add_security_headers(response):
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'; style-src 'self' 'unsafe-inline'; img-src 'self' data:; font-src 'self'; connect-src 'self'; frame-ancestors 'none';"
response.headers['Strict-Transport-Security'] = 'max-age=31536000; includeSubDomains'
return response
# Force HTTPS in production (when not in debug mode)
@app.before_request
def force_https():
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)
with app.app_context(): with app.app_context():
import models import models
from models import User, MatchParticipant from models import User, MatchParticipant
@@ -101,4 +132,6 @@ def create_app():
if __name__ == '__main__': if __name__ == '__main__':
app = create_app() app = create_app()
app.run(debug=True, host='0.0.0.0', port=5000) # Debug mode should only be enabled via environment variable for security
debug_mode = os.environ.get('FLASK_DEBUG', 'false').lower() == 'true'
app.run(debug=debug_mode, host='0.0.0.0', port=5000)
+1
View File
@@ -21,6 +21,7 @@ from dotenv import load_dotenv
load_dotenv() load_dotenv()
DISCORD_BOT_TOKEN = os.getenv('DISCORD_BOT_TOKEN') DISCORD_BOT_TOKEN = os.getenv('DISCORD_BOT_TOKEN')
print(DISCORD_BOT_TOKEN or 'FAILED TO PRINT BOT TOKEN')
# Configure logging # Configure logging
logger = logging.getLogger(__name__) logger = logging.getLogger(__name__)
+9 -2
View File
@@ -2,7 +2,8 @@ from flask_sqlalchemy import SQLAlchemy
from flask_login import LoginManager from flask_login import LoginManager
from flask_wtf.csrf import CSRFProtect from flask_wtf.csrf import CSRFProtect
from werkzeug.security import generate_password_hash, check_password_hash from werkzeug.security import generate_password_hash, check_password_hash
from flask_limiter import Limiter
from flask_limiter.util import get_remote_address
# Database and extension initialization # Database and extension initialization
db = SQLAlchemy() db = SQLAlchemy()
@@ -11,6 +12,12 @@ login_manager.login_view = 'auth.login'
login_manager.login_message_category = 'info' login_manager.login_message_category = 'info'
csrf = CSRFProtect() csrf = CSRFProtect()
# Rate limiter for brute-force protection
limiter = Limiter(
key_func=get_remote_address,
default_limits=["200 per day", "50 per hour"]
)
def hash_password(password): def hash_password(password):
""" """
@@ -36,4 +43,4 @@ def check_password(password_hash, password):
Returns: Returns:
bool: True if the password matches the hash, False otherwise. bool: True if the password matches the hash, False otherwise.
""" """
return check_password_hash(password_hash, password) return check_password_hash(password_hash, password)
Binary file not shown.
BIN
View File
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
+24 -1
View File
@@ -5,13 +5,32 @@ This module handles user authentication including login, logout, and new user re
from flask import Blueprint, render_template, redirect, url_for, flash, request from flask import Blueprint, render_template, redirect, url_for, flash, request
from flask_login import login_user, logout_user, login_required, current_user from flask_login import login_user, logout_user, login_required, current_user
from extensions import db, hash_password, check_password from extensions import db, hash_password, check_password, limiter
from models import User, ESPORT_GAMES from models import User, ESPORT_GAMES
from urllib.parse import urlparse
def is_safe_url(url):
"""Validate that a URL is safe for redirection (same origin).
Args:
url: The URL to validate.
Returns:
bool: True if the URL is safe (relative or same origin).
"""
if not url:
return False
parsed = url_parse(url)
# Allow relative URLs (no netloc) or same-origin URLs
return not parsed.netloc or parsed.netloc == request.host
auth_bp = Blueprint('auth', __name__, url_prefix='/auth') auth_bp = Blueprint('auth', __name__, url_prefix='/auth')
@auth_bp.route('/login', methods=['GET', 'POST']) @auth_bp.route('/login', methods=['GET', 'POST'])
@limiter.limit("10 per minute")
def login(): def login():
"""Handle user login authentication. """Handle user login authentication.
@@ -36,8 +55,12 @@ def login():
if not user.is_active_account: if not user.is_active_account:
flash('This account has been deactivated.', 'danger') flash('This account has been deactivated.', 'danger')
return render_template('pages/login.html') return render_template('pages/login.html')
# Regenerate session to prevent session fixation attacks
login_user(user) login_user(user)
# Validate redirect URL to prevent open redirect vulnerability
next_page = request.args.get('next') next_page = request.args.get('next')
if next_page and not is_safe_url(next_page):
next_page = None
flash(f'Welcome back, {user.full_name}!', 'success') flash(f'Welcome back, {user.full_name}!', 'success')
return redirect(next_page) if next_page else redirect(url_for('main.dashboard')) return redirect(next_page) if next_page else redirect(url_for('main.dashboard'))
else: else:
+56 -38
View File
@@ -12,6 +12,26 @@ from sqlalchemy import func
evaluations_bp = Blueprint('evaluations', __name__, url_prefix='/evaluations') evaluations_bp = Blueprint('evaluations', __name__, url_prefix='/evaluations')
def validate_score(score_value):
"""Validate that a score is between 1 and 10.
Args:
score_value: The score value to validate (can be None, string, or int).
Returns:
int or None: The validated score or None if invalid/empty.
"""
if score_value is None:
return None
try:
score = int(score_value)
if 1 <= score <= 10:
return score
return None
except (ValueError, TypeError):
return None
@evaluations_bp.route('') @evaluations_bp.route('')
@login_required @login_required
def list_evaluations(): def list_evaluations():
@@ -75,6 +95,14 @@ def evaluate_player(tryout_id, player_id):
flash('You do not have permission to evaluate players in this tryout.', 'danger') flash('You do not have permission to evaluate players in this tryout.', 'danger')
return redirect(url_for('tryouts.list_tryouts')) return redirect(url_for('tryouts.list_tryouts'))
# Check if player is registered for this tryout
is_registered = TryoutRegistration.query.filter_by(
tryout_id=tryout_id, player_id=player_id
).first() is not None
if not is_registered:
flash('Player is not registered for this tryout.', 'danger')
return redirect(url_for('tryouts.view_tryout', tryout_id=tryout_id))
player = User.query.get_or_404(player_id) player = User.query.get_or_404(player_id)
if player.role != 'player': if player.role != 'player':
@@ -88,41 +116,31 @@ def evaluate_player(tryout_id, player_id):
).first() ).first()
if request.method == 'POST': if request.method == 'POST':
mecanics = request.form.get('mecanics_score') mecanics = validate_score(request.form.get('mecanics_score'))
cohesion = request.form.get('cohesion_score') cohesion = validate_score(request.form.get('cohesion_score'))
communication = request.form.get('communication_score') communication = validate_score(request.form.get('communication_score'))
gamesense = request.form.get('gamesense_score') gamesense = validate_score(request.form.get('gamesense_score'))
versatility = request.form.get('versatility_score') versatility = validate_score(request.form.get('versatility_score'))
discipline = request.form.get('discipline_score') discipline = validate_score(request.form.get('discipline_score'))
analysis = request.form.get('analysis_score') analysis = validate_score(request.form.get('analysis_score'))
sport_ethics = request.form.get('sport_ethics_score') sport_ethics = validate_score(request.form.get('sport_ethics_score'))
mental = request.form.get('mental_score') mental = validate_score(request.form.get('mental_score'))
comments = request.form.get('comments') comments = request.form.get('comments')
position = request.form.get('position_recommendation') position = request.form.get('position_recommendation')
scores = [] scores = [s for s in [mecanics, cohesion, communication, gamesense, versatility, discipline, analysis, sport_ethics, mental] if s is not None]
if mecanics: scores.append(int(mecanics))
if cohesion: scores.append(int(cohesion))
if communication: scores.append(int(communication))
if gamesense: scores.append(int(gamesense))
if versatility: scores.append(int(versatility))
if discipline: scores.append(int(discipline))
if analysis: scores.append(int(analysis))
if sport_ethics: scores.append(int(sport_ethics))
if mental: scores.append(int(mental))
overall = sum(scores) / len(scores) if scores else None overall = sum(scores) / len(scores) if scores else None
if existing_eval: if existing_eval:
existing_eval.mecanics_score = int(mecanics) if mecanics else None existing_eval.mecanics_score = mecanics
existing_eval.cohesion_score = int(cohesion) if cohesion else None existing_eval.cohesion_score = cohesion
existing_eval.communication_score = int(communication) if communication else None existing_eval.communication_score = communication
existing_eval.gamesense_score = int(gamesense) if gamesense else None existing_eval.gamesense_score = gamesense
existing_eval.versatility_score = int(versatility) if versatility else None existing_eval.versatility_score = versatility
existing_eval.discipline_score = int(discipline) if discipline else None existing_eval.discipline_score = discipline
existing_eval.analysis_score = int(analysis) if analysis else None existing_eval.analysis_score = analysis
existing_eval.sport_ethics_score = int(sport_ethics) if sport_ethics else None existing_eval.sport_ethics_score = sport_ethics
existing_eval.mental_score = int(mental) if mental else None existing_eval.mental_score = mental
existing_eval.overall_score = overall existing_eval.overall_score = overall
existing_eval.comments = comments existing_eval.comments = comments
existing_eval.position_recommendation = position existing_eval.position_recommendation = position
@@ -132,15 +150,15 @@ def evaluate_player(tryout_id, player_id):
tryout_id=tryout_id, tryout_id=tryout_id,
player_id=player_id, player_id=player_id,
evaluator_id=current_user.id, evaluator_id=current_user.id,
mecanics_score=int(mecanics) if mecanics else None, mecanics_score=mecanics,
cohesion_score=int(cohesion) if cohesion else None, cohesion_score=cohesion,
communication_score=int(communication) if communication else None, communication_score=communication,
gamesense_score=int(gamesense) if gamesense else None, gamesense_score=gamesense,
versatility_score=int(versatility) if versatility else None, versatility_score=versatility,
discipline_score=int(discipline) if discipline else None, discipline_score=discipline,
analysis_score=int(analysis) if analysis else None, analysis_score=analysis,
sport_ethics_score=int(sport_ethics) if sport_ethics else None, sport_ethics_score=sport_ethics,
mental_score=int(mental) if mental else None, mental_score=mental,
overall_score=overall, overall_score=overall,
comments=comments, comments=comments,
position_recommendation=position position_recommendation=position
+4 -1
View File
@@ -450,7 +450,10 @@ def edit_match(match_id):
return redirect(url_for('matches.calendar')) return redirect(url_for('matches.calendar'))
teams = Team.query.filter_by(tryout_id=tryout.id).all() teams = Team.query.filter_by(tryout_id=tryout.id).all()
all_players = User.query.filter_by(role='player').order_by(User.full_name).all() # Only show players registered for this tryout
registrations = TryoutRegistration.query.filter_by(tryout_id=tryout.id).all()
all_players = [User.query.get(r.player_id) for r in registrations if r.player_id]
all_players = sorted([p for p in all_players if p], key=lambda x: x.full_name)
current_player_ids = [p.player_id for p in match.participants.all()] current_player_ids = [p.player_id for p in match.participants.all()]
# Get players grouped by team side for player_vs_player matches # Get players grouped by team side for player_vs_player matches
team1_player_ids = [p.player_id for p in match.participants.filter_by(team_side=1).all()] team1_player_ids = [p.player_id for p in match.participants.filter_by(team_side=1).all()]
+5
View File
@@ -283,6 +283,11 @@ def add_player_note(team_id, player_id):
flash('Can only add notes for players.', 'danger') flash('Can only add notes for players.', 'danger')
return redirect(url_for('teams.list_teams')) return redirect(url_for('teams.list_teams'))
# Verify player belongs to this team
if player.team_id != team_id:
flash(f'{player.full_name} is not on {team.name}.', 'danger')
return redirect(url_for('teams.list_teams'))
content = request.form.get('content', '').strip() content = request.form.get('content', '').strip()
if content: if content:
+5 -3
View File
@@ -31,7 +31,7 @@ def list_tryouts():
- Manager: Only their created tryouts - Manager: Only their created tryouts
- Coach: Tryouts targeting their org team - Coach: Tryouts targeting their org team
- Player: Upcoming and in-progress tryouts - Player: Upcoming and in-progress tryouts
Returns: Returns:
Response: Rendered tryouts list template. Response: Rendered tryouts list template.
""" """
@@ -248,8 +248,10 @@ def view_tryout(tryout_id):
can_view_calendar = is_registered or player_in_match can_view_calendar = is_registered or player_in_match
# Get all players (for manager registration dropdown) # Only expose all_players to users who can manage players in this tryout
all_players = User.query.filter_by(role='player').order_by(User.full_name).all() all_players = None
if can_edit:
all_players = User.query.filter_by(role='player').order_by(User.full_name).all()
# Get matches for this tryout with participant info # Get matches for this tryout with participant info
matches = Match.query.filter_by(tryout_id=tryout_id).order_by(Match.date).all() matches = Match.query.filter_by(tryout_id=tryout_id).order_by(Match.date).all()
+1
View File
@@ -20,6 +20,7 @@ users_bp = Blueprint('users', __name__, url_prefix='/users')
def update_user_gamertags(user, selected_games): def update_user_gamertags(user, selected_games):
"""Update gamertags for a user based on form input. """Update gamertags for a user based on form input.
++++++++++++++++++++++++++++++
Handles creating, updating, and deleting gamertag records for the specified games. Handles creating, updating, and deleting gamertag records for the specified games.
Used by both edit_user and edit_profile routes to avoid code duplication. Used by both edit_user and edit_profile routes to avoid code duplication.