422 lines
16 KiB
HTML
422 lines
16 KiB
HTML
<!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>
|