feat: ✨ SSO
This commit is contained in:
@@ -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'])
|
||||
|
||||
Reference in New Issue
Block a user