diff --git a/app/.env.exemple b/app/.env.exemple index 0b789b6..6ec56e3 100644 --- a/app/.env.exemple +++ b/app/.env.exemple @@ -17,6 +17,12 @@ FLASK_DEBUG=true # Dicord_User_ID in the db / remove if you don't want discord notifs. DISCORD_BOT_TOKEN=my_discord_bot_token +# Discord OAuth2 Configuration (for "Connect Discord" on sign-up page) +# Create an application at https://discord.com/developers/applications +DISCORD_CLIENT_ID= +DISCORD_CLIENT_SECRET= +DISCORD_REDIRECT_URI=http://localhost:5000/auth/discord/callback + #where to find the db (hosted on render for now) DATABASE_URL=URI_vers_db_posgres diff --git a/app/app.py b/app/app.py index 4f5cffb..fdebaef 100644 --- a/app/app.py +++ b/app/app.py @@ -151,7 +151,7 @@ def create_app(): "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:; " + "img-src 'self' data: https://cdn.discordapp.com; " "connect-src 'self'; " "frame-ancestors 'none'; " "base-uri 'self'; " diff --git a/app/routes/auth.py b/app/routes/auth.py index 611cd2c..9aeee6a 100644 --- a/app/routes/auth.py +++ b/app/routes/auth.py @@ -6,6 +6,7 @@ password policy enforcement and CAPTCHA verification. """ import uuid +import os 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 @@ -14,11 +15,27 @@ from app.models import User, Player, ESPORT_GAMES from app.validators import RegisterSchema, LoginSchema from marshmallow import ValidationError from urllib.parse import urlparse +import requests # Account lockout settings MAX_LOGIN_ATTEMPTS = 5 LOCKOUT_DURATION_MINUTES = 15 +# Discord OAuth2 configuration +DISCORD_CLIENT_ID = os.getenv('DISCORD_CLIENT_ID') +DISCORD_CLIENT_SECRET = os.getenv('DISCORD_CLIENT_SECRET') +DISCORD_REDIRECT_URI = os.getenv('DISCORD_REDIRECT_URI') +DISCORD_API_BASE = 'https://discord.com/api/v10' + +# Mapping from Discord connection platform to E-Sports games +DISCORD_PLATFORM_TO_GAMES = { + 'steam': ['Counter-Strike 2'], + 'battlenet': ['Overwatch 2'], + 'epicgames': ['Rocket League'], + 'xbox': ['Apex Legends', 'Rainbow Six Siege', 'Rocket League'], + 'playstation': ['Apex Legends', 'Rainbow Six Siege', 'Rocket League'], +} + def is_safe_url(url): """Validate that a URL is safe for redirection (same origin). @@ -224,6 +241,9 @@ def register(): phone = validated.get('phone') selected_games = validated.get('games', []) discord_username = validated.get('discord_username') + discord_user_id = validated.get('discord_user_id') + trn_username = request.form.get('trn_username', '').strip() or None + league_os_profile = validated.get('league_os_profile') if User.query.filter_by(username=username).first(): flash('Username already exists.', 'danger') @@ -253,6 +273,8 @@ def register(): phone=phone, games=','.join(selected_games) if selected_games else None, discord_username=discord_username, + discord_user_id=discord_user_id, + league_os_profile=league_os_profile, ) db.session.add(user) db.session.commit() @@ -271,6 +293,9 @@ def register(): db.session.add(gamertag) db.session.commit() + # Clear Discord OAuth data from session after successful registration + session.pop('discord_oauth', None) + flash('Your account has been created! You can now log in.', 'success') return redirect(url_for('auth.login')) @@ -279,6 +304,139 @@ def register(): return render_template('pages/register.html', esport_games=ESPORT_GAMES, captcha=captcha) +@auth_bp.route('/discord/login') +def discord_login(): + """Redirect the user to Discord's OAuth2 authorization page. + + Requests the 'identify' and 'connections' scopes so we can retrieve + the user's Discord username, ID, and linked gaming accounts. + + Returns: + Response: Redirect to Discord authorization URL. + """ + if not DISCORD_CLIENT_ID: + flash('Discord OAuth2 is not configured.', 'danger') + return redirect(url_for('auth.register')) + + params = { + 'client_id': DISCORD_CLIENT_ID, + 'redirect_uri': DISCORD_REDIRECT_URI, + 'response_type': 'code', + 'scope': 'identify connections', + } + query = '&'.join(f'{k}={requests.utils.quote(v)}' for k, v in params.items()) + auth_url = f'{DISCORD_API_BASE}/oauth2/authorize?{query}' + return redirect(auth_url) + + +@auth_bp.route('/discord/callback') +def discord_callback(): + """Handle the OAuth2 callback from Discord. + + Exchanges the authorization code for an access token, then fetches + the user's profile (/users/@me) and connections (/users/@me/connections). + Results are stored in the session and the user is redirected back to + the registration form where fields will be pre-filled. + + Returns: + Response: Redirect to registration page. + """ + code = request.args.get('code') + if not code: + flash('Discord authorization failed. No code received.', 'danger') + return redirect(url_for('auth.register')) + + # Exchange the authorization code for an access token + token_data = { + 'client_id': DISCORD_CLIENT_ID, + 'client_secret': DISCORD_CLIENT_SECRET, + 'grant_type': 'authorization_code', + 'code': code, + 'redirect_uri': DISCORD_REDIRECT_URI, + } + headers = {'Content-Type': 'application/x-www-form-urlencoded'} + + try: + token_response = requests.post( + f'{DISCORD_API_BASE}/oauth2/token', + data=token_data, + headers=headers, + timeout=10, + ) + token_response.raise_for_status() + token_json = token_response.json() + access_token = token_json.get('access_token') + except requests.RequestException as e: + flash(f'Failed to connect to Discord. Please try again.', 'danger') + return redirect(url_for('auth.register')) + + if not access_token: + flash('Failed to obtain Discord access token.', 'danger') + return redirect(url_for('auth.register')) + + auth_headers = {'Authorization': f'Bearer {access_token}'} + + # Fetch the user's Discord profile + try: + user_response = requests.get( + f'{DISCORD_API_BASE}/users/@me', + headers=auth_headers, + timeout=10, + ) + user_response.raise_for_status() + user_data = user_response.json() + except requests.RequestException: + flash('Failed to fetch Discord user profile.', 'danger') + return redirect(url_for('auth.register')) + + # Fetch the user's connected gaming accounts + connections = [] + try: + conn_response = requests.get( + f'{DISCORD_API_BASE}/users/@me/connections', + headers=auth_headers, + timeout=10, + ) + conn_response.raise_for_status() + connections = conn_response.json() + except requests.RequestException: + # Non-critical: we can still proceed without connections + pass + + # Build gamertag suggestions from Discord connections + gamertag_suggestions = {} + for conn in connections: + platform = conn.get('type', '') + name = conn.get('name', '').strip() + if not name or platform not in DISCORD_PLATFORM_TO_GAMES: + continue + for game in DISCORD_PLATFORM_TO_GAMES[platform]: + # Only set if not already set (first connection wins) + if game not in gamertag_suggestions: + gamertag_suggestions[game] = name + + # Build a list of games to auto-select (unambiguous platform mappings) + auto_select_games = [] + for conn in connections: + platform = conn.get('type', '') + if platform in ('steam', 'battlenet', 'epicgames'): + for game in DISCORD_PLATFORM_TO_GAMES[platform]: + if game not in auto_select_games: + auto_select_games.append(game) + + # Store in session for the registration form to use + session['discord_oauth'] = { + 'id': user_data.get('id'), + 'username': user_data.get('username'), + 'avatar': user_data.get('avatar'), + 'gamertag_suggestions': gamertag_suggestions, + 'auto_select_games': auto_select_games, + } + + flash('Discord account connected! Your profile has been pre-filled.', 'success') + return redirect(url_for('auth.register')) + + @auth_bp.route('/logout') @login_required def logout(): diff --git a/app/templates/pages/register.html b/app/templates/pages/register.html index 80ccd24..0f2c032 100644 --- a/app/templates/pages/register.html +++ b/app/templates/pages/register.html @@ -1,7 +1,7 @@ {% extends "layouts/base.html" %} {% block title %}Register - TryoutPro{% endblock %} {% block auth_content %} -
+
@@ -22,32 +22,102 @@

