added discord oauth2 to get basic user info to complete profile when registering
This commit is contained in:
@@ -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():
|
||||
|
||||
Reference in New Issue
Block a user