diff --git a/src/pages/login.vue b/src/pages/login.vue index 040c16d5..3b0408f2 100644 --- a/src/pages/login.vue +++ b/src/pages/login.vue @@ -74,64 +74,169 @@ const loading = ref(false) // PassKey 登录按钮 loading const passkeyLoading = ref(false) -// 使用PassKey登录 -async function loginWithPassKey() { +// Conditional UI 的 AbortController +let conditionalAbortController: AbortController | null = null + +// 手动模式的 AbortController(用于防止重复点击) +let manualAbortController: AbortController | null = null + +// 标记当前是否有手动模式的 PassKey 请求正在进行 +let isManualPassKeyActive = false + +// PassKey 认证核心函数 - 处理 WebAuthn 认证流程 +interface PassKeyAuthOptions { + username?: string // 可选的用户名,用于 MFA 场景 + isConditional?: boolean // 是否为 Conditional UI 模式 + signal?: AbortSignal // AbortController 信号 +} + +// PassKey API 响应类型 +interface PassKeyStartResponse { + success: boolean + message?: string + data: { + options: string // JSON 字符串 + challenge: string + } +} + +interface PassKeyFinishResponse { + success: boolean + message?: string + access_token: string + super_user: boolean + user_id: number + user_name: string + avatar: string + level: number + permissions: Record + wizard: boolean +} + +async function authenticateWithPassKey(options: PassKeyAuthOptions = {}): Promise { + const { username, isConditional = false, signal } = options + + // 1. 开始认证流程 + const startResponse = (await api.post( + '/mfa/passkey/authenticate/start', + username ? { username } : {}, + )) as any as PassKeyStartResponse + + if (!startResponse.success) { + throw new Error(startResponse.message || 'PassKey start failed') + } + + const { options: optionsStr, challenge } = startResponse.data + const publicKeyOptions = JSON.parse(optionsStr) + + // 2. 调用WebAuthn API + const credentialRequestOptions: any = { + publicKey: { + ...publicKeyOptions, + challenge: base64UrlToUint8Array(publicKeyOptions.challenge), + allowCredentials: publicKeyOptions.allowCredentials?.map((cred: any) => ({ + ...cred, + id: base64UrlToUint8Array(cred.id), + })), + }, + } + + // 如果是 Conditional UI 模式,添加 mediation 和 signal + if (isConditional) { + credentialRequestOptions.mediation = 'conditional' + if (signal) { + credentialRequestOptions.signal = signal + } + } + + const credential = await navigator.credentials.get(credentialRequestOptions) + + // Conditional UI 模式下,用户选择通行密钥后才显示 loading + if (isConditional) { + passkeyLoading.value = true + } + + if (!credential) { + throw new Error('No credential selected') + } + + // 3. 转换credential为可传输格式 + const publicKeyCredential = credential as PublicKeyCredential + const assertionResponse = publicKeyCredential.response as AuthenticatorAssertionResponse + const credentialJSON = { + id: publicKeyCredential.id, + rawId: bufferToBase64Url(publicKeyCredential.rawId), + type: publicKeyCredential.type, + response: { + authenticatorData: bufferToBase64Url(assertionResponse.authenticatorData), + clientDataJSON: bufferToBase64Url(assertionResponse.clientDataJSON), + signature: bufferToBase64Url(assertionResponse.signature), + userHandle: assertionResponse.userHandle ? bufferToBase64Url(assertionResponse.userHandle) : null, + }, + } + + // 4. 完成认证 + const finishResponse = (await api.post('/mfa/passkey/authenticate/finish', { + credential: credentialJSON, + challenge: challenge, + })) as any as PassKeyFinishResponse + + return finishResponse +} + +// 使用PassKey登录 (支持 Conditional UI) +async function loginWithPassKey(isConditional = false) { errorMessage.value = '' - passkeyLoading.value = true + + // 如果是手动触发(非 Conditional UI) + if (!isConditional) { + // 取消之前的 Conditional UI 请求 + if (conditionalAbortController) { + conditionalAbortController.abort() + conditionalAbortController = null + } + + // 取消之前的手动请求(防止重复点击) + if (manualAbortController) { + manualAbortController.abort() + } + + // 创建新的 AbortController + manualAbortController = new AbortController() + + // 标记手动请求为活跃状态,并立即设置 loading + isManualPassKeyActive = true + passkeyLoading.value = true + } try { - // 1. 开始认证流程 - const startResponse: any = await api.post('/mfa/passkey/authenticate/start', {}) - - if (!startResponse.success) { - errorMessage.value = startResponse.message || t('login.passkeyLoginStartFailed') - return - } - - const { options, challenge } = startResponse.data - const publicKeyOptions = JSON.parse(options) - - // 2. 调用WebAuthn API - const credential = await navigator.credentials.get({ - publicKey: { - ...publicKeyOptions, - challenge: base64UrlToUint8Array(publicKeyOptions.challenge), - allowCredentials: publicKeyOptions.allowCredentials?.map((cred: any) => ({ - ...cred, - id: base64UrlToUint8Array(cred.id), - })), - }, - }) - - if (!credential) { - errorMessage.value = t('login.passkeyNotSelected') - return - } - - // 3. 转换credential为可传输格式 - const credentialJSON = { - id: credential.id, - rawId: bufferToBase64Url((credential as any).rawId), - type: credential.type, - response: { - authenticatorData: bufferToBase64Url((credential as any).response.authenticatorData), - clientDataJSON: bufferToBase64Url((credential as any).response.clientDataJSON), - signature: bufferToBase64Url((credential as any).response.signature), - userHandle: (credential as any).response.userHandle - ? bufferToBase64Url((credential as any).response.userHandle) - : null, - }, - } - - // 4. 完成认证 - const finishResponse: any = await api.post('/mfa/passkey/authenticate/finish', { - credential: credentialJSON, - challenge: challenge, + const finishResponse = await authenticateWithPassKey({ + isConditional, + signal: + isConditional && conditionalAbortController + ? conditionalAbortController.signal + : !isConditional && manualAbortController + ? manualAbortController.signal + : undefined, }) await handleLoginSuccess(finishResponse) } catch (error: any) { - console.error('PassKey login failed:', error) + // Conditional UI 模式下: + // 1. 如果 passkeyLoading 为 false,说明错误发生在用户选择密钥之前(如初始化失败、用户取消等),此时应静默 + // 2. 如果是 AbortError,始终静默 + if (isConditional && (!passkeyLoading.value || error.name === 'AbortError')) { + console.warn('[PassKey] Conditional UI silenced error:', error) + return + } + + // 手动模式下的 AbortError 也应该静默(用户重复点击导致) + if (!isConditional && error.name === 'AbortError') { + console.warn('[PassKey] Manual request aborted (likely due to rapid clicking):', error) + return + } + + // 设置错误信息 if (error.response) { errorMessage.value = error.response.data?.detail || t('login.passkeyLoginFailed') } else if (error.name === 'NotAllowedError') { @@ -140,7 +245,18 @@ async function loginWithPassKey() { errorMessage.value = t('login.passkeyLoginRetry') } } finally { - passkeyLoading.value = false + // 清除 loading 状态 + if (!isConditional) { + // 手动模式:始终清除,并取消手动活跃标记 + isManualPassKeyActive = false + passkeyLoading.value = false + manualAbortController = null + } else { + // Conditional UI 模式:只有在没有手动请求活跃时才清除 + if (!isManualPassKeyActive && passkeyLoading.value) { + passkeyLoading.value = false + } + } } } @@ -318,63 +434,15 @@ async function verifyWithPassKey() { errorMessage.value = '' try { - // 1. 开始认证流程(指定用户名) - const startResponse: any = await api.post('/mfa/passkey/authenticate/start', { + const finishResponse = await authenticateWithPassKey({ username: form.value.username, }) - if (!startResponse.success) { - errorMessage.value = startResponse.message || t('login.passkeyLoginStartFailed') - return - } - - const { options, challenge } = startResponse.data - const publicKeyOptions = JSON.parse(options) - - // 2. 调用WebAuthn API - const credential = await navigator.credentials.get({ - publicKey: { - ...publicKeyOptions, - challenge: base64UrlToUint8Array(publicKeyOptions.challenge), - allowCredentials: publicKeyOptions.allowCredentials?.map((cred: any) => ({ - ...cred, - id: base64UrlToUint8Array(cred.id), - })), - }, - }) - - if (!credential) { - errorMessage.value = t('login.passkeyNotSelected') - return - } - - // 3. 转换credential - const credentialJSON = { - id: credential.id, - rawId: bufferToBase64Url((credential as any).rawId), - type: credential.type, - response: { - authenticatorData: bufferToBase64Url((credential as any).response.authenticatorData), - clientDataJSON: bufferToBase64Url((credential as any).response.clientDataJSON), - signature: bufferToBase64Url((credential as any).response.signature), - userHandle: (credential as any).response.userHandle - ? bufferToBase64Url((credential as any).response.userHandle) - : null, - }, - } - - // 4. 完成认证(直接登录,不需要密码) - const finishResponse: any = await api.post('/mfa/passkey/authenticate/finish', { - credential: credentialJSON, - challenge: challenge, - }) - // 关闭MFA对话框 mfaDialog.value = false await handleLoginSuccess(finishResponse) } catch (error: any) { - console.error('PassKey MFA verification failed:', error) if (error.response) { errorMessage.value = error.response.data?.detail || t('login.passkeyVerifyFailed') } else if (error.name === 'NotAllowedError') { @@ -396,6 +464,51 @@ onMounted(async () => { // 如果token存在,且保持登录状态为true,则跳转到首页 if (token && remember) { router.push('/') + return + } + + // 初始化 Conditional UI 的 PassKey 自动填充 + await initConditionalPasskey() +}) + +// 初始化 Conditional UI 的 PassKey 自动填充 +async function initConditionalPasskey() { + // 检查浏览器是否支持 WebAuthn 和 Conditional UI + if (!window.PublicKeyCredential || !PublicKeyCredential.isConditionalMediationAvailable) { + return + } + + try { + const available = await PublicKeyCredential.isConditionalMediationAvailable() + if (!available) { + return + } + + // 安全防御:如果已存在 controller,先 abort 掉旧的,防止重复调用产生幽灵请求 + if (conditionalAbortController) { + conditionalAbortController.abort() + conditionalAbortController = null + } + + // 创建 AbortController 用于取消请求 + conditionalAbortController = new AbortController() + + // 启动 Conditional UI 模式的 PassKey 认证 + await loginWithPassKey(true) + } catch (error) { + console.error('[PassKey] Failed to initialize Conditional UI:', error) + } +} + +// 组件卸载时清理 +onUnmounted(() => { + if (conditionalAbortController) { + conditionalAbortController.abort() + conditionalAbortController = null + } + if (manualAbortController) { + manualAbortController.abort() + manualAbortController = null } }) @@ -459,7 +572,8 @@ onMounted(async () => { :label="t('login.username')" type="text" name="username" - autocomplete="username" + id="username" + autocomplete="username webauthn" :rules="[requiredValidator]" hide-details /> @@ -470,8 +584,9 @@ onMounted(async () => { v-model="form.password" :label="t('login.password')" :type="isPasswordVisible ? 'text' : 'password'" - name="current-password" - autocomplete="current-password" + name="password" + id="password" + autocomplete="current-password webauthn" :append-inner-icon="isPasswordVisible ? 'mdi-eye-off-outline' : 'mdi-eye-outline'" :rules="[requiredValidator]" hide-details @@ -494,10 +609,10 @@ onMounted(async () => { block variant="tonal" color="success" - class="mt-3" + class="mt-3 passkey-btn" prepend-icon="mdi-key-variant" :loading="passkeyLoading" - @click="loginWithPassKey" + @click="loginWithPassKey(false)" > {{ t('login.loginWithPasskey') }} @@ -517,7 +632,7 @@ onMounted(async () => { {{ t('login.twoFactorAuth') }}

{{ t('login.mfa.selectVerificationMethod') }}

- + @@ -527,8 +642,10 @@ onMounted(async () => { :label="t('login.otpCode')" :placeholder="t('login.otpPlaceholder')" type="text" - inputmode="numeric" + name="otp" + id="otp" autocomplete="one-time-code" + inputmode="numeric" prepend-inner-icon="mdi-shield-key" class="mb-2" /> @@ -547,6 +664,7 @@ onMounted(async () => { block variant="tonal" color="success" + class="passkey-btn" prepend-icon="mdi-key-variant" :loading="mfaPasskeyLoading" @click="verifyWithPassKey" @@ -585,4 +703,10 @@ onMounted(async () => { backdrop-filter: blur(10px) !important; background: rgba(var(--v-theme-surface), 0.7) !important; } + +.v-theme--light { + .passkey-btn.v-btn--variant-tonal { + color: rgb(86, 170, 0) !important; + } +}