feat: ✨ SSO
This commit is contained in:
@@ -10,6 +10,18 @@
|
||||
- `NAME`: Organisation name displayed in header (default: JDB-NET)
|
||||
- `LOGO_PNG`: URL or path to organisation logo (default: JDB-NET logo)
|
||||
|
||||
### Optional external sign-in (Logto)
|
||||
|
||||
External sign-in is **disabled and hidden** unless all three variables below are set. Self-hosted deployments should leave these unset so users cannot use the operator's identity provider.
|
||||
|
||||
- `LOGTO_OIDC_ENDPOINT`: Logto instance URL (e.g. `https://sso.jdbnet.co.uk`)
|
||||
- `LOGTO_APP_ID`: Traditional web application client ID (OIDC login flow)
|
||||
- `LOGTO_APP_SECRET`: Traditional web application client secret
|
||||
|
||||
When configured, enable external sign-in per deployment from **Users → SSO** (admin only). Pre-provisioned users are redirected to Logto after entering their email, where they can sign in with **Microsoft 365** or **Discord**. Local password and TOTP authentication remain available when external sign-in is disabled or the user is not pre-provisioned.
|
||||
|
||||
**Operator setup (managed deployments only):** Configure Microsoft (multi-tenant) and Discord connectors once in Logto Console. Register each IPAM instance redirect URI on the Logto application: `https://{host}/api/v2/auth/sso/callback`. Ensure the **email** scope is enabled on the Logto application.
|
||||
|
||||
### Database Setup
|
||||
|
||||
The application automatically initializes the database schema on first run. Ensure the database and user exist with appropriate
|
||||
|
||||
@@ -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'])
|
||||
|
||||
@@ -540,6 +540,16 @@ def init_db(app=None):
|
||||
|
||||
logging.info("Database indexes created successfully")
|
||||
|
||||
cursor.execute('''
|
||||
CREATE TABLE IF NOT EXISTS AppSettings (
|
||||
id INTEGER PRIMARY KEY,
|
||||
is_sso_enabled BOOLEAN DEFAULT FALSE
|
||||
)
|
||||
''')
|
||||
cursor.execute('SELECT id FROM AppSettings WHERE id = 1')
|
||||
if not cursor.fetchone():
|
||||
cursor.execute('INSERT INTO AppSettings (id) VALUES (1)')
|
||||
|
||||
run_v2_migrations(cursor, conn)
|
||||
|
||||
conn.commit()
|
||||
@@ -596,3 +606,24 @@ def run_v2_migrations(cursor, conn):
|
||||
logging.info("Removed orphaned permission: %s", perm_name)
|
||||
|
||||
logging.info("v2 database migrations complete")
|
||||
|
||||
|
||||
def get_app_settings(conn):
|
||||
cursor = conn.cursor(dictionary=True)
|
||||
cursor.execute('SELECT is_sso_enabled FROM AppSettings WHERE id = 1')
|
||||
row = cursor.fetchone()
|
||||
if not row:
|
||||
return {'is_sso_enabled': False}
|
||||
return row
|
||||
|
||||
|
||||
def update_app_settings(conn, **fields):
|
||||
allowed = {'is_sso_enabled'}
|
||||
updates = {k: v for k, v in fields.items() if k in allowed}
|
||||
if not updates:
|
||||
return
|
||||
columns = ', '.join(f'{k} = %s' for k in updates)
|
||||
values = list(updates.values())
|
||||
cursor = conn.cursor()
|
||||
cursor.execute(f'UPDATE AppSettings SET {columns} WHERE id = 1', values)
|
||||
conn.commit()
|
||||
|
||||
@@ -26,6 +26,11 @@ export interface MeResponse {
|
||||
org?: { name: string; logo: string };
|
||||
user?: { id: number; name: string; email: string };
|
||||
permissions?: string[];
|
||||
features?: { sso?: boolean };
|
||||
}
|
||||
|
||||
export interface SsoSettings {
|
||||
is_sso_enabled: boolean;
|
||||
}
|
||||
|
||||
export interface Device {
|
||||
@@ -158,6 +163,15 @@ export const api = {
|
||||
}),
|
||||
);
|
||||
},
|
||||
async loginPrecheck(email: string, redirect?: string) {
|
||||
return handle<{ method: "local" | "sso"; redirect_url?: string }>(
|
||||
await fetchApi("/api/v2/auth/login/precheck", {
|
||||
method: "POST",
|
||||
headers: jsonHeaders,
|
||||
body: JSON.stringify({ email, redirect }),
|
||||
}),
|
||||
);
|
||||
},
|
||||
async verify2fa(code: string, useBackup = false) {
|
||||
return handle(await fetchApi("/api/v2/auth/verify-2fa", {
|
||||
method: "POST",
|
||||
@@ -177,6 +191,16 @@ export const api = {
|
||||
async logout() {
|
||||
return handle(await fetchApi("/api/v2/auth/logout", { method: "POST" }));
|
||||
},
|
||||
async ssoSettings() {
|
||||
return handle<SsoSettings>(await fetchApi("/api/v2/settings/sso"));
|
||||
},
|
||||
async saveSsoSettings(body: { is_sso_enabled: boolean }) {
|
||||
return handle<SsoSettings>(await fetchApi("/api/v2/settings/sso", {
|
||||
method: "POST",
|
||||
headers: jsonHeaders,
|
||||
body: JSON.stringify(body),
|
||||
}));
|
||||
},
|
||||
async dashboard() {
|
||||
return handle<{
|
||||
stats: {
|
||||
|
||||
@@ -9,6 +9,7 @@ export const useAuthStore = defineStore("auth", {
|
||||
permissions: [] as string[],
|
||||
org: { name: "IPAM", logo: "" },
|
||||
version: "unknown",
|
||||
features: { sso: false },
|
||||
}),
|
||||
getters: {
|
||||
can: (state) => (perm: string) => state.permissions.includes(perm),
|
||||
@@ -22,6 +23,7 @@ export const useAuthStore = defineStore("auth", {
|
||||
this.permissions = data.permissions ?? [];
|
||||
this.org = data.org ?? this.org;
|
||||
this.version = data.app_version ?? "unknown";
|
||||
this.features = data.features ?? { sso: false };
|
||||
},
|
||||
async login(email: string, password: string) {
|
||||
return api.login(email, password);
|
||||
|
||||
@@ -1,17 +1,51 @@
|
||||
<script setup lang="ts">
|
||||
import { ref } from "vue";
|
||||
import { ref, onMounted } from "vue";
|
||||
import { useRouter, useRoute } from "vue-router";
|
||||
import { useAuthStore } from "@/stores/auth";
|
||||
import { api } from "@/api";
|
||||
|
||||
const SSO_ERRORS: Record<string, string> = {
|
||||
sso_failed: "External sign-in failed. Please try again or use your password.",
|
||||
sso_no_email: "External sign-in did not return an email address. Contact your administrator.",
|
||||
sso_email_mismatch: "The signed-in account does not match the email you entered.",
|
||||
sso_user_not_found: "No account exists for that email. Contact your administrator.",
|
||||
};
|
||||
|
||||
const email = ref("");
|
||||
const password = ref("");
|
||||
const err = ref("");
|
||||
const busy = ref(false);
|
||||
const step = ref<"email" | "password">("email");
|
||||
const auth = useAuthStore();
|
||||
const router = useRouter();
|
||||
const route = useRoute();
|
||||
|
||||
async function submit() {
|
||||
onMounted(() => {
|
||||
const code = route.query.error;
|
||||
if (typeof code === "string" && code in SSO_ERRORS) {
|
||||
err.value = SSO_ERRORS[code];
|
||||
}
|
||||
});
|
||||
|
||||
async function continueWithEmail() {
|
||||
err.value = "";
|
||||
busy.value = true;
|
||||
try {
|
||||
const redirect = (route.query.redirect as string) || undefined;
|
||||
const r = await api.loginPrecheck(email.value.trim(), redirect);
|
||||
if (r.method === "sso" && r.redirect_url) {
|
||||
window.location.href = r.redirect_url;
|
||||
return;
|
||||
}
|
||||
step.value = "password";
|
||||
} catch (e) {
|
||||
err.value = e instanceof Error ? e.message : "Login failed";
|
||||
} finally {
|
||||
busy.value = false;
|
||||
}
|
||||
}
|
||||
|
||||
async function submitPassword() {
|
||||
err.value = "";
|
||||
busy.value = true;
|
||||
try {
|
||||
@@ -32,23 +66,46 @@ async function submit() {
|
||||
busy.value = false;
|
||||
}
|
||||
}
|
||||
|
||||
function backToEmail() {
|
||||
step.value = "email";
|
||||
password.value = "";
|
||||
err.value = "";
|
||||
}
|
||||
</script>
|
||||
<template>
|
||||
<div class="flex min-h-screen items-center justify-center bg-surface p-6">
|
||||
<div class="card w-full max-w-md p-8">
|
||||
<h1 class="text-2xl font-semibold">Sign in</h1>
|
||||
<p class="mt-1 text-sm text-slate-500">Access your IP address management workspace.</p>
|
||||
<form class="mt-8 space-y-4" @submit.prevent="submit">
|
||||
<form class="mt-8 space-y-4" @submit.prevent="step === 'email' ? continueWithEmail() : submitPassword()">
|
||||
<div>
|
||||
<label class="mb-1 block text-xs font-medium uppercase tracking-wide text-slate-500">Email</label>
|
||||
<input v-model="email" type="email" class="input-field" required autocomplete="username" />
|
||||
<input
|
||||
v-model="email"
|
||||
type="email"
|
||||
class="input-field"
|
||||
required
|
||||
autocomplete="username"
|
||||
:readonly="step === 'password'"
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<div v-if="step === 'password'">
|
||||
<label class="mb-1 block text-xs font-medium uppercase tracking-wide text-slate-500">Password</label>
|
||||
<input v-model="password" type="password" class="input-field" required autocomplete="current-password" />
|
||||
<input v-model="password" type="password" class="input-field" required autocomplete="current-password" autofocus />
|
||||
</div>
|
||||
<p v-if="err" class="text-sm text-red-500">{{ err }}</p>
|
||||
<button type="submit" class="btn-primary w-full" :disabled="busy">{{ busy ? "Signing in…" : "Sign in" }}</button>
|
||||
<button type="submit" class="btn-primary w-full" :disabled="busy">
|
||||
{{ busy ? "Please wait…" : step === "email" ? "Continue" : "Sign in" }}
|
||||
</button>
|
||||
<button
|
||||
v-if="step === 'password'"
|
||||
type="button"
|
||||
class="btn-secondary w-full"
|
||||
@click="backToEmail"
|
||||
>
|
||||
Back
|
||||
</button>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -1,10 +1,11 @@
|
||||
<script setup lang="ts">
|
||||
import { ref, onMounted, computed } from "vue";
|
||||
import { api, type UserRow, type RoleRow } from "@/api";
|
||||
import { ref, onMounted, computed, watch } from "vue";
|
||||
import { api, type UserRow, type RoleRow, type SsoSettings } from "@/api";
|
||||
import { useAuthStore } from "@/stores/auth";
|
||||
|
||||
const auth = useAuthStore();
|
||||
const tab = ref<"users" | "roles">("users");
|
||||
const tab = ref<"users" | "roles" | "sso">("users");
|
||||
const showSsoTab = computed(() => auth.features?.sso && auth.can("view_admin"));
|
||||
const users = ref<UserRow[]>([]);
|
||||
const roles = ref<RoleRow[]>([]);
|
||||
const permissions = ref<{ id: number; name: string; category?: string }[]>([]);
|
||||
@@ -19,6 +20,46 @@ const editRoleId = ref<number | null>(null);
|
||||
const roleForm = ref({ name: "", description: "", require_2fa: false, permission_ids: [] as number[] });
|
||||
|
||||
const showApiKey = ref("");
|
||||
|
||||
const ssoSettings = ref<SsoSettings | null>(null);
|
||||
const ssoLoading = ref(false);
|
||||
const ssoErr = ref("");
|
||||
const ssoMsg = ref("");
|
||||
const ssoForm = ref({ is_sso_enabled: false });
|
||||
|
||||
async function loadSso() {
|
||||
if (!showSsoTab.value) return;
|
||||
ssoLoading.value = true;
|
||||
ssoErr.value = "";
|
||||
try {
|
||||
ssoSettings.value = await api.ssoSettings();
|
||||
ssoForm.value = {
|
||||
is_sso_enabled: ssoSettings.value.is_sso_enabled,
|
||||
};
|
||||
} catch (e) {
|
||||
ssoErr.value = e instanceof Error ? e.message : "Failed to load SSO settings";
|
||||
} finally {
|
||||
ssoLoading.value = false;
|
||||
}
|
||||
}
|
||||
|
||||
watch(tab, (value) => {
|
||||
if (value === "sso") loadSso();
|
||||
});
|
||||
|
||||
async function saveSso() {
|
||||
ssoErr.value = "";
|
||||
ssoMsg.value = "";
|
||||
try {
|
||||
ssoSettings.value = await api.saveSsoSettings({
|
||||
is_sso_enabled: ssoForm.value.is_sso_enabled,
|
||||
});
|
||||
ssoMsg.value = "SSO settings saved.";
|
||||
} catch (e) {
|
||||
ssoErr.value = e instanceof Error ? e.message : "Failed to save SSO settings";
|
||||
}
|
||||
}
|
||||
|
||||
const permByCategory = computed(() => {
|
||||
const m: Record<string, typeof permissions.value> = {};
|
||||
for (const p of permissions.value) {
|
||||
@@ -132,6 +173,12 @@ async function delRole(id: number) {
|
||||
<div class="mt-4 flex gap-2">
|
||||
<button class="rounded-lg px-3 py-1 text-sm" :class="tab === 'users' ? 'bg-accent text-slate-950' : 'bg-surface-overlay'" @click="tab = 'users'">Users</button>
|
||||
<button class="rounded-lg px-3 py-1 text-sm" :class="tab === 'roles' ? 'bg-accent text-slate-950' : 'bg-surface-overlay'" @click="tab = 'roles'">Roles</button>
|
||||
<button
|
||||
v-if="showSsoTab"
|
||||
class="rounded-lg px-3 py-1 text-sm"
|
||||
:class="tab === 'sso' ? 'bg-accent text-slate-950' : 'bg-surface-overlay'"
|
||||
@click="tab = 'sso'"
|
||||
>SSO</button>
|
||||
</div>
|
||||
|
||||
<section v-if="tab === 'users'" class="mt-8">
|
||||
@@ -184,6 +231,33 @@ async function delRole(id: number) {
|
||||
</ul>
|
||||
</section>
|
||||
|
||||
<section v-if="tab === 'sso' && showSsoTab" class="mt-8 space-y-6">
|
||||
<div>
|
||||
<h2 class="font-semibold text-accent">Single sign-on</h2>
|
||||
<p class="mt-1 text-sm text-slate-500">External sign-in for pre-provisioned users.</p>
|
||||
</div>
|
||||
|
||||
<p v-if="ssoLoading" class="text-slate-500">Loading…</p>
|
||||
<template v-else>
|
||||
<form class="card space-y-4" @submit.prevent="saveSso">
|
||||
<h3 class="font-medium">External sign-in</h3>
|
||||
<label class="flex items-center gap-2 text-sm">
|
||||
<input v-model="ssoForm.is_sso_enabled" type="checkbox" />
|
||||
Enable external sign-in for this deployment
|
||||
</label>
|
||||
<p class="text-sm text-slate-500">
|
||||
When enabled, pre-provisioned users can sign in via <strong>Microsoft 365</strong> or
|
||||
<strong>Discord</strong> through our identity provider. Local password login remains
|
||||
available for users not routed to external sign-in.
|
||||
</p>
|
||||
<button type="submit" class="btn-primary text-sm">Save</button>
|
||||
</form>
|
||||
|
||||
<p v-if="ssoMsg" class="text-sm text-emerald-600">{{ ssoMsg }}</p>
|
||||
<p v-if="ssoErr" class="text-sm text-red-500">{{ ssoErr }}</p>
|
||||
</template>
|
||||
</section>
|
||||
|
||||
<div v-if="showUserForm" class="fixed inset-0 z-50 flex items-center justify-center bg-black/40 p-4" @click.self="showUserForm = false">
|
||||
<form class="card w-full max-w-md space-y-3" @submit.prevent="saveUser">
|
||||
<h2 class="text-lg font-semibold">{{ editUserId ? "Edit user" : "Add user" }}</h2>
|
||||
|
||||
@@ -0,0 +1,76 @@
|
||||
"""Logto OIDC helpers for external sign-in."""
|
||||
|
||||
import logging
|
||||
import os
|
||||
|
||||
from authlib.integrations.flask_client import OAuth
|
||||
|
||||
REQUIRED_LOGTO_VARS = (
|
||||
'LOGTO_OIDC_ENDPOINT',
|
||||
'LOGTO_APP_ID',
|
||||
'LOGTO_APP_SECRET',
|
||||
)
|
||||
|
||||
oauth = OAuth()
|
||||
|
||||
|
||||
def logto_sso_available() -> bool:
|
||||
return all(os.environ.get(k) for k in REQUIRED_LOGTO_VARS)
|
||||
|
||||
|
||||
def _endpoint() -> str:
|
||||
return os.environ['LOGTO_OIDC_ENDPOINT'].rstrip('/')
|
||||
|
||||
|
||||
def init_logto_oauth(app):
|
||||
"""Register Authlib OAuth client when Logto env vars are present."""
|
||||
oauth.init_app(app)
|
||||
if not logto_sso_available():
|
||||
return
|
||||
endpoint = _endpoint()
|
||||
oauth.register(
|
||||
name='logto',
|
||||
client_id=app.config['LOGTO_APP_ID'],
|
||||
client_secret=app.config['LOGTO_APP_SECRET'],
|
||||
server_metadata_url=f'{endpoint}/oidc/.well-known/openid-configuration',
|
||||
client_kwargs={'scope': 'openid email profile'},
|
||||
)
|
||||
|
||||
|
||||
def extract_logto_email(oauth_client, token) -> str | None:
|
||||
"""Resolve the user's email from Logto token claims or the userinfo endpoint."""
|
||||
if not isinstance(token, dict):
|
||||
return None
|
||||
|
||||
def normalize(value) -> str | None:
|
||||
if not value or not isinstance(value, str):
|
||||
return None
|
||||
normalized = value.strip().lower()
|
||||
return normalized if '@' in normalized else None
|
||||
|
||||
userinfo = token.get('userinfo')
|
||||
for source in (userinfo, token):
|
||||
if not source:
|
||||
continue
|
||||
email = normalize(source.get('email'))
|
||||
if email:
|
||||
return email
|
||||
email = normalize(source.get('preferred_username'))
|
||||
if email:
|
||||
return email
|
||||
|
||||
try:
|
||||
fetched = oauth_client.userinfo(token=token)
|
||||
email = normalize(fetched.get('email'))
|
||||
if email:
|
||||
return email
|
||||
email = normalize(fetched.get('preferred_username'))
|
||||
if email:
|
||||
return email
|
||||
logging.warning(
|
||||
'Logto userinfo did not include an email claim (scope=%s)',
|
||||
token.get('scope'),
|
||||
)
|
||||
except Exception:
|
||||
logging.exception('Failed to fetch Logto userinfo')
|
||||
return None
|
||||
+3
-1
@@ -3,4 +3,6 @@ mysql-connector-python
|
||||
dotenv
|
||||
gunicorn
|
||||
pyotp
|
||||
qrcode[pil]
|
||||
qrcode[pil]
|
||||
authlib
|
||||
requests
|
||||
Reference in New Issue
Block a user