Files
iot-dboard/frontend/templates/mfa_setup.html
T
2026-08-11 19:57:21 +08:00

422 lines
16 KiB
HTML
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.
<!DOCTYPE html>
<html lang="zh-CN">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>安全设置 - Cal Display</title>
<style>
:root {
--bg: #0f1117;
--card-bg: #1a1d2e;
--text: #e0e0e0;
--text-dim: #888;
--accent: #4A90D9;
--accent-green: #4CAF50;
--accent-red: #E05555;
--radius: 12px;
--input-bg: #242838;
--input-border: rgba(255,255,255,0.14);
--divider: rgba(255,255,255,0.16);
--card-border: rgba(255,255,255,0.10);
--soft-bg: rgba(255,255,255,0.06);
--shadow: 0 4px 24px rgba(0,0,0,0.30);
}
/* 浅色主题覆盖 */
.theme-light {
--bg: #e8edf4;
--card-bg: #ffffff;
--text: #1c2333;
--text-dim: #5b6a7e;
--accent: #2f6fb2;
--accent-green: #3a9450;
--accent-red: #c74545;
--radius: 12px;
--input-bg: #fafafa;
--input-border: #d7dfe8;
--divider: #d7dfe8;
--card-border: transparent;
--soft-bg: #eef2f7;
--shadow: 0 2px 12px rgba(30,40,60,0.08);
}
/* 主题切换动画 */
html.theme-anim, html.theme-anim * {
transition: background-color 0.55s ease, color 0.55s ease,
border-color 0.55s ease, box-shadow 0.55s ease !important;
}
* { margin: 0; padding: 0; box-sizing: border-box; }
html, body {
min-height: 100vh;
font-family: -apple-system, "Segoe UI", "PingFang SC", "Microsoft YaHei", sans-serif;
background: var(--bg);
color: var(--text);
display: flex; align-items: center; justify-content: center;
}
.setup-box {
width: min(92vw, 500px);
background: var(--card-bg);
border-radius: var(--radius);
padding: 28px 32px;
box-shadow: 0 2px 12px rgba(0,0,0,0.06);
}
.setup-box h1 { font-size: 22px; margin-bottom: 6px; font-weight: 600; }
.setup-box .subtitle { font-size: 14px; color: var(--text-dim); margin-bottom: 24px; }
.method-card {
border: 2px solid #e0e0e0;
border-radius: var(--radius);
padding: 20px;
margin-bottom: 16px;
transition: border-color 0.2s;
}
.method-card.active { border-color: var(--accent); }
.method-card.done { border-color: var(--accent-green); }
.method-header {
display: flex;
align-items: center;
justify-content: space-between;
margin-bottom: 12px;
}
.method-header h3 { font-size: 16px; font-weight: 600; display: flex; align-items: center; gap: 8px; }
.method-header .badge {
font-size: 12px;
padding: 2px 8px;
border-radius: 4px;
font-weight: 500;
}
.badge-done { background: #e8f5e9; color: #2e7d32; }
.badge-pending { background: #fff3e0; color: #e65100; }
.method-body { font-size: 14px; color: var(--text-dim); line-height: 1.6; }
.method-body .btn {
display: inline-block;
padding: 10px 20px;
font-size: 14px;
font-weight: 500;
border: none;
border-radius: 8px;
cursor: pointer;
font-family: inherit;
margin-top: 12px;
}
.btn-primary { background: var(--accent); color: #fff; }
.btn-primary:hover { opacity: 0.9; }
.btn-success { background: var(--accent-green); color: #fff; }
.btn-success:hover { opacity: 0.9; }
.btn-secondary { background: #eee; color: var(--text); }
.btn-secondary:hover { opacity: 0.9; }
.totp-setup input { font-size: 24px; letter-spacing: 8px; text-align: center; padding: 10px; width: 100%; border: 1px solid #ddd; border-radius: 8px; font-family: monospace; margin-top: 8px; }
.totp-setup .qr-wrap { text-align: center; margin: 12px 0; }
.totp-setup .qr-wrap img { max-width: 200px; border-radius: 8px; }
.totp-setup .secret-display { font-family: monospace; font-size: 14px; background: #f5f5f5; padding: 8px; border-radius: 6px; text-align: center; margin: 8px 0; word-break: break-all; }
.passkey-status { font-size: 13px; color: var(--text-dim); margin-top: 8px; min-height: 20px; }
.recovery-section { margin-top: 20px; padding: 16px; background: #fff8e1; border-radius: 8px; border: 1px solid #ffe082; display: none; }
.recovery-section.visible { display: block; }
.recovery-section h4 { font-size: 14px; color: #e65100; margin-bottom: 8px; }
.recovery-codes { font-family: monospace; font-size: 14px; line-height: 2; }
.recovery-codes code { display: inline-block; background: #fff; padding: 2px 8px; border-radius: 4px; margin: 2px; border: 1px solid #e0e0e0; }
.finish-section { margin-top: 20px; text-align: center; }
.finish-section .btn { padding: 14px 40px; font-size: 16px; }
.msg { font-size: 13px; color: var(--accent-red); margin-top: 8px; min-height: 20px; }
.success { color: var(--accent-green); }
/* ═══ 主题适配(MFA 设置页)═══ */
body { background: var(--bg); }
.setup-box { background: var(--card-bg); box-shadow: var(--shadow); border: 1px solid var(--card-border); }
.method-card { border-color: var(--divider); }
.method-card.active { border-color: var(--accent); }
.totp-setup input { background: var(--input-bg); border-color: var(--input-border); color: var(--text); }
.secret-display { background: var(--soft-bg); }
.btn-secondary { background: var(--soft-bg); color: var(--text); }
.recovery-section { background: rgba(255,200,87,0.10); border-color: rgba(255,200,87,0.30); }
.badge-done { background: rgba(74,175,80,0.18); color: #7bc47f; }
.badge-pending { background: rgba(230,81,0,0.20); color: #ffb27a; }
.theme-light .recovery-section { background: #fff8e1; border-color: #ffe082; }
.theme-light .badge-done { background: #e8f5e9; color: #2e7d32; }
.theme-light .badge-pending { background: #fff3e0; color: #e65100; }
</style>
<script src="/static/js/token.js"></script>
<script src="/static/js/passkey_bridge.js"></script>
<script src="/static/js/theme.js"></script>
</head>
<body>
<div class="setup-box">
<h1>🔐 安全设置</h1>
<p class="subtitle">请至少配置一种双重认证方式以保护你的设备</p>
<!-- Passkey -->
<div class="method-card" id="passkey-card">
<div class="method-header">
<h3>🔑 Passkey (生物/设备密钥)</h3>
<span class="badge badge-pending" id="passkey-badge">未配置</span>
</div>
<div class="method-body">
<p>使用设备内置的安全模块(指纹、面容、PIN 或安全密钥)快速登录。</p>
<button class="btn btn-primary" id="register-passkey-btn">注册 Passkey</button>
<div class="passkey-status" id="passkey-status"></div>
</div>
</div>
<!-- TOTP -->
<div class="method-card" id="totp-card">
<div class="method-header">
<h3>📱 TOTP (身份验证器应用)</h3>
<span class="badge badge-pending" id="totp-badge">未配置</span>
</div>
<div class="method-body">
<p>使用 Google Authenticator、Microsoft Authenticator 等应用扫描二维码。</p>
<button class="btn btn-secondary" id="setup-totp-btn">设置 TOTP</button>
<div id="totp-setup-area" style="display:none;" class="totp-setup">
<div class="qr-wrap" id="totp-qr-wrap"></div>
<div class="secret-display" id="totp-secret-display"></div>
<p style="font-size:13px;color:var(--text-dim);">在应用中添加后,输入显示的 6 位验证码确认:</p>
<input type="text" id="totp-verify-input" maxlength="6" inputmode="numeric" pattern="[0-9]*" placeholder="000000">
<button class="btn btn-success" id="totp-verify-btn">验证并启用</button>
<div id="totp-msg" class="msg"></div>
</div>
</div>
</div>
<!-- Recovery Codes (shown after first MFA configured) -->
<div class="recovery-section" id="recovery-section">
<h4>⚠️ 恢复码(请妥善保管)</h4>
<p style="font-size:13px;color:#e65100;margin-bottom:8px;">每个恢复码仅可使用一次。建议截图或打印保存。</p>
<div class="recovery-codes" id="recovery-codes"></div>
<button class="btn btn-secondary" id="copy-recovery-btn" style="margin-top:8px;">📋 复制</button>
</div>
<!-- Finish -->
<div class="finish-section">
<button class="btn btn-success" id="finish-btn">✅ 完成设置,进入控制面板</button>
<div id="finish-msg" class="msg" style="margin-top:8px;"></div>
</div>
</div>
<script>
(function () {
'use strict';
var passkeyDone = false;
var totpDone = false;
// ─── Check MFA status ──────────────────
function checkMFAStatus() {
Auth.request('GET', '/api/auth/mfa/status', null, function (s, status) {
if (status !== 200) return;
// Temp accounts must configure ONE login method to activate the account.
if (s.is_temp_account) {
document.querySelector('.setup-box h1').textContent = '🔐 激活你的账号';
document.querySelector('.subtitle').textContent =
'请配置至少一种登录方式(Passkey 或 TOTP)。配置并验证完成后,你的临时验证码将失效,账号立即正式生效。';
}
if (s.passkey_enabled) { passkeyDone = true; updatePasskeyUI(true); }
if (s.totp_enabled) { totpDone = true; updateTOTPUI(true); }
if (s.recovery_codes_remaining > 0) {
showRecoveryCodes();
}
updateFinishBtn();
});
}
checkMFAStatus();
// ─── Passkey ───────────────────────────
function updatePasskeyUI(done) {
var card = document.getElementById('passkey-card');
var badge = document.getElementById('passkey-badge');
if (done) {
card.classList.add('done');
badge.textContent = '✅ 已配置';
badge.className = 'badge badge-done';
document.getElementById('register-passkey-btn').textContent = '重新注册';
}
}
document.getElementById('register-passkey-btn').addEventListener('click', function () {
var statusEl = document.getElementById('passkey-status');
var btn = this;
btn.disabled = true;
statusEl.textContent = '正在准备注册...';
Auth.request('POST', '/api/auth/webauthn/register/begin', null, function (options, st) {
if (st !== 200) {
statusEl.textContent = '❌ 获取注册参数失败';
btn.disabled = false;
return;
}
// Passkey 中间件:浏览器走 WebAuthn;iOS App 走原生 ASAuthorizationController
PasskeyBridge.creation(options)
.then(function (credential) {
credential.user_name = options.user && options.user.name ? options.user.name : 'user';
Auth.request('POST', '/api/auth/webauthn/register/complete', { credential: credential }, function (res, st2) {
if (st2 === 200) {
statusEl.textContent = '✅ Passkey 注册成功!';
passkeyDone = true;
updatePasskeyUI(true);
checkRecoveryAndFinish();
if (res.finalized) {
if (res.access_token) Auth.setAccess(res.access_token);
statusEl.textContent = '✅ 账号已激活!即将进入控制面板...';
setTimeout(function () { location.href = '/'; }, 2500);
}
} else {
statusEl.textContent = '❌ ' + (res.error || '注册失败');
btn.disabled = false;
}
});
})
.catch(function (err) {
statusEl.textContent = '⏸️ ' + (err.message || '操作取消');
btn.disabled = false;
});
});
});
// ─── TOTP ──────────────────────────────
function updateTOTPUI(done) {
var card = document.getElementById('totp-card');
var badge = document.getElementById('totp-badge');
if (done) {
card.classList.add('done');
badge.textContent = '✅ 已配置';
badge.className = 'badge badge-done';
document.getElementById('setup-totp-btn').textContent = '重新设置';
}
}
document.getElementById('setup-totp-btn').addEventListener('click', function () {
var area = document.getElementById('totp-setup-area');
if (area.style.display === 'block') {
area.style.display = 'none';
return;
}
area.style.display = 'block';
document.getElementById('setup-totp-btn').textContent = '取消';
// Request TOTP setup from server
Auth.request('POST', '/api/auth/totp/setup', null, function (data, status) {
if (status !== 200) return;
document.getElementById('totp-qr-wrap').innerHTML = '<img src="data:image/png;base64,' + data.qr_base64 + '" alt="TOTP QR Code">';
document.getElementById('totp-secret-display').textContent = '密钥: ' + data.secret;
});
});
document.getElementById('totp-verify-btn').addEventListener('click', function () {
var code = document.getElementById('totp-verify-input').value;
var msg = document.getElementById('totp-msg');
msg.textContent = '';
Auth.request('POST', '/api/auth/totp/verify', { code: code }, function (res, st) {
if (st === 200) {
msg.className = 'msg success';
msg.textContent = '✅ TOTP 已启用!';
totpDone = true;
updateTOTPUI(true);
document.getElementById('setup-totp-btn').textContent = '重新设置';
// Show recovery codes
if (res.recovery_codes && res.recovery_codes.length > 0) {
showRecoveryCodesList(res.recovery_codes);
} else {
checkMFAStatus();
}
checkRecoveryAndFinish();
if (res.finalized) {
if (res.access_token) Auth.setAccess(res.access_token);
msg.textContent = '✅ 账号已激活!即将进入控制面板...';
setTimeout(function () { location.href = '/'; }, 2500);
}
} else {
msg.textContent = res.error || '验证失败';
}
});
});
// ─── Recovery codes ────────────────────
function showRecoveryCodesList(codes) {
var section = document.getElementById('recovery-section');
var container = document.getElementById('recovery-codes');
section.classList.add('visible');
container.innerHTML = codes.map(function (c) { return '<code>' + c + '</code>'; }).join(' ');
}
function showRecoveryCodes() {
Auth.request('GET', '/api/auth/recovery-codes', null, function (data, status) {
if (status === 200 && data.codes && data.codes.length > 0) {
showRecoveryCodesList(data.codes);
}
});
}
function checkRecoveryAndFinish() {
// After any MFA is set up, fetch recovery codes
showRecoveryCodes();
updateFinishBtn();
}
document.getElementById('copy-recovery-btn').addEventListener('click', function () {
var text = document.getElementById('recovery-codes').textContent;
navigator.clipboard.writeText(text).then(function () {
document.getElementById('copy-recovery-btn').textContent = '✅ 已复制';
setTimeout(function () {
document.getElementById('copy-recovery-btn').textContent = '📋 复制';
}, 2000);
});
});
// ─── Finish ────────────────────────────
function updateFinishBtn() {
var btn = document.getElementById('finish-btn');
if (passkeyDone || totpDone) {
btn.disabled = false;
btn.textContent = '✅ 完成设置,进入控制面板';
} else {
btn.disabled = true;
btn.textContent = '⚠️ 请至少配置一种认证方式';
}
}
document.getElementById('finish-btn').addEventListener('click', function () {
var msg = document.getElementById('finish-msg');
msg.textContent = '正在完成设置...';
Auth.request('POST', '/api/auth/mfa/complete', null, function (res, status) {
if (status === 200) {
if (res.access_token) Auth.setAccess(res.access_token);
location.href = '/';
} else {
msg.textContent = res.error || '完成失败,请重试';
}
});
});
// ─── Helpers ────────────────────────────
// Convert base64url (from server/webauthn) to ArrayBuffer
function _base64ToArrayBuffer(b64) {
var base64 = b64.replace(/-/g, '+').replace(/_/g, '/');
while (base64.length % 4) base64 += '=';
var binary = atob(base64);
var bytes = new Uint8Array(binary.length);
for (var i = 0; i < binary.length; i++) bytes[i] = binary.charCodeAt(i);
return bytes.buffer;
}
// Convert ArrayBuffer to base64url (for WebAuthn API)
function _arrayBufferToBase64(buf) {
var bytes = new Uint8Array(buf);
var binary = '';
for (var i = 0; i < bytes.byteLength; i++) binary += String.fromCharCode(bytes[i]);
return btoa(binary).replace(/\+/g, '-').replace(/\//g, '_').replace(/=+$/, '');
}
})();
</script>
</body>
</html>