Files
2025-06-09 15:32:58 +02:00

412 lines
17 KiB
Python

"""
Authentication helper functions for OAuth providers
"""
import os
from flask import current_app, url_for, session, flash, redirect, request
from authlib.integrations.flask_client import OAuth
from functools import wraps
from datetime import datetime, timedelta
import requests
# Initialize OAuth object
oauth = OAuth()
def init_oauth(app):
"""
Initialize OAuth with the Flask app and register providers
"""
oauth.init_app(app)
# Register Google OAuth client
if app.config.get('GOOGLE_CLIENT_ID') and app.config.get('GOOGLE_CLIENT_SECRET'):
oauth.register(
name='google',
client_id=app.config.get('GOOGLE_CLIENT_ID'),
client_secret=app.config.get('GOOGLE_CLIENT_SECRET'),
server_metadata_url='https://accounts.google.com/.well-known/openid-configuration',
client_kwargs={
'scope': 'openid email profile'
}
)
app.logger.info("Google OAuth client registered")
else:
app.logger.warning("Google OAuth client not registered - missing client ID or secret")
# Register Authentik OAuth client
if app.config.get('AUTHENTIK_CLIENT_ID') and app.config.get('AUTHENTIK_CLIENT_SECRET'):
oauth.register(
name='authentik',
client_id=app.config.get('AUTHENTIK_CLIENT_ID'),
client_secret=app.config.get('AUTHENTIK_CLIENT_SECRET'),
server_metadata_url=app.config.get('AUTHENTIK_METADATA_URL'),
client_kwargs={
'scope': 'openid email profile'
}
)
app.logger.info("Authentik OAuth client registered")
else:
app.logger.warning("Authentik OAuth client not registered - missing client ID or secret")
# Register Dropbox OAuth client
if app.config.get('DROPBOX_APP_KEY') and app.config.get('DROPBOX_APP_SECRET'):
oauth.register(
name='dropbox',
client_id=app.config.get('DROPBOX_APP_KEY'),
client_secret=app.config.get('DROPBOX_APP_SECRET'),
authorize_url='https://www.dropbox.com/oauth2/authorize',
authorize_params=None,
access_token_url='https://api.dropboxapi.com/oauth2/token',
access_token_params=None,
refresh_token_url='https://api.dropboxapi.com/oauth2/token',
client_kwargs={
'scope': 'files.content.write account_info.read'
}
)
app.logger.info("Dropbox OAuth client registered")
else:
app.logger.warning("Dropbox OAuth client not registered - missing app key or secret")
# Register Spotify OAuth client
if app.config.get('SPOTIFY_CLIENT_ID') and app.config.get('SPOTIFY_CLIENT_SECRET'):
oauth.register(
name='spotify',
client_id=app.config.get('SPOTIFY_CLIENT_ID'),
client_secret=app.config.get('SPOTIFY_CLIENT_SECRET'),
api_base_url='https://api.spotify.com/v1/',
authorize_url='https://accounts.spotify.com/authorize',
authorize_params={'show_dialog': 'true'}, # Force re-approval
access_token_url='https://accounts.spotify.com/api/token',
access_token_params=None,
refresh_token_url='https://accounts.spotify.com/api/token',
client_kwargs={
'scope': app.config.get('SPOTIFY_SCOPE')
},
userinfo_endpoint='https://api.spotify.com/v1/me' # Added for fetching user info
)
app.logger.info("Spotify OAuth client registered")
else:
app.logger.warning("Spotify OAuth client not registered - missing client ID or secret")
return oauth
def get_google_user_info(token):
"""
Get Google user info from the token
"""
try:
resp = oauth.google.get('https://www.googleapis.com/oauth2/v3/userinfo')
profile = resp.json()
# Create a standardized user info dictionary
user_info = {
'id': profile.get('sub'), # Google uses 'sub' as the unique identifier
'email': profile.get('email'),
'name': profile.get('name'),
'given_name': profile.get('given_name'),
'family_name': profile.get('family_name'),
'picture': profile.get('picture')
}
# Add 'sub' field explicitly for backwards compatibility
if profile.get('sub'):
user_info['sub'] = profile.get('sub')
return user_info
except Exception as e:
current_app.logger.error(f"Error getting Google user info: {str(e)}")
return None
def get_authentik_user_info(token):
"""
Get Authentik user info from the token
"""
try:
resp = oauth.authentik.get('userinfo')
profile = resp.json()
return {
'id': profile.get('sub'),
'email': profile.get('email'),
'name': profile.get('name'),
'given_name': profile.get('given_name', ''),
'family_name': profile.get('family_name', ''),
'picture': profile.get('picture', '')
}
except Exception as e:
current_app.logger.error(f"Error getting Authentik user info: {str(e)}")
return None
def get_dropbox_user_info(token):
"""
Get Dropbox user info from the token
"""
try:
# Add debug logging for token
current_app.logger.debug(f"Retrieving Dropbox user info with token: {token}")
# Make sure we have an access token
access_token = token.get("access_token")
if not access_token:
# Try direct token string if token is not a dict
if isinstance(token, str):
access_token = token
else:
current_app.logger.error("No access token found in token object")
return None
# Set proper headers for Dropbox API - no Content-Type for null body
headers = {
'Authorization': f'Bearer {access_token}'
}
# The Dropbox API for get_current_account actually expects a null body with no Content-Type header
response = requests.post(
'https://api.dropboxapi.com/2/users/get_current_account',
headers=headers,
data=None # Send null body
)
# Check for successful response
if response.status_code != 200:
current_app.logger.error(f"Dropbox API error: {response.status_code} - {response.text}")
return None
# Parse response
profile = response.json()
current_app.logger.debug(f"Dropbox user info response: {profile}")
# Create a standardized user info dictionary
user_info = {
'id': profile.get('account_id', ''),
'email': profile.get('email', ''),
'name': profile.get('name', {}).get('display_name', ''),
'given_name': profile.get('name', {}).get('given_name', ''),
'family_name': profile.get('name', {}).get('surname', ''),
'picture': profile.get('profile_photo_url', '')
}
return user_info
except Exception as e:
current_app.logger.error(f"Error getting Dropbox user info: {str(e)}")
return None
def get_spotify_user_info(token):
"""
Get Spotify user info from the token
"""
try:
# Authlib should handle token refresh automatically if configured correctly
# and if the token object is managed by Authlib's token session or similar mechanism.
# Use the registered Authlib client to fetch user info
# The 'userinfo_endpoint' configured during registration will be used.
# We pass the token explicitly to ensure it's used for this request.
# Authlib's `oauth.spotify.get()` will prepend the base URL if 'userinfo_endpoint' is relative,
# but since we provided an absolute one, it should use that.
# The error "Invalid URL 'me'" suggests that 'me' alone was passed somewhere.
# Let's ensure we are calling the fully qualified endpoint via the client.
resp = oauth.spotify.get('https://api.spotify.com/v1/me', token=token)
resp.raise_for_status() # Raise an exception for HTTP errors (4xx or 5xx)
profile = resp.json()
current_app.logger.debug(f"Spotify user info response: {profile}")
user_info = {
'id': profile.get('id'),
'email': profile.get('email'), # Note: Spotify email might be private
'name': profile.get('display_name'),
'picture': profile.get('images')[0]['url'] if profile.get('images') else None,
# Spotify doesn't provide given_name and family_name directly
'given_name': profile.get('display_name', '').split(' ')[0] if profile.get('display_name') else '',
'family_name': ' '.join(profile.get('display_name', '').split(' ')[1:]) if profile.get('display_name') and ' ' in profile.get('display_name') else ''
}
return user_info
except requests.exceptions.HTTPError as http_err:
current_app.logger.error(f"HTTP error getting Spotify user info: {http_err} - Response: {http_err.response.text}")
return None
except Exception as e:
current_app.logger.error(f"Error getting Spotify user info: {str(e)}")
return None
def find_or_create_user(user_info, auth_provider):
"""
Find existing user or create a new one based on OAuth user info
"""
from musicround.models import db, User
if not user_info:
return None
# First try to find user by provider-specific ID
if auth_provider == 'google':
user = User.query.filter_by(google_id=user_info['id']).first()
elif auth_provider == 'authentik':
user = User.query.filter_by(authentik_id=user_info['id']).first()
elif auth_provider == 'dropbox':
user = User.query.filter_by(dropbox_id=user_info['id']).first()
elif auth_provider == 'spotify':
user = User.query.filter_by(spotify_id=user_info['id']).first()
else:
return None
# If not found by provider ID, try email
if user is None and user_info.get('email'):
user = User.query.filter_by(email=user_info['email']).first()
# If user exists but doesn't have provider ID, update it
if user:
if auth_provider == 'google':
user.google_id = user_info['id']
elif auth_provider == 'authentik':
user.authentik_id = user_info['id']
elif auth_provider == 'dropbox':
user.dropbox_id = user_info['id']
elif auth_provider == 'spotify':
user.spotify_id = user_info['id']
db.session.commit()
current_app.logger.info(f"Updated existing user {user.username} with {auth_provider} ID")
# If user still not found, check if new signups are allowed before creating
if user is None:
# Check system setting if new signups are allowed
from musicround.models import SystemSetting
allow_signups = SystemSetting.get('allow_signups', 'true') == 'true'
if not allow_signups:
current_app.logger.warning(f"OAuth signup attempted for {auth_provider} but new signups are disabled")
return None
# Generate a username from email
email = user_info.get('email', '')
base_username = email.split('@')[0] if email else f"{auth_provider}_{user_info['id']}"
# Ensure username is unique
username = base_username
counter = 1
while User.query.filter_by(username=username).first():
username = f"{base_username}_{counter}"
counter += 1
# Create new user
user = User(
username=username,
email=user_info.get('email', ''),
first_name=user_info.get('given_name', ''),
last_name=user_info.get('family_name', ''),
auth_provider=auth_provider,
created_at=datetime.now(),
last_login=datetime.now()
)
# Set provider-specific fields
if auth_provider == 'google':
user.google_id = user_info['id']
elif auth_provider == 'authentik':
user.authentik_id = user_info['id']
elif auth_provider == 'dropbox':
user.dropbox_id = user_info['id']
elif auth_provider == 'spotify':
user.spotify_id = user_info['id']
db.session.add(user)
try:
db.session.commit()
current_app.logger.info(f"Created new user {username} with {auth_provider} auth")
except Exception as e:
db.session.rollback()
current_app.logger.error(f"Error creating user: {str(e)}")
return None
return user
def update_oauth_tokens(user, tokens, auth_provider):
"""
Update user's OAuth tokens
"""
from musicround.models import db
if auth_provider == 'google':
user.google_token = tokens.get('access_token')
user.google_refresh_token = tokens.get('refresh_token')
elif auth_provider == 'authentik':
user.authentik_token = tokens.get('access_token')
user.authentik_refresh_token = tokens.get('refresh_token')
elif auth_provider == 'dropbox':
user.dropbox_token = tokens.get('access_token')
user.dropbox_refresh_token = tokens.get('refresh_token')
if tokens.get('expires_in'):
user.dropbox_token_expiry = datetime.now() + timedelta(seconds=int(tokens.get('expires_in')))
elif auth_provider == 'spotify':
user.spotify_token = tokens.get('access_token')
user.spotify_refresh_token = tokens.get('refresh_token')
if tokens.get('expires_in'):
user.spotify_token_expiry = datetime.now() + timedelta(seconds=int(tokens.get('expires_in')))
user.last_login = datetime.now()
try:
db.session.commit()
return True
except Exception as e:
db.session.rollback()
current_app.logger.error(f"Error updating {auth_provider} tokens: {str(e)}")
return False
def get_oauth_redirect_uri(endpoint, provider=None):
"""
Generate OAuth redirect URI with proper scheme handling for reverse proxy environments
This function chooses the redirect URI using the following priority:
1. Static URL from config (if STATIC_OAUTH_URLS is True)
2. Dynamic URL generated by url_for() with PREFERRED_URL_SCHEME
3. Force to HTTPS if USE_HTTPS=True regardless of incoming request
"""
# Check if static OAuth URLs are enabled
use_static_urls = current_app.config.get('STATIC_OAUTH_URLS', False)
use_https = current_app.config.get('USE_HTTPS', False)
# Define mapping from endpoint to config key for static URLs
static_url_mapping = {
'auth.callback': 'OAUTH_SPOTIFY_AUTH_URL',
'users.spotify_link_callback': 'OAUTH_SPOTIFY_LINK_URL',
'users.google_callback': 'OAUTH_GOOGLE_URL',
'users.authentik_callback': 'OAUTH_AUTHENTIK_URL',
'users.dropbox_callback': 'OAUTH_DROPBOX_URL'
}
# First try to use a static URL if enabled and available
redirect_uri = None
if use_static_urls and endpoint in static_url_mapping:
config_key = static_url_mapping[endpoint]
redirect_uri = current_app.config.get(config_key)
if redirect_uri:
current_app.logger.debug(f"Using static OAuth URL for {endpoint}: {redirect_uri}")
else:
current_app.logger.warning(
f"Static OAuth URLs enabled but no URL defined for {endpoint} "
f"(expected config key: {config_key})"
)
# If no static URL, use Flask's url_for which respects PREFERRED_URL_SCHEME
if not redirect_uri:
if provider:
redirect_uri = url_for(endpoint, provider=provider, _external=True)
else:
redirect_uri = url_for(endpoint, _external=True)
# Force HTTPS when USE_HTTPS=True regardless of the generated URL scheme
if use_https and redirect_uri.startswith('http:'):
redirect_uri = 'https:' + redirect_uri[5:]
current_app.logger.info(f"Forcing HTTPS for OAuth redirect URI: {redirect_uri}")
# Log details about the generated URL for debugging
preferred_scheme = current_app.config.get('PREFERRED_URL_SCHEME', 'http')
static_enabled = "Yes" if use_static_urls else "No"
static_url_used = "Yes" if use_static_urls and redirect_uri and endpoint in static_url_mapping and current_app.config.get(static_url_mapping[endpoint]) else "No"
current_app.logger.debug(
f"OAuth Redirect URI: {redirect_uri} | "
f"Endpoint: {endpoint} | "
f"USE_HTTPS: {use_https} | "
f"PREFERRED_URL_SCHEME: {preferred_scheme} | "
f"Static URLs enabled: {static_enabled} | "
f"Used static URL: {static_url_used} | "
f"Request scheme: {request.scheme if request else 'N/A'} | "
f"X-Forwarded-Proto: {request.headers.get('X-Forwarded-Proto', 'N/A') if request else 'N/A'}"
)
return redirect_uri