feat(auth): replace TOTP with phone-first login

This commit is contained in:
2026-07-28 17:39:29 +08:00
parent e7d350ec3d
commit c7f9a4e3c9
43 changed files with 18386 additions and 882 deletions

View File

@@ -29,12 +29,6 @@
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;
@@ -68,13 +62,8 @@
pendingResolve = null;
}
function finishAuthentication(result, recoveryCodes = []) {
function finishAuthentication(result) {
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();
}
@@ -82,13 +71,11 @@
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>`);
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="8" required /></label><button type="submit">登录</button></form>`);
document.querySelector('#platformLoginForm').addEventListener('submit', async event => {
event.preventDefault();
const form = event.currentTarget;
@@ -101,7 +88,7 @@
}
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>`);
shell('设置正式密码', '至少 8 位,且必须同时包含字母和数字。', `<form id="platformPasswordForm"><label>新密码<input name="newPassword" type="password" autocomplete="new-password" minlength="8" required /></label><label>确认新密码<input name="confirmPassword" type="password" autocomplete="new-password" minlength="8" required /></label><button type="submit">更新密码并继续</button></form>`);
document.querySelector('#platformPasswordForm').addEventListener('submit', async event => {
event.preventDefault();
const form = event.currentTarget;
@@ -112,31 +99,6 @@
});
}
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));
}

View File

@@ -46,5 +46,4 @@
.platform-auth-card input:focus { outline: 2px solid #3b82f6; outline-offset: 1px; }
.platform-auth-card button { width: 100%; min-height: 42px; margin-top: 14px; border: 0; border-radius: 9px; background: #2563eb; color: white; font: inherit; font-weight: 700; cursor: pointer; }
.platform-auth-card button:disabled { opacity: .55; cursor: wait; }
.platform-auth-card code, .platform-auth-recovery { display: block; margin-top: 8px; padding: 12px; border-radius: 8px; background: #020617; color: #93c5fd; overflow-wrap: anywhere; }
.platform-auth-error { min-height: 20px; margin: 14px 0 0; color: #fca5a5; font-size: 13px; }