anicove / api /routes /shared /auth.py
mwask's picture
Upload 136 files
be99f9f verified
Raw
History Blame Contribute Delete
20.5 kB
from flask import Blueprint, render_template, request, redirect, url_for, session, flash, jsonify, current_app
from urllib.parse import urlencode
import secrets
import requests
import logging
from ...utils.helpers import verify_turnstile, get_anilist_user_info
from ...core.caching import clear_user_cache
from ...models.user import (
get_user, user_exists, email_exists, create_user, get_user_by_id,
get_user_by_anilist_id, create_anilist_user, update_anilist_user,
link_anilist_to_existing_user, unlink_anilist_from_user, delete_anilist_data,
connect_anilist_to_user
)
from ...core.config import Config
auth_bp = Blueprint('auth', __name__)
logger = logging.getLogger(__name__)
@auth_bp.route('/anilist/login')
def anilist_login():
"""Initiate AniList OAuth login/signup for non-logged-in users."""
# If user is already logged in, redirect to home
if 'username' in session and '_id' in session:
return redirect(url_for('home_routes.home'))
# Generate a random state parameter for security
state = secrets.token_urlsafe(32)
session['oauth_state'] = state
# Build the authorization URL
params = {
'client_id': Config.ANILIST_CLIENT_ID,
'redirect_uri': Config.ANILIST_REDIRECT_URI,
'response_type': 'code',
'state': state
}
auth_url = f"https://anilist.co/api/v2/oauth/authorize?{urlencode(params)}"
return redirect(auth_url)
@auth_bp.route('/anilist/link')
def link_anilist_account():
"""Link AniList account to existing logged-in user."""
# Check if user is already logged in
if 'username' not in session or '_id' not in session:
flash('Please log in first to link your AniList account.', 'warning')
return redirect(url_for('home_routes.home'))
# Generate a random state parameter for security
state = secrets.token_urlsafe(32)
session['oauth_state'] = state
session['linking_account'] = True # Flag to indicate we're linking, not creating new
# Build the authorization URL
params = {
'client_id': Config.ANILIST_CLIENT_ID,
'redirect_uri': Config.ANILIST_REDIRECT_URI,
'response_type': 'code',
'state': state
}
auth_url = f"https://anilist.co/api/v2/oauth/authorize?{urlencode(params)}"
return redirect(auth_url)
@auth_bp.route('/anilist/callback')
def anilist_callback():
"""Handle AniList OAuth callback with account linking support."""
code = request.args.get('code')
state = request.args.get('state')
error = request.args.get('error')
current_app.logger.info(f"AniList callback received - code: {bool(code)}, state: {bool(state)}, error: {error}")
# Check for errors
if error:
current_app.logger.error(f"AniList OAuth error: {error}")
flash('Login failed. Please try again.', 'error')
return redirect(url_for('home_routes.home'))
# Check if we're linking to existing account (only if user is logged in)
is_linking = 'username' in session and '_id' in session
current_user_id = session.get('_id') if is_linking else None
current_username = session.get('username') if is_linking else None
current_app.logger.info(f"Is linking: {is_linking}, User ID: {current_user_id}")
if not code:
current_app.logger.error("No authorization code received from AniList")
flash('Login cancelled or failed.', 'error')
return redirect(url_for('home_routes.home'))
try:
# Validate configuration
if not Config.ANILIST_CLIENT_ID or not Config.ANILIST_CLIENT_SECRET:
current_app.logger.error("AniList OAuth credentials not configured")
flash('AniList integration is not properly configured. Please contact the administrator.', 'error')
return redirect(url_for('home_routes.home'))
# Exchange code for access token
token_data = {
'grant_type': 'authorization_code',
'client_id': Config.ANILIST_CLIENT_ID,
'client_secret': Config.ANILIST_CLIENT_SECRET,
'redirect_uri': Config.ANILIST_REDIRECT_URI,
'code': code
}
current_app.logger.info(f"Exchanging code for token with redirect_uri: {Config.ANILIST_REDIRECT_URI}")
token_response = requests.post('https://anilist.co/api/v2/oauth/token', json=token_data, timeout=10)
if token_response.status_code != 200:
current_app.logger.error(f"Token exchange failed with status {token_response.status_code}: {token_response.text}")
flash('Login failed. Unable to get access token.', 'error')
return redirect(url_for('home_routes.home'))
token_info = token_response.json()
access_token = token_info.get('access_token')
if not access_token:
flash('Login failed. No access token received.', 'error')
return redirect(url_for('home_routes.home'))
# Get user info from AniList
current_app.logger.info("Fetching user info from AniList")
user_info = get_anilist_user_info(access_token)
if not user_info:
current_app.logger.error("Failed to get user info from AniList")
flash('Login failed. Unable to get user information.', 'error')
return redirect(url_for('home_routes.home'))
current_app.logger.info(f"AniList user info retrieved: {user_info.get('name')} (ID: {user_info.get('id')})")
# Check if this AniList account is already linked to another user
existing_anilist_user = get_user_by_anilist_id(user_info['id'])
if is_linking and current_user_id:
# ACCOUNT LINKING MODE (user is already logged in)
# Check if current user already has an AniList account connected
current_user = get_user_by_id(current_user_id)
if current_user and current_user.get('anilist_id'):
if current_user.get('anilist_id') == user_info['id']:
flash('This AniList account is already linked to your account.', 'info')
return redirect(url_for('catalog_routes.settings'))
else:
flash('You already have a different AniList account connected. Please disconnect it first.', 'error')
return redirect(url_for('catalog_routes.settings'))
# Check if this AniList account is linked to another user
if existing_anilist_user:
if existing_anilist_user['_id'] == current_user_id:
flash('This AniList account is already linked to your account.', 'info')
else:
flash('This AniList account is already linked to another user account.', 'error')
return redirect(url_for('catalog_routes.settings'))
# Connect the AniList account
current_app.logger.info(f"Connecting AniList account {user_info['id']} to user {current_user_id}")
result = connect_anilist_to_user(current_user_id, user_info, access_token)
if result:
session['anilist_authenticated'] = True
session['anilist_id'] = user_info['id']
# Update avatar in session from AniList
anilist_avatar = user_info.get('avatar', {}).get('large') or user_info.get('avatar', {}).get('medium')
if anilist_avatar:
session['avatar'] = anilist_avatar
current_app.logger.info(f"AniList account {user_info['id']} successfully linked to user {current_username}")
flash('AniList account successfully connected!', 'success')
else:
current_app.logger.error(f"Failed to connect AniList account to user {current_user_id}")
flash('Failed to connect AniList account. Please try again.', 'error')
return redirect(url_for('catalog_routes.settings'))
else:
# NORMAL LOGIN/SIGNUP MODE (user is not logged in)
current_app.logger.info("Processing AniList login/signup")
if existing_anilist_user:
# Update existing user with latest AniList info
current_app.logger.info(f"Updating existing user {existing_anilist_user['_id']} with AniList info")
update_anilist_user(existing_anilist_user['_id'], user_info, access_token)
user_id = existing_anilist_user['_id']
username = existing_anilist_user['username']
else:
# Create new user
current_app.logger.info(f"Creating new user from AniList: {user_info['name']}")
user_id = create_anilist_user(user_info, access_token)
username = user_info['name']
# Set session
session.clear()
session['username'] = username
session['_id'] = user_id
session['anilist_authenticated'] = True
session['anilist_id'] = user_info['id']
session['avatar'] = user_info.get('avatar', {}).get('large') or user_info.get('avatar', {}).get('medium')
# Sync password_version so validate_session_version doesn't clear the session
if existing_anilist_user:
session['password_version'] = existing_anilist_user.get('password_version', 0)
else:
session['password_version'] = 0
session.permanent = True
current_app.logger.info(f"User {username} (ID: {user_id}) logged in via AniList successfully")
flash(f'Welcome, {username}!', 'success')
return redirect(url_for('home_routes.home'))
except Exception as e:
current_app.logger.error(f"AniList OAuth error: {e}")
flash('Login failed. Please try again.', 'error')
return redirect(url_for('home_routes.home'))
@auth_bp.route('/anilist/unlink', methods=['POST'])
def unlink_anilist_account():
"""Unlink AniList account from current user."""
if 'username' not in session or '_id' not in session:
return jsonify({'success': False, 'message': 'Not logged in.'}), 401
try:
user_id = session.get('_id')
if not user_id:
return jsonify({'success': False, 'message': 'User not found.'}), 404
# Get user data before unlinking to log the action
user = get_user_by_id(user_id)
if not user:
return jsonify({'success': False, 'message': 'User not found.'}), 404
anilist_id = user.get('anilist_id')
username = user.get('username', 'Unknown')
# Delete all AniList-related data from the user
result = delete_anilist_data(int(user_id))
if result:
# Update session to reflect the change
session['anilist_authenticated'] = False
if 'anilist_id' in session:
del session['anilist_id']
logger.info(f"AniList account (ID: {anilist_id}) disconnected from user {username} (ID: {user_id})")
return jsonify({
'success': True,
'message': 'AniList account disconnected successfully. All AniList data has been removed from your account.'
})
else:
logger.error(f"Failed to disconnect AniList account for user {username} (ID: {user_id})")
return jsonify({'success': False, 'message': 'Failed to disconnect AniList account. Please try again.'})
except Exception as e:
logger.error(f"Error disconnecting AniList account for user {session.get('username', 'Unknown')}: {e}")
return jsonify({'success': False, 'message': 'An unexpected error occurred. Please try again.'})
@auth_bp.route('/anilist/connect', methods=['GET'])
def connect_anilist_account():
"""Connect AniList account - same as link but with different messaging."""
# Check if user is already logged in
if 'username' not in session or '_id' not in session:
flash('Please log in first to connect your AniList account.', 'warning')
return redirect(url_for('home_routes.home'))
# Check if already connected
user_id = session.get('_id')
user = get_user_by_id(user_id)
if user and user.get('anilist_id'):
flash('Your AniList account is already connected.', 'info')
return redirect(url_for('catalog_routes.settings'))
# Generate a random state parameter for security
state = secrets.token_urlsafe(32)
session['oauth_state'] = state
session['connecting_account'] = True # Flag to indicate we're connecting
# Build the authorization URL
params = {
'client_id': Config.ANILIST_CLIENT_ID,
'redirect_uri': Config.ANILIST_REDIRECT_URI,
'response_type': 'code',
'state': state
}
auth_url = f"https://anilist.co/api/v2/oauth/authorize?{urlencode(params)}"
return redirect(auth_url)
@auth_bp.route('/anilist/disconnect', methods=['POST'])
def disconnect_anilist_account():
"""Disconnect AniList account from current user (alternative endpoint)."""
return unlink_anilist_account()
@auth_bp.route('/anilist/status', methods=['GET'])
def anilist_status():
"""Get current AniList connection status for the user."""
if 'username' not in session or '_id' not in session:
return jsonify({'connected': False, 'message': 'Not logged in.'}), 401
try:
user_id = session.get('_id')
user = get_user_by_id(user_id)
if not user:
return jsonify({'connected': False, 'message': 'User not found.'}), 404
is_connected = bool(user.get('anilist_id'))
anilist_data = {}
if is_connected:
anilist_data = {
'anilist_id': user.get('anilist_id'),
'avatar': user.get('avatar'),
'anilist_stats': user.get('anilist_stats', {}),
'connected_at': user.get('updated_at')
}
return jsonify({
'connected': is_connected,
'anilist_data': anilist_data if is_connected else None,
'message': 'Connected to AniList' if is_connected else 'Not connected to AniList'
})
except Exception as e:
logger.error(f"Error checking AniList status for user {session.get('username', 'Unknown')}: {e}")
return jsonify({'connected': False, 'message': 'Error checking connection status.'}), 500
# ──────────────────────────────────────────────────────────────
# MyAnimeList OAuth routes
# ──────────────────────────────────────────────────────────────
@auth_bp.route('/mal/connect', methods=['GET'])
def connect_mal_account():
"""Redirect user to MAL OAuth authorization page (with PKCE)."""
if 'username' not in session or '_id' not in session:
flash('Please log in first to connect your MyAnimeList account.', 'warning')
return redirect(url_for('home_routes.home'))
# Check if already connected
user_id = session.get('_id')
user = get_user_by_id(user_id)
if user and user.get('mal_id'):
flash('Your MyAnimeList account is already connected.', 'info')
return redirect(url_for('catalog_routes.settings'))
from ..utils.mal_service import get_mal_auth_url, _generate_code_verifier
state = secrets.token_urlsafe(32)
code_verifier = _generate_code_verifier()
session['mal_oauth_state'] = state
session['mal_code_verifier'] = code_verifier
auth_url = get_mal_auth_url(state, code_verifier)
return redirect(auth_url)
@auth_bp.route('/mal/callback')
def mal_callback():
"""Handle MAL OAuth callback — exchange code for tokens and store."""
code = request.args.get('code')
state = request.args.get('state')
error = request.args.get('error')
if error:
logger.error(f"MAL OAuth error: {error}")
flash('MyAnimeList login failed. Please try again.', 'error')
return redirect(url_for('catalog_routes.settings'))
if not code:
flash('No authorization code received from MyAnimeList.', 'error')
return redirect(url_for('catalog_routes.settings'))
# Must be logged in
if 'username' not in session or '_id' not in session:
flash('Please log in first.', 'warning')
return redirect(url_for('home_routes.home'))
code_verifier = session.pop('mal_code_verifier', None)
if not code_verifier:
flash('Session expired. Please try connecting again.', 'error')
return redirect(url_for('catalog_routes.settings'))
from ..utils.mal_service import exchange_mal_code, get_mal_user_info
from ..models.user import connect_mal_to_user
try:
# Exchange code for tokens
tokens = exchange_mal_code(code, code_verifier)
if not tokens:
flash('Failed to get access token from MyAnimeList.', 'error')
return redirect(url_for('catalog_routes.settings'))
# Fetch user info
mal_user = get_mal_user_info(tokens['access_token'])
if not mal_user:
flash('Failed to get user info from MyAnimeList.', 'error')
return redirect(url_for('catalog_routes.settings'))
# Store in DB
user_id = session['_id']
result = connect_mal_to_user(
user_id, mal_user, tokens['access_token'],
tokens.get('refresh_token', ''), tokens.get('expires_in', 3600)
)
if result:
session['mal_authenticated'] = True
session['mal_username'] = mal_user.get('name')
flash(f'MyAnimeList account ({mal_user.get("name")}) connected!', 'success')
else:
flash('Failed to save MyAnimeList connection.', 'error')
except Exception as e:
logger.error(f"MAL callback error: {e}")
flash('An error occurred connecting MyAnimeList.', 'error')
return redirect(url_for('catalog_routes.settings'))
@auth_bp.route('/mal/disconnect', methods=['POST'])
def disconnect_mal_account():
"""Remove MAL connection from the current user."""
if 'username' not in session or '_id' not in session:
return jsonify({'success': False, 'message': 'Not logged in.'}), 401
try:
from ..models.user import delete_mal_data
user_id = session.get('_id')
result = delete_mal_data(user_id)
if result:
session.pop('mal_authenticated', None)
session.pop('mal_username', None)
return jsonify({'success': True, 'message': 'MyAnimeList disconnected successfully.'})
return jsonify({'success': False, 'message': 'Failed to disconnect.'}), 500
except Exception as e:
logger.error(f"MAL disconnect error: {e}")
return jsonify({'success': False, 'message': 'An error occurred.'}), 500
@auth_bp.route('/mal/status', methods=['GET'])
def mal_status():
"""Check MAL connection status for the current user."""
if 'username' not in session or '_id' not in session:
return jsonify({'connected': False}), 200
try:
user = get_user_by_id(session.get('_id'))
if not user:
return jsonify({'connected': False}), 200
is_connected = bool(user.get('mal_id'))
return jsonify({
'connected': is_connected,
'mal_username': user.get('mal_username') if is_connected else None,
'mal_id': user.get('mal_id') if is_connected else None,
})
except Exception as e:
logger.error(f"MAL status error: {e}")
return jsonify({'connected': False}), 500