E-Sports Profile

-

Select the games you play and provide your gamertag for each.

- -
- - {% for game in esport_games %} -
- - -
- {% endfor %} - Check each game you play, then enter your gamertag for that game. -
+

Set up your competitive gaming profile for tryouts.

- - + +
+ {% for game in esport_games %} + + {% endfor %} +
+ Select all games you're signing in for. +
+ + + {% set discord_suggestions = session.get('discord_oauth', {}).get('gamertag_suggestions', {}) %} + {% for game in esport_games %} +
+ + + + Your in-game name or username for {{ game }}. + {% if game in discord_suggestions %} + Pre-filled from Discord connections. + {% endif %} + +
+ {% endfor %} + +
+ + + Your public Tracker Network profile name. Others can click it to view your stats. +
+ + + {% set discord_data = session.get('discord_oauth') %} + {% if discord_data %} +
+ +
+
+ {% if discord_data.avatar %} + {% set avatar_url = 'https://cdn.discordapp.com/avatars/' + discord_data.id|string + '/' + discord_data.avatar + '.png?size=64' %} + Discord Avatar + {% else %} +
+ +
+ {% endif %} + +
+ + Reconnect + +
+ + + + Your Discord account is connected. Gamertags from your linked game accounts have been pre-filled. + +
+ {% else %} +
+ +

Connect your Discord account to automatically fill your profile and gamertags from your connected game accounts (Steam, Battle.net, Xbox, etc.).

+ + Connect Discord Account + + You can also enter your Discord username manually below. +
+
+ + +
+ {% endif %} + +
+ + + Connect your League OS profile for organized play.

+

Security

@@ -58,67 +128,162 @@
- - + +
-{% endblock %} -{% block scripts %} + + {% endblock %} \ No newline at end of file diff --git a/app/validators.py b/app/validators.py index b6a7c4f..907a5b6 100644 --- a/app/validators.py +++ b/app/validators.py @@ -208,6 +208,20 @@ class RegisterSchema(StripMixin): allow_none=True, load_default=None, ) + discord_user_id = fields.String( + validate=validate_discord_user_id, + allow_none=True, + load_default=None, + ) + trn_username = fields.String( + allow_none=True, + load_default=None, + ) + league_os_profile = fields.String( + validate=validate.Length(max=256), + allow_none=True, + load_default=None, + ) @validates_schema def validate_password_match(self, data, **kwargs):