feat: 完成 Passkey 能力与前后台加载优化

更新说明:\n1. 新增用户端与管理员端 Passkey 登录/注册/设备管理(最多3台,支持设备备注、删除设备)。\n2. 修复 Passkey 注册与登录流程中的浏览器/证书/CSRF相关问题,增强错误提示。\n3. 前台登录页改为独立入口,首屏仅加载必要资源,其他页面按需加载。\n4. 系统配置页改为静默获取金山文档状态,避免首屏阻塞,并优化状态展示为“检测中/已登录/未登录/异常”。\n5. 补充后端接口与页面渲染适配,修复多入口下样式依赖注入问题。\n6. 同步更新前后台构建产物与相关静态资源。
This commit is contained in:
2026-02-15 23:51:12 +08:00
parent ebfac7266b
commit 7007f5f6f5
129 changed files with 3747 additions and 432 deletions

View File

@@ -121,6 +121,23 @@
transform: translateY(0);
}
.btn-passkey {
width: 100%;
margin-top: 10px;
padding: 11px;
border-radius: 10px;
border: 1px solid rgba(17,24,39,0.14);
background: #f8fafc;
color: #0f172a;
font-size: 14px;
font-weight: 700;
cursor: pointer;
}
.btn-passkey:hover {
background: #f1f5f9;
}
.back-link {
text-align: center;
margin-top: 20px;
@@ -213,6 +230,7 @@
</div>
<button type="submit" class="btn-login">登录后台</button>
<button type="button" class="btn-passkey" onclick="handlePasskeyLogin()">使用 Passkey 登录</button>
</form>
<div class="back-link">
@@ -224,6 +242,72 @@
let captchaSession = '';
let needCaptcha = false;
function getCookie(name) {
const escaped = String(name || '').replace(/([.*+?^${}()|[\]\\])/g, '\\$1');
const match = document.cookie.match(new RegExp(`(?:^|; )${escaped}=([^;]*)`));
return match ? decodeURIComponent(match[1]) : '';
}
function jsonHeaders() {
const headers = { 'Content-Type': 'application/json' };
const csrfToken = getCookie('csrf_token');
if (csrfToken) {
headers['X-CSRF-Token'] = csrfToken;
}
return headers;
}
function isPasskeyAvailable() {
return window.isSecureContext && !!window.PublicKeyCredential && !!navigator.credentials;
}
function base64UrlToUint8Array(base64url) {
const value = String(base64url || '');
const padding = '='.repeat((4 - (value.length % 4)) % 4);
const base64 = (value + padding).replace(/-/g, '+').replace(/_/g, '/');
const raw = window.atob(base64);
const bytes = new Uint8Array(raw.length);
for (let i = 0; i < raw.length; i += 1) bytes[i] = raw.charCodeAt(i);
return bytes;
}
function uint8ArrayToBase64Url(input) {
const bytes = input instanceof ArrayBuffer ? new Uint8Array(input) : new Uint8Array(input || []);
let binary = '';
for (let i = 0; i < bytes.length; i += 1) binary += String.fromCharCode(bytes[i]);
return window.btoa(binary).replace(/\+/g, '-').replace(/\//g, '_').replace(/=+$/g, '');
}
function normalizeRequestOptions(rawOptions) {
const options = rawOptions && rawOptions.publicKey ? rawOptions.publicKey : rawOptions;
const normalized = {
...options,
challenge: base64UrlToUint8Array(options.challenge),
};
if (Array.isArray(options.allowCredentials)) {
normalized.allowCredentials = options.allowCredentials.map((item) => ({
...item,
id: base64UrlToUint8Array(item.id),
}));
}
return normalized;
}
function serializeAssertion(credential) {
return {
id: credential.id,
rawId: uint8ArrayToBase64Url(credential.rawId),
type: credential.type,
authenticatorAttachment: credential.authenticatorAttachment || undefined,
response: {
clientDataJSON: uint8ArrayToBase64Url(credential.response.clientDataJSON),
authenticatorData: uint8ArrayToBase64Url(credential.response.authenticatorData),
signature: uint8ArrayToBase64Url(credential.response.signature),
userHandle: credential.response.userHandle ? uint8ArrayToBase64Url(credential.response.userHandle) : null,
},
};
}
async function handleLogin(event) {
event.preventDefault();
@@ -253,9 +337,7 @@
const response = await fetch('/yuyx/api/login', {
method: 'POST',
credentials: 'same-origin', // 确保发送和接收cookies
headers: {
'Content-Type': 'application/json'
},
headers: jsonHeaders(),
body: JSON.stringify({
username: username,
password: password,
@@ -290,13 +372,65 @@
}
}
async function handlePasskeyLogin() {
const username = document.getElementById('username').value.trim();
const errorDiv = document.getElementById('errorMessage');
const successDiv = document.getElementById('successMessage');
errorDiv.style.display = 'none';
successDiv.style.display = 'none';
if (!isPasskeyAvailable()) {
errorDiv.textContent = '当前浏览器或环境不支持Passkey需HTTPS';
errorDiv.style.display = 'block';
return;
}
try {
const optionsResp = await fetch('/yuyx/api/passkeys/login/options', {
method: 'POST',
credentials: 'same-origin',
headers: jsonHeaders(),
body: JSON.stringify(username ? { username } : {}),
});
const optionsData = await optionsResp.json();
if (!optionsResp.ok) {
throw new Error(optionsData.error || '获取Passkey挑战失败');
}
const publicKey = normalizeRequestOptions(optionsData.publicKey || {});
const assertion = await navigator.credentials.get({ publicKey });
const credential = serializeAssertion(assertion);
const verifyResp = await fetch('/yuyx/api/passkeys/login/verify', {
method: 'POST',
credentials: 'same-origin',
headers: jsonHeaders(),
body: JSON.stringify(username ? { username, credential } : { credential }),
});
const verifyData = await verifyResp.json();
if (!verifyResp.ok) {
throw new Error(verifyData.error || 'Passkey登录失败');
}
successDiv.textContent = 'Passkey 登录成功,正在跳转...';
successDiv.style.display = 'block';
await new Promise(resolve => setTimeout(resolve, 500));
window.location.replace(verifyData.redirect || '/yuyx/admin');
} catch (error) {
const msg = error?.name === 'NotAllowedError'
? 'Passkey验证未完成可能取消、超时或设备未响应'
: (error?.message || 'Passkey登录失败');
errorDiv.textContent = msg;
errorDiv.style.display = 'block';
}
}
async function generateCaptcha() {
try {
const response = await fetch('/api/generate_captcha', {
method: 'POST',
headers: {
'Content-Type': 'application/json'
}
headers: jsonHeaders()
});
const data = await response.json();