mirror of
https://github.com/jxxghp/MoviePilot-Frontend.git
synced 2026-09-05 23:56:42 +08:00
fix(mfa): 修复双重验证漏洞
This commit is contained in:
@@ -35,6 +35,19 @@ export function urlBase64ToUint8Array(base64String: string) {
|
|||||||
return outputArray
|
return outputArray
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Uint8Array 转 Base64URL
|
||||||
|
export function bufferToBase64Url(buffer: ArrayBuffer): string {
|
||||||
|
return btoa(String.fromCharCode(...new Uint8Array(buffer)))
|
||||||
|
.replace(/\+/g, '-')
|
||||||
|
.replace(/\//g, '_')
|
||||||
|
.replace(/=/g, '')
|
||||||
|
}
|
||||||
|
|
||||||
|
// Base64URL 转 Uint8Array
|
||||||
|
export function base64UrlToUint8Array(base64Url: string): Uint8Array {
|
||||||
|
return Uint8Array.from(atob(base64Url.replace(/-/g, '+').replace(/_/g, '/')), c => c.charCodeAt(0))
|
||||||
|
}
|
||||||
|
|
||||||
// 判断是否为PWA
|
// 判断是否为PWA
|
||||||
export const isPWA = async (): Promise<boolean> => {
|
export const isPWA = async (): Promise<boolean> => {
|
||||||
if ('serviceWorker' in navigator) {
|
if ('serviceWorker' in navigator) {
|
||||||
|
|||||||
+20
-1
@@ -248,6 +248,22 @@ export default {
|
|||||||
serverError: 'Login failed, server error!',
|
serverError: 'Login failed, server error!',
|
||||||
loginFailed: 'Login Failed',
|
loginFailed: 'Login Failed',
|
||||||
checkCredentials: 'Please check your username, password or two-factor authentication code!',
|
checkCredentials: 'Please check your username, password or two-factor authentication code!',
|
||||||
|
twoFactorAuth: 'Two-Factor Authentication',
|
||||||
|
loginWithPasskey: 'Login with Passkey',
|
||||||
|
loginWithOtp: 'Login with OTP',
|
||||||
|
orUsePasskey: 'Or use Passkey for verification',
|
||||||
|
verifyWithPasskey: 'Verify with Passkey',
|
||||||
|
otpPlaceholder: 'Enter 6-digit code',
|
||||||
|
passkeyLoginStartFailed: 'Failed to start Passkey authentication',
|
||||||
|
passkeyNotSelected: 'No Passkey selected',
|
||||||
|
passkeyLoginFailed: 'Passkey login failed',
|
||||||
|
passkeyAuthCanceled: 'Passkey authentication canceled',
|
||||||
|
passkeyLoginRetry: 'Passkey login failed, please try again',
|
||||||
|
passkeyVerifyFailed: 'Passkey verification failed',
|
||||||
|
passkeyVerifyFailedRetry: 'Passkey verification failed, please try again',
|
||||||
|
mfa: {
|
||||||
|
selectVerificationMethod: 'Please select a verification method',
|
||||||
|
},
|
||||||
},
|
},
|
||||||
menu: {
|
menu: {
|
||||||
start: 'Start',
|
start: 'Start',
|
||||||
@@ -380,7 +396,7 @@ export default {
|
|||||||
username: 'Username',
|
username: 'Username',
|
||||||
usernameHint: 'Username for system login',
|
usernameHint: 'Username for system login',
|
||||||
password: 'Password',
|
password: 'Password',
|
||||||
passwordHint: 'Password for system login',
|
passwordHint: 'Please enter your login password',
|
||||||
confirmPassword: 'Confirm Password',
|
confirmPassword: 'Confirm Password',
|
||||||
confirmPasswordHint: 'Please enter the password again to confirm',
|
confirmPasswordHint: 'Please enter the password again to confirm',
|
||||||
role: 'Role',
|
role: 'Role',
|
||||||
@@ -2598,6 +2614,9 @@ export default {
|
|||||||
otpCodeRequired: 'Please enter the 6-digit verification code',
|
otpCodeRequired: 'Please enter the 6-digit verification code',
|
||||||
otpEnableSuccess: 'Two-factor authentication enabled successfully!',
|
otpEnableSuccess: 'Two-factor authentication enabled successfully!',
|
||||||
otpEnableFailed: 'Failed to enable OTP: {message}!',
|
otpEnableFailed: 'Failed to enable OTP: {message}!',
|
||||||
|
otpDisableRestrictedByPasskey: 'You have registered Passkeys. Please delete all Passkeys before disabling OTP verification.',
|
||||||
|
confirmToDisableOtp: 'For security reasons, verifying your login password is required to disable two-factor authentication.',
|
||||||
|
confirmToDeletePasskey: 'For security reasons, verifying your login password is required to delete a Passkey.',
|
||||||
authenticatorApp: 'Authenticator App',
|
authenticatorApp: 'Authenticator App',
|
||||||
authenticatorAppDescription:
|
authenticatorAppDescription:
|
||||||
'Use an authenticator app like Google Authenticator, Microsoft Authenticator, Authy, or 1Password to scan the QR code. It will generate a 6-digit code for you to enter below.',
|
'Use an authenticator app like Google Authenticator, Microsoft Authenticator, Authy, or 1Password to scan the QR code. It will generate a 6-digit code for you to enter below.',
|
||||||
|
|||||||
+20
-1
@@ -247,6 +247,22 @@ export default {
|
|||||||
serverError: '登录失败,服务器错误!',
|
serverError: '登录失败,服务器错误!',
|
||||||
loginFailed: '登录失败',
|
loginFailed: '登录失败',
|
||||||
checkCredentials: '请检查用户名、密码或双重验证码是否正确!',
|
checkCredentials: '请检查用户名、密码或双重验证码是否正确!',
|
||||||
|
twoFactorAuth: '双重验证',
|
||||||
|
loginWithPasskey: '使用通行密钥登录',
|
||||||
|
loginWithOtp: '使用验证码登录',
|
||||||
|
orUsePasskey: '或使用通行密钥进行验证',
|
||||||
|
verifyWithPasskey: '使用通行密钥验证',
|
||||||
|
otpPlaceholder: '请输入6位验证码',
|
||||||
|
passkeyLoginStartFailed: '启动通行密钥认证失败',
|
||||||
|
passkeyNotSelected: '未选择通行密钥',
|
||||||
|
passkeyLoginFailed: '通行密钥登录失败',
|
||||||
|
passkeyAuthCanceled: '通行密钥认证被取消',
|
||||||
|
passkeyLoginRetry: '通行密钥登录失败,请重试',
|
||||||
|
passkeyVerifyFailed: '通行密钥验证失败',
|
||||||
|
passkeyVerifyFailedRetry: '通行密钥验证失败,请重试',
|
||||||
|
mfa: {
|
||||||
|
selectVerificationMethod: '请选择验证方式',
|
||||||
|
},
|
||||||
},
|
},
|
||||||
menu: {
|
menu: {
|
||||||
start: '开始',
|
start: '开始',
|
||||||
@@ -379,7 +395,7 @@ export default {
|
|||||||
username: '用户名',
|
username: '用户名',
|
||||||
usernameHint: '用于登录系统的用户名',
|
usernameHint: '用于登录系统的用户名',
|
||||||
password: '密码',
|
password: '密码',
|
||||||
passwordHint: '用于登录系统的密码',
|
passwordHint: '请输入登录密码',
|
||||||
confirmPassword: '确认密码',
|
confirmPassword: '确认密码',
|
||||||
confirmPasswordHint: '请再次输入密码以确认',
|
confirmPasswordHint: '请再次输入密码以确认',
|
||||||
role: '角色',
|
role: '角色',
|
||||||
@@ -2567,6 +2583,9 @@ export default {
|
|||||||
otpCodeRequired: '请填写6位验证码',
|
otpCodeRequired: '请填写6位验证码',
|
||||||
otpEnableSuccess: '开启登录双重验证成功!',
|
otpEnableSuccess: '开启登录双重验证成功!',
|
||||||
otpEnableFailed: '开启otp失败:{message}!',
|
otpEnableFailed: '开启otp失败:{message}!',
|
||||||
|
otpDisableRestrictedByPasskey: '您已注册通行密钥,请先删除所有通行密钥再关闭 OTP 验证。',
|
||||||
|
confirmToDisableOtp: '为了安全起见,关闭双重验证需要验证您的登录密码。',
|
||||||
|
confirmToDeletePasskey: '为了安全起见,删除通行密钥需要验证您的登录密码。',
|
||||||
authenticatorApp: '身份验证器',
|
authenticatorApp: '身份验证器',
|
||||||
authenticatorAppDescription:
|
authenticatorAppDescription:
|
||||||
'使用像Google Authenticator、Microsoft Authenticator、Authy或1Password这样的身份验证器应用程序,扫描二维码。它将为您生成一个6位数的代码,供您在下方输入。',
|
'使用像Google Authenticator、Microsoft Authenticator、Authy或1Password这样的身份验证器应用程序,扫描二维码。它将为您生成一个6位数的代码,供您在下方输入。',
|
||||||
|
|||||||
+20
-1
@@ -248,6 +248,22 @@ export default {
|
|||||||
noPermission: '登錄失敗,您沒有任何功能權限,請聯繫管理員!',
|
noPermission: '登錄失敗,您沒有任何功能權限,請聯繫管理員!',
|
||||||
loginFailed: '登錄失敗',
|
loginFailed: '登錄失敗',
|
||||||
checkCredentials: '請檢查用戶名、密碼或雙重驗證碼是否正確!',
|
checkCredentials: '請檢查用戶名、密碼或雙重驗證碼是否正確!',
|
||||||
|
twoFactorAuth: '雙重驗證',
|
||||||
|
loginWithPasskey: '使用通行密鑰登錄',
|
||||||
|
loginWithOtp: '使用驗證碼登錄',
|
||||||
|
orUsePasskey: '或使用通行密鑰進行驗證',
|
||||||
|
verifyWithPasskey: '使用通行密鑰驗證',
|
||||||
|
otpPlaceholder: '請輸入6位驗證碼',
|
||||||
|
passkeyLoginStartFailed: '啟動通行密鑰驗證失敗',
|
||||||
|
passkeyNotSelected: '未選擇通行密鑰',
|
||||||
|
passkeyLoginFailed: '通行密鑰登錄失敗',
|
||||||
|
passkeyAuthCanceled: '通行密鑰驗證被取消',
|
||||||
|
passkeyLoginRetry: '通行密鑰登錄失敗,請重試',
|
||||||
|
passkeyVerifyFailed: '通行密鑰驗证失敗',
|
||||||
|
passkeyVerifyFailedRetry: '通行密鑰驗证失敗,請重試',
|
||||||
|
mfa: {
|
||||||
|
selectVerificationMethod: '請選擇驗证方式',
|
||||||
|
},
|
||||||
},
|
},
|
||||||
menu: {
|
menu: {
|
||||||
start: '開始',
|
start: '開始',
|
||||||
@@ -380,7 +396,7 @@ export default {
|
|||||||
username: '用戶名',
|
username: '用戶名',
|
||||||
usernameHint: '用於登入系統的用戶名',
|
usernameHint: '用於登入系統的用戶名',
|
||||||
password: '密碼',
|
password: '密碼',
|
||||||
passwordHint: '用於登入系統的密碼',
|
passwordHint: '請輸入登入密碼',
|
||||||
confirmPassword: '確認密碼',
|
confirmPassword: '確認密碼',
|
||||||
confirmPasswordHint: '請再次輸入密碼以確認',
|
confirmPasswordHint: '請再次輸入密碼以確認',
|
||||||
role: '角色',
|
role: '角色',
|
||||||
@@ -2553,6 +2569,9 @@ export default {
|
|||||||
otpCodeRequired: '請填寫6位驗證碼',
|
otpCodeRequired: '請填寫6位驗證碼',
|
||||||
otpEnableSuccess: '開啟登錄雙重驗證成功!',
|
otpEnableSuccess: '開啟登錄雙重驗證成功!',
|
||||||
otpEnableFailed: '開啟otp失敗:{message}!',
|
otpEnableFailed: '開啟otp失敗:{message}!',
|
||||||
|
otpDisableRestrictedByPasskey: '您已註冊通行密鑰,請先刪除所有通行密鑰再關閉 OTP 驗證。',
|
||||||
|
confirmToDisableOtp: '為了安全起見,關閉雙重驗證需要驗證您的登錄密碼。',
|
||||||
|
confirmToDeletePasskey: '為了安全起見,刪除通行密鑰需要驗證您的登錄密碼。',
|
||||||
authenticatorApp: '身份驗證器',
|
authenticatorApp: '身份驗證器',
|
||||||
authenticatorAppDescription:
|
authenticatorAppDescription:
|
||||||
'使用像Google Authenticator、Microsoft Authenticator、Authy或1Password這樣的身份驗證器應用程序,掃描二維碼。它將為您生成一個6位數的代碼,供您在下方輸入。',
|
'使用像Google Authenticator、Microsoft Authenticator、Authy或1Password這樣的身份驗證器應用程序,掃描二維碼。它將為您生成一個6位數的代碼,供您在下方輸入。',
|
||||||
|
|||||||
+109
-222
@@ -6,7 +6,7 @@ import { requiredValidator } from '@/@validators'
|
|||||||
import api from '@/api'
|
import api from '@/api'
|
||||||
import router from '@/router'
|
import router from '@/router'
|
||||||
import logo from '@images/logo.png'
|
import logo from '@images/logo.png'
|
||||||
import { urlBase64ToUint8Array } from '@/@core/utils/navigator'
|
import { bufferToBase64Url, base64UrlToUint8Array, urlBase64ToUint8Array } from '@/@core/utils/navigator'
|
||||||
import { SUPPORTED_LOCALES, SupportedLocale } from '@/types/i18n'
|
import { SUPPORTED_LOCALES, SupportedLocale } from '@/types/i18n'
|
||||||
import { getCurrentLocale, setI18nLanguage } from '@/plugins/i18n'
|
import { getCurrentLocale, setI18nLanguage } from '@/plugins/i18n'
|
||||||
import { useTheme } from 'vuetify'
|
import { useTheme } from 'vuetify'
|
||||||
@@ -45,9 +45,6 @@ const isOTP = ref(false)
|
|||||||
// 双重验证对话框
|
// 双重验证对话框
|
||||||
const mfaDialog = ref(false)
|
const mfaDialog = ref(false)
|
||||||
|
|
||||||
// MFA challenge(用于PassKey验证)
|
|
||||||
const mfaChallenge = ref('')
|
|
||||||
|
|
||||||
// MFA PassKey loading
|
// MFA PassKey loading
|
||||||
const mfaPasskeyLoading = ref(false)
|
const mfaPasskeyLoading = ref(false)
|
||||||
|
|
||||||
@@ -87,8 +84,7 @@ async function loginWithPassKey() {
|
|||||||
const startResponse: any = await api.post('/mfa/passkey/authenticate/start', {})
|
const startResponse: any = await api.post('/mfa/passkey/authenticate/start', {})
|
||||||
|
|
||||||
if (!startResponse.success) {
|
if (!startResponse.success) {
|
||||||
errorMessage.value = startResponse.message || '启动通行密钥认证失败'
|
errorMessage.value = startResponse.message || t('login.passkeyLoginStartFailed')
|
||||||
passkeyLoading.value = false
|
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -99,48 +95,30 @@ async function loginWithPassKey() {
|
|||||||
const credential = await navigator.credentials.get({
|
const credential = await navigator.credentials.get({
|
||||||
publicKey: {
|
publicKey: {
|
||||||
...publicKeyOptions,
|
...publicKeyOptions,
|
||||||
challenge: Uint8Array.from(atob(publicKeyOptions.challenge.replace(/-/g, '+').replace(/_/g, '/')), c =>
|
challenge: base64UrlToUint8Array(publicKeyOptions.challenge),
|
||||||
c.charCodeAt(0),
|
|
||||||
),
|
|
||||||
allowCredentials: publicKeyOptions.allowCredentials?.map((cred: any) => ({
|
allowCredentials: publicKeyOptions.allowCredentials?.map((cred: any) => ({
|
||||||
...cred,
|
...cred,
|
||||||
id: Uint8Array.from(atob(cred.id.replace(/-/g, '+').replace(/_/g, '/')), c => c.charCodeAt(0)),
|
id: base64UrlToUint8Array(cred.id),
|
||||||
})),
|
})),
|
||||||
},
|
},
|
||||||
})
|
})
|
||||||
|
|
||||||
if (!credential) {
|
if (!credential) {
|
||||||
errorMessage.value = '未选择通行密钥'
|
errorMessage.value = t('login.passkeyNotSelected')
|
||||||
passkeyLoading.value = false
|
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
// 3. 转换credential为可传输格式
|
// 3. 转换credential为可传输格式
|
||||||
const credentialJSON = {
|
const credentialJSON = {
|
||||||
id: credential.id,
|
id: credential.id,
|
||||||
rawId: btoa(String.fromCharCode(...new Uint8Array((credential as any).rawId)))
|
rawId: bufferToBase64Url((credential as any).rawId),
|
||||||
.replace(/\+/g, '-')
|
|
||||||
.replace(/\//g, '_')
|
|
||||||
.replace(/=/g, ''),
|
|
||||||
type: credential.type,
|
type: credential.type,
|
||||||
response: {
|
response: {
|
||||||
authenticatorData: btoa(String.fromCharCode(...new Uint8Array((credential as any).response.authenticatorData)))
|
authenticatorData: bufferToBase64Url((credential as any).response.authenticatorData),
|
||||||
.replace(/\+/g, '-')
|
clientDataJSON: bufferToBase64Url((credential as any).response.clientDataJSON),
|
||||||
.replace(/\//g, '_')
|
signature: bufferToBase64Url((credential as any).response.signature),
|
||||||
.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
|
userHandle: (credential as any).response.userHandle
|
||||||
? btoa(String.fromCharCode(...new Uint8Array((credential as any).response.userHandle)))
|
? bufferToBase64Url((credential as any).response.userHandle)
|
||||||
.replace(/\+/g, '-')
|
|
||||||
.replace(/\//g, '_')
|
|
||||||
.replace(/=/g, '')
|
|
||||||
: null,
|
: null,
|
||||||
},
|
},
|
||||||
}
|
}
|
||||||
@@ -151,47 +129,17 @@ async function loginWithPassKey() {
|
|||||||
challenge: challenge,
|
challenge: challenge,
|
||||||
})
|
})
|
||||||
|
|
||||||
// 处理登录响应(与密码登录相同)
|
await handleLoginSuccess(finishResponse)
|
||||||
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) {
|
} catch (error: any) {
|
||||||
console.error('PassKey登录失败:', error)
|
console.error('PassKey login failed:', error)
|
||||||
if (error.response) {
|
if (error.response) {
|
||||||
errorMessage.value = error.response.data?.detail || '通行密钥登录失败'
|
errorMessage.value = error.response.data?.detail || t('login.passkeyLoginFailed')
|
||||||
} else if (error.name === 'NotAllowedError') {
|
} else if (error.name === 'NotAllowedError') {
|
||||||
errorMessage.value = '通行密钥认证被取消'
|
errorMessage.value = t('login.passkeyAuthCanceled')
|
||||||
} else {
|
} else {
|
||||||
errorMessage.value = '通行密钥登录失败,请重试'
|
errorMessage.value = t('login.passkeyLoginRetry')
|
||||||
}
|
}
|
||||||
|
} finally {
|
||||||
passkeyLoading.value = false
|
passkeyLoading.value = false
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -262,8 +210,40 @@ async function afterLogin(superuser: boolean, userPayload: userState, filteredMe
|
|||||||
|
|
||||||
// 订阅推送通知
|
// 订阅推送通知
|
||||||
if (superuser) await subscribeForPushNotifications()
|
if (superuser) await subscribeForPushNotifications()
|
||||||
// 登录按钮 loading
|
}
|
||||||
loading.value = false
|
|
||||||
|
// 处理登录成功
|
||||||
|
async function handleLoginSuccess(response: any) {
|
||||||
|
const userPayload: userState = {
|
||||||
|
superUser: response.super_user,
|
||||||
|
userID: response.user_id,
|
||||||
|
userName: response.user_name,
|
||||||
|
avatar: response.avatar,
|
||||||
|
level: response.level,
|
||||||
|
permissions: response.permissions,
|
||||||
|
wizard: response.wizard,
|
||||||
|
}
|
||||||
|
|
||||||
|
const userPermissions = {
|
||||||
|
is_superuser: userPayload.superUser,
|
||||||
|
...userPayload.permissions,
|
||||||
|
}
|
||||||
|
|
||||||
|
const filteredMenus = filterMenusByPermission(navMenus, userPermissions)
|
||||||
|
if (filteredMenus.length === 0) {
|
||||||
|
errorMessage.value = t('login.noPermission')
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
const authPayLoad: authState = {
|
||||||
|
token: response.access_token,
|
||||||
|
remember: form.value.remember,
|
||||||
|
}
|
||||||
|
|
||||||
|
authStore.login(authPayLoad)
|
||||||
|
userStore.loginUser(userPayload)
|
||||||
|
|
||||||
|
await afterLogin(userPayload.superUser, userPayload, filteredMenus)
|
||||||
}
|
}
|
||||||
|
|
||||||
// 登录获取token事件
|
// 登录获取token事件
|
||||||
@@ -278,94 +258,50 @@ async function login() {
|
|||||||
// 登录按钮 loading
|
// 登录按钮 loading
|
||||||
loading.value = true
|
loading.value = true
|
||||||
|
|
||||||
// 用户名密码
|
try {
|
||||||
const formData = new FormData()
|
// 用户名密码
|
||||||
|
const formData = new FormData()
|
||||||
|
|
||||||
formData.append('username', form.value.username)
|
formData.append('username', form.value.username)
|
||||||
formData.append('password', form.value.password)
|
formData.append('password', form.value.password)
|
||||||
formData.append('otp_password', form.value.otp_password)
|
formData.append('otp_password', form.value.otp_password)
|
||||||
|
|
||||||
// 请求token
|
// 请求token
|
||||||
api
|
const response: any = await api.post('/login/access-token', formData, {
|
||||||
.post('/login/access-token', formData, {
|
|
||||||
headers: {
|
headers: {
|
||||||
Accept: 'application/json', // 设置 Accept 类型
|
Accept: 'application/json', // 设置 Accept 类型
|
||||||
},
|
},
|
||||||
})
|
})
|
||||||
.then((response: any) => {
|
|
||||||
const userPayload: userState = {
|
|
||||||
superUser: response.super_user,
|
|
||||||
userID: response.user_id,
|
|
||||||
userName: response.user_name,
|
|
||||||
avatar: response.avatar,
|
|
||||||
level: response.level,
|
|
||||||
permissions: response.permissions,
|
|
||||||
wizard: response.widzard,
|
|
||||||
}
|
|
||||||
|
|
||||||
// 在保存用户信息之前检查权限
|
await handleLoginSuccess(response)
|
||||||
const userPermissions = {
|
} catch (error: any) {
|
||||||
is_superuser: userPayload.superUser,
|
// 登录失败,显示错误提示
|
||||||
...userPayload.permissions,
|
if (!error.response) {
|
||||||
}
|
errorMessage.value = t('login.networkError')
|
||||||
|
} else if (error.response.status === 401) {
|
||||||
const filteredMenus = filterMenusByPermission(navMenus, userPermissions)
|
// 401错误可能是需要MFA或者认证失败
|
||||||
// 如果用户没有任何可用菜单,拒绝登录
|
// 检查响应头是否有MFA要求标识
|
||||||
if (filteredMenus.length === 0) {
|
const mfaRequired = error.response.headers?.['x-mfa-required'] === 'true'
|
||||||
// 显示错误信息
|
if (mfaRequired && !form.value.otp_password) {
|
||||||
errorMessage.value = t('login.noPermission')
|
// 需要MFA验证,弹出对话框
|
||||||
loading.value = false
|
isOTP.value = true
|
||||||
|
mfaDialog.value = true
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
// 不需要MFA或已填写OTP但认证失败
|
||||||
// 权限检查通过,保存用户信息
|
errorMessage.value = t('login.authFailure')
|
||||||
const authPayLoad: authState = {
|
// 认证失败后清空OTP密码,防止下次点击不弹出对话框
|
||||||
token: response.access_token,
|
form.value.otp_password = ''
|
||||||
remember: form.value.remember,
|
} else if (error.response.status === 403) {
|
||||||
}
|
errorMessage.value = t('login.permissionDenied')
|
||||||
|
} else if (error.response.status === 500) {
|
||||||
authStore.login(authPayLoad)
|
errorMessage.value = t('login.serverError')
|
||||||
userStore.loginUser(userPayload)
|
} else {
|
||||||
|
errorMessage.value = `${t('login.loginFailed')} ${error.response.status},${t('login.checkCredentials')}`
|
||||||
// 登录后处理
|
}
|
||||||
afterLogin(userPayload.superUser, userPayload, filteredMenus)
|
} finally {
|
||||||
})
|
loading.value = false
|
||||||
.catch(async (error: any) => {
|
}
|
||||||
// 登录失败,显示错误提示
|
|
||||||
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码继续登录
|
// 使用OTP码继续登录
|
||||||
@@ -388,8 +324,7 @@ async function verifyWithPassKey() {
|
|||||||
})
|
})
|
||||||
|
|
||||||
if (!startResponse.success) {
|
if (!startResponse.success) {
|
||||||
errorMessage.value = startResponse.message || '启动通行密钥认证失败'
|
errorMessage.value = startResponse.message || t('login.passkeyLoginStartFailed')
|
||||||
mfaPasskeyLoading.value = false
|
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -400,48 +335,30 @@ async function verifyWithPassKey() {
|
|||||||
const credential = await navigator.credentials.get({
|
const credential = await navigator.credentials.get({
|
||||||
publicKey: {
|
publicKey: {
|
||||||
...publicKeyOptions,
|
...publicKeyOptions,
|
||||||
challenge: Uint8Array.from(atob(publicKeyOptions.challenge.replace(/-/g, '+').replace(/_/g, '/')), c =>
|
challenge: base64UrlToUint8Array(publicKeyOptions.challenge),
|
||||||
c.charCodeAt(0),
|
|
||||||
),
|
|
||||||
allowCredentials: publicKeyOptions.allowCredentials?.map((cred: any) => ({
|
allowCredentials: publicKeyOptions.allowCredentials?.map((cred: any) => ({
|
||||||
...cred,
|
...cred,
|
||||||
id: Uint8Array.from(atob(cred.id.replace(/-/g, '+').replace(/_/g, '/')), c => c.charCodeAt(0)),
|
id: base64UrlToUint8Array(cred.id),
|
||||||
})),
|
})),
|
||||||
},
|
},
|
||||||
})
|
})
|
||||||
|
|
||||||
if (!credential) {
|
if (!credential) {
|
||||||
errorMessage.value = '未选择通行密钥'
|
errorMessage.value = t('login.passkeyNotSelected')
|
||||||
mfaPasskeyLoading.value = false
|
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
// 3. 转换credential
|
// 3. 转换credential
|
||||||
const credentialJSON = {
|
const credentialJSON = {
|
||||||
id: credential.id,
|
id: credential.id,
|
||||||
rawId: btoa(String.fromCharCode(...new Uint8Array((credential as any).rawId)))
|
rawId: bufferToBase64Url((credential as any).rawId),
|
||||||
.replace(/\+/g, '-')
|
|
||||||
.replace(/\//g, '_')
|
|
||||||
.replace(/=/g, ''),
|
|
||||||
type: credential.type,
|
type: credential.type,
|
||||||
response: {
|
response: {
|
||||||
authenticatorData: btoa(String.fromCharCode(...new Uint8Array((credential as any).response.authenticatorData)))
|
authenticatorData: bufferToBase64Url((credential as any).response.authenticatorData),
|
||||||
.replace(/\+/g, '-')
|
clientDataJSON: bufferToBase64Url((credential as any).response.clientDataJSON),
|
||||||
.replace(/\//g, '_')
|
signature: bufferToBase64Url((credential as any).response.signature),
|
||||||
.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
|
userHandle: (credential as any).response.userHandle
|
||||||
? btoa(String.fromCharCode(...new Uint8Array((credential as any).response.userHandle)))
|
? bufferToBase64Url((credential as any).response.userHandle)
|
||||||
.replace(/\+/g, '-')
|
|
||||||
.replace(/\//g, '_')
|
|
||||||
.replace(/=/g, '')
|
|
||||||
: null,
|
: null,
|
||||||
},
|
},
|
||||||
}
|
}
|
||||||
@@ -455,47 +372,17 @@ async function verifyWithPassKey() {
|
|||||||
// 关闭MFA对话框
|
// 关闭MFA对话框
|
||||||
mfaDialog.value = false
|
mfaDialog.value = false
|
||||||
|
|
||||||
// 处理登录响应
|
await handleLoginSuccess(finishResponse)
|
||||||
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) {
|
} catch (error: any) {
|
||||||
console.error('PassKey MFA验证失败:', error)
|
console.error('PassKey MFA verification failed:', error)
|
||||||
if (error.response) {
|
if (error.response) {
|
||||||
errorMessage.value = error.response.data?.detail || '通行密钥验证失败'
|
errorMessage.value = error.response.data?.detail || t('login.passkeyVerifyFailed')
|
||||||
} else if (error.name === 'NotAllowedError') {
|
} else if (error.name === 'NotAllowedError') {
|
||||||
errorMessage.value = '通行密钥认证被取消'
|
errorMessage.value = t('login.passkeyAuthCanceled')
|
||||||
} else {
|
} else {
|
||||||
errorMessage.value = '通行密钥验证失败,请重试'
|
errorMessage.value = t('login.passkeyVerifyFailedRetry')
|
||||||
}
|
}
|
||||||
|
} finally {
|
||||||
mfaPasskeyLoading.value = false
|
mfaPasskeyLoading.value = false
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -612,7 +499,7 @@ onMounted(async () => {
|
|||||||
:loading="passkeyLoading"
|
:loading="passkeyLoading"
|
||||||
@click="loginWithPassKey"
|
@click="loginWithPassKey"
|
||||||
>
|
>
|
||||||
使用通行密钥登录
|
{{ t('login.loginWithPasskey') }}
|
||||||
</VBtn>
|
</VBtn>
|
||||||
<VAlert v-if="errorMessage" type="error" variant="tonal" class="mt-3">
|
<VAlert v-if="errorMessage" type="error" variant="tonal" class="mt-3">
|
||||||
{{ errorMessage }}
|
{{ errorMessage }}
|
||||||
@@ -627,9 +514,9 @@ onMounted(async () => {
|
|||||||
<!-- MFA双重验证对话框 -->
|
<!-- MFA双重验证对话框 -->
|
||||||
<VDialog v-model="mfaDialog" max-width="400" persistent>
|
<VDialog v-model="mfaDialog" max-width="400" persistent>
|
||||||
<VCard>
|
<VCard>
|
||||||
<VCardTitle class="text-h5 text-center mt-4">双重验证</VCardTitle>
|
<VCardTitle class="text-h5 text-center mt-4">{{ t('login.twoFactorAuth') }}</VCardTitle>
|
||||||
<VCardText>
|
<VCardText>
|
||||||
<p class="text-center mb-4">请选择验证方式</p>
|
<p class="text-center mb-4">{{ t('login.mfa.selectVerificationMethod') }}</p>
|
||||||
|
|
||||||
<!-- TOTP验证 -->
|
<!-- TOTP验证 -->
|
||||||
<VCard variant="tonal" class="mb-3">
|
<VCard variant="tonal" class="mb-3">
|
||||||
@@ -637,8 +524,8 @@ onMounted(async () => {
|
|||||||
<VForm @submit.prevent="loginWithOTP">
|
<VForm @submit.prevent="loginWithOTP">
|
||||||
<VTextField
|
<VTextField
|
||||||
v-model="form.otp_password"
|
v-model="form.otp_password"
|
||||||
label="验证码"
|
:label="t('login.otpCode')"
|
||||||
placeholder="请输入6位验证码"
|
:placeholder="t('login.otpPlaceholder')"
|
||||||
type="text"
|
type="text"
|
||||||
inputmode="numeric"
|
inputmode="numeric"
|
||||||
autocomplete="one-time-code"
|
autocomplete="one-time-code"
|
||||||
@@ -646,7 +533,7 @@ onMounted(async () => {
|
|||||||
class="mb-2"
|
class="mb-2"
|
||||||
/>
|
/>
|
||||||
<VBtn block type="submit" color="primary" :disabled="!form.otp_password">
|
<VBtn block type="submit" color="primary" :disabled="!form.otp_password">
|
||||||
使用验证码登录
|
{{ t('login.loginWithOtp') }}
|
||||||
</VBtn>
|
</VBtn>
|
||||||
</VForm>
|
</VForm>
|
||||||
</VCardText>
|
</VCardText>
|
||||||
@@ -655,7 +542,7 @@ onMounted(async () => {
|
|||||||
<!-- PassKey验证 -->
|
<!-- PassKey验证 -->
|
||||||
<VCard variant="tonal">
|
<VCard variant="tonal">
|
||||||
<VCardText>
|
<VCardText>
|
||||||
<p class="text-body-2 mb-2">或使用通行密钥进行验证</p>
|
<p class="text-body-2 mb-2">{{ t('login.orUsePasskey') }}</p>
|
||||||
<VBtn
|
<VBtn
|
||||||
block
|
block
|
||||||
variant="tonal"
|
variant="tonal"
|
||||||
@@ -664,12 +551,12 @@ onMounted(async () => {
|
|||||||
:loading="mfaPasskeyLoading"
|
:loading="mfaPasskeyLoading"
|
||||||
@click="verifyWithPassKey"
|
@click="verifyWithPassKey"
|
||||||
>
|
>
|
||||||
使用通行密钥验证
|
{{ t('login.verifyWithPasskey') }}
|
||||||
</VBtn>
|
</VBtn>
|
||||||
</VCardText>
|
</VCardText>
|
||||||
</VCard>
|
</VCard>
|
||||||
|
|
||||||
<VBtn block variant="text" class="mt-4" @click="mfaDialog = false">取消</VBtn>
|
<VBtn block variant="text" class="mt-4" @click="mfaDialog = false">{{ t('common.cancel') }}</VBtn>
|
||||||
</VCardText>
|
</VCardText>
|
||||||
</VCard>
|
</VCard>
|
||||||
</VDialog>
|
</VDialog>
|
||||||
|
|||||||
@@ -1,4 +1,5 @@
|
|||||||
<script lang="ts" setup>
|
<script lang="ts" setup>
|
||||||
|
import { bufferToBase64Url, base64UrlToUint8Array } from '@/@core/utils/navigator'
|
||||||
import { useToast } from 'vue-toastification'
|
import { useToast } from 'vue-toastification'
|
||||||
import QrcodeVue from 'qrcode.vue'
|
import QrcodeVue from 'qrcode.vue'
|
||||||
import { VForm } from 'vuetify/lib/components/index.mjs'
|
import { VForm } from 'vuetify/lib/components/index.mjs'
|
||||||
@@ -10,7 +11,7 @@ import { useUserStore } from '@/stores'
|
|||||||
import { useI18n } from 'vue-i18n'
|
import { useI18n } from 'vue-i18n'
|
||||||
|
|
||||||
// 国际化
|
// 国际化
|
||||||
const { t } = useI18n()
|
const { t, locale } = useI18n()
|
||||||
|
|
||||||
// 显示器宽度
|
// 显示器宽度
|
||||||
const display = useDisplay()
|
const display = useDisplay()
|
||||||
@@ -67,8 +68,18 @@ const accountInfo = ref<User>({
|
|||||||
// 二维码信息
|
// 二维码信息
|
||||||
const qrCode = ref('')
|
const qrCode = ref('')
|
||||||
|
|
||||||
|
// PassKey类型
|
||||||
|
interface PassKey {
|
||||||
|
id: number
|
||||||
|
name: string
|
||||||
|
created_at: string
|
||||||
|
last_used_at?: string
|
||||||
|
aaguid?: string
|
||||||
|
transports?: string
|
||||||
|
}
|
||||||
|
|
||||||
// PassKey列表
|
// PassKey列表
|
||||||
const passkeyList = ref<any[]>([])
|
const passkeyList = ref<PassKey[]>([])
|
||||||
|
|
||||||
// PassKey对话框
|
// PassKey对话框
|
||||||
const passkeyDialog = ref(false)
|
const passkeyDialog = ref(false)
|
||||||
@@ -85,6 +96,21 @@ const passkeyChallenge = ref('')
|
|||||||
// 双重验证菜单
|
// 双重验证菜单
|
||||||
const mfaMenu = ref(false)
|
const mfaMenu = ref(false)
|
||||||
|
|
||||||
|
// 密码验证对话框
|
||||||
|
const verifyPasswordDialog = ref(false)
|
||||||
|
|
||||||
|
// 验证密码
|
||||||
|
const verifyPassword = ref('')
|
||||||
|
|
||||||
|
// 验证后的回调
|
||||||
|
const verifyCallback = ref<(() => void) | null>(null)
|
||||||
|
|
||||||
|
// 验证对话框标题
|
||||||
|
const verifyTitle = ref('')
|
||||||
|
|
||||||
|
// 验证对话框提示
|
||||||
|
const verifyText = ref('')
|
||||||
|
|
||||||
// 检查是否已启用任何双重验证
|
// 检查是否已启用任何双重验证
|
||||||
const hasMfaEnabled = computed(() => {
|
const hasMfaEnabled = computed(() => {
|
||||||
return accountInfo.value.is_otp || passkeyList.value.length > 0
|
return accountInfo.value.is_otp || passkeyList.value.length > 0
|
||||||
@@ -147,7 +173,7 @@ async function fetchUserInfo() {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// 加载时获取用户信息和PassKey列表
|
// 保存账户信息
|
||||||
async function saveAccountInfo() {
|
async function saveAccountInfo() {
|
||||||
if (isSaving.value) {
|
if (isSaving.value) {
|
||||||
$toast.error(t('profile.savingInProgress'))
|
$toast.error(t('profile.savingInProgress'))
|
||||||
@@ -245,19 +271,49 @@ async function getOtpUri() {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// 密码验证并执行回调
|
||||||
|
function withPasswordVerification(title: string, text: string, callback: () => void) {
|
||||||
|
verifyTitle.value = title
|
||||||
|
verifyText.value = text
|
||||||
|
verifyCallback.value = callback
|
||||||
|
verifyPassword.value = ''
|
||||||
|
verifyPasswordDialog.value = true
|
||||||
|
}
|
||||||
|
|
||||||
|
// 确认密码验证
|
||||||
|
async function confirmVerifyPassword() {
|
||||||
|
if (!verifyPassword.value) {
|
||||||
|
$toast.error(t('user.passwordHint'))
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if (verifyCallback.value) {
|
||||||
|
verifyCallback.value()
|
||||||
|
}
|
||||||
|
verifyPasswordDialog.value = false
|
||||||
|
}
|
||||||
|
|
||||||
// 关闭当前用户的双重验证
|
// 关闭当前用户的双重验证
|
||||||
async function disableOtp() {
|
async function disableOtp() {
|
||||||
try {
|
if (passkeyList.value.length > 0) {
|
||||||
const result: { [key: string]: any } = await api.post('mfa/otp/disable')
|
$toast.error(t('profile.otpDisableRestrictedByPasskey'))
|
||||||
if (result.success) {
|
return
|
||||||
accountInfo.value.is_otp = false
|
|
||||||
$toast.success(t('profile.otpDisableSuccess'))
|
|
||||||
} else {
|
|
||||||
$toast.error(t('profile.otpDisableFailed', { message: result.message }))
|
|
||||||
}
|
|
||||||
} catch (error) {
|
|
||||||
console.log(error)
|
|
||||||
}
|
}
|
||||||
|
withPasswordVerification(t('profile.disableTwoFactor'), t('profile.confirmToDisableOtp'), async () => {
|
||||||
|
try {
|
||||||
|
const result: { [key: string]: any } = await api.post('mfa/otp/disable', {
|
||||||
|
password: verifyPassword.value,
|
||||||
|
})
|
||||||
|
if (result.success) {
|
||||||
|
accountInfo.value.is_otp = false
|
||||||
|
$toast.success(t('profile.otpDisableSuccess'))
|
||||||
|
otpDialog.value = false
|
||||||
|
} else {
|
||||||
|
$toast.error(t('profile.otpDisableFailed', { message: result.message }))
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
console.log(error)
|
||||||
|
}
|
||||||
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
// 启用Otp
|
// 启用Otp
|
||||||
@@ -331,47 +387,31 @@ async function registerPassKey() {
|
|||||||
const credential = await navigator.credentials.create({
|
const credential = await navigator.credentials.create({
|
||||||
publicKey: {
|
publicKey: {
|
||||||
...publicKeyOptions,
|
...publicKeyOptions,
|
||||||
challenge: Uint8Array.from(atob(publicKeyOptions.challenge.replace(/-/g, '+').replace(/_/g, '/')), c =>
|
challenge: base64UrlToUint8Array(publicKeyOptions.challenge),
|
||||||
c.charCodeAt(0),
|
|
||||||
),
|
|
||||||
user: {
|
user: {
|
||||||
...publicKeyOptions.user,
|
...publicKeyOptions.user,
|
||||||
id: Uint8Array.from(atob(publicKeyOptions.user.id.replace(/-/g, '+').replace(/_/g, '/')), c =>
|
id: base64UrlToUint8Array(publicKeyOptions.user.id),
|
||||||
c.charCodeAt(0),
|
|
||||||
),
|
|
||||||
},
|
},
|
||||||
excludeCredentials: publicKeyOptions.excludeCredentials?.map((cred: any) => ({
|
excludeCredentials: publicKeyOptions.excludeCredentials?.map((cred: any) => ({
|
||||||
...cred,
|
...cred,
|
||||||
id: Uint8Array.from(atob(cred.id.replace(/-/g, '+').replace(/_/g, '/')), c => c.charCodeAt(0)),
|
id: base64UrlToUint8Array(cred.id),
|
||||||
})),
|
})),
|
||||||
},
|
},
|
||||||
})
|
})
|
||||||
|
|
||||||
if (!credential) {
|
if (!credential) {
|
||||||
$toast.error(t('profile.passkeyRegisterCancelled'))
|
$toast.error(t('profile.passkeyRegisterCancelled'))
|
||||||
passkeyRegistering.value = false
|
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
// 3. 转换credential为可传输格式
|
// 3. 转换credential为可传输格式
|
||||||
const credentialJSON = {
|
const credentialJSON = {
|
||||||
id: credential.id,
|
id: credential.id,
|
||||||
rawId: btoa(String.fromCharCode(...new Uint8Array((credential as any).rawId)))
|
rawId: bufferToBase64Url((credential as any).rawId),
|
||||||
.replace(/\+/g, '-')
|
|
||||||
.replace(/\//g, '_')
|
|
||||||
.replace(/=/g, ''),
|
|
||||||
type: credential.type,
|
type: credential.type,
|
||||||
response: {
|
response: {
|
||||||
attestationObject: btoa(
|
attestationObject: bufferToBase64Url((credential as any).response.attestationObject),
|
||||||
String.fromCharCode(...new Uint8Array((credential as any).response.attestationObject)),
|
clientDataJSON: bufferToBase64Url((credential as any).response.clientDataJSON),
|
||||||
)
|
|
||||||
.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() : [],
|
transports: (credential as any).response.getTransports ? (credential as any).response.getTransports() : [],
|
||||||
},
|
},
|
||||||
}
|
}
|
||||||
@@ -403,18 +443,23 @@ async function registerPassKey() {
|
|||||||
|
|
||||||
// 删除PassKey
|
// 删除PassKey
|
||||||
async function deletePassKey(passkeyId: number) {
|
async function deletePassKey(passkeyId: number) {
|
||||||
try {
|
withPasswordVerification(t('common.delete') + t('profile.usePasskey'), t('profile.confirmToDeletePasskey'), async () => {
|
||||||
const result: { [key: string]: any } = await api.delete(`mfa/passkey/${passkeyId}`)
|
try {
|
||||||
if (result.success) {
|
const result: { [key: string]: any } = await api.post('mfa/passkey/delete', {
|
||||||
$toast.success(t('profile.passkeyDeleteSuccess'))
|
passkey_id: passkeyId,
|
||||||
await fetchPassKeyList()
|
password: verifyPassword.value,
|
||||||
} else {
|
})
|
||||||
$toast.error(result.message || t('profile.passkeyDeleteFailed'))
|
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'))
|
||||||
}
|
}
|
||||||
} catch (error) {
|
})
|
||||||
console.log(error)
|
|
||||||
$toast.error(t('profile.passkeyDeleteFailed'))
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// 加载当前用户数据
|
// 加载当前用户数据
|
||||||
@@ -727,7 +772,11 @@ watch(
|
|||||||
class="mb-6"
|
class="mb-6"
|
||||||
icon="mdi-alert"
|
icon="mdi-alert"
|
||||||
>
|
>
|
||||||
<span v-html="t('profile.passkeyDomainWarning', { domain: '<b>' + t('profile.accessDomain') + '</b>' })"></span>
|
<i18n-t keypath="profile.passkeyDomainWarning" tag="span">
|
||||||
|
<template #domain>
|
||||||
|
<b>{{ t('profile.accessDomain') }}</b>
|
||||||
|
</template>
|
||||||
|
</i18n-t>
|
||||||
</VAlert>
|
</VAlert>
|
||||||
|
|
||||||
<!-- 注册新通行密钥 -->
|
<!-- 注册新通行密钥 -->
|
||||||
@@ -764,7 +813,11 @@ watch(
|
|||||||
class="mb-6"
|
class="mb-6"
|
||||||
icon="mdi-shield-lock"
|
icon="mdi-shield-lock"
|
||||||
>
|
>
|
||||||
<span v-html="t('profile.otpRequiredForPasskey', { otp: '<b>' + t('profile.otpAuthenticator') + '</b>' })"></span>
|
<i18n-t keypath="profile.otpRequiredForPasskey" tag="span">
|
||||||
|
<template #otp>
|
||||||
|
<b>{{ t('profile.otpAuthenticator') }}</b>
|
||||||
|
</template>
|
||||||
|
</i18n-t>
|
||||||
</VAlert>
|
</VAlert>
|
||||||
|
|
||||||
<!-- 已注册的通行密钥列表 -->
|
<!-- 已注册的通行密钥列表 -->
|
||||||
@@ -786,7 +839,7 @@ watch(
|
|||||||
{{ passkey.name }}
|
{{ passkey.name }}
|
||||||
</VListItemTitle>
|
</VListItemTitle>
|
||||||
<VListItemSubtitle>
|
<VListItemSubtitle>
|
||||||
{{ t('profile.createdAt') }}: {{ new Date(passkey.created_at).toLocaleString('zh-CN') }}
|
{{ t('profile.createdAt') }}: {{ new Date(passkey.created_at).toLocaleString(locale) }}
|
||||||
</VListItemSubtitle>
|
</VListItemSubtitle>
|
||||||
<template #append>
|
<template #append>
|
||||||
<VBtn
|
<VBtn
|
||||||
@@ -811,5 +864,35 @@ watch(
|
|||||||
</VCardText>
|
</VCardText>
|
||||||
</VCard>
|
</VCard>
|
||||||
</VDialog>
|
</VDialog>
|
||||||
|
|
||||||
|
<!-- 密码验证对话框 -->
|
||||||
|
<VDialog v-model="verifyPasswordDialog" max-width="30rem">
|
||||||
|
<VCard>
|
||||||
|
<VCardTitle class="text-h5 text-center mt-4">{{ verifyTitle }}</VCardTitle>
|
||||||
|
<VCardText>
|
||||||
|
<p class="mb-4">{{ verifyText }}</p>
|
||||||
|
<VForm @submit.prevent="confirmVerifyPassword">
|
||||||
|
<VTextField
|
||||||
|
v-model="verifyPassword"
|
||||||
|
:type="isConfirmPasswordVisible ? 'text' : 'password'"
|
||||||
|
:label="t('user.password')"
|
||||||
|
:append-inner-icon="isConfirmPasswordVisible ? 'mdi-eye-off-outline' : 'mdi-eye-outline'"
|
||||||
|
variant="outlined"
|
||||||
|
prepend-inner-icon="mdi-lock"
|
||||||
|
autocomplete="current-password"
|
||||||
|
@click:append-inner="isConfirmPasswordVisible = !isConfirmPasswordVisible"
|
||||||
|
/>
|
||||||
|
<div class="d-flex justify-end gap-4 mt-4">
|
||||||
|
<VBtn variant="outlined" color="secondary" @click="verifyPasswordDialog = false">
|
||||||
|
{{ t('common.cancel') }}
|
||||||
|
</VBtn>
|
||||||
|
<VBtn type="submit" color="primary">
|
||||||
|
{{ t('common.confirm') }}
|
||||||
|
</VBtn>
|
||||||
|
</div>
|
||||||
|
</VForm>
|
||||||
|
</VCardText>
|
||||||
|
</VCard>
|
||||||
|
</VDialog>
|
||||||
</div>
|
</div>
|
||||||
</template>
|
</template>
|
||||||
|
|||||||
Reference in New Issue
Block a user