From c17365b6c983f09bc52d641ea3868b715ff10171 Mon Sep 17 00:00:00 2001 From: PKC278 <52959804+PKC278@users.noreply.github.com> Date: Thu, 8 Jan 2026 16:24:09 +0800 Subject: [PATCH 1/4] =?UTF-8?q?feat(login):=20=E4=BC=98=E5=8C=96=E8=A1=A8?= =?UTF-8?q?=E5=8D=95=E8=87=AA=E5=8A=A8=E5=A1=AB=E5=85=85?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/pages/login.vue | 8 ++++++-- 1 file changed, 6 insertions(+), 2 deletions(-) diff --git a/src/pages/login.vue b/src/pages/login.vue index 040c16d5..9578676c 100644 --- a/src/pages/login.vue +++ b/src/pages/login.vue @@ -459,6 +459,7 @@ onMounted(async () => { :label="t('login.username')" type="text" name="username" + id="username" autocomplete="username" :rules="[requiredValidator]" hide-details @@ -470,7 +471,8 @@ onMounted(async () => { v-model="form.password" :label="t('login.password')" :type="isPasswordVisible ? 'text' : 'password'" - name="current-password" + name="password" + id="password" autocomplete="current-password" :append-inner-icon="isPasswordVisible ? 'mdi-eye-off-outline' : 'mdi-eye-outline'" :rules="[requiredValidator]" @@ -527,8 +529,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" /> From e4684b2e129b353d6e314a9f443899e338902dd7 Mon Sep 17 00:00:00 2001 From: PKC278 <52959804+PKC278@users.noreply.github.com> Date: Thu, 8 Jan 2026 18:51:53 +0800 Subject: [PATCH 2/4] =?UTF-8?q?fix(login):=20=E4=BF=AE=E6=94=B9=E6=B5=85?= =?UTF-8?q?=E8=89=B2=E4=B8=BB=E9=A2=98=E4=B8=8BPassKey=E6=8C=89=E9=92=AE?= =?UTF-8?q?=E6=A0=B7=E5=BC=8F=EF=BC=8C=E6=8F=90=E9=AB=98=E6=96=87=E5=AD=97?= =?UTF-8?q?=E5=AF=B9=E6=AF=94=E5=BA=A6?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/pages/login.vue | 9 ++++++++- 1 file changed, 8 insertions(+), 1 deletion(-) diff --git a/src/pages/login.vue b/src/pages/login.vue index 9578676c..fd2a7ebf 100644 --- a/src/pages/login.vue +++ b/src/pages/login.vue @@ -496,7 +496,7 @@ onMounted(async () => { block variant="tonal" color="success" - class="mt-3" + class="mt-3 passkey-btn" prepend-icon="mdi-key-variant" :loading="passkeyLoading" @click="loginWithPassKey" @@ -551,6 +551,7 @@ onMounted(async () => { block variant="tonal" color="success" + class="passkey-btn" prepend-icon="mdi-key-variant" :loading="mfaPasskeyLoading" @click="verifyWithPassKey" @@ -589,4 +590,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; + } +} From 843f6388357efcc4c9add210487aec8bd8d83c5d Mon Sep 17 00:00:00 2001 From: PKC278 <52959804+PKC278@users.noreply.github.com> Date: Thu, 8 Jan 2026 23:05:12 +0800 Subject: [PATCH 3/4] =?UTF-8?q?feat(auth):=20=E6=B7=BB=E5=8A=A0=20Passkey?= =?UTF-8?q?=20=E6=9D=A1=E4=BB=B6=20UI(conditional=20ui)=20=E6=94=AF?= =?UTF-8?q?=E6=8C=81?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/pages/login.vue | 295 ++++++++++++++++++++++++++++---------------- 1 file changed, 190 insertions(+), 105 deletions(-) diff --git a/src/pages/login.vue b/src/pages/login.vue index fd2a7ebf..c88c5fb7 100644 --- a/src/pages/login.vue +++ b/src/pages/login.vue @@ -74,64 +74,146 @@ const loading = ref(false) // PassKey 登录按钮 loading const passkeyLoading = ref(false) -// 使用PassKey登录 -async function loginWithPassKey() { +// Conditional UI 的 AbortController +let conditionalAbortController: 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 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 = (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),先取消 Conditional UI 请求 + if (!isConditional && conditionalAbortController) { + conditionalAbortController.abort() + conditionalAbortController = null + } + + // 手动模式下,标记手动请求为活跃状态,并立即设置 loading + if (!isConditional) { + 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 : 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 + } + + // 设置错误信息 if (error.response) { errorMessage.value = error.response.data?.detail || t('login.passkeyLoginFailed') } else if (error.name === 'NotAllowedError') { @@ -140,7 +222,17 @@ async function loginWithPassKey() { errorMessage.value = t('login.passkeyLoginRetry') } } finally { - passkeyLoading.value = false + // 清除 loading 状态 + if (!isConditional) { + // 手动模式:始终清除,并取消手动活跃标记 + isManualPassKeyActive = false + passkeyLoading.value = false + } else { + // Conditional UI 模式:只有在没有手动请求活跃时才清除 + if (!isManualPassKeyActive && passkeyLoading.value) { + passkeyLoading.value = false + } + } } } @@ -318,63 +410,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 +440,47 @@ 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 } }) @@ -460,7 +545,7 @@ onMounted(async () => { type="text" name="username" id="username" - autocomplete="username" + autocomplete="username webauthn" :rules="[requiredValidator]" hide-details /> @@ -473,7 +558,7 @@ onMounted(async () => { :type="isPasswordVisible ? 'text' : 'password'" name="password" id="password" - autocomplete="current-password" + autocomplete="current-password webauthn" :append-inner-icon="isPasswordVisible ? 'mdi-eye-off-outline' : 'mdi-eye-outline'" :rules="[requiredValidator]" hide-details @@ -499,7 +584,7 @@ onMounted(async () => { class="mt-3 passkey-btn" prepend-icon="mdi-key-variant" :loading="passkeyLoading" - @click="loginWithPassKey" + @click="loginWithPassKey(false)" > {{ t('login.loginWithPasskey') }} @@ -519,7 +604,7 @@ onMounted(async () => { {{ t('login.twoFactorAuth') }}

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

- + From 816cab252deb4625efe47e7f89dbb1ba430b0f15 Mon Sep 17 00:00:00 2001 From: PKC278 <52959804+PKC278@users.noreply.github.com> Date: Thu, 8 Jan 2026 23:42:52 +0800 Subject: [PATCH 4/4] =?UTF-8?q?feat(login):=20=E6=B7=BB=E5=8A=A0=E6=89=8B?= =?UTF-8?q?=E5=8A=A8=E7=82=B9=E5=87=BBPasskey=E6=8C=89=E9=92=AE=E7=9A=84?= =?UTF-8?q?=20AbortController=20=E4=BB=A5=E9=98=B2=E6=AD=A2=E9=87=8D?= =?UTF-8?q?=E5=A4=8D=E7=82=B9=E5=87=BB?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/pages/login.vue | 62 ++++++++++++++++++++++++++++++++------------- 1 file changed, 45 insertions(+), 17 deletions(-) diff --git a/src/pages/login.vue b/src/pages/login.vue index c88c5fb7..3b0408f2 100644 --- a/src/pages/login.vue +++ b/src/pages/login.vue @@ -77,6 +77,9 @@ const passkeyLoading = ref(false) // Conditional UI 的 AbortController let conditionalAbortController: AbortController | null = null +// 手动模式的 AbortController(用于防止重复点击) +let manualAbortController: AbortController | null = null + // 标记当前是否有手动模式的 PassKey 请求正在进行 let isManualPassKeyActive = false @@ -158,17 +161,17 @@ async function authenticateWithPassKey(options: PassKeyAuthOptions = {}): Promis } // 3. 转换credential为可传输格式 + const publicKeyCredential = credential as PublicKeyCredential + const assertionResponse = publicKeyCredential.response as AuthenticatorAssertionResponse const credentialJSON = { - id: credential.id, - rawId: bufferToBase64Url((credential as any).rawId), - type: credential.type, + id: publicKeyCredential.id, + rawId: bufferToBase64Url(publicKeyCredential.rawId), + type: publicKeyCredential.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, + authenticatorData: bufferToBase64Url(assertionResponse.authenticatorData), + clientDataJSON: bufferToBase64Url(assertionResponse.clientDataJSON), + signature: bufferToBase64Url(assertionResponse.signature), + userHandle: assertionResponse.userHandle ? bufferToBase64Url(assertionResponse.userHandle) : null, }, } @@ -185,14 +188,23 @@ async function authenticateWithPassKey(options: PassKeyAuthOptions = {}): Promis async function loginWithPassKey(isConditional = false) { errorMessage.value = '' - // 如果是手动触发(非 Conditional UI),先取消 Conditional UI 请求 - if (!isConditional && conditionalAbortController) { - conditionalAbortController.abort() - conditionalAbortController = null - } - - // 手动模式下,标记手动请求为活跃状态,并立即设置 loading + // 如果是手动触发(非 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 } @@ -200,7 +212,12 @@ async function loginWithPassKey(isConditional = false) { try { const finishResponse = await authenticateWithPassKey({ isConditional, - signal: isConditional && conditionalAbortController ? conditionalAbortController.signal : undefined, + signal: + isConditional && conditionalAbortController + ? conditionalAbortController.signal + : !isConditional && manualAbortController + ? manualAbortController.signal + : undefined, }) await handleLoginSuccess(finishResponse) @@ -213,6 +230,12 @@ async function loginWithPassKey(isConditional = false) { 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') @@ -227,6 +250,7 @@ async function loginWithPassKey(isConditional = false) { // 手动模式:始终清除,并取消手动活跃标记 isManualPassKeyActive = false passkeyLoading.value = false + manualAbortController = null } else { // Conditional UI 模式:只有在没有手动请求活跃时才清除 if (!isManualPassKeyActive && passkeyLoading.value) { @@ -482,6 +506,10 @@ onUnmounted(() => { conditionalAbortController.abort() conditionalAbortController = null } + if (manualAbortController) { + manualAbortController.abort() + manualAbortController = null + } })