"""Auth API routes for AnchorHost.""" import asyncio from database_service import database_connection, dict_from_row from datetime import datetime, timezone from email_service import get_email_service from flask import Blueprint, jsonify, redirect, request import json import jwt import os import requests from runtime_config import ( APPLE_CLIENT_ID, APPLE_KEY_ID, APPLE_PRIVATE_KEY, APPLE_REDIRECT_URI, APPLE_TEAM_ID, GOOGLE_CLIENT_ID, GOOGLE_CLIENT_SECRET, GOOGLE_REDIRECT_URI, connected_clients, logger, ) import secrets import urllib.parse from user_service import create_user, generate_oauth_state, generate_token, get_user_by_apple_id, get_user_by_email, get_user_by_google_id, verify_oauth_state from websocket_service import send_message __all__ = ['apple_callback', 'apple_login', 'get_auth_config', 'google_callback', 'google_login', 'verify_email', 'verify_email_api'] bp = Blueprint("auth", __name__) @bp.route('/api/auth/google/login', methods=['GET']) def google_login(): """Initiate Google OAuth login""" if not GOOGLE_CLIENT_ID: return jsonify({'error': 'Google OAuth not configured'}), 500 # Generate state token for CSRF protection state = generate_oauth_state('google') # Store state in session or return it to frontend # For simplicity, we'll include it in the redirect URL auth_url = ( f"https://accounts.google.com/o/oauth2/v2/auth?" f"client_id={GOOGLE_CLIENT_ID}&" f"redirect_uri={urllib.parse.quote(GOOGLE_REDIRECT_URI)}&" f"response_type=code&" f"scope=openid email profile&" f"state={state}&" f"access_type=offline&" f"prompt=consent" ) return jsonify({'auth_url': auth_url, 'state': state}) @bp.route('/api/auth/google/callback', methods=['GET', 'POST']) def google_callback(): """Handle Google OAuth callback""" try: code = request.args.get('code') state = request.args.get('state') if not verify_oauth_state(state, 'google'): return jsonify({'error': 'Invalid OAuth state'}), 400 if not code: return jsonify({'error': 'Authorization code not provided'}), 400 # Exchange authorization code for access token token_url = 'https://oauth2.googleapis.com/token' token_data = { 'code': code, 'client_id': GOOGLE_CLIENT_ID, 'client_secret': GOOGLE_CLIENT_SECRET, 'redirect_uri': GOOGLE_REDIRECT_URI, 'grant_type': 'authorization_code' } token_response = requests.post(token_url, data=token_data) token_response.raise_for_status() tokens = token_response.json() access_token = tokens.get('access_token') if not access_token: return jsonify({'error': 'Failed to obtain access token'}), 400 # Get user info from Google user_info_url = 'https://www.googleapis.com/oauth2/v2/userinfo' headers = {'Authorization': f'Bearer {access_token}'} user_info_response = requests.get(user_info_url, headers=headers) user_info_response.raise_for_status() google_user = user_info_response.json() google_id = google_user.get('id') email = google_user.get('email') name = google_user.get('name', '') given_name = google_user.get('given_name', '') family_name = google_user.get('family_name', '') picture = google_user.get('picture', '') verified_email = google_user.get('verified_email', False) if not google_id or not email: return jsonify({'error': 'Failed to retrieve user information from Google'}), 400 # Check if user exists by Google ID user = get_user_by_google_id(google_id) if not user: # Check if user exists by email (for linking accounts) existing_user = get_user_by_email(email) if existing_user: # Link Google account to existing user with database_connection() as conn: cursor = conn.cursor() cursor.execute(''' UPDATE users SET google_id = ?, email_verified = ? WHERE id = ? ''', (google_id, True, existing_user['id'])) conn.commit() user = get_user_by_google_id(google_id) else: # Create new user with Google account # Generate username from email username_base = email.split('@')[0] username = username_base counter = 1 # Ensure username is unique with database_connection() as conn: cursor = conn.cursor() while True: cursor.execute('SELECT id FROM users WHERE username = ?', (username,)) if not cursor.fetchone(): break username = f"{username_base}{counter}" counter += 1 user = create_user( username=username, email=email, password=None, # No password for OAuth users google_id=google_id, display_name=name, first_name=given_name, last_name=family_name, email_verified=verified_email ) if not user: return jsonify({'error': 'Failed to create or retrieve user'}), 500 # Update last login with database_connection() as conn: cursor = conn.cursor() cursor.execute(''' UPDATE users SET last_login = CURRENT_TIMESTAMP, login_count = login_count + 1, email_verified = ? WHERE id = ? ''', (True, user['id'])) conn.commit() # Generate JWT token token = generate_token(user['id']) # Redirect to frontend with token frontend_url = os.environ.get('FRONTEND_URL', 'https://anchorhost.eu') redirect_url = f"{frontend_url}/?token={token}&user={urllib.parse.quote(json.dumps(user))}" # Redirect to frontend return redirect(redirect_url) except requests.exceptions.RequestException as e: logger.error(f"Google OAuth error: {e}") return jsonify({'error': 'Failed to authenticate with Google'}), 500 except Exception as e: logger.error(f"Error in Google OAuth callback: {e}", exc_info=True) return jsonify({'error': 'An error occurred during authentication'}), 500 @bp.route('/api/auth/apple/login', methods=['GET']) def apple_login(): """Initiate Apple OAuth login""" if not APPLE_CLIENT_ID: return jsonify({'error': 'Apple OAuth not configured'}), 500 # Generate state token for CSRF protection nonce = secrets.token_urlsafe(32) state = generate_oauth_state('apple', nonce) # Store state in session or return it to frontend # Apple authorization URL auth_url = ( f"https://appleid.apple.com/auth/authorize?" f"client_id={APPLE_CLIENT_ID}&" f"redirect_uri={urllib.parse.quote(APPLE_REDIRECT_URI)}&" f"response_type=code id_token&" f"scope=name email&" f"response_mode=form_post&" f"nonce={urllib.parse.quote(nonce)}&" f"state={state}" ) return jsonify({'auth_url': auth_url, 'state': state}) @bp.route('/api/auth/apple/callback', methods=['POST']) def apple_callback(): """Handle Apple OAuth callback""" try: # Apple sends data as form_post (application/x-www-form-urlencoded) code = request.form.get('code') id_token = request.form.get('id_token') state = request.form.get('state') user_json = request.form.get('user') # Only sent on first login state_payload = verify_oauth_state(state, 'apple') if not state_payload or not state_payload.get('nonce'): return jsonify({'error': 'Invalid OAuth state'}), 400 if not id_token: return jsonify({'error': 'ID token not provided'}), 400 # Validate Apple's signature, issuer, audience, expiry, and state-bound nonce. try: signing_key = jwt.PyJWKClient( 'https://appleid.apple.com/auth/keys', cache_jwk_set=True, ).get_signing_key_from_jwt(id_token).key decoded_id_token = jwt.decode( id_token, signing_key, algorithms=['RS256'], audience=APPLE_CLIENT_ID, issuer='https://appleid.apple.com', options={'require': ['exp', 'iat', 'sub', 'nonce']}, ) if not secrets.compare_digest(decoded_id_token['nonce'], state_payload['nonce']): return jsonify({'error': 'Invalid Apple identity token nonce'}), 400 except Exception as e: logger.warning(f"Apple identity token validation failed: {e}") return jsonify({'error': 'Invalid ID token'}), 400 apple_id = decoded_id_token.get('sub') email = decoded_id_token.get('email') if not apple_id or not email: return jsonify({'error': 'Failed to retrieve user information from Apple'}), 400 # Parse user info if provided (name is only sent on first login) first_name = "" last_name = "" display_name = "" if user_json: try: user_data = json.loads(user_json) name_data = user_data.get('name', {}) first_name = name_data.get('firstName', '') last_name = name_data.get('lastName', '') if first_name or last_name: display_name = f"{first_name} {last_name}".strip() except: pass # Check if user exists by Apple ID user = get_user_by_apple_id(apple_id) if not user: # Check if user exists by email (for linking accounts) existing_user = get_user_by_email(email) if existing_user: # Link Apple account to existing user with database_connection() as conn: cursor = conn.cursor() cursor.execute(''' UPDATE users SET apple_id = ?, email_verified = ? WHERE id = ? ''', (apple_id, True, existing_user['id'])) conn.commit() user = get_user_by_apple_id(apple_id) else: # Create new user with Apple account # Generate username from email username_base = email.split('@')[0] username = username_base counter = 1 # Ensure username is unique with database_connection() as conn: cursor = conn.cursor() while True: cursor.execute('SELECT id FROM users WHERE username = ?', (username,)) if not cursor.fetchone(): break username = f"{username_base}{counter}" counter += 1 user = create_user( username=username, email=email, password=None, # No password for OAuth users apple_id=apple_id, display_name=display_name, first_name=first_name, last_name=last_name, email_verified=True # Apple verified email ) if not user: return jsonify({'error': 'Failed to create or retrieve user'}), 500 # Update last login with database_connection() as conn: cursor = conn.cursor() cursor.execute(''' UPDATE users SET last_login = CURRENT_TIMESTAMP, login_count = login_count + 1, email_verified = ? WHERE id = ? ''', (True, user['id'])) conn.commit() # Generate JWT token token = generate_token(user['id']) # Redirect to frontend with token frontend_url = os.environ.get('FRONTEND_URL', 'https://anchorhost.eu') redirect_url = f"{frontend_url}/?token={token}&user={urllib.parse.quote(json.dumps(user))}" # Redirect to frontend return redirect(redirect_url) except Exception as e: logger.error(f"Error in Apple OAuth callback: {e}", exc_info=True) return jsonify({'error': 'An error occurred during authentication'}), 500 @bp.route('/verify-email', methods=['GET']) def verify_email(): """Email verification page""" token = request.args.get('token') if not token: return '''
This verification link has expired. Please request a new verification email.
Go to HomeYour email has been successfully verified. You can now log in and start using AnchorHost!
Go to Dashboard