705 lines
28 KiB
Python
705 lines
28 KiB
Python
"""Authentication routes for user login, logout, and 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 sign-up screening.
|
|
"""
|
|
|
|
import os
|
|
import secrets
|
|
import time
|
|
from datetime import datetime, timedelta
|
|
from urllib.parse import urlencode, urlparse
|
|
|
|
import requests
|
|
from flask import Blueprint, flash, redirect, render_template, request, session, url_for
|
|
from flask_babel import gettext as _
|
|
from flask_login import current_user, login_required, login_user, logout_user
|
|
from marshmallow import ValidationError
|
|
|
|
from app.extensions import check_password, db, hash_password, limiter
|
|
from app.i18n import LOCALE_SESSION_KEY
|
|
from app.logging_config import log_auth_event
|
|
from app.models import ESPORT_GAMES, Player, User
|
|
from app.validators import LoginSchema, RegisterSchema, validate_discord_user_id
|
|
|
|
#: Session key holding the pending OAuth2 anti-forgery token.
|
|
DISCORD_STATE_KEY = 'discord_oauth_state'
|
|
#: Whether the OAuth result should create a registration draft or relink the
|
|
#: signed-in account. Kept server-side and covered by the same signed session
|
|
#: as the anti-forgery state.
|
|
DISCORD_PURPOSE_KEY = 'discord_oauth_purpose'
|
|
|
|
# Failed-attempt tracking. The tally is kept for the audit trail and for the
|
|
# cool-off marker below; it no longer refuses a correct password (SEC-018).
|
|
MAX_LOGIN_ATTEMPTS = 5
|
|
LOCKOUT_DURATION_MINUTES = 15
|
|
#: Ceiling on the doubling cool-off window.
|
|
MAX_LOCKOUT_MINUTES = 240
|
|
|
|
#: Hash of a value nobody can submit. Verifying against it when the username
|
|
#: is unknown makes that path cost the same scrypt work as a real one, so the
|
|
#: response time stops telling a caller which usernames exist (SEC-017).
|
|
_ABSENT_USER_HASH = None
|
|
|
|
#: Session key recording when the registration form was handed out.
|
|
REGISTRATION_ISSUED_KEY = 'registration_form_issued_at'
|
|
|
|
#: Name of the honeypot input. Plausible enough that a form-filler wants it,
|
|
#: absent from the visible form. Hidden by .honeypot in style.css — not by an
|
|
#: inline style, so that the rule survives a tightening of style-src.
|
|
REGISTRATION_HONEYPOT_FIELD = 'website'
|
|
|
|
#: Floor on how long a genuine registration takes. Eleven fields and a
|
|
#: password typed twice; three seconds is generous.
|
|
MIN_REGISTRATION_SECONDS = 3
|
|
|
|
# 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).
|
|
|
|
Accepts an absolute URL on this host, or a path beginning with exactly
|
|
one slash. Everything else is refused, including the two forms that
|
|
read differently to urlparse and to a browser:
|
|
|
|
/\\evil.com several browsers normalise the backslash to a slash,
|
|
turning this into the protocol-relative //evil.com.
|
|
urlparse reports no netloc at all, so the old check
|
|
let it through and the redirect left the site.
|
|
/\\n//evil.com control characters are stripped before parsing.
|
|
|
|
Args:
|
|
url: The URL to validate.
|
|
|
|
Returns:
|
|
bool: True if the URL is safe.
|
|
"""
|
|
if not url:
|
|
return False
|
|
if any(ord(char) < 0x20 or char in '\\\x7f' for char in url):
|
|
return False
|
|
|
|
parsed = urlparse(url)
|
|
if parsed.netloc:
|
|
return parsed.netloc == request.host and parsed.scheme in ('', 'http', 'https')
|
|
# Relative targets must be rooted. 'dashboard' or 'javascript:...' are
|
|
# not paths on this site.
|
|
return url.startswith('/')
|
|
|
|
|
|
def _absent_user_hash():
|
|
"""A hash to verify against when the submitted username does not exist.
|
|
|
|
check_password() used to be reached only when a user row was found, so
|
|
an unknown username answered as fast as the database lookup, and a known
|
|
one as slowly as scrypt. The gap is measurable and enumerates accounts.
|
|
Computed once per process, from a random secret, so no submitted password
|
|
can ever match it.
|
|
"""
|
|
global _ABSENT_USER_HASH
|
|
if _ABSENT_USER_HASH is None:
|
|
_ABSENT_USER_HASH = hash_password(secrets.token_urlsafe(32))
|
|
return _ABSENT_USER_HASH
|
|
|
|
|
|
def cooloff_minutes(failed_attempts):
|
|
"""Length of the cool-off window earned by this many failed attempts.
|
|
|
|
Doubles every MAX_LOGIN_ATTEMPTS further failures, up to a ceiling.
|
|
|
|
Args:
|
|
failed_attempts: Consecutive failures recorded on the account.
|
|
|
|
Returns:
|
|
int: Minutes.
|
|
"""
|
|
steps = max(failed_attempts // MAX_LOGIN_ATTEMPTS - 1, 0)
|
|
return min(LOCKOUT_DURATION_MINUTES * (2**steps), MAX_LOCKOUT_MINUTES)
|
|
|
|
|
|
def issue_registration_challenge():
|
|
"""Mark that the registration form has been handed out, and when.
|
|
|
|
Kept in the signed session rather than in a form field, so that the
|
|
timestamp is not something the submitter can choose. Left in place across
|
|
a failed submission: someone correcting a typo should not be told to slow
|
|
down, and a robot has already paid for the round trip by then.
|
|
"""
|
|
session.setdefault(REGISTRATION_ISSUED_KEY, time.time())
|
|
|
|
|
|
def check_registration_challenge(form):
|
|
"""Say why this registration should be refused, or None to accept.
|
|
|
|
What replaced the arithmetic CAPTCHA, and why (SEC-AUTH-008).
|
|
|
|
`a + b = ?` with both operands between 1 and 10 has nineteen possible
|
|
answers and is solvable by reading the string. It stopped no automated
|
|
registration whatsoever. What it did do was add a step for every human,
|
|
including anyone using a screen reader, in exchange for an appearance of
|
|
protection — which is worse than no protection, because it gets counted
|
|
as one.
|
|
|
|
The audit's alternative was a real CAPTCHA service. That means a third
|
|
party, an API key, a request on every page load, and putting a foreign
|
|
script back into script-src — undoing the CSP work that closed
|
|
SEC-WEB-001. Disproportionate for a club site.
|
|
|
|
So: two checks that cost the visitor nothing.
|
|
|
|
- a honeypot field, hidden in the stylesheet, that a form-filling
|
|
robot completes and a person never sees;
|
|
- a minimum dwell time between being handed the form and sending it
|
|
back. Eleven fields and a password typed twice do not get filled in
|
|
under three seconds, and a POST with no issued form at all never
|
|
fetched the page.
|
|
|
|
Be clear about the ceiling: this stops commodity spam, not somebody who
|
|
looks at the form for five minutes. The thing that would actually gate
|
|
registration is staff activation of new accounts, which does not exist —
|
|
`is_active_account` defaults to True. That is a product decision, not one
|
|
to slip in here.
|
|
|
|
The session-forgery angle in the constat is moot: with SECRET_KEY
|
|
compromised (SEC-001) an attacker forges a logged-in session for any
|
|
account and has no reason to register at all.
|
|
|
|
Returns:
|
|
str | None: a short reason for the log, or None to let it through.
|
|
"""
|
|
if form.get(REGISTRATION_HONEYPOT_FIELD, '').strip():
|
|
return 'honeypot'
|
|
|
|
issued_at = session.get(REGISTRATION_ISSUED_KEY)
|
|
if issued_at is None:
|
|
return 'no-form-issued'
|
|
if time.time() - issued_at < MIN_REGISTRATION_SECONDS:
|
|
return 'too-fast'
|
|
return None
|
|
|
|
|
|
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.
|
|
|
|
GET: Render the login form.
|
|
POST: Authenticate user credentials, with audit logging.
|
|
|
|
Failed attempts are counted and open a cool-off window, recorded in
|
|
``locked_until`` and in the authentication log. The window does not
|
|
refuse correct credentials: when it did, five wrong guesses against a
|
|
known username took that account out of service for fifteen minutes,
|
|
repeatably, and on a president's account that meant no administration
|
|
at all. Guess rate is bounded by the rate limit on this view.
|
|
|
|
Returns:
|
|
Response: Login form or redirect to dashboard/next page.
|
|
"""
|
|
if current_user.is_authenticated:
|
|
return redirect(url_for('main.dashboard'))
|
|
|
|
if request.method == 'POST':
|
|
# 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(_('%(field)s: %(msg)s', field=field, msg=msg), 'danger')
|
|
return render_template('pages/login.html')
|
|
|
|
username = validated['username']
|
|
password = validated['password']
|
|
user = User.query.filter_by(username=username).first()
|
|
|
|
# Verified before anything else is decided, and on both branches.
|
|
# Reaching this only when a row exists made the response time a
|
|
# reliable oracle for which usernames are registered (SEC-017).
|
|
credentials_ok = check_password(
|
|
user.password_hash if user else _absent_user_hash(), password
|
|
)
|
|
|
|
if user and credentials_ok:
|
|
if not user.is_active_account:
|
|
log_auth_event('login.rejected.deactivated', username=username, user_id=user.id)
|
|
flash(_('This account has been deactivated.'), 'danger')
|
|
return render_template('pages/login.html')
|
|
|
|
# Correct credentials clear the tally, cool-off window included.
|
|
# The window used to refuse them too, which is what turned it
|
|
# into a way to lock a known account out at will (SEC-018).
|
|
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).
|
|
#
|
|
# The language choice is carried across too. Someone who reads the
|
|
# login page in English and signs in would otherwise be dropped
|
|
# back into French — the preference lives in the session, and
|
|
# clearing it discards a decision the user just made.
|
|
_preserved = {
|
|
key: session[key] for key in ('csrf_token', LOCALE_SESSION_KEY) if key in session
|
|
}
|
|
session.clear()
|
|
session.update(_preserved)
|
|
|
|
# Mark the session permanent so PERMANENT_SESSION_LIFETIME applies.
|
|
# Without this, Flask emits a browser-session cookie with no expiry
|
|
# and the configured lifetime is silently ignored.
|
|
session.permanent = True
|
|
|
|
login_user(user)
|
|
log_auth_event('login.success', username=user.username, user_id=user.id, role=user.role)
|
|
|
|
# Validate redirect URL to prevent open redirect vulnerability
|
|
next_page = request.args.get('next')
|
|
if next_page and not is_safe_url(next_page):
|
|
next_page = None
|
|
flash(_('Welcome back, %(username)s!', username=user.username), 'success')
|
|
return redirect(next_page) if next_page else redirect(url_for('main.dashboard'))
|
|
# One message for every failure. The old code said "N attempts
|
|
# remaining" to a real account and "check username and password"
|
|
# to an unknown one, which listed the club's accounts to anyone
|
|
# who asked (SEC-017).
|
|
if user:
|
|
user.failed_login_attempts += 1
|
|
log_auth_event(
|
|
'login.failure',
|
|
username=username,
|
|
user_id=user.id,
|
|
attempts=user.failed_login_attempts,
|
|
)
|
|
if user.failed_login_attempts >= MAX_LOGIN_ATTEMPTS:
|
|
minutes = cooloff_minutes(user.failed_login_attempts)
|
|
user.locked_until = datetime.utcnow() + timedelta(minutes=minutes)
|
|
log_auth_event(
|
|
'account.throttled',
|
|
username=username,
|
|
user_id=user.id,
|
|
minutes=minutes,
|
|
attempts=user.failed_login_attempts,
|
|
)
|
|
db.session.commit()
|
|
else:
|
|
log_auth_event('login.failure.unknown_user', username=username)
|
|
|
|
flash(
|
|
_(
|
|
'Login unsuccessful. Please check your username and '
|
|
'password, or ask a president for help.'
|
|
),
|
|
'danger',
|
|
)
|
|
|
|
return render_template('pages/login.html')
|
|
|
|
|
|
def _rerender_registration(form_data):
|
|
"""Re-render the registration form after a refusal.
|
|
|
|
Was copied out four times, near-identically (ARCH-005). Dropping the two
|
|
password fields is the part that must not be forgotten in the fifth copy:
|
|
echoing a password back into the HTML puts it in the browser's cache and
|
|
in any proxy log along the way.
|
|
"""
|
|
form_data = dict(form_data)
|
|
form_data.pop('password', None)
|
|
form_data.pop('confirm_password', None)
|
|
return render_template(
|
|
'pages/register.html',
|
|
esport_games=ESPORT_GAMES,
|
|
honeypot_field=REGISTRATION_HONEYPOT_FIELD,
|
|
form_data=form_data,
|
|
)
|
|
|
|
|
|
@auth_bp.route('/register', methods=['GET', 'POST'])
|
|
@limiter.limit("20 per hour")
|
|
def register():
|
|
"""Handle new player registration.
|
|
|
|
GET: Render the registration form with the E-Sports games list.
|
|
POST: Screen the submission (see check_registration_challenge), validate
|
|
every input against RegisterSchema, 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'))
|
|
|
|
if request.method == 'POST':
|
|
# Build form data from request to preserve state across re-renders
|
|
form_data = dict(request.form)
|
|
form_data['games'] = request.form.getlist('games')
|
|
|
|
# Once Discord has authenticated the identity, neither its display
|
|
# name nor its snowflake is input data anymore. Remove any client
|
|
# copies before validation as well as before persistence: otherwise a
|
|
# forged, malformed hidden value can still make the verified flow fail.
|
|
discord_oauth = session.get('discord_oauth') or {}
|
|
if discord_oauth.get('id'):
|
|
form_data.pop('discord_username', None)
|
|
form_data.pop('discord_user_id', None)
|
|
|
|
refusal = check_registration_challenge(request.form)
|
|
if refusal is not None:
|
|
# Logged, because this is the only place abuse of the sign-up
|
|
# form becomes visible at all. Deliberately vague to the sender:
|
|
# naming the honeypot tells whoever tripped it how to avoid it.
|
|
log_auth_event('account.registration_refused', reason=refusal)
|
|
flash(_('Your registration could not be processed. Please try again.'), 'danger')
|
|
issue_registration_challenge()
|
|
return _rerender_registration(form_data)
|
|
|
|
# Validate input with marshmallow schema
|
|
register_schema = RegisterSchema()
|
|
try:
|
|
validated = register_schema.load(form_data)
|
|
except ValidationError as err:
|
|
for field, messages in err.messages.items():
|
|
for msg in messages:
|
|
flash(_('%(field)s: %(msg)s', field=field, msg=msg), 'danger')
|
|
return _rerender_registration(form_data)
|
|
|
|
username = validated['username']
|
|
email = validated['email']
|
|
password = validated['password']
|
|
full_name = validated['full_name']
|
|
phone = validated.get('phone')
|
|
selected_games = validated.get('games', [])
|
|
# The OAuth identity is server-side state. It used to be copied into
|
|
# hidden inputs and read back from request.form, which let anyone
|
|
# replace the verified Discord account before submitting (SEC-AUTH-005).
|
|
# A manual registration may still provide a display name, but never a
|
|
# Discord snowflake: that identifier is an authentication factor for
|
|
# bot reactions and must come from Discord itself.
|
|
discord_user_id = discord_oauth.get('id')
|
|
if discord_user_id:
|
|
discord_user_id = str(discord_user_id)
|
|
discord_username = (
|
|
discord_oauth.get('username') if discord_user_id else 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 _rerender_registration(form_data)
|
|
|
|
if User.query.filter_by(email=email).first():
|
|
flash(_('Email already registered.'), 'danger')
|
|
return _rerender_registration(form_data)
|
|
|
|
# The database constraint belongs to DB-002, after production has
|
|
# been backed up and deduplicated. Refuse new duplicates now instead
|
|
# of leaving the critical impersonation path open until then.
|
|
if discord_user_id and User.query.filter_by(discord_user_id=discord_user_id).first():
|
|
flash(_('This Discord account is already linked to another account.'), 'danger')
|
|
return _rerender_registration(form_data)
|
|
|
|
hashed_password = hash_password(password)
|
|
user = Player(
|
|
username=username,
|
|
password_hash=hashed_password,
|
|
role='player',
|
|
full_name=full_name,
|
|
email=email,
|
|
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)
|
|
# flush, not commit: the id is needed for the gamertag rows below,
|
|
# and signing up is one operation. Committing here made it two, so a
|
|
# failure while writing the gamertags left an account whose declared
|
|
# games were silently absent (ARCH-006).
|
|
db.session.flush()
|
|
|
|
# Create UserGamertag records for each selected game
|
|
from app.models import UserGamertag
|
|
|
|
for game in selected_games:
|
|
field_name = f'gamertag_{game}'
|
|
gamertag_value = request.form.get(field_name, '').strip()
|
|
if gamertag_value:
|
|
gamertag = UserGamertag(
|
|
user_id=user.id,
|
|
game=game,
|
|
gamertag=gamertag_value,
|
|
)
|
|
db.session.add(gamertag)
|
|
db.session.commit()
|
|
|
|
# Clear Discord OAuth data from session after successful registration
|
|
session.pop('discord_oauth', None)
|
|
session.pop(REGISTRATION_ISSUED_KEY, None)
|
|
|
|
log_auth_event('account.registered', username=user.username, user_id=user.id)
|
|
|
|
flash(_('Your account has been created! You can now log in.'), 'success')
|
|
return redirect(url_for('auth.login'))
|
|
|
|
# GET request — render empty form
|
|
issue_registration_challenge()
|
|
return _rerender_registration({})
|
|
|
|
|
|
@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.
|
|
"""
|
|
purpose = 'profile' if current_user.is_authenticated else 'registration'
|
|
session[DISCORD_PURPOSE_KEY] = purpose
|
|
return_endpoint = 'users.edit_profile' if purpose == 'profile' else 'auth.register'
|
|
|
|
# DISCORD_REDIRECT_URI is checked too: quoting it when unset used to
|
|
# raise inside the query builder rather than report a configuration error.
|
|
if not DISCORD_CLIENT_ID or not DISCORD_REDIRECT_URI:
|
|
flash(_('Discord OAuth2 is not configured.'), 'danger')
|
|
return redirect(url_for(return_endpoint))
|
|
|
|
# Anti-forgery token, required by RFC 6749 §10.12. Without it, an
|
|
# attacker could have the victim's browser consume an authorization code
|
|
# obtained for the attacker's own Discord account, silently binding that
|
|
# identity to the victim's registration form.
|
|
state = secrets.token_urlsafe(32)
|
|
session[DISCORD_STATE_KEY] = state
|
|
|
|
params = {
|
|
'client_id': DISCORD_CLIENT_ID,
|
|
'redirect_uri': DISCORD_REDIRECT_URI,
|
|
'response_type': 'code',
|
|
'scope': 'identify connections',
|
|
'state': state,
|
|
}
|
|
auth_url = f'{DISCORD_API_BASE}/oauth2/authorize?{urlencode(params)}'
|
|
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. During registration, connected game accounts are also
|
|
loaded into server-side draft state. For a signed-in profile relink, the
|
|
verified identity is written directly without passing through a form.
|
|
|
|
Returns:
|
|
Response: Redirect to the registration form or profile editor.
|
|
"""
|
|
# The state is consumed whatever happens next: a token is single-use, and
|
|
# leaving it in the session would allow a replay.
|
|
purpose = session.pop(DISCORD_PURPOSE_KEY, 'registration')
|
|
if purpose == 'profile' and current_user.is_authenticated:
|
|
return_endpoint = 'users.edit_profile'
|
|
elif purpose == 'profile':
|
|
return_endpoint = 'auth.login'
|
|
else:
|
|
return_endpoint = 'auth.register'
|
|
|
|
expected_state = session.pop(DISCORD_STATE_KEY, None)
|
|
received_state = request.args.get('state', '')
|
|
|
|
if not expected_state or not secrets.compare_digest(expected_state, received_state):
|
|
flash(
|
|
_(
|
|
'Discord authorization could not be verified. '
|
|
'Please start the connection again from this page.'
|
|
),
|
|
'danger',
|
|
)
|
|
return redirect(url_for(return_endpoint))
|
|
|
|
code = request.args.get('code')
|
|
if not code:
|
|
flash(_('Discord authorization failed. No code received.'), 'danger')
|
|
return redirect(url_for(return_endpoint))
|
|
|
|
# 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:
|
|
flash(_('Failed to connect to Discord. Please try again.'), 'danger')
|
|
return redirect(url_for(return_endpoint))
|
|
|
|
if not access_token:
|
|
flash(_('Failed to obtain Discord access token.'), 'danger')
|
|
return redirect(url_for(return_endpoint))
|
|
|
|
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(return_endpoint))
|
|
|
|
discord_user_id = user_data.get('id')
|
|
try:
|
|
if not discord_user_id:
|
|
raise ValidationError('missing Discord user id')
|
|
discord_user_id = str(discord_user_id)
|
|
validate_discord_user_id(discord_user_id)
|
|
except ValidationError:
|
|
flash(_('Failed to fetch Discord user profile.'), 'danger')
|
|
return redirect(url_for(return_endpoint))
|
|
|
|
if purpose == 'profile':
|
|
# If the session expired while Discord was open, do not turn a profile
|
|
# relink into registration state for an anonymous browser.
|
|
if not current_user.is_authenticated:
|
|
flash(_('Please log in to connect your Discord account.'), 'danger')
|
|
return redirect(url_for('auth.login'))
|
|
|
|
clash = User.query.filter(
|
|
User.discord_user_id == discord_user_id,
|
|
User.id != current_user.id,
|
|
).first()
|
|
if clash:
|
|
flash(_('This Discord account is already linked to another account.'), 'danger')
|
|
return redirect(url_for('users.edit_profile'))
|
|
|
|
current_user.discord_user_id = discord_user_id
|
|
current_user.discord_username = user_data.get('username') or None
|
|
db.session.commit()
|
|
log_auth_event(
|
|
'account.discord_linked',
|
|
username=current_user.username,
|
|
user_id=current_user.id,
|
|
)
|
|
flash(_('Discord account connected!'), 'success')
|
|
return redirect(url_for('users.edit_profile'))
|
|
|
|
# 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': discord_user_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', methods=['POST'])
|
|
@login_required
|
|
def logout():
|
|
"""Log out the current user and clear the session.
|
|
|
|
POST, not GET: a GET route is not covered by CSRF protection, so any
|
|
page on the internet could sign a user out with an <img> tag pointing
|
|
here. A nuisance rather than a compromise, but it costs one form to
|
|
close (SEC-019).
|
|
|
|
Clears the user session and regenerates session ID to prevent
|
|
session fixation/replay after logout.
|
|
|
|
Returns:
|
|
Response: Redirect to login page with logout message.
|
|
"""
|
|
log_auth_event('logout', username=current_user.username, user_id=current_user.id)
|
|
logout_user()
|
|
# Same reasoning as at login: the language is a display preference, not
|
|
# session state belonging to the account being signed out.
|
|
_locale = session.get(LOCALE_SESSION_KEY)
|
|
session.clear()
|
|
if _locale:
|
|
session[LOCALE_SESSION_KEY] = _locale
|
|
flash(_('You have been logged out.'), 'info')
|
|
return redirect(url_for('auth.login'))
|