feat(passkey): 添加PassKey支持并优化双重验证登录逻辑

This commit is contained in:
PKC278
2025-12-23 13:53:55 +08:00
parent 58a3532c1b
commit e77dcdd3d4
5 changed files with 827 additions and 72 deletions

View File

@@ -2560,9 +2560,38 @@ export default {
vocechatUser: 'VoceChat User',
synologychatUser: 'SynologyChat User',
doubanUser: 'Douban User',
twoFactorAuthentication: 'Two-Factor Authentication',
setupAuthenticator: 'Setup Authenticator',
authenticatorManagement: 'Authenticator Management',
authenticatorEnabled: 'You have enabled authenticator two-factor authentication',
clearAuthenticatorTip: 'To set up a new authenticator, please clear the current configuration first.',
clearAuthenticator: 'Clear Authenticator',
enableTwoFactor: 'Enable Two-Factor Authentication',
disableTwoFactor: 'Disable Two-Factor Authentication',
setupMfa: 'Setup Two-Factor Authentication',
enableMfa: 'Enable Two-Factor Authentication',
useAuthenticator: 'Use Authenticator',
usePasskey: 'Use Passkey',
enabled: 'Enabled',
keysCount: '{count} keys',
passkeyManagement: 'Passkey Management',
registerNewPasskey: 'Register New Passkey',
passkeyDescription: 'Passkeys allow you to sign in quickly and securely without a password.',
passkeyName: 'Passkey Name',
passkeyNamePlaceholder: 'e.g.: iPhone, Windows Hello',
registerPasskey: 'Register Passkey',
registeredPasskeys: 'Registered Passkeys',
createdAt: 'Created At',
noPasskeys: 'You have not registered any passkeys yet',
passkeyNameRequired: 'Please enter a passkey name',
passkeyRegisterSuccess: 'Passkey registered successfully',
passkeyRegisterFailed: 'Registration failed',
passkeyRegisterCancelled: 'Registration cancelled',
passkeyDeleteSuccess: 'Passkey deleted',
passkeyDeleteFailed: 'Delete failed',
passkeyDomainWarning: 'The availability of PassKeys is closely related to the {domain}. In a public network environment, please make sure to configure the correct access domain name in "Basic Settings". Domain changes or configuration errors will cause the PassKey to be unusable.',
otpRequiredForPasskey: 'For security reasons, you must first enable {otp} before you can register a PassKey. This is to ensure that you can still log in to your account via OTP code if the PassKey becomes invalid due to domain configuration changes.',
accessDomain: 'access domain name',
otpAuthenticator: 'OTP Authenticator',
otpGenerateFailed: 'Failed to get OTP URI: {message}!',
otpDisableSuccess: 'Two-factor authentication disabled successfully!',
otpDisableFailed: 'Failed to disable OTP: {message}!',

View File

@@ -2529,9 +2529,38 @@ export default {
vocechatUser: 'VoceChat用户',
synologychatUser: 'SynologyChat用户',
doubanUser: '豆瓣用户',
twoFactorAuthentication: '登录双重验证',
setupAuthenticator: '设置身份验证',
authenticatorManagement: '身份验证器管理',
authenticatorEnabled: '您已启用身份验证器双重验证',
clearAuthenticatorTip: '如需设置新的身份验证器,请先清除当前配置。',
clearAuthenticator: '清除身份验证器',
enableTwoFactor: '开启双重验证',
disableTwoFactor: '关闭双重验证',
setupMfa: '设置双重验证',
enableMfa: '开启双重验证',
useAuthenticator: '使用身份验证器',
usePasskey: '使用通行密钥',
enabled: '已启用',
keysCount: '{count} 个密钥',
passkeyManagement: '通行密钥管理',
registerNewPasskey: '注册新通行密钥',
passkeyDescription: '通行密钥可以让您无需密码即可快速安全地登录。',
passkeyName: '通行密钥名称',
passkeyNamePlaceholder: '例如iPhone、Windows Hello',
registerPasskey: '注册通行密钥',
registeredPasskeys: '已注册的通行密钥',
createdAt: '创建时间',
noPasskeys: '您还没有注册任何通行密钥',
passkeyNameRequired: '请输入通行密钥名称',
passkeyRegisterSuccess: '通行密钥注册成功',
passkeyRegisterFailed: '注册失败',
passkeyRegisterCancelled: '注册被取消',
passkeyDeleteSuccess: '通行密钥已删除',
passkeyDeleteFailed: '删除失败',
passkeyDomainWarning: '通行密钥PassKey的可用性与 {domain} 紧密相关。在公网环境下,请务必在“基础设置”中配置正确的访问域名。域名变更或配置错误将导致通行密钥无法使用。',
otpRequiredForPasskey: '为了安全起见,您必须先启用 {otp} 验证码,然后才能注册通行密钥。这是为了防止在域名配置变动导致 PassKey 失效时,您仍能通过 OTP 码登录账户。',
accessDomain: '访问域名',
otpAuthenticator: 'OTP 身份验证器',
otpGenerateFailed: '获取otp uri失败{message}',
otpDisableSuccess: '关闭登录双重验证成功!',
otpDisableFailed: '关闭otp失败{message}',

View File

@@ -2515,9 +2515,38 @@ export default {
vocechatUser: 'VoceChat用戶',
synologychatUser: 'SynologyChat用戶',
doubanUser: '豆瓣用戶',
twoFactorAuthentication: '登錄雙重驗證',
setupAuthenticator: '設置身份驗證',
authenticatorManagement: '身份驗證器管理',
authenticatorEnabled: '您已啟用身份驗證器雙重驗證',
clearAuthenticatorTip: '如需設置新的身份驗證器,請先清除當前配置。',
clearAuthenticator: '清除身份驗證器',
enableTwoFactor: '開啟雙重驗證',
disableTwoFactor: '關閉雙重驗證',
setupMfa: '設置雙重驗證',
enableMfa: '開啟雙重驗證',
useAuthenticator: '使用身份驗證器',
usePasskey: '使用通行密鑰',
enabled: '已啟用',
keysCount: '{count} 個密鑰',
passkeyManagement: '通行密鑰管理',
registerNewPasskey: '註冊新通行密鑰',
passkeyDescription: '通行密鑰可以讓您無需密碼即可快速安全地登入。',
passkeyName: '通行密鑰名稱',
passkeyNamePlaceholder: '例如iPhone、Windows Hello',
registerPasskey: '註冊通行密鑰',
registeredPasskeys: '已註冊的通行密鑰',
createdAt: '建立時間',
noPasskeys: '您還沒有註冊任何通行密鑰',
passkeyNameRequired: '請輸入通行密鑰名稱',
passkeyRegisterSuccess: '通行密鑰註冊成功',
passkeyRegisterFailed: '註冊失敗',
passkeyRegisterCancelled: '註冊被取消',
passkeyDeleteSuccess: '通行密鑰已刪除',
passkeyDeleteFailed: '刪除失敗',
passkeyDomainWarning: '通行密鑰PassKey的可用性與 {domain} 緊密相關。在公網環境下,請務必在「基本設定」中配置正確的訪問域名。域名變更或配置錯誤將導致通行密鑰無法使用。',
otpRequiredForPasskey: '為了安全起見,您必須先啟用 {otp} 驗證碼,然後才能註冊通行密鑰。這是為了防止在網域配置變動導致 PassKey 失效時,您仍能通過 OTP 碼登入帳戶。',
accessDomain: '訪問域名',
otpAuthenticator: 'OTP 身份驗證器',
otpGenerateFailed: '獲取otp uri失敗{message}',
otpDisableSuccess: '關閉登錄雙重驗證成功!',
otpDisableFailed: '關閉otp失敗{message}',

View File

@@ -1,5 +1,4 @@
<script setup lang="ts">
import { debounce } from 'lodash-es'
import { VForm } from 'vuetify/components/VForm'
import { useAuthStore, useUserStore } from '@/stores'
import { authState, userState } from '@/stores/types'
@@ -43,6 +42,15 @@ const errorMessage = ref('')
// 是否开启双重验证
const isOTP = ref(false)
// 双重验证对话框
const mfaDialog = ref(false)
// MFA challenge用于PassKey验证
const mfaChallenge = ref('')
// MFA PassKey loading
const mfaPasskeyLoading = ref(false)
// 用户名称输入框
const usernameInput = ref()
@@ -66,6 +74,128 @@ const locales = Object.values(SUPPORTED_LOCALES)
// 登录按钮 loading
const loading = ref(false)
// PassKey 登录按钮 loading
const passkeyLoading = ref(false)
// 使用PassKey登录
async function loginWithPassKey() {
errorMessage.value = ''
passkeyLoading.value = true
try {
// 1. 开始认证流程
const startResponse: any = await api.post('/mfa/passkey/authenticate/start', {})
if (!startResponse.success) {
errorMessage.value = startResponse.message || '启动通行密钥认证失败'
passkeyLoading.value = false
return
}
const { options, challenge } = startResponse.data
const publicKeyOptions = JSON.parse(options)
// 2. 调用WebAuthn API
const credential = await navigator.credentials.get({
publicKey: {
...publicKeyOptions,
challenge: Uint8Array.from(atob(publicKeyOptions.challenge.replace(/-/g, '+').replace(/_/g, '/')), c =>
c.charCodeAt(0),
),
allowCredentials: publicKeyOptions.allowCredentials?.map((cred: any) => ({
...cred,
id: Uint8Array.from(atob(cred.id.replace(/-/g, '+').replace(/_/g, '/')), c => c.charCodeAt(0)),
})),
},
})
if (!credential) {
errorMessage.value = '未选择通行密钥'
passkeyLoading.value = false
return
}
// 3. 转换credential为可传输格式
const credentialJSON = {
id: credential.id,
rawId: btoa(String.fromCharCode(...new Uint8Array((credential as any).rawId)))
.replace(/\+/g, '-')
.replace(/\//g, '_')
.replace(/=/g, ''),
type: credential.type,
response: {
authenticatorData: btoa(String.fromCharCode(...new Uint8Array((credential as any).response.authenticatorData)))
.replace(/\+/g, '-')
.replace(/\//g, '_')
.replace(/=/g, ''),
clientDataJSON: btoa(String.fromCharCode(...new Uint8Array((credential as any).response.clientDataJSON)))
.replace(/\+/g, '-')
.replace(/\//g, '_')
.replace(/=/g, ''),
signature: btoa(String.fromCharCode(...new Uint8Array((credential as any).response.signature)))
.replace(/\+/g, '-')
.replace(/\//g, '_')
.replace(/=/g, ''),
userHandle: (credential as any).response.userHandle
? btoa(String.fromCharCode(...new Uint8Array((credential as any).response.userHandle)))
.replace(/\+/g, '-')
.replace(/\//g, '_')
.replace(/=/g, '')
: null,
},
}
// 4. 完成认证
const finishResponse: any = await api.post('/mfa/passkey/authenticate/finish', {
credential: credentialJSON,
challenge: challenge,
})
// 处理登录响应(与密码登录相同)
const userPayload: userState = {
superUser: finishResponse.super_user,
userID: finishResponse.user_id,
userName: finishResponse.user_name,
avatar: finishResponse.avatar,
level: finishResponse.level,
permissions: finishResponse.permissions,
wizard: finishResponse.widzard,
}
const userPermissions = {
is_superuser: userPayload.superUser,
...userPayload.permissions,
}
const filteredMenus = filterMenusByPermission(navMenus, userPermissions)
if (filteredMenus.length === 0) {
errorMessage.value = t('login.noPermission')
passkeyLoading.value = false
return
}
const authPayLoad: authState = {
token: finishResponse.access_token,
remember: form.value.remember,
}
authStore.login(authPayLoad)
userStore.loginUser(userPayload)
await afterLogin(userPayload.superUser, userPayload, filteredMenus)
} catch (error: any) {
console.error('PassKey登录失败:', error)
if (error.response) {
errorMessage.value = error.response.data?.detail || '通行密钥登录失败'
} else if (error.name === 'NotAllowedError') {
errorMessage.value = '通行密钥认证被取消'
} else {
errorMessage.value = '通行密钥登录失败,请重试'
}
passkeyLoading.value = false
}
}
// 切换语言
async function switchLanguage(locale: SupportedLocale) {
await setI18nLanguage(locale)
@@ -74,21 +204,21 @@ async function switchLanguage(locale: SupportedLocale) {
}
// 查询是否开启双重验证
const fetchOTP = debounce(async () => {
const userid = usernameInput.value?.value
if (!userid) {
async function fetchOTP(): Promise<boolean> {
if (!form.value.username) {
isOTP.value = false
return
return false
}
api
.get(`/user/otp/${userid}`)
.then((response: any) => {
isOTP.value = response.success
})
.catch((error: any) => {
console.log(error)
})
}, 500)
try {
const response: any = await api.get(`/mfa/status/${form.value.username}`)
isOTP.value = response.success
return response.success
} catch (error: any) {
console.log(error)
isOTP.value = false
return false
}
}
// 订阅推送通知
async function subscribeForPushNotifications() {
@@ -137,11 +267,11 @@ async function afterLogin(superuser: boolean, userPayload: userState, filteredMe
}
// 登录获取token事件
function login() {
async function login() {
errorMessage.value = ''
// 进行表单校验
if (!form.value.username || !form.value.password || (isOTP.value && !form.value.otp_password)) {
if (!form.value.username || !form.value.password) {
return
}
@@ -200,18 +330,176 @@ function login() {
// 登录后处理
afterLogin(userPayload.superUser, userPayload, filteredMenus)
})
.catch((error: any) => {
.catch(async (error: any) => {
// 登录失败,显示错误提示
if (!error.response) errorMessage.value = t('login.networkError')
else if (error.response.status === 401) errorMessage.value = t('login.authFailure')
else if (error.response.status === 403) errorMessage.value = t('login.permissionDenied')
else if (error.response.status === 500) errorMessage.value = t('login.serverError')
else errorMessage.value = `${t('login.loginFailed')} ${error.response.status}${t('login.checkCredentials')}`
// 登录按钮 loading
loading.value = false
if (!error.response) {
errorMessage.value = t('login.networkError')
loading.value = false
}
else if (error.response.status === 401) {
// 401错误可能是需要MFA或者认证失败
// 检查响应头是否有MFA要求标识
const mfaRequired = error.response.headers?.['x-mfa-required'] === 'true'
if (mfaRequired && !form.value.otp_password) {
// 需要MFA验证弹出对话框
isOTP.value = true
mfaDialog.value = true
loading.value = false
return
}
// 不需要MFA或已填写OTP但认证失败
errorMessage.value = t('login.authFailure')
// 认证失败后清空OTP密码防止下次点击不弹出对话框
form.value.otp_password = ''
loading.value = false
}
else if (error.response.status === 403) {
errorMessage.value = t('login.permissionDenied')
loading.value = false
}
else if (error.response.status === 500) {
errorMessage.value = t('login.serverError')
loading.value = false
}
else {
errorMessage.value = `${t('login.loginFailed')} ${error.response.status}${t('login.checkCredentials')}`
loading.value = false
}
})
}
// 使用OTP码继续登录
function loginWithOTP() {
mfaDialog.value = false
login()
}
// 使用PassKey进行MFA验证
async function verifyWithPassKey() {
if (!form.value.username) return
mfaPasskeyLoading.value = true
errorMessage.value = ''
try {
// 1. 开始认证流程(指定用户名)
const startResponse: any = await api.post('/mfa/passkey/authenticate/start', {
username: form.value.username,
})
if (!startResponse.success) {
errorMessage.value = startResponse.message || '启动通行密钥认证失败'
mfaPasskeyLoading.value = false
return
}
const { options, challenge } = startResponse.data
const publicKeyOptions = JSON.parse(options)
// 2. 调用WebAuthn API
const credential = await navigator.credentials.get({
publicKey: {
...publicKeyOptions,
challenge: Uint8Array.from(atob(publicKeyOptions.challenge.replace(/-/g, '+').replace(/_/g, '/')), c =>
c.charCodeAt(0),
),
allowCredentials: publicKeyOptions.allowCredentials?.map((cred: any) => ({
...cred,
id: Uint8Array.from(atob(cred.id.replace(/-/g, '+').replace(/_/g, '/')), c => c.charCodeAt(0)),
})),
},
})
if (!credential) {
errorMessage.value = '未选择通行密钥'
mfaPasskeyLoading.value = false
return
}
// 3. 转换credential
const credentialJSON = {
id: credential.id,
rawId: btoa(String.fromCharCode(...new Uint8Array((credential as any).rawId)))
.replace(/\+/g, '-')
.replace(/\//g, '_')
.replace(/=/g, ''),
type: credential.type,
response: {
authenticatorData: btoa(String.fromCharCode(...new Uint8Array((credential as any).response.authenticatorData)))
.replace(/\+/g, '-')
.replace(/\//g, '_')
.replace(/=/g, ''),
clientDataJSON: btoa(String.fromCharCode(...new Uint8Array((credential as any).response.clientDataJSON)))
.replace(/\+/g, '-')
.replace(/\//g, '_')
.replace(/=/g, ''),
signature: btoa(String.fromCharCode(...new Uint8Array((credential as any).response.signature)))
.replace(/\+/g, '-')
.replace(/\//g, '_')
.replace(/=/g, ''),
userHandle: (credential as any).response.userHandle
? btoa(String.fromCharCode(...new Uint8Array((credential as any).response.userHandle)))
.replace(/\+/g, '-')
.replace(/\//g, '_')
.replace(/=/g, '')
: null,
},
}
// 4. 完成认证(直接登录,不需要密码)
const finishResponse: any = await api.post('/mfa/passkey/authenticate/finish', {
credential: credentialJSON,
challenge: challenge,
})
// 关闭MFA对话框
mfaDialog.value = false
// 处理登录响应
const userPayload: userState = {
superUser: finishResponse.super_user,
userID: finishResponse.user_id,
userName: finishResponse.user_name,
avatar: finishResponse.avatar,
level: finishResponse.level,
permissions: finishResponse.permissions,
wizard: finishResponse.widzard,
}
const userPermissions = {
is_superuser: userPayload.superUser,
...userPayload.permissions,
}
const filteredMenus = filterMenusByPermission(navMenus, userPermissions)
if (filteredMenus.length === 0) {
errorMessage.value = t('login.noPermission')
mfaPasskeyLoading.value = false
return
}
const authPayLoad: authState = {
token: finishResponse.access_token,
remember: form.value.remember,
}
authStore.login(authPayLoad)
userStore.loginUser(userPayload)
await afterLogin(userPayload.superUser, userPayload, filteredMenus)
} catch (error: any) {
console.error('PassKey MFA验证失败:', error)
if (error.response) {
errorMessage.value = error.response.data?.detail || '通行密钥验证失败'
} else if (error.name === 'NotAllowedError') {
errorMessage.value = '通行密钥认证被取消'
} else {
errorMessage.value = '通行密钥验证失败,请重试'
}
mfaPasskeyLoading.value = false
}
}
// 自动登录
onMounted(async () => {
// 获取token和remember状态
@@ -274,7 +562,7 @@ onMounted(async () => {
</template>
</VCardItem>
<VCardText>
<VForm ref="refForm" autocomplete="on" @submit.prevent="() => {}">
<VForm ref="refForm" autocomplete="on" @submit.prevent="login">
<VRow>
<!-- username -->
<VCol cols="12">
@@ -286,7 +574,7 @@ onMounted(async () => {
name="username"
autocomplete="username"
:rules="[requiredValidator]"
@input="fetchOTP"
hide-details
/>
</VCol>
<!-- password -->
@@ -299,11 +587,11 @@ onMounted(async () => {
autocomplete="current-password"
:append-inner-icon="isPasswordVisible ? 'mdi-eye-off-outline' : 'mdi-eye-outline'"
:rules="[requiredValidator]"
hide-details
@click:append-inner="isPasswordVisible = !isPasswordVisible"
/>
</VCol>
<VCol cols="12">
<VTextField v-if="isOTP" v-model="form.otp_password" :label="t('login.otpCode')" type="input" />
<!-- remember me checkbox -->
<div class="d-flex align-center justify-space-between flex-wrap">
<VCheckbox v-model="form.remember" :label="t('login.stayLoggedIn')" required />
@@ -311,9 +599,21 @@ onMounted(async () => {
</VCol>
<VCol cols="12">
<!-- login button -->
<VBtn block type="submit" @click="login" prepend-icon="mdi-login" :loading="loading">
<VBtn block type="submit" prepend-icon="mdi-login" :loading="loading">
{{ t('login.login') }}
</VBtn>
<!-- passkey login button -->
<VBtn
block
variant="tonal"
color="success"
class="mt-3"
prepend-icon="mdi-key-variant"
:loading="passkeyLoading"
@click="loginWithPassKey"
>
使用通行密钥登录
</VBtn>
<VAlert v-if="errorMessage" type="error" variant="tonal" class="mt-3">
{{ errorMessage }}
</VAlert>
@@ -323,6 +623,56 @@ onMounted(async () => {
</VCardText>
</VCard>
</div>
<!-- MFA双重验证对话框 -->
<VDialog v-model="mfaDialog" max-width="400" persistent>
<VCard>
<VCardTitle class="text-h5 text-center mt-4">双重验证</VCardTitle>
<VCardText>
<p class="text-center mb-4">请选择验证方式</p>
<!-- TOTP验证 -->
<VCard variant="tonal" class="mb-3">
<VCardText>
<VForm @submit.prevent="loginWithOTP">
<VTextField
v-model="form.otp_password"
label="验证码"
placeholder="请输入6位验证码"
type="text"
inputmode="numeric"
autocomplete="one-time-code"
prepend-inner-icon="mdi-shield-key"
class="mb-2"
/>
<VBtn block type="submit" color="primary" :disabled="!form.otp_password">
使用验证码登录
</VBtn>
</VForm>
</VCardText>
</VCard>
<!-- PassKey验证 -->
<VCard variant="tonal">
<VCardText>
<p class="text-body-2 mb-2">或使用通行密钥进行验证</p>
<VBtn
block
variant="tonal"
color="success"
prepend-icon="mdi-key-variant"
:loading="mfaPasskeyLoading"
@click="verifyWithPassKey"
>
使用通行密钥验证
</VBtn>
</VCardText>
</VCard>
<VBtn block variant="text" class="mt-4" @click="mfaDialog = false">取消</VBtn>
</VCardText>
</VCard>
</VDialog>
</div>
</template>

View File

@@ -67,6 +67,29 @@ const accountInfo = ref<User>({
// 二维码信息
const qrCode = ref('')
// PassKey列表
const passkeyList = ref<any[]>([])
// PassKey对话框
const passkeyDialog = ref(false)
// PassKey注册loading
const passkeyRegistering = ref(false)
// PassKey名称
const passkeyName = ref('')
// PassKey challenge
const passkeyChallenge = ref('')
// 双重验证菜单
const mfaMenu = ref(false)
// 检查是否已启用任何双重验证
const hasMfaEnabled = computed(() => {
return accountInfo.value.is_otp || passkeyList.value.length > 0
})
// 更新头像
function changeAvatar(file: Event) {
const fileReader = new FileReader()
@@ -116,13 +139,15 @@ async function fetchUserInfo() {
accountInfo.value.avatar = accountInfo.value.avatar ? accountInfo.value.avatar : avatar1
currentUserName.value = accountInfo.value.name
currentAvatar.value = accountInfo.value.avatar
// 同时加载PassKey列表
await fetchPassKeyList()
}
} catch (error) {
console.log(error)
}
}
// 保存用户信息
// 加载时获取用户信息和PassKey列表
async function saveAccountInfo() {
if (isSaving.value) {
$toast.error(t('profile.savingInProgress'))
@@ -197,8 +222,16 @@ async function saveAccountInfo() {
// 为当前用户获取Otp Uri
async function getOtpUri() {
// 如果已经启用OTP只打开对话框不生成新的二维码
if (accountInfo.value.is_otp) {
qrCode.value = '' // 清空二维码,这样对话框会显示清除界面
otpDialog.value = true
return
}
// 未启用OTP生成新的二维码
try {
const result: { [key: string]: any } = await api.post('user/otp/generate')
const result: { [key: string]: any } = await api.post('mfa/otp/generate')
if (result.success) {
otpUri.value = result.data.uri
secret.value = result.data.secret
@@ -215,7 +248,7 @@ async function getOtpUri() {
// 关闭当前用户的双重验证
async function disableOtp() {
try {
const result: { [key: string]: any } = await api.post('user/otp/disable')
const result: { [key: string]: any } = await api.post('mfa/otp/disable')
if (result.success) {
accountInfo.value.is_otp = false
$toast.success(t('profile.otpDisableSuccess'))
@@ -234,7 +267,7 @@ async function judgeOtpPassword() {
return
}
try {
const result: { [key: string]: any } = await api.post('user/otp/judge', {
const result: { [key: string]: any } = await api.post('mfa/otp/verify', {
uri: otpUri.value,
otpPassword: otpPassword.value,
})
@@ -251,6 +284,139 @@ async function judgeOtpPassword() {
}
}
// 获取PassKey列表
async function fetchPassKeyList() {
try {
const result: { [key: string]: any } = await api.get('mfa/passkey/list')
if (result.success) {
passkeyList.value = result.data || []
}
} catch (error) {
console.log(error)
}
}
// 打开PassKey注册对话框
async function openPassKeyDialog() {
passkeyName.value = ''
passkeyDialog.value = true
await fetchPassKeyList()
}
// 注册PassKey
async function registerPassKey() {
if (!passkeyName.value) {
$toast.error(t('profile.passkeyNameRequired'))
return
}
passkeyRegistering.value = true
try {
// 1. 开始注册
const startResult: { [key: string]: any } = await api.post('mfa/passkey/register/start', {
name: passkeyName.value,
})
if (!startResult.success) {
$toast.error(startResult.message || t('profile.passkeyRegisterFailed'))
passkeyRegistering.value = false
return
}
const { options, challenge } = startResult.data
const publicKeyOptions = JSON.parse(options)
passkeyChallenge.value = challenge
// 2. 调用WebAuthn API
const credential = await navigator.credentials.create({
publicKey: {
...publicKeyOptions,
challenge: Uint8Array.from(atob(publicKeyOptions.challenge.replace(/-/g, '+').replace(/_/g, '/')), c =>
c.charCodeAt(0),
),
user: {
...publicKeyOptions.user,
id: Uint8Array.from(atob(publicKeyOptions.user.id.replace(/-/g, '+').replace(/_/g, '/')), c =>
c.charCodeAt(0),
),
},
excludeCredentials: publicKeyOptions.excludeCredentials?.map((cred: any) => ({
...cred,
id: Uint8Array.from(atob(cred.id.replace(/-/g, '+').replace(/_/g, '/')), c => c.charCodeAt(0)),
})),
},
})
if (!credential) {
$toast.error(t('profile.passkeyRegisterCancelled'))
passkeyRegistering.value = false
return
}
// 3. 转换credential为可传输格式
const credentialJSON = {
id: credential.id,
rawId: btoa(String.fromCharCode(...new Uint8Array((credential as any).rawId)))
.replace(/\+/g, '-')
.replace(/\//g, '_')
.replace(/=/g, ''),
type: credential.type,
response: {
attestationObject: btoa(
String.fromCharCode(...new Uint8Array((credential as any).response.attestationObject)),
)
.replace(/\+/g, '-')
.replace(/\//g, '_')
.replace(/=/g, ''),
clientDataJSON: btoa(String.fromCharCode(...new Uint8Array((credential as any).response.clientDataJSON)))
.replace(/\+/g, '-')
.replace(/\//g, '_')
.replace(/=/g, ''),
transports: (credential as any).response.getTransports ? (credential as any).response.getTransports() : [],
},
}
// 4. 完成注册
const finishResult: { [key: string]: any } = await api.post('mfa/passkey/register/finish', {
credential: credentialJSON,
challenge: passkeyChallenge.value,
name: passkeyName.value,
})
if (finishResult.success) {
$toast.success(t('profile.passkeyRegisterSuccess'))
passkeyName.value = ''
await fetchPassKeyList()
} else {
$toast.error(finishResult.message || t('profile.passkeyRegisterFailed'))
}
} catch (error: any) {
console.error('PassKey注册失败:', error)
if (error.name === 'NotAllowedError') {
$toast.error(t('profile.passkeyRegisterCancelled'))
} else {
$toast.error(t('profile.passkeyRegisterFailed'))
}
}
passkeyRegistering.value = false
}
// 删除PassKey
async function deletePassKey(passkeyId: number) {
try {
const result: { [key: string]: any } = await api.delete(`mfa/passkey/${passkeyId}`)
if (result.success) {
$toast.success(t('profile.passkeyDeleteSuccess'))
await fetchPassKeyList()
} else {
$toast.error(result.message || t('profile.passkeyDeleteFailed'))
}
} catch (error) {
console.log(error)
$toast.error(t('profile.passkeyDeleteFailed'))
}
}
// 加载当前用户数据
onMounted(() => {
fetchUserInfo()
@@ -301,16 +467,42 @@ watch(
<span v-if="display.mdAndUp.value" class="ms-2">{{ t('common.default') }}</span>
</VBtn>
<VBtn
:color="accountInfo.is_otp ? 'warning' : 'success'"
variant="tonal"
@click.stop="accountInfo.is_otp ? disableOtp() : getOtpUri()"
>
<VIcon icon="mdi-account-key" />
<span v-if="display.mdAndUp.value" class="ms-2">{{
accountInfo.is_otp ? t('profile.disableTwoFactor') : t('profile.enableTwoFactor')
}}</span>
</VBtn>
<!-- 双重验证菜单按钮 -->
<VMenu v-model="mfaMenu" :close-on-content-click="false">
<template #activator="{ props }">
<VBtn
:color="hasMfaEnabled ? 'warning' : 'success'"
variant="tonal"
v-bind="props"
>
<VIcon icon="mdi-shield-key" />
<span v-if="display.mdAndUp.value" class="ms-2">
{{ hasMfaEnabled ? t('profile.setupMfa') : t('profile.enableMfa') }}
</span>
<VIcon icon="mdi-menu-down" class="ms-1" />
</VBtn>
</template>
<VList>
<VListItem @click="getOtpUri(); mfaMenu = false">
<template #prepend>
<VIcon icon="mdi-cellphone-key" />
</template>
<VListItemTitle>{{ t('profile.useAuthenticator') }}</VListItemTitle>
<VListItemSubtitle v-if="accountInfo.is_otp" class="text-success">
{{ t('profile.enabled') }}
</VListItemSubtitle>
</VListItem>
<VListItem @click="openPassKeyDialog(); mfaMenu = false">
<template #prepend>
<VIcon icon="mdi-key-variant" />
</template>
<VListItemTitle>{{ t('profile.usePasskey') }}</VListItemTitle>
<VListItemSubtitle v-if="passkeyList.length > 0" class="text-success">
{{ t('profile.keysCount', { count: passkeyList.length }) }}
</VListItemSubtitle>
</VListItem>
</VList>
</VMenu>
</div>
<p class="text-body-1 mb-0">{{ t('profile.avatarFormatTip') }}</p>
@@ -455,41 +647,167 @@ watch(
<!-- 双重验证弹窗 -->
<VDialog v-if="otpDialog" v-model="otpDialog" max-width="45rem" scrollable>
<!-- 开启双重验证弹窗内容 -->
<VCard>
<VDialogCloseBtn @click="otpDialog = false" />
<VCardText>
<h4 class="text-h4 text-center mb-6 mt-5">{{ t('profile.twoFactorAuthentication') }}</h4>
<h5 class="text-h5 font-weight-medium mb-2">{{ t('profile.authenticatorApp') }}</h5>
<p class="mb-6">
{{ t('profile.authenticatorAppDescription') }}
</p>
<div class="my-6">
<QrcodeVue class="mx-auto" :value="qrCode" :size="200" max-width="25rem" />
</div>
<VAlert :title="secret" variant="tonal" type="warning" class="my-4" :text="t('profile.secretKeyTip')">
<template #prepend />
</VAlert>
<VForm>
<VTextField
v-model="otpPassword"
type="text"
:label="t('profile.enterVerificationCode')"
autocomplete=""
class="mb-8"
variant="outlined"
prepend-inner-icon="mdi-shield-key"
/>
<!-- 如果已启用OTP显示清除界面 -->
<template v-if="accountInfo.is_otp && !qrCode">
<h4 class="text-h4 text-center mb-6 mt-5">{{ t('profile.authenticatorManagement') }}</h4>
<VAlert type="success" variant="tonal" class="mb-4">
{{ t('profile.authenticatorEnabled') }}
</VAlert>
<p class="mb-6">
{{ t('profile.clearAuthenticatorTip') }}
</p>
<div class="d-flex justify-end flex-wrap gap-4">
<VBtn variant="outlined" color="secondary" @click="otpDialog = false"> {{ t('common.cancel') }} </VBtn>
<VBtn @click="judgeOtpPassword">
<VBtn variant="outlined" color="secondary" @click="otpDialog = false">
{{ t('common.cancel') }}
</VBtn>
<VBtn color="error" @click="disableOtp(); otpDialog = false">
<template #prepend>
<VIcon icon="mdi-check" />
<VIcon icon="mdi-delete" />
</template>
{{ t('common.confirm') }}
{{ t('profile.clearAuthenticator') }}
</VBtn>
</div>
</VForm>
</template>
<!-- 设置新的OTP -->
<template v-else>
<h4 class="text-h4 text-center mb-6 mt-5">{{ t('profile.setupAuthenticator') }}</h4>
<h5 class="text-h5 font-weight-medium mb-2">{{ t('profile.authenticatorApp') }}</h5>
<p class="mb-6">
{{ t('profile.authenticatorAppDescription') }}
</p>
<div class="my-6">
<QrcodeVue class="mx-auto" :value="qrCode" :size="200" max-width="25rem" />
</div>
<VAlert :title="secret" variant="tonal" type="warning" class="my-4" :text="t('profile.secretKeyTip')">
<template #prepend />
</VAlert>
<VForm @submit.prevent="judgeOtpPassword">
<VTextField
v-model="otpPassword"
type="text"
inputmode="numeric"
autocomplete="one-time-code"
:label="t('profile.enterVerificationCode')"
class="mb-8"
variant="outlined"
prepend-inner-icon="mdi-shield-key"
/>
<div class="d-flex justify-end flex-wrap gap-4">
<VBtn variant="outlined" color="secondary" @click="otpDialog = false">
{{ t('common.cancel') }}
</VBtn>
<VBtn type="submit">
<template #prepend>
<VIcon icon="mdi-check" />
</template>
{{ t('common.confirm') }}
</VBtn>
</div>
</VForm>
</template>
</VCardText>
</VCard>
</VDialog>
<!-- PassKey管理对话框 -->
<VDialog v-if="passkeyDialog" v-model="passkeyDialog" max-width="45rem" scrollable>
<VCard>
<VDialogCloseBtn @click="passkeyDialog = false" />
<VCardText>
<h4 class="text-h4 text-center mb-6 mt-5">{{ t('profile.passkeyManagement') }}</h4>
<!-- 安全警告 -->
<VAlert
type="warning"
variant="tonal"
class="mb-6"
icon="mdi-alert"
>
<span v-html="t('profile.passkeyDomainWarning', { domain: '<b>' + t('profile.accessDomain') + '</b>' })"></span>
</VAlert>
<!-- 注册新通行密钥 -->
<VCard v-if="accountInfo.is_otp" variant="tonal" class="mb-6">
<VCardText>
<h5 class="text-h5 font-weight-medium mb-2">{{ t('profile.registerNewPasskey') }}</h5>
<p class="mb-4">{{ t('profile.passkeyDescription') }}</p>
<VForm @submit.prevent="registerPassKey">
<VTextField
v-model="passkeyName"
:label="t('profile.passkeyName')"
:placeholder="t('profile.passkeyNamePlaceholder')"
class="mb-4"
variant="outlined"
prepend-inner-icon="mdi-form-textbox"
/>
<VBtn
color="primary"
type="submit"
:loading="passkeyRegistering"
prepend-icon="mdi-plus"
>
{{ t('profile.registerPasskey') }}
</VBtn>
</VForm>
</VCardText>
</VCard>
<!-- 未启用 OTP 提示 -->
<VAlert
v-else
type="error"
variant="tonal"
class="mb-6"
icon="mdi-shield-lock"
>
<span v-html="t('profile.otpRequiredForPasskey', { otp: '<b>' + t('profile.otpAuthenticator') + '</b>' })"></span>
</VAlert>
<!-- 已注册的通行密钥列表 -->
<VCard variant="tonal">
<VCardText>
<h5 class="text-h5 font-weight-medium mb-2">{{ t('profile.registeredPasskeys') }}</h5>
<VList v-if="passkeyList.length > 0" class="mt-4">
<VListItem
v-for="passkey in passkeyList"
:key="passkey.id"
class="mb-2 py-4"
rounded="lg"
border
>
<template #prepend>
<VIcon icon="mdi-key-variant" size="32" class="me-4" />
</template>
<VListItemTitle class="font-weight-medium">
{{ passkey.name }}
</VListItemTitle>
<VListItemSubtitle>
{{ t('profile.createdAt') }}: {{ new Date(passkey.created_at).toLocaleString('zh-CN') }}
</VListItemSubtitle>
<template #append>
<VBtn
icon="mdi-delete"
variant="text"
color="error"
size="small"
@click="deletePassKey(passkey.id)"
/>
</template>
</VListItem>
</VList>
<VAlert v-else type="info" variant="tonal" class="mt-4">
{{ t('profile.noPasskeys') }}
</VAlert>
</VCardText>
</VCard>
<div class="d-flex justify-end mt-6">
<VBtn variant="outlined" @click="passkeyDialog = false">{{ t('common.close') }}</VBtn>
</div>
</VCardText>
</VCard>
</VDialog>