forked from xiongyuxing/tiku-backend.net
feat: bootstrap local platform development
This commit is contained in:
172
Tiku.Api/wwwroot/platform-admin/platform-auth.js
Normal file
172
Tiku.Api/wwwroot/platform-admin/platform-auth.js
Normal file
@@ -0,0 +1,172 @@
|
||||
(() => {
|
||||
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 => ({
|
||||
'&': '&', '<': '<', '>': '>', "'": ''', '"': '"',
|
||||
})[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,
|
||||
});
|
||||
})();
|
||||
Reference in New Issue
Block a user