feat: SSO

This commit is contained in:
2026-05-28 23:59:14 +00:00
parent fc5699a04c
commit 7526736e80
9 changed files with 442 additions and 27 deletions
+153 -16
View File
@@ -11,7 +11,7 @@ import logging
from io import StringIO, BytesIO
from datetime import datetime
from functools import wraps
from urllib.parse import urlparse
from urllib.parse import urlencode, urlparse
from ipaddress import ip_network, ip_address, IPv4Address, IPv6Address
import pyotp
@@ -40,8 +40,19 @@ app.config['SESSION_COOKIE_SAMESITE'] = 'Lax'
app.config['NAME'] = os.environ.get('NAME', 'JDB-NET')
app.config['LOGO_PNG'] = os.environ.get('LOGO_PNG', 'https://assets.jdbnet.co.uk/projects/ipam.png')
app.config['VERSION'] = os.environ.get('VERSION', 'unknown')
app.config['LOGTO_OIDC_ENDPOINT'] = os.environ.get('LOGTO_OIDC_ENDPOINT', '')
app.config['LOGTO_APP_ID'] = os.environ.get('LOGTO_APP_ID', '')
app.config['LOGTO_APP_SECRET'] = os.environ.get('LOGTO_APP_SECRET', '')
from db import init_db, hash_password, get_db_connection, verify_password, generate_api_key
from db import (
init_db, hash_password, get_db_connection, verify_password, generate_api_key,
get_app_settings, update_app_settings,
)
from logto_client import (
logto_sso_available, init_logto_oauth, oauth, extract_logto_email,
)
init_logto_oauth(app)
# ── TOTP / 2FA helpers ───────────────────────────────────────────────────────
def generate_totp_secret():
@@ -155,7 +166,7 @@ def get_user_from_api_key(api_key):
return None
def establish_user_session(user_id, conn=None):
def establish_user_session(user_id, conn=None, via_sso=False):
"""Populate session after successful authentication."""
close_conn = False
if conn is None:
@@ -170,6 +181,10 @@ def establish_user_session(user_id, conn=None):
session['permissions'] = list(load_permissions_for_user(user_id, conn))
session['logged_in'] = True
session['user_id'] = user_id
session['auth_via_sso'] = bool(via_sso)
if via_sso:
session.pop('pending_user_id', None)
session.pop('pending_email', None)
session.modified = True
finally:
if close_conn:
@@ -251,6 +266,37 @@ def require_auth(f):
return decorated_function
def require_sso_available(f):
"""Decorator: SSO subsystem must be enabled via environment variables."""
@wraps(f)
def decorated_function(*args, **kwargs):
if not logto_sso_available():
abort(404)
return f(*args, **kwargs)
return decorated_function
def _sso_oidc_callback_url():
return f"{request.scheme}://{request.host}/api/v2/auth/sso/callback"
def _me_payload(user=None):
payload = {
'app_version': app.config['VERSION'],
'org': {'name': app.config['NAME'], 'logo': app.config['LOGO_PNG']},
'features': {'sso': logto_sso_available()},
}
if user:
payload.update({
'logged_in': True,
'user': {'id': user['id'], 'name': user['name'], 'email': user.get('email', '')},
'permissions': sorted(user.get('permissions') or []),
})
else:
payload['logged_in'] = False
return payload
def json_body():
return request.get_json(silent=True) or {}
@@ -1216,6 +1262,76 @@ def group_devices_by_site(devices):
# ── Auth & account (v2) ───────────────────────────────────────────────────────
@app.route('/api/v2/auth/login/precheck', methods=['POST'])
def api_auth_login_precheck():
data = json_body()
email = (data.get('email') or '').strip().lower()
if not email:
return jsonify({'error': 'Email required'}), 400
if not logto_sso_available():
return jsonify({'method': 'local'})
with get_db_connection(current_app) as conn:
settings = get_app_settings(conn)
if not settings.get('is_sso_enabled'):
return jsonify({'method': 'local'})
cursor = conn.cursor()
cursor.execute('SELECT id FROM User WHERE LOWER(email) = %s', (email,))
if not cursor.fetchone():
return jsonify({'method': 'local'})
params = {'email': email}
redirect_target = (data.get('redirect') or '').strip()
if redirect_target:
params['redirect'] = redirect_target
return jsonify({
'method': 'sso',
'redirect_url': f"/api/v2/auth/sso/login?{urlencode(params)}",
})
@app.route('/api/v2/auth/sso/login', methods=['GET'])
@require_sso_available
def api_auth_sso_login():
email = (request.args.get('email') or '').strip().lower()
if not email:
return jsonify({'error': 'Email required'}), 400
redirect_target = (request.args.get('redirect') or '').strip()
if redirect_target:
session['sso_post_login_redirect'] = redirect_target
session['sso_expected_email'] = email
session.modified = True
redirect_uri = _sso_oidc_callback_url()
return oauth.logto.authorize_redirect(redirect_uri, login_hint=email)
@app.route('/api/v2/auth/sso/callback', methods=['GET'])
@require_sso_available
def api_auth_sso_callback():
try:
token = oauth.logto.authorize_access_token()
except Exception as exc:
logging.exception('Logto OIDC callback failed')
return redirect('/login?error=sso_failed')
email = extract_logto_email(oauth.logto, token)
if not email:
return redirect('/login?error=sso_no_email')
expected = (session.pop('sso_expected_email', '') or '').strip().lower()
if expected and email != expected:
logging.warning('SSO email mismatch: expected %s got %s', expected, email)
return redirect('/login?error=sso_email_mismatch')
with get_db_connection(current_app) as conn:
cursor = conn.cursor()
cursor.execute('SELECT id FROM User WHERE LOWER(email) = %s', (email,))
row = cursor.fetchone()
if not row:
return redirect('/login?error=sso_user_not_found')
user_id = row[0]
establish_user_session(user_id, conn=conn, via_sso=True)
add_audit_log(user_id, 'sso_login', f'SSO login for {email}', conn=conn)
logging.info('User %s logged in via SSO.', email)
redirect_to = session.pop('sso_post_login_redirect', None) or '/'
return redirect(redirect_to)
@app.route('/api/v2/auth/login', methods=['POST'])
def api_auth_login():
data = json_body()
@@ -1261,19 +1377,7 @@ def api_auth_logout():
@app.route('/api/v2/auth/me', methods=['GET'])
def api_auth_me():
user = resolve_auth()
if not user:
return jsonify({
'logged_in': False,
'app_version': app.config['VERSION'],
'org': {'name': app.config['NAME'], 'logo': app.config['LOGO_PNG']},
})
return jsonify({
'logged_in': True,
'app_version': app.config['VERSION'],
'org': {'name': app.config['NAME'], 'logo': app.config['LOGO_PNG']},
'user': {'id': user['id'], 'name': user['name'], 'email': user.get('email', '')},
'permissions': sorted(user.get('permissions') or []),
})
return jsonify(_me_payload(user))
@app.route('/api/v2/auth/verify-2fa', methods=['POST'])
@@ -1442,6 +1546,39 @@ def api_account_regenerate_backup_codes():
return jsonify({'backup_codes': backup_codes})
def _sso_settings_response(settings):
return {
'is_sso_enabled': bool(settings.get('is_sso_enabled')),
}
@app.route('/api/v2/settings/sso', methods=['GET'])
@require_sso_available
@require_permission('view_admin')
def api_settings_sso_get():
with get_db_connection(current_app) as conn:
settings = get_app_settings(conn)
return jsonify(_sso_settings_response(settings))
@app.route('/api/v2/settings/sso', methods=['POST'])
@require_sso_available
@require_permission('view_admin')
def api_settings_sso_post():
data = json_body()
is_sso_enabled = bool(data.get('is_sso_enabled'))
with get_db_connection(current_app) as conn:
update_app_settings(conn, is_sso_enabled=is_sso_enabled)
settings = get_app_settings(conn)
add_audit_log(
get_current_user_id(),
'sso_settings_update',
f"External sign-in {'enabled' if is_sso_enabled else 'disabled'}",
conn=conn,
)
return jsonify(_sso_settings_response(settings))
# ── API v2 routes ─────────────────────────────────────────────────────────────
@app.route('/api/v2/info', methods=['GET'])