feat: 完成 Passkey 能力与前后台加载优化
更新说明:\n1. 新增用户端与管理员端 Passkey 登录/注册/设备管理(最多3台,支持设备备注、删除设备)。\n2. 修复 Passkey 注册与登录流程中的浏览器/证书/CSRF相关问题,增强错误提示。\n3. 前台登录页改为独立入口,首屏仅加载必要资源,其他页面按需加载。\n4. 系统配置页改为静默获取金山文档状态,避免首屏阻塞,并优化状态展示为“检测中/已登录/未登录/异常”。\n5. 补充后端接口与页面渲染适配,修复多入口下样式依赖注入问题。\n6. 同步更新前后台构建产物与相关静态资源。
This commit is contained in:
@@ -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();
|
||||
|
||||
Reference in New Issue
Block a user