mirror of
https://github.com/PhialsBasement/LibreCrawl.git
synced 2026-10-18 00:19:37 +02:00
Add optional Zoho OAuth login
Setting ZOHO_OAUTH_ENABLED with a client ID and secret adds a "Login with Zoho" button to the login page. Zoho users are matched by a new users.zoho_id column. An existing account with the same email is linked to Zoho. Otherwise a new verified account is created, unless ZOHO_ALLOW_SIGNUP is off, which is the default when registration is disabled. Also configurable: redirect URI, data-center accounts server, scopes, allowed email domains and the tier for new accounts. All are documented in .env.example and passed through in docker-compose.yml. Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
This commit is contained in:
1 parent
26cce27bf0
commit
d451885216
7 files changed
+367
-3
No files matched your search
@@ -32,6 +32,31 @@ REGISTRATION_DISABLED=false
|
||||
# DO NOT enable on a public network or in production.
|
||||
DANGEROUSLY_SKIP_AUTH=false
|
||||
|
||||
# Zoho OAuth login
|
||||
# Adds a "Login with Zoho" button to the login page. Create a "Server-based
|
||||
# Application" client at https://api-console.zoho.com and register the
|
||||
# redirect URI below as its Authorized Redirect URI.
|
||||
ZOHO_OAUTH_ENABLED=false
|
||||
# ZOHO_CLIENT_ID=
|
||||
# ZOHO_CLIENT_SECRET=
|
||||
# Must match the Authorized Redirect URI exactly. Set it explicitly when
|
||||
# running behind a reverse proxy (e.g. Caddy/nginx terminating HTTPS).
|
||||
# Default: <current host>/auth/zoho/callback
|
||||
# ZOHO_REDIRECT_URI=https://crawl.example.com/auth/zoho/callback
|
||||
# Accounts server for the data center your client was created in:
|
||||
# .com (default), .eu, .in, .com.au, .jp, .sa, or https://accounts.zohocloud.ca
|
||||
# ZOHO_ACCOUNTS_URL=https://accounts.zoho.com
|
||||
# Requested scopes (default: openid,email,profile)
|
||||
# ZOHO_SCOPES=openid,email,profile
|
||||
# Only allow these email domains (comma-separated). Empty = any Zoho account.
|
||||
# ZOHO_ALLOWED_DOMAINS=example.com,example.org
|
||||
# Create a LibreCrawl account the first time a Zoho user logs in. Defaults to
|
||||
# "true", or "false" when REGISTRATION_DISABLED=true. Existing accounts with a
|
||||
# matching email are always linked.
|
||||
# ZOHO_ALLOW_SIGNUP=true
|
||||
# Tier given to accounts created via Zoho: guest, user, extra, admin
|
||||
# ZOHO_DEFAULT_TIER=user
|
||||
|
||||
# Email Configuration for verification emails
|
||||
# SMTP server settings
|
||||
SMTP_HOST=smtp.gmail.com
|
||||
|
||||
@@ -96,6 +96,20 @@ REGISTRATION_DISABLED=false
|
||||
SECRET_KEY=replace-with-a-long-random-string
|
||||
```
|
||||
|
||||
To add a **Login with Zoho** button, create a "Server-based Application" client at
|
||||
[api-console.zoho.com](https://api-console.zoho.com) with the redirect URI
|
||||
`https://your-host/auth/zoho/callback`, then set:
|
||||
|
||||
```bash
|
||||
ZOHO_OAUTH_ENABLED=true
|
||||
ZOHO_CLIENT_ID=...
|
||||
ZOHO_CLIENT_SECRET=...
|
||||
ZOHO_REDIRECT_URI=https://your-host/auth/zoho/callback
|
||||
# Optional: ZOHO_ACCOUNTS_URL, ZOHO_ALLOWED_DOMAINS, ZOHO_ALLOW_SIGNUP, ZOHO_DEFAULT_TIER
|
||||
```
|
||||
|
||||
See `.env.example` for what each option does.
|
||||
|
||||
|
||||
#### Option 2: Python
|
||||
|
||||
|
||||
@@ -22,6 +22,16 @@ services:
|
||||
# DANGEROUS: Set to "true" to allow login as any username with no password
|
||||
# (username only used to separate sessions). Do NOT use in production.
|
||||
- DANGEROUSLY_SKIP_AUTH=${DANGEROUSLY_SKIP_AUTH:-false}
|
||||
# Zoho OAuth login (see .env.example for details)
|
||||
- ZOHO_OAUTH_ENABLED=${ZOHO_OAUTH_ENABLED:-false}
|
||||
- ZOHO_CLIENT_ID=${ZOHO_CLIENT_ID:-}
|
||||
- ZOHO_CLIENT_SECRET=${ZOHO_CLIENT_SECRET:-}
|
||||
- ZOHO_REDIRECT_URI=${ZOHO_REDIRECT_URI:-}
|
||||
- ZOHO_ACCOUNTS_URL=${ZOHO_ACCOUNTS_URL:-}
|
||||
- ZOHO_SCOPES=${ZOHO_SCOPES:-}
|
||||
- ZOHO_ALLOWED_DOMAINS=${ZOHO_ALLOWED_DOMAINS:-}
|
||||
- ZOHO_ALLOW_SIGNUP=${ZOHO_ALLOW_SIGNUP:-}
|
||||
- ZOHO_DEFAULT_TIER=${ZOHO_DEFAULT_TIER:-}
|
||||
# Uncomment these for SMTP sending
|
||||
# - SMTP_HOST=${SMTP_HOST}
|
||||
# - SMTP_PORT=${SMTP_PORT}
|
||||
|
||||
@@ -17,8 +17,9 @@ from flask_compress import Compress
|
||||
from functools import wraps
|
||||
from src.crawler import WebCrawler
|
||||
from src.settings_manager import SettingsManager
|
||||
from src.auth_db import init_db, create_user, authenticate_user, get_user_by_id, log_guest_crawl, get_guest_crawls_last_24h, verify_user, set_user_tier, create_verification_token, verify_token, get_user_by_email
|
||||
from src.auth_db import init_db, create_user, authenticate_user, get_user_by_id, log_guest_crawl, get_guest_crawls_last_24h, verify_user, set_user_tier, create_verification_token, verify_token, get_user_by_email, get_or_create_zoho_user
|
||||
from src.email_service import send_verification_email, send_welcome_email
|
||||
from src.zoho_oauth import ZohoOAuthConfig, ZohoOAuthError
|
||||
|
||||
# Load environment variables from .env file
|
||||
from dotenv import load_dotenv
|
||||
@@ -45,6 +46,7 @@ DISABLE_REGISTER = args.disable_register or os.getenv('REGISTRATION_DISABLED', '
|
||||
DISABLE_GUEST = args.disable_guest or os.getenv('DISABLE_GUEST', '').lower() in ('true', '1', 'yes')
|
||||
DEMO_MODE = args.demo or os.getenv('DEMO_MODE', '').lower() in ('true', '1', 'yes')
|
||||
SKIP_AUTH = args.dangerously_skip_auth or os.getenv('DANGEROUSLY_SKIP_AUTH', '').lower() in ('true', '1', 'yes')
|
||||
ZOHO = ZohoOAuthConfig(registration_disabled=DISABLE_REGISTER)
|
||||
|
||||
app = Flask(__name__, template_folder='web/templates', static_folder='web/static')
|
||||
app.secret_key = os.environ.get('SECRET_KEY') or secrets.token_hex(32)
|
||||
@@ -196,6 +198,15 @@ if SKIP_AUTH:
|
||||
print("DO NOT use on a public network or production server!")
|
||||
print("=" * 60)
|
||||
|
||||
if ZOHO.enabled:
|
||||
print("=" * 60)
|
||||
print("ZOHO OAUTH LOGIN ENABLED")
|
||||
print(f"Accounts server: {ZOHO.accounts_url}")
|
||||
if ZOHO.allowed_domains:
|
||||
print(f"Allowed email domains: {', '.join(ZOHO.allowed_domains)}")
|
||||
print(f"New Zoho users: {'created with tier ' + repr(ZOHO.default_tier) if ZOHO.allow_signup else 'not allowed'}")
|
||||
print("=" * 60)
|
||||
|
||||
def get_client_ip():
|
||||
"""Get the real client IP address, checking Cloudflare headers first"""
|
||||
# Check Cloudflare header first
|
||||
@@ -503,7 +514,60 @@ def login_page():
|
||||
# Redirect to app if already logged in
|
||||
if 'user_id' in session:
|
||||
return redirect(url_for('index'))
|
||||
return render_template('login.html', registration_disabled=DISABLE_REGISTER, guest_disabled=DISABLE_GUEST, skip_auth=SKIP_AUTH)
|
||||
return render_template('login.html', registration_disabled=DISABLE_REGISTER, guest_disabled=DISABLE_GUEST, skip_auth=SKIP_AUTH,
|
||||
zoho_enabled=ZOHO.enabled, login_error=session.pop('login_error', None))
|
||||
|
||||
def _zoho_redirect_uri():
|
||||
return ZOHO.redirect_uri or url_for('zoho_callback', _external=True)
|
||||
|
||||
@app.route('/auth/zoho/login')
|
||||
def zoho_login():
|
||||
if not ZOHO.enabled:
|
||||
return redirect(url_for('login_page'))
|
||||
state = secrets.token_urlsafe(32)
|
||||
session['zoho_oauth_state'] = state
|
||||
return redirect(ZOHO.authorize_url(_zoho_redirect_uri(), state))
|
||||
|
||||
@app.route('/auth/zoho/callback')
|
||||
def zoho_callback():
|
||||
if not ZOHO.enabled:
|
||||
return redirect(url_for('login_page'))
|
||||
|
||||
def fail(message):
|
||||
session['login_error'] = message
|
||||
return redirect(url_for('login_page'))
|
||||
|
||||
expected_state = session.pop('zoho_oauth_state', None)
|
||||
if request.args.get('error'):
|
||||
return fail('Zoho login was cancelled or denied.')
|
||||
if not expected_state or not secrets.compare_digest(expected_state, request.args.get('state', '')):
|
||||
return fail('Zoho login expired or was invalid. Please try again.')
|
||||
code = request.args.get('code')
|
||||
if not code:
|
||||
return fail('Zoho did not return an authorization code.')
|
||||
|
||||
try:
|
||||
accounts_server = ZOHO.accounts_server_for(request.args.get('accounts-server'))
|
||||
identity = ZOHO.fetch_identity(code, _zoho_redirect_uri(), accounts_server)
|
||||
except ZohoOAuthError as e:
|
||||
print(f"Zoho OAuth error: {e}")
|
||||
return fail(str(e))
|
||||
|
||||
if not ZOHO.domain_allowed(identity['email']):
|
||||
return fail('Your Zoho account\'s email domain is not allowed to log in here.')
|
||||
|
||||
success, message, user_data = get_or_create_zoho_user(
|
||||
identity['zoho_id'], identity['email'], identity['name'],
|
||||
default_tier=ZOHO.default_tier, allow_create=ZOHO.allow_signup)
|
||||
if not success:
|
||||
return fail(message)
|
||||
|
||||
session.clear()
|
||||
session['user_id'] = user_data['id']
|
||||
session['username'] = user_data['username']
|
||||
session['tier'] = user_data['tier']
|
||||
session.permanent = True
|
||||
return redirect(url_for('index'))
|
||||
|
||||
@app.route('/register')
|
||||
def register_page():
|
||||
|
||||
@@ -104,6 +104,16 @@ def init_db():
|
||||
except:
|
||||
pass # Column already exists
|
||||
|
||||
# Zoho account ID, set for users who sign in with Zoho OAuth
|
||||
try:
|
||||
cursor.execute("ALTER TABLE users ADD COLUMN zoho_id TEXT")
|
||||
except:
|
||||
pass # Column already exists
|
||||
cursor.execute('''
|
||||
CREATE UNIQUE INDEX IF NOT EXISTS idx_users_zoho_id
|
||||
ON users(zoho_id)
|
||||
''')
|
||||
|
||||
print("Database initialized successfully")
|
||||
|
||||
# Initialize crawl persistence tables
|
||||
@@ -557,3 +567,62 @@ def get_user_by_email(email):
|
||||
except Exception as e:
|
||||
print(f"Error fetching user by email: {e}")
|
||||
return None
|
||||
|
||||
def get_or_create_zoho_user(zoho_id, email, display_name, default_tier='user', allow_create=True):
|
||||
"""
|
||||
Find the user for a Zoho account, linking or creating one as needed.
|
||||
|
||||
Lookup order: an account already linked to this Zoho ID, then an existing
|
||||
account with the same (Zoho-verified) email, which gets linked. Otherwise a
|
||||
new verified account is created when allow_create is set.
|
||||
Returns (success, message, user_data)
|
||||
"""
|
||||
try:
|
||||
with get_db() as conn:
|
||||
cursor = conn.cursor()
|
||||
cursor.execute('SELECT id, username, email, tier FROM users WHERE zoho_id = ?', (zoho_id,))
|
||||
user = cursor.fetchone()
|
||||
|
||||
if not user and email:
|
||||
cursor.execute('SELECT id, username, email, tier FROM users WHERE email = ? COLLATE NOCASE', (email,))
|
||||
user = cursor.fetchone()
|
||||
if user:
|
||||
# Zoho has verified the email, so the account counts as verified
|
||||
cursor.execute('UPDATE users SET zoho_id = ?, verified = 1 WHERE id = ?', (zoho_id, user['id']))
|
||||
|
||||
if not user:
|
||||
if not allow_create:
|
||||
return False, "No LibreCrawl account is linked to this Zoho account", None
|
||||
|
||||
# Build a unique username from the display name or email
|
||||
base = (display_name or email.split('@')[0]).strip()
|
||||
base = ''.join(c for c in base if c.isalnum() or c in '._-')[:40] or 'zoho_user'
|
||||
if len(base) < 3:
|
||||
base = f'{base}_zoho'
|
||||
username = base
|
||||
suffix = 1
|
||||
while cursor.execute('SELECT 1 FROM users WHERE username = ?', (username,)).fetchone():
|
||||
suffix += 1
|
||||
username = f'{base}{suffix}'
|
||||
|
||||
# Nobody knows this password, so password login stays unusable
|
||||
password_hash = hash_password(secrets.token_urlsafe(32))
|
||||
cursor.execute('''
|
||||
INSERT INTO users (username, email, password_hash, verified, tier, zoho_id)
|
||||
VALUES (?, ?, ?, 1, ?, ?)
|
||||
''', (username, email, password_hash, default_tier, zoho_id))
|
||||
user = {'id': cursor.lastrowid, 'username': username, 'email': email, 'tier': default_tier}
|
||||
|
||||
cursor.execute('UPDATE users SET last_login = CURRENT_TIMESTAMP WHERE id = ?', (user['id'],))
|
||||
|
||||
user_data = {
|
||||
'id': user['id'],
|
||||
'username': user['username'],
|
||||
'email': user['email'],
|
||||
'tier': user['tier'] or 'guest'
|
||||
}
|
||||
return True, "Login successful", user_data
|
||||
|
||||
except Exception as e:
|
||||
print(f"Zoho login error: {e}")
|
||||
return False, "An error occurred during Zoho login", None
|
||||
@@ -0,0 +1,145 @@
|
||||
"""
|
||||
Zoho OAuth 2.0 / OpenID Connect login.
|
||||
|
||||
Configured entirely through environment variables (see .env.example):
|
||||
ZOHO_OAUTH_ENABLED "true" to show "Login with Zoho"
|
||||
ZOHO_CLIENT_ID client ID from https://api-console.zoho.com
|
||||
ZOHO_CLIENT_SECRET client secret from the same place
|
||||
ZOHO_REDIRECT_URI callback URL registered in the API console
|
||||
(default: <this host>/auth/zoho/callback)
|
||||
ZOHO_ACCOUNTS_URL Zoho accounts server for your data center
|
||||
(default: https://accounts.zoho.com)
|
||||
ZOHO_SCOPES requested scopes (default: openid,email,profile)
|
||||
ZOHO_ALLOWED_DOMAINS comma-separated email domains allowed to log in
|
||||
(default: any domain)
|
||||
ZOHO_ALLOW_SIGNUP create accounts for new Zoho users
|
||||
(default: follows REGISTRATION_DISABLED)
|
||||
ZOHO_DEFAULT_TIER tier for accounts created via Zoho (default: user)
|
||||
"""
|
||||
import base64
|
||||
import json
|
||||
import os
|
||||
import re
|
||||
from urllib.parse import urlencode
|
||||
|
||||
import requests
|
||||
|
||||
VALID_TIERS = ('guest', 'user', 'extra', 'admin')
|
||||
|
||||
# Zoho data centers: accounts.zoho.com, .eu, .in, .com.au, .jp, .sa,
|
||||
# accounts.zohocloud.ca, ...
|
||||
ACCOUNTS_SERVER_RE = re.compile(r'^https://accounts\.zoho(cloud)?\.[a-z]{2,3}(\.[a-z]{2})?$')
|
||||
|
||||
|
||||
def _env_bool(key, default=False):
|
||||
value = os.getenv(key)
|
||||
if value is None or value.strip() == '':
|
||||
return default
|
||||
return value.strip().lower() in ('true', '1', 'yes')
|
||||
|
||||
|
||||
class ZohoOAuthConfig:
|
||||
def __init__(self, registration_disabled=False):
|
||||
self.client_id = os.getenv('ZOHO_CLIENT_ID', '').strip()
|
||||
self.client_secret = os.getenv('ZOHO_CLIENT_SECRET', '').strip()
|
||||
self.redirect_uri = os.getenv('ZOHO_REDIRECT_URI', '').strip() or None
|
||||
self.accounts_url = (os.getenv('ZOHO_ACCOUNTS_URL', '').strip() or 'https://accounts.zoho.com').rstrip('/')
|
||||
self.scopes = os.getenv('ZOHO_SCOPES', '').strip() or 'openid,email,profile'
|
||||
self.allowed_domains = [d.strip().lower().lstrip('@')
|
||||
for d in os.getenv('ZOHO_ALLOWED_DOMAINS', '').split(',') if d.strip()]
|
||||
self.allow_signup = _env_bool('ZOHO_ALLOW_SIGNUP', default=not registration_disabled)
|
||||
|
||||
self.default_tier = os.getenv('ZOHO_DEFAULT_TIER', '').strip().lower() or 'user'
|
||||
if self.default_tier not in VALID_TIERS:
|
||||
print(f"⚠️ ZOHO_DEFAULT_TIER '{self.default_tier}' is invalid, using 'user'")
|
||||
self.default_tier = 'user'
|
||||
|
||||
self.enabled = _env_bool('ZOHO_OAUTH_ENABLED')
|
||||
if self.enabled and not (self.client_id and self.client_secret):
|
||||
print('⚠️ ZOHO_OAUTH_ENABLED is set but ZOHO_CLIENT_ID / ZOHO_CLIENT_SECRET are missing. '
|
||||
'Zoho login is disabled.')
|
||||
self.enabled = False
|
||||
|
||||
def authorize_url(self, redirect_uri, state):
|
||||
params = {
|
||||
'response_type': 'code',
|
||||
'client_id': self.client_id,
|
||||
'scope': self.scopes,
|
||||
'redirect_uri': redirect_uri,
|
||||
'state': state,
|
||||
'access_type': 'online',
|
||||
'prompt': 'select_account',
|
||||
}
|
||||
return f'{self.accounts_url}/oauth/v2/auth?{urlencode(params)}'
|
||||
|
||||
def accounts_server_for(self, callback_accounts_server):
|
||||
"""Zoho sends users from other data centers back with an
|
||||
`accounts-server` param; the code must be exchanged there."""
|
||||
if callback_accounts_server and ACCOUNTS_SERVER_RE.match(callback_accounts_server):
|
||||
return callback_accounts_server.rstrip('/')
|
||||
return self.accounts_url
|
||||
|
||||
def fetch_identity(self, code, redirect_uri, accounts_server):
|
||||
"""Exchange the auth code and return the user's identity claims.
|
||||
Raises ZohoOAuthError on failure."""
|
||||
try:
|
||||
resp = requests.post(f'{accounts_server}/oauth/v2/token', data={
|
||||
'grant_type': 'authorization_code',
|
||||
'client_id': self.client_id,
|
||||
'client_secret': self.client_secret,
|
||||
'redirect_uri': redirect_uri,
|
||||
'code': code,
|
||||
}, timeout=15)
|
||||
token = resp.json()
|
||||
except (requests.RequestException, ValueError) as e:
|
||||
raise ZohoOAuthError(f'Could not reach Zoho: {e}')
|
||||
|
||||
if 'error' in token or 'access_token' not in token:
|
||||
raise ZohoOAuthError(f"Zoho token exchange failed: {token.get('error', resp.status_code)}")
|
||||
|
||||
claims = {}
|
||||
# The id_token came straight from Zoho's token endpoint over TLS, so
|
||||
# its claims can be read without verifying the signature.
|
||||
if token.get('id_token'):
|
||||
claims.update(_decode_jwt_payload(token['id_token']))
|
||||
|
||||
try:
|
||||
info = requests.get(f'{accounts_server}/oauth/v2/userinfo',
|
||||
headers={'Authorization': f"Zoho-oauthtoken {token['access_token']}"},
|
||||
timeout=15)
|
||||
if info.ok:
|
||||
claims.update(info.json())
|
||||
except (requests.RequestException, ValueError):
|
||||
pass
|
||||
|
||||
zoho_id = str(claims.get('sub') or claims.get('ZUID') or '')
|
||||
email = (claims.get('email') or claims.get('Email') or '').strip().lower()
|
||||
name = (claims.get('name') or claims.get('Display_Name')
|
||||
or ' '.join(filter(None, [claims.get('given_name'), claims.get('family_name')])))
|
||||
if not zoho_id or not email:
|
||||
raise ZohoOAuthError('Zoho did not return an account ID and email. '
|
||||
'Check that ZOHO_SCOPES includes openid and email.')
|
||||
|
||||
email_verified = claims.get('email_verified', True)
|
||||
if email_verified in (False, 'false'):
|
||||
raise ZohoOAuthError('Your Zoho email address is not verified.')
|
||||
|
||||
return {'zoho_id': zoho_id, 'email': email, 'name': name}
|
||||
|
||||
def domain_allowed(self, email):
|
||||
if not self.allowed_domains:
|
||||
return True
|
||||
return email.rsplit('@', 1)[-1] in self.allowed_domains
|
||||
|
||||
|
||||
class ZohoOAuthError(Exception):
|
||||
pass
|
||||
|
||||
|
||||
def _decode_jwt_payload(jwt):
|
||||
try:
|
||||
payload = jwt.split('.')[1]
|
||||
payload += '=' * (-len(payload) % 4)
|
||||
return json.loads(base64.urlsafe_b64decode(payload))
|
||||
except (IndexError, ValueError):
|
||||
return {}
|
||||
@@ -123,6 +123,35 @@
|
||||
font-size: 14px;
|
||||
}
|
||||
|
||||
.btn-zoho {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
gap: 10px;
|
||||
background: white;
|
||||
color: #374151;
|
||||
border: 2px solid #e5e7eb;
|
||||
text-decoration: none;
|
||||
}
|
||||
|
||||
.btn-zoho:hover {
|
||||
border-color: #e42527;
|
||||
box-shadow: 0 8px 16px rgba(228, 37, 39, 0.15);
|
||||
}
|
||||
|
||||
.btn-zoho .zoho-mark {
|
||||
width: 22px;
|
||||
height: 22px;
|
||||
border-radius: 5px;
|
||||
background: #e42527;
|
||||
color: white;
|
||||
font-size: 13px;
|
||||
font-weight: 700;
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
}
|
||||
|
||||
.register-link {
|
||||
text-align: center;
|
||||
margin-top: 24px;
|
||||
@@ -172,7 +201,15 @@
|
||||
<p>SEO Spider Tool</p>
|
||||
</div>
|
||||
|
||||
<div id="alert" class="alert"></div>
|
||||
<div id="alert" class="alert{% if login_error %} alert-error show{% endif %}">{{ login_error or '' }}</div>
|
||||
|
||||
{% if zoho_enabled %}
|
||||
<a href="/auth/zoho/login" class="btn btn-zoho" id="zohoBtn">
|
||||
<span class="zoho-mark" aria-hidden="true">Z</span>
|
||||
Login with Zoho
|
||||
</a>
|
||||
<div class="divider">or</div>
|
||||
{% endif %}
|
||||
|
||||
{% if skip_auth %}
|
||||
<div class="alert alert-error show" style="text-align: left;">
|
||||
|
||||
Reference in new issue
Block a user