Flask Session -> Double Token
This commit is contained in:
@@ -258,7 +258,9 @@
|
||||
<button type="button" id="user-add-admin" class="btn-primary">👨💼 管理员代注册</button>
|
||||
<button type="button" id="user-add-temp" class="btn-secondary">🕐 创建临时账户(用户填写信息模式)</button>
|
||||
<button type="button" id="user-refresh-btn" class="btn-secondary">刷新列表</button>
|
||||
<button type="button" id="kick-all-users-btn" class="btn-danger">🚫 踢掉所有用户</button>
|
||||
</div>
|
||||
<p style="font-size:13px;color:#E05555;margin-bottom:12px;">🚫 踢掉所有用户:所有用户的控制端与显示端立即全部下线(含当前设备),需二次验证身份。</p>
|
||||
<div id="pending-requests" style="margin-bottom:16px;"></div>
|
||||
<div id="user-list"></div>
|
||||
</div>
|
||||
@@ -308,8 +310,9 @@
|
||||
</div>
|
||||
</div>
|
||||
<div style="margin-top:8px;display:flex;gap:8px;">
|
||||
<button type="button" id="kick-all-btn" class="btn-danger">踢掉所有显示端</button>
|
||||
<button type="button" id="kick-all-btn" class="btn-danger">踢掉当前账号的所有设备</button>
|
||||
</div>
|
||||
<div style="margin-top:6px;font-size:12px;color:var(--text-dim);">包含当前控制端与所有显示端,全部立即下线。</div>
|
||||
<div id="mode-msg" style="margin-top:8px;font-size:13px;color:var(--text-dim);"></div>
|
||||
</div>
|
||||
<div class="section">
|
||||
@@ -388,6 +391,7 @@
|
||||
</main>
|
||||
</div>
|
||||
|
||||
<script src="/static/js/token.js"></script>
|
||||
<script src="/static/js/theme.js"></script>
|
||||
<script src="/static/js/passkey_bridge.js"></script>
|
||||
<script src="/static/js/control.js"></script>
|
||||
|
||||
@@ -242,6 +242,7 @@
|
||||
<audio id="alarm-audio" loop preload="auto" style="display:none"></audio>
|
||||
</div>
|
||||
|
||||
<script src="/static/js/token.js"></script>
|
||||
<script src="/static/js/theme.js"></script>
|
||||
<script src="/static/js/display.js"></script>
|
||||
</body>
|
||||
|
||||
@@ -75,6 +75,7 @@
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<script src="/static/js/token.js"></script>
|
||||
<script>
|
||||
(function () {
|
||||
'use strict';
|
||||
@@ -149,21 +150,28 @@
|
||||
|
||||
// SSE: listen for control commands (force_refresh / mode_change / custom_notification)
|
||||
function connectSSE() {
|
||||
var es = new EventSource('/api/events');
|
||||
es.onmessage = function (e) {
|
||||
try {
|
||||
var msg = JSON.parse(e.data);
|
||||
if (msg.type === 'force_refresh' || msg.type === 'display_mode_changed') {
|
||||
location.reload();
|
||||
} else if (msg.type === 'custom_notification') {
|
||||
showCustomNotification(msg.data);
|
||||
}
|
||||
} catch (_) {}
|
||||
};
|
||||
es.onerror = function () {
|
||||
es.close();
|
||||
setTimeout(connectSSE, 5000);
|
||||
};
|
||||
// EventSource 不能带 Authorization header → 先换一次性 ticket
|
||||
Auth.request('POST', '/api/events/ticket', null, function (res, status) {
|
||||
if (status !== 201 || !res.ticket) {
|
||||
setTimeout(connectSSE, 5000);
|
||||
return;
|
||||
}
|
||||
var es = new EventSource('/api/events?ticket=' + encodeURIComponent(res.ticket));
|
||||
es.onmessage = function (e) {
|
||||
try {
|
||||
var msg = JSON.parse(e.data);
|
||||
if (msg.type === 'force_refresh' || msg.type === 'display_mode_changed') {
|
||||
location.reload();
|
||||
} else if (msg.type === 'custom_notification') {
|
||||
showCustomNotification(msg.data);
|
||||
}
|
||||
} catch (_) {}
|
||||
};
|
||||
es.onerror = function () {
|
||||
es.close();
|
||||
setTimeout(connectSSE, 5000);
|
||||
};
|
||||
});
|
||||
}
|
||||
connectSSE();
|
||||
})();
|
||||
|
||||
@@ -155,6 +155,7 @@
|
||||
.theme-light .alt-btn:active { background: #e4e9f0; }
|
||||
.theme-light .recovery-mode input[type="text"] { background: #fafafa; border-color: #d7dfe8; }
|
||||
</style>
|
||||
<script src="/static/js/token.js"></script>
|
||||
<script src="/static/js/passkey_bridge.js"></script>
|
||||
<script src="/static/js/theme.js"></script>
|
||||
</head>
|
||||
@@ -227,17 +228,15 @@
|
||||
}
|
||||
|
||||
// ─── Auth status check ──────────────────
|
||||
var xhr = new XMLHttpRequest();
|
||||
xhr.open('GET', '/api/auth/status');
|
||||
xhr.onload = function () {
|
||||
if (xhr.status === 200) {
|
||||
var status = JSON.parse(xhr.responseText);
|
||||
// 携带本地 access token 检查(token 有效则直接放行,避免重复登录)
|
||||
Auth.fetchWithAuth('/api/auth/status')
|
||||
.then(function (r) { return r.json(); })
|
||||
.then(function (status) {
|
||||
if (status.authenticated) {
|
||||
_redirect(redirect);
|
||||
}
|
||||
}
|
||||
};
|
||||
xhr.send();
|
||||
})
|
||||
.catch(function () {});
|
||||
|
||||
// ─── TOTP dots ──────────────────────────
|
||||
function updateDots(ok) {
|
||||
@@ -259,6 +258,7 @@
|
||||
xhr.onload = function () {
|
||||
if (xhr.status === 200) {
|
||||
var res = JSON.parse(xhr.responseText || '{}');
|
||||
if (res.access_token) Auth.setAccess(res.access_token);
|
||||
if (res.method === 'temp_password' || res.must_configure_mfa) {
|
||||
_redirect('/mfa-setup');
|
||||
} else {
|
||||
@@ -317,6 +317,7 @@
|
||||
verify.onload = function () {
|
||||
var res = JSON.parse(verify.responseText || '{}');
|
||||
if (verify.status === 200) {
|
||||
if (res.access_token) Auth.setAccess(res.access_token);
|
||||
msgEl.textContent = '✅ 登录成功!';
|
||||
setTimeout(function () { _redirect(redirect); }, 300);
|
||||
} else {
|
||||
@@ -368,6 +369,7 @@
|
||||
xhr.onload = function () {
|
||||
var res = JSON.parse(xhr.responseText || '{}');
|
||||
if (xhr.status === 200) {
|
||||
if (res.access_token) Auth.setAccess(res.access_token);
|
||||
_redirect(redirect);
|
||||
} else {
|
||||
msgEl.textContent = res.error || '恢复码错误';
|
||||
@@ -383,32 +385,6 @@
|
||||
}
|
||||
});
|
||||
|
||||
// ─── SSE background listener ────────────
|
||||
(function () {
|
||||
function connectSSE() {
|
||||
var es = new EventSource('/api/events');
|
||||
es.onmessage = function (e) {
|
||||
try {
|
||||
var msg = JSON.parse(e.data);
|
||||
if (msg.type === 'force_refresh' || msg.type === 'display_mode_changed') {
|
||||
location.href = '/';
|
||||
} else if (msg.type === 'kick_all') {
|
||||
var xhr = new XMLHttpRequest();
|
||||
xhr.open('POST', '/api/auth/logout');
|
||||
xhr.onload = function () { location.reload(); };
|
||||
xhr.send();
|
||||
}
|
||||
} catch (_) {}
|
||||
};
|
||||
es.onerror = function () {
|
||||
es.close();
|
||||
setTimeout(connectSSE, 5000);
|
||||
};
|
||||
}
|
||||
|
||||
connectSSE();
|
||||
})();
|
||||
|
||||
// ─── Helpers ────────────────────────────
|
||||
// Convert base64url (from server/webauthn) to ArrayBuffer
|
||||
function _base64ToArrayBuffer(b64) {
|
||||
|
||||
@@ -172,6 +172,7 @@
|
||||
}
|
||||
.divider::before, .divider::after { background: var(--divider); }
|
||||
</style>
|
||||
<script src="/static/js/token.js"></script>
|
||||
<script src="/static/js/passkey_bridge.js"></script>
|
||||
<script src="/static/js/theme.js"></script>
|
||||
</head>
|
||||
@@ -247,12 +248,10 @@
|
||||
});
|
||||
|
||||
function checkStatus(callback) {
|
||||
var xhr = new XMLHttpRequest();
|
||||
xhr.open('GET', '/api/auth/status');
|
||||
xhr.onload = function () {
|
||||
if (xhr.status === 200) callback(JSON.parse(xhr.responseText));
|
||||
};
|
||||
xhr.send();
|
||||
Auth.fetchWithAuth('/api/auth/status')
|
||||
.then(function (r) { return r.json(); })
|
||||
.then(function (data) { callback(data); })
|
||||
.catch(function () { callback({}); });
|
||||
}
|
||||
|
||||
function showLoginForm(mfaConfigured) {
|
||||
@@ -280,6 +279,7 @@
|
||||
xhr.onload = function () {
|
||||
var res = JSON.parse(xhr.responseText || '{}');
|
||||
if (xhr.status === 200) {
|
||||
if (res.access_token) Auth.setAccess(res.access_token);
|
||||
if (res.method === 'temp_password' || res.must_configure_mfa) {
|
||||
_redirect('/mfa-setup');
|
||||
} else {
|
||||
@@ -358,6 +358,7 @@
|
||||
verify.onload = function () {
|
||||
var res = JSON.parse(verify.responseText || '{}');
|
||||
if (verify.status === 200) {
|
||||
if (res.access_token) Auth.setAccess(res.access_token);
|
||||
statusEl.textContent = '✅ 登录成功!';
|
||||
_redirect(redirect);
|
||||
} else {
|
||||
|
||||
@@ -141,6 +141,7 @@
|
||||
.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>
|
||||
@@ -207,29 +208,21 @@
|
||||
|
||||
// ─── Check MFA status ──────────────────
|
||||
function checkMFAStatus() {
|
||||
var xhr = new XMLHttpRequest();
|
||||
xhr.open('GET', '/api/auth/mfa/status');
|
||||
xhr.onload = function () {
|
||||
if (xhr.status === 200) {
|
||||
var s = JSON.parse(xhr.responseText);
|
||||
// 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();
|
||||
} else if (xhr.status === 401) {
|
||||
// Not logged in — redirect to login
|
||||
location.href = '/login?view=control&next=/mfa-setup';
|
||||
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)。配置并验证完成后,你的临时验证码将失效,账号立即正式生效。';
|
||||
}
|
||||
};
|
||||
xhr.send();
|
||||
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();
|
||||
|
||||
@@ -251,33 +244,26 @@
|
||||
btn.disabled = true;
|
||||
statusEl.textContent = '正在准备注册...';
|
||||
|
||||
var xhr = new XMLHttpRequest();
|
||||
xhr.open('POST', '/api/auth/webauthn/register/begin');
|
||||
xhr.setRequestHeader('Content-Type', 'application/json');
|
||||
xhr.onload = function () {
|
||||
if (xhr.status !== 200) {
|
||||
Auth.request('POST', '/api/auth/webauthn/register/begin', null, function (options, st) {
|
||||
if (st !== 200) {
|
||||
statusEl.textContent = '❌ 获取注册参数失败';
|
||||
btn.disabled = false;
|
||||
return;
|
||||
}
|
||||
|
||||
var options = JSON.parse(xhr.responseText);
|
||||
// Passkey 中间件:浏览器走 WebAuthn;iOS App 走原生 ASAuthorizationController
|
||||
PasskeyBridge.creation(options)
|
||||
.then(function (credential) {
|
||||
credential.user_name = options.user && options.user.name ? options.user.name : 'user';
|
||||
|
||||
var verify = new XMLHttpRequest();
|
||||
verify.open('POST', '/api/auth/webauthn/register/complete');
|
||||
verify.setRequestHeader('Content-Type', 'application/json');
|
||||
verify.onload = function () {
|
||||
var res = JSON.parse(verify.responseText || '{}');
|
||||
if (verify.status === 200) {
|
||||
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);
|
||||
}
|
||||
@@ -285,15 +271,13 @@
|
||||
statusEl.textContent = '❌ ' + (res.error || '注册失败');
|
||||
btn.disabled = false;
|
||||
}
|
||||
};
|
||||
verify.send(JSON.stringify({ credential: credential }));
|
||||
});
|
||||
})
|
||||
.catch(function (err) {
|
||||
statusEl.textContent = '⏸️ ' + (err.message || '操作取消');
|
||||
btn.disabled = false;
|
||||
});
|
||||
};
|
||||
xhr.send();
|
||||
});
|
||||
});
|
||||
|
||||
// ─── TOTP ──────────────────────────────
|
||||
@@ -318,16 +302,11 @@
|
||||
document.getElementById('setup-totp-btn').textContent = '取消';
|
||||
|
||||
// Request TOTP setup from server
|
||||
var xhr = new XMLHttpRequest();
|
||||
xhr.open('POST', '/api/auth/totp/setup');
|
||||
xhr.setRequestHeader('Content-Type', 'application/json');
|
||||
xhr.onload = function () {
|
||||
if (xhr.status !== 200) return;
|
||||
var data = JSON.parse(xhr.responseText);
|
||||
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;
|
||||
};
|
||||
xhr.send();
|
||||
});
|
||||
});
|
||||
|
||||
document.getElementById('totp-verify-btn').addEventListener('click', function () {
|
||||
@@ -335,12 +314,8 @@
|
||||
var msg = document.getElementById('totp-msg');
|
||||
msg.textContent = '';
|
||||
|
||||
var xhr = new XMLHttpRequest();
|
||||
xhr.open('POST', '/api/auth/totp/verify');
|
||||
xhr.setRequestHeader('Content-Type', 'application/json');
|
||||
xhr.onload = function () {
|
||||
var res = JSON.parse(xhr.responseText || '{}');
|
||||
if (xhr.status === 200) {
|
||||
Auth.request('POST', '/api/auth/totp/verify', { code: code }, function (res, st) {
|
||||
if (st === 200) {
|
||||
msg.className = 'msg success';
|
||||
msg.textContent = '✅ TOTP 已启用!';
|
||||
totpDone = true;
|
||||
@@ -354,14 +329,14 @@
|
||||
}
|
||||
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 || '验证失败';
|
||||
}
|
||||
};
|
||||
xhr.send(JSON.stringify({ code: code }));
|
||||
});
|
||||
});
|
||||
|
||||
// ─── Recovery codes ────────────────────
|
||||
@@ -373,17 +348,11 @@
|
||||
}
|
||||
|
||||
function showRecoveryCodes() {
|
||||
var xhr = new XMLHttpRequest();
|
||||
xhr.open('GET', '/api/auth/recovery-codes');
|
||||
xhr.onload = function () {
|
||||
if (xhr.status === 200) {
|
||||
var data = JSON.parse(xhr.responseText);
|
||||
if (data.codes && data.codes.length > 0) {
|
||||
showRecoveryCodesList(data.codes);
|
||||
}
|
||||
Auth.request('GET', '/api/auth/recovery-codes', null, function (data, status) {
|
||||
if (status === 200 && data.codes && data.codes.length > 0) {
|
||||
showRecoveryCodesList(data.codes);
|
||||
}
|
||||
};
|
||||
xhr.send();
|
||||
});
|
||||
}
|
||||
|
||||
function checkRecoveryAndFinish() {
|
||||
@@ -417,18 +386,14 @@
|
||||
document.getElementById('finish-btn').addEventListener('click', function () {
|
||||
var msg = document.getElementById('finish-msg');
|
||||
msg.textContent = '正在完成设置...';
|
||||
var xhr = new XMLHttpRequest();
|
||||
xhr.open('POST', '/api/auth/mfa/complete');
|
||||
xhr.setRequestHeader('Content-Type', 'application/json');
|
||||
xhr.onload = function () {
|
||||
if (xhr.status === 200) {
|
||||
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 {
|
||||
var res = JSON.parse(xhr.responseText || '{}');
|
||||
msg.textContent = res.error || '完成失败,请重试';
|
||||
}
|
||||
};
|
||||
xhr.send();
|
||||
});
|
||||
});
|
||||
|
||||
// ─── Helpers ────────────────────────────
|
||||
|
||||
Reference in New Issue
Block a user