feat: ✨ SSO
This commit is contained in:
@@ -10,6 +10,18 @@
|
|||||||
- `NAME`: Organisation name displayed in header (default: JDB-NET)
|
- `NAME`: Organisation name displayed in header (default: JDB-NET)
|
||||||
- `LOGO_PNG`: URL or path to organisation logo (default: JDB-NET logo)
|
- `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
|
### Database Setup
|
||||||
|
|
||||||
The application automatically initializes the database schema on first run. Ensure the database and user exist with appropriate
|
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 io import StringIO, BytesIO
|
||||||
from datetime import datetime
|
from datetime import datetime
|
||||||
from functools import wraps
|
from functools import wraps
|
||||||
from urllib.parse import urlparse
|
from urllib.parse import urlencode, urlparse
|
||||||
from ipaddress import ip_network, ip_address, IPv4Address, IPv6Address
|
from ipaddress import ip_network, ip_address, IPv4Address, IPv6Address
|
||||||
|
|
||||||
import pyotp
|
import pyotp
|
||||||
@@ -40,8 +40,19 @@ app.config['SESSION_COOKIE_SAMESITE'] = 'Lax'
|
|||||||
app.config['NAME'] = os.environ.get('NAME', 'JDB-NET')
|
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['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['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 ───────────────────────────────────────────────────────
|
# ── TOTP / 2FA helpers ───────────────────────────────────────────────────────
|
||||||
def generate_totp_secret():
|
def generate_totp_secret():
|
||||||
@@ -155,7 +166,7 @@ def get_user_from_api_key(api_key):
|
|||||||
return None
|
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."""
|
"""Populate session after successful authentication."""
|
||||||
close_conn = False
|
close_conn = False
|
||||||
if conn is None:
|
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['permissions'] = list(load_permissions_for_user(user_id, conn))
|
||||||
session['logged_in'] = True
|
session['logged_in'] = True
|
||||||
session['user_id'] = user_id
|
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
|
session.modified = True
|
||||||
finally:
|
finally:
|
||||||
if close_conn:
|
if close_conn:
|
||||||
@@ -251,6 +266,37 @@ def require_auth(f):
|
|||||||
return decorated_function
|
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():
|
def json_body():
|
||||||
return request.get_json(silent=True) or {}
|
return request.get_json(silent=True) or {}
|
||||||
|
|
||||||
@@ -1216,6 +1262,76 @@ def group_devices_by_site(devices):
|
|||||||
|
|
||||||
# ── Auth & account (v2) ───────────────────────────────────────────────────────
|
# ── 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'])
|
@app.route('/api/v2/auth/login', methods=['POST'])
|
||||||
def api_auth_login():
|
def api_auth_login():
|
||||||
data = json_body()
|
data = json_body()
|
||||||
@@ -1261,19 +1377,7 @@ def api_auth_logout():
|
|||||||
@app.route('/api/v2/auth/me', methods=['GET'])
|
@app.route('/api/v2/auth/me', methods=['GET'])
|
||||||
def api_auth_me():
|
def api_auth_me():
|
||||||
user = resolve_auth()
|
user = resolve_auth()
|
||||||
if not user:
|
return jsonify(_me_payload(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 []),
|
|
||||||
})
|
|
||||||
|
|
||||||
|
|
||||||
@app.route('/api/v2/auth/verify-2fa', methods=['POST'])
|
@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})
|
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 ─────────────────────────────────────────────────────────────
|
# ── API v2 routes ─────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
@app.route('/api/v2/info', methods=['GET'])
|
@app.route('/api/v2/info', methods=['GET'])
|
||||||
|
|||||||
@@ -540,6 +540,16 @@ def init_db(app=None):
|
|||||||
|
|
||||||
logging.info("Database indexes created successfully")
|
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)
|
run_v2_migrations(cursor, conn)
|
||||||
|
|
||||||
conn.commit()
|
conn.commit()
|
||||||
@@ -596,3 +606,24 @@ def run_v2_migrations(cursor, conn):
|
|||||||
logging.info("Removed orphaned permission: %s", perm_name)
|
logging.info("Removed orphaned permission: %s", perm_name)
|
||||||
|
|
||||||
logging.info("v2 database migrations complete")
|
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 };
|
org?: { name: string; logo: string };
|
||||||
user?: { id: number; name: string; email: string };
|
user?: { id: number; name: string; email: string };
|
||||||
permissions?: string[];
|
permissions?: string[];
|
||||||
|
features?: { sso?: boolean };
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface SsoSettings {
|
||||||
|
is_sso_enabled: boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface Device {
|
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) {
|
async verify2fa(code: string, useBackup = false) {
|
||||||
return handle(await fetchApi("/api/v2/auth/verify-2fa", {
|
return handle(await fetchApi("/api/v2/auth/verify-2fa", {
|
||||||
method: "POST",
|
method: "POST",
|
||||||
@@ -177,6 +191,16 @@ export const api = {
|
|||||||
async logout() {
|
async logout() {
|
||||||
return handle(await fetchApi("/api/v2/auth/logout", { method: "POST" }));
|
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() {
|
async dashboard() {
|
||||||
return handle<{
|
return handle<{
|
||||||
stats: {
|
stats: {
|
||||||
|
|||||||
@@ -9,6 +9,7 @@ export const useAuthStore = defineStore("auth", {
|
|||||||
permissions: [] as string[],
|
permissions: [] as string[],
|
||||||
org: { name: "IPAM", logo: "" },
|
org: { name: "IPAM", logo: "" },
|
||||||
version: "unknown",
|
version: "unknown",
|
||||||
|
features: { sso: false },
|
||||||
}),
|
}),
|
||||||
getters: {
|
getters: {
|
||||||
can: (state) => (perm: string) => state.permissions.includes(perm),
|
can: (state) => (perm: string) => state.permissions.includes(perm),
|
||||||
@@ -22,6 +23,7 @@ export const useAuthStore = defineStore("auth", {
|
|||||||
this.permissions = data.permissions ?? [];
|
this.permissions = data.permissions ?? [];
|
||||||
this.org = data.org ?? this.org;
|
this.org = data.org ?? this.org;
|
||||||
this.version = data.app_version ?? "unknown";
|
this.version = data.app_version ?? "unknown";
|
||||||
|
this.features = data.features ?? { sso: false };
|
||||||
},
|
},
|
||||||
async login(email: string, password: string) {
|
async login(email: string, password: string) {
|
||||||
return api.login(email, password);
|
return api.login(email, password);
|
||||||
|
|||||||
@@ -1,17 +1,51 @@
|
|||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
import { ref } from "vue";
|
import { ref, onMounted } from "vue";
|
||||||
import { useRouter, useRoute } from "vue-router";
|
import { useRouter, useRoute } from "vue-router";
|
||||||
import { useAuthStore } from "@/stores/auth";
|
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 email = ref("");
|
||||||
const password = ref("");
|
const password = ref("");
|
||||||
const err = ref("");
|
const err = ref("");
|
||||||
const busy = ref(false);
|
const busy = ref(false);
|
||||||
|
const step = ref<"email" | "password">("email");
|
||||||
const auth = useAuthStore();
|
const auth = useAuthStore();
|
||||||
const router = useRouter();
|
const router = useRouter();
|
||||||
const route = useRoute();
|
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 = "";
|
err.value = "";
|
||||||
busy.value = true;
|
busy.value = true;
|
||||||
try {
|
try {
|
||||||
@@ -32,23 +66,46 @@ async function submit() {
|
|||||||
busy.value = false;
|
busy.value = false;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function backToEmail() {
|
||||||
|
step.value = "email";
|
||||||
|
password.value = "";
|
||||||
|
err.value = "";
|
||||||
|
}
|
||||||
</script>
|
</script>
|
||||||
<template>
|
<template>
|
||||||
<div class="flex min-h-screen items-center justify-center bg-surface p-6">
|
<div class="flex min-h-screen items-center justify-center bg-surface p-6">
|
||||||
<div class="card w-full max-w-md p-8">
|
<div class="card w-full max-w-md p-8">
|
||||||
<h1 class="text-2xl font-semibold">Sign in</h1>
|
<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>
|
<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>
|
<div>
|
||||||
<label class="mb-1 block text-xs font-medium uppercase tracking-wide text-slate-500">Email</label>
|
<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>
|
<div v-if="step === 'password'">
|
||||||
<label class="mb-1 block text-xs font-medium uppercase tracking-wide text-slate-500">Password</label>
|
<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>
|
</div>
|
||||||
<p v-if="err" class="text-sm text-red-500">{{ err }}</p>
|
<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>
|
</form>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -1,10 +1,11 @@
|
|||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
import { ref, onMounted, computed } from "vue";
|
import { ref, onMounted, computed, watch } from "vue";
|
||||||
import { api, type UserRow, type RoleRow } from "@/api";
|
import { api, type UserRow, type RoleRow, type SsoSettings } from "@/api";
|
||||||
import { useAuthStore } from "@/stores/auth";
|
import { useAuthStore } from "@/stores/auth";
|
||||||
|
|
||||||
const auth = useAuthStore();
|
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 users = ref<UserRow[]>([]);
|
||||||
const roles = ref<RoleRow[]>([]);
|
const roles = ref<RoleRow[]>([]);
|
||||||
const permissions = ref<{ id: number; name: string; category?: string }[]>([]);
|
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 roleForm = ref({ name: "", description: "", require_2fa: false, permission_ids: [] as number[] });
|
||||||
|
|
||||||
const showApiKey = ref("");
|
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 permByCategory = computed(() => {
|
||||||
const m: Record<string, typeof permissions.value> = {};
|
const m: Record<string, typeof permissions.value> = {};
|
||||||
for (const p of permissions.value) {
|
for (const p of permissions.value) {
|
||||||
@@ -132,6 +173,12 @@ async function delRole(id: number) {
|
|||||||
<div class="mt-4 flex gap-2">
|
<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 === '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 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>
|
</div>
|
||||||
|
|
||||||
<section v-if="tab === 'users'" class="mt-8">
|
<section v-if="tab === 'users'" class="mt-8">
|
||||||
@@ -184,6 +231,33 @@ async function delRole(id: number) {
|
|||||||
</ul>
|
</ul>
|
||||||
</section>
|
</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">
|
<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">
|
<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>
|
<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
|
||||||
@@ -4,3 +4,5 @@ dotenv
|
|||||||
gunicorn
|
gunicorn
|
||||||
pyotp
|
pyotp
|
||||||
qrcode[pil]
|
qrcode[pil]
|
||||||
|
authlib
|
||||||
|
requests
|
||||||
Reference in New Issue
Block a user