Merge pull request #421 from PKC278/v2

This commit is contained in:
jxxghp
2026-01-09 07:51:26 +08:00
committed by GitHub
+231 -107
View File
@@ -74,64 +74,169 @@ const loading = ref(false)
// PassKey 登录按钮 loading // PassKey 登录按钮 loading
const passkeyLoading = ref(false) const passkeyLoading = ref(false)
// 使用PassKey登录 // Conditional UI 的 AbortController
async function loginWithPassKey() { 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<string, boolean>
wizard: boolean
}
async function authenticateWithPassKey(options: PassKeyAuthOptions = {}): Promise<PassKeyFinishResponse> {
const { username, isConditional = false, signal } = options
// 1. 开始认证流程
const startResponse = (await api.post<PassKeyStartResponse>(
'/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<PassKeyFinishResponse>('/mfa/passkey/authenticate/finish', {
credential: credentialJSON,
challenge: challenge,
})) as any as PassKeyFinishResponse
return finishResponse
}
// 使用PassKey登录 (支持 Conditional UI)
async function loginWithPassKey(isConditional = false) {
errorMessage.value = '' 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 { try {
// 1. 开始认证流程 const finishResponse = await authenticateWithPassKey({
const startResponse: any = await api.post('/mfa/passkey/authenticate/start', {}) isConditional,
signal:
if (!startResponse.success) { isConditional && conditionalAbortController
errorMessage.value = startResponse.message || t('login.passkeyLoginStartFailed') ? conditionalAbortController.signal
return : !isConditional && manualAbortController
} ? manualAbortController.signal
: undefined,
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,
}) })
await handleLoginSuccess(finishResponse) await handleLoginSuccess(finishResponse)
} catch (error: any) { } 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) { if (error.response) {
errorMessage.value = error.response.data?.detail || t('login.passkeyLoginFailed') errorMessage.value = error.response.data?.detail || t('login.passkeyLoginFailed')
} else if (error.name === 'NotAllowedError') { } else if (error.name === 'NotAllowedError') {
@@ -140,7 +245,18 @@ async function loginWithPassKey() {
errorMessage.value = t('login.passkeyLoginRetry') errorMessage.value = t('login.passkeyLoginRetry')
} }
} finally { } 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 = '' errorMessage.value = ''
try { try {
// 1. 开始认证流程(指定用户名) const finishResponse = await authenticateWithPassKey({
const startResponse: any = await api.post('/mfa/passkey/authenticate/start', {
username: form.value.username, 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对话框 // 关闭MFA对话框
mfaDialog.value = false mfaDialog.value = false
await handleLoginSuccess(finishResponse) await handleLoginSuccess(finishResponse)
} catch (error: any) { } catch (error: any) {
console.error('PassKey MFA verification failed:', error)
if (error.response) { if (error.response) {
errorMessage.value = error.response.data?.detail || t('login.passkeyVerifyFailed') errorMessage.value = error.response.data?.detail || t('login.passkeyVerifyFailed')
} else if (error.name === 'NotAllowedError') { } else if (error.name === 'NotAllowedError') {
@@ -396,6 +464,51 @@ onMounted(async () => {
// 如果token存在,且保持登录状态为true,则跳转到首页 // 如果token存在,且保持登录状态为true,则跳转到首页
if (token && remember) { if (token && remember) {
router.push('/') 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
} }
}) })
</script> </script>
@@ -459,7 +572,8 @@ onMounted(async () => {
:label="t('login.username')" :label="t('login.username')"
type="text" type="text"
name="username" name="username"
autocomplete="username" id="username"
autocomplete="username webauthn"
:rules="[requiredValidator]" :rules="[requiredValidator]"
hide-details hide-details
/> />
@@ -470,8 +584,9 @@ onMounted(async () => {
v-model="form.password" v-model="form.password"
:label="t('login.password')" :label="t('login.password')"
:type="isPasswordVisible ? 'text' : 'password'" :type="isPasswordVisible ? 'text' : 'password'"
name="current-password" name="password"
autocomplete="current-password" id="password"
autocomplete="current-password webauthn"
:append-inner-icon="isPasswordVisible ? 'mdi-eye-off-outline' : 'mdi-eye-outline'" :append-inner-icon="isPasswordVisible ? 'mdi-eye-off-outline' : 'mdi-eye-outline'"
:rules="[requiredValidator]" :rules="[requiredValidator]"
hide-details hide-details
@@ -494,10 +609,10 @@ onMounted(async () => {
block block
variant="tonal" variant="tonal"
color="success" color="success"
class="mt-3" class="mt-3 passkey-btn"
prepend-icon="mdi-key-variant" prepend-icon="mdi-key-variant"
:loading="passkeyLoading" :loading="passkeyLoading"
@click="loginWithPassKey" @click="loginWithPassKey(false)"
> >
{{ t('login.loginWithPasskey') }} {{ t('login.loginWithPasskey') }}
</VBtn> </VBtn>
@@ -527,8 +642,10 @@ onMounted(async () => {
:label="t('login.otpCode')" :label="t('login.otpCode')"
:placeholder="t('login.otpPlaceholder')" :placeholder="t('login.otpPlaceholder')"
type="text" type="text"
inputmode="numeric" name="otp"
id="otp"
autocomplete="one-time-code" autocomplete="one-time-code"
inputmode="numeric"
prepend-inner-icon="mdi-shield-key" prepend-inner-icon="mdi-shield-key"
class="mb-2" class="mb-2"
/> />
@@ -547,6 +664,7 @@ onMounted(async () => {
block block
variant="tonal" variant="tonal"
color="success" color="success"
class="passkey-btn"
prepend-icon="mdi-key-variant" prepend-icon="mdi-key-variant"
:loading="mfaPasskeyLoading" :loading="mfaPasskeyLoading"
@click="verifyWithPassKey" @click="verifyWithPassKey"
@@ -585,4 +703,10 @@ onMounted(async () => {
backdrop-filter: blur(10px) !important; backdrop-filter: blur(10px) !important;
background: rgba(var(--v-theme-surface), 0.7) !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;
}
}
</style> </style>