Files
tiku-backend.net/Tiku.Api/wwwroot/platform-admin/platform-auth.js

173 lines
8.8 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

(() => {
const ACCESS_TOKEN_KEY = 'tiku_platform_access_token';
const REFRESH_TOKEN_KEY = 'tiku_platform_refresh_token';
const ACCESS_EXPIRES_KEY = 'tiku_platform_access_expires_at';
const USER_KEY = 'tiku_platform_user';
const runtime = window.GONGXUE_PLATFORM_RUNTIME_CONFIG || {};
let challengeToken = '';
let pendingResolve;
function apiUrl(path) {
return `${String(runtime.apiBaseUrl || '').replace(/\/+$/, '')}${path}`;
}
async function post(path, body) {
const response = await fetch(apiUrl(path), {
method: 'POST',
headers: { Accept: 'application/json', 'Content-Type': 'application/json' },
credentials: 'omit',
body: JSON.stringify(body),
});
const text = await response.text();
let payload = {};
if (text) {
try { payload = JSON.parse(text); } catch { payload = { detail: text.slice(0, 500) }; }
}
if (!response.ok) throw new Error(payload.detail || payload.title || payload.message || `请求失败HTTP ${response.status}`);
return payload;
}
function gate() { return document.querySelector('#platformAuthGate'); }
function escapeHtml(value) {
return String(value ?? '').replace(/[&<>'"]/g, character => ({
'&': '&amp;', '<': '&lt;', '>': '&gt;', "'": '&#39;', '"': '&quot;',
})[character]);
}
function shell(title, description, content) {
gate().innerHTML = `<section class="platform-auth-card" role="dialog" aria-modal="true"><header><img src="./assets/logo.png" alt="" /><div><h1>${title}</h1><p>${description}</p></div></header>${content}<p class="platform-auth-error" id="platformAuthError" role="alert"></p></section>`;
gate().hidden = false;
document.querySelector('.platform-app')?.setAttribute('inert', '');
}
function showError(error) {
const target = document.querySelector('#platformAuthError');
if (target) target.textContent = error?.message || '操作失败,请重试';
}
function setBusy(form, busy) {
form.querySelectorAll('button,input').forEach(control => { control.disabled = busy; });
}
function storeAuthenticated(result) {
const user = result?.user;
const tokens = user?.tokens;
if (!tokens?.accessToken || !tokens?.refreshToken) throw new Error('登录响应缺少令牌');
sessionStorage.setItem(ACCESS_TOKEN_KEY, tokens.accessToken);
sessionStorage.setItem(REFRESH_TOKEN_KEY, tokens.refreshToken);
sessionStorage.setItem(ACCESS_EXPIRES_KEY, tokens.accessTokenExpiresAt || '');
sessionStorage.setItem(USER_KEY, JSON.stringify({ userId: user.userId, email: user.email, name: user.name }));
}
function completeGate() {
gate().hidden = true;
gate().innerHTML = '';
document.querySelector('.platform-app')?.removeAttribute('inert');
pendingResolve?.(true);
pendingResolve = null;
}
function finishAuthentication(result, recoveryCodes = []) {
storeAuthenticated(result);
if (recoveryCodes.length) {
shell('保存恢复代码', '这些代码只显示一次,请保存到安全位置。', `<div class="platform-auth-recovery">${recoveryCodes.map(code => `<div>${escapeHtml(code)}</div>`).join('')}</div><p class="auth-help">保存后再进入平台控制台。</p><button type="button" id="platformAuthContinue">我已保存,进入控制台</button>`);
document.querySelector('#platformAuthContinue').addEventListener('click', completeGate);
return;
}
completeGate();
}
async function handleAuthenticationResult(result) {
challengeToken = result.challengeToken || '';
if (result.status === 'authenticated') { finishAuthentication(result); return; }
if (result.status === 'password_change_required') { renderPasswordChange(); return; }
if (result.status === 'mfa_enrollment_required') { await renderMfaEnrollment(); return; }
if (result.status === 'mfa_required') { renderMfaVerification(); return; }
throw new Error(`不支持的认证状态:${result.status || 'unknown'}`);
}
function renderLogin() {
shell('平台管理员登录', '连接真实 PostgreSQL 与 ASP.NET Core API。', `<form id="platformLoginForm"><label>平台账号<input name="identifier" type="email" autocomplete="username" required /></label><label>密码<input name="password" type="password" autocomplete="current-password" minlength="10" required /></label><button type="submit">登录</button></form>`);
document.querySelector('#platformLoginForm').addEventListener('submit', async event => {
event.preventDefault();
const form = event.currentTarget;
setBusy(form, true);
try {
const result = await post('/api/auth/login/password', { realm: 'platform', identifier: form.elements.identifier.value.trim(), password: form.elements.password.value });
await handleAuthenticationResult(result);
} catch (error) { showError(error); setBusy(form, false); }
});
}
function renderPasswordChange() {
shell('设置正式密码', '首次登录必须先替换临时密码。', `<form id="platformPasswordForm"><label>新密码<input name="newPassword" type="password" autocomplete="new-password" minlength="10" required /></label><label>确认新密码<input name="confirmPassword" type="password" autocomplete="new-password" minlength="10" required /></label><button type="submit">更新密码并继续</button></form>`);
document.querySelector('#platformPasswordForm').addEventListener('submit', async event => {
event.preventDefault();
const form = event.currentTarget;
if (form.elements.newPassword.value !== form.elements.confirmPassword.value) { showError(new Error('两次输入的密码不一致')); return; }
setBusy(form, true);
try { await handleAuthenticationResult(await post('/api/auth/password/change-required', { challengeToken, newPassword: form.elements.newPassword.value })); }
catch (error) { showError(error); setBusy(form, false); }
});
}
async function renderMfaEnrollment() {
const setup = await post('/api/auth/mfa/totp/setup', { challengeToken });
shell('绑定双重验证', '在认证器中添加密钥,然后输入当前 6 位验证码。', `<code>${escapeHtml(setup.sharedKey)}</code><p class="auth-help">也可在支持的认证器中导入:${escapeHtml(setup.authenticatorUri)}</p><form id="platformMfaForm"><label>动态验证码<input name="code" inputmode="numeric" autocomplete="one-time-code" pattern="[0-9]{6}" maxlength="6" required /></label><button type="submit">确认绑定</button></form>`);
document.querySelector('#platformMfaForm').addEventListener('submit', async event => {
event.preventDefault();
const form = event.currentTarget;
setBusy(form, true);
try {
const result = await post('/api/auth/mfa/totp/confirm', { challengeToken, code: form.elements.code.value.trim() });
finishAuthentication(result.authentication, result.recoveryCodes || []);
} catch (error) { showError(error); setBusy(form, false); }
});
}
function renderMfaVerification() {
shell('双重验证', '输入认证器中的当前 6 位验证码。', `<form id="platformMfaForm"><label>动态验证码<input name="code" inputmode="numeric" autocomplete="one-time-code" pattern="[0-9]{6}" maxlength="6" required autofocus /></label><button type="submit">验证并登录</button></form>`);
document.querySelector('#platformMfaForm').addEventListener('submit', async event => {
event.preventDefault();
const form = event.currentTarget;
setBusy(form, true);
try { await handleAuthenticationResult(await post('/api/auth/mfa/totp/verify', { challengeToken, code: form.elements.code.value.trim() })); }
catch (error) { showError(error); setBusy(form, false); }
});
}
function clearSession() {
[ACCESS_TOKEN_KEY, REFRESH_TOKEN_KEY, ACCESS_EXPIRES_KEY, USER_KEY].forEach(key => sessionStorage.removeItem(key));
}
function hasSession() {
const token = sessionStorage.getItem(ACCESS_TOKEN_KEY);
const expiresAt = Date.parse(sessionStorage.getItem(ACCESS_EXPIRES_KEY) || '');
return Boolean(token) && Number.isFinite(expiresAt) && expiresAt > Date.now() + 15_000;
}
async function requireSession() {
if (hasSession()) return true;
clearSession();
renderLogin();
return new Promise(resolve => { pendingResolve = resolve; });
}
async function logout() {
const refreshToken = sessionStorage.getItem(REFRESH_TOKEN_KEY);
try { if (refreshToken) await post('/api/auth/logout', { refreshToken }); } catch { /* Clear the browser session even when revocation cannot be reached. */ }
clearSession();
location.reload();
}
window.GongxuePlatformAuth = Object.freeze({
getAccessToken: () => sessionStorage.getItem(ACCESS_TOKEN_KEY) || '',
getUser: () => {
try { return JSON.parse(sessionStorage.getItem(USER_KEY) || 'null'); } catch { return null; }
},
logout,
requireSession,
});
})();