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:
PhialsBasementandClaude Opus 5.5 committed 2026-09-25 12:20:46 +10:00
1 parent 26cce27bf0
commit d451885216
7 files changed
+367 -3

No files matched your search

+25
View File
@@ -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
+14
View File
@@ -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
+10
View File
@@ -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}
+66 -2
View File
@@ -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():
+69
View File
@@ -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
+145
View File
@@ -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 {}
+38 -1
View File
@@ -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;">