From 202235d88a01916edb6632ae90b371b464760f05 Mon Sep 17 00:00:00 2001
From: InfinityPacer <160988576+InfinityPacer@users.noreply.github.com>
Date: Tue, 11 Aug 2026 11:33:00 +0800
Subject: [PATCH] fix(auth): prevent stale login attempts from replacing
sessions (#663)
---
eslint-suppressions.json | 5 -
.../auth/__tests__/LoginMfaStep.spec.ts | 50 +-
src/pages/__tests__/login.spec.ts | 996 ++++++++++++++++++
src/pages/login.vue | 150 ++-
vite.config.ts | 14 +
5 files changed, 1175 insertions(+), 40 deletions(-)
create mode 100644 src/pages/__tests__/login.spec.ts
diff --git a/eslint-suppressions.json b/eslint-suppressions.json
index c479f58f..3412cdd6 100644
--- a/eslint-suppressions.json
+++ b/eslint-suppressions.json
@@ -673,11 +673,6 @@
"count": 1
}
},
- "src/pages/login.vue": {
- "@typescript-eslint/no-unused-vars": {
- "count": 1
- }
- },
"src/pages/resource.vue": {
"@typescript-eslint/no-explicit-any": {
"count": 6
diff --git a/src/components/auth/__tests__/LoginMfaStep.spec.ts b/src/components/auth/__tests__/LoginMfaStep.spec.ts
index e6c0f2c2..ceab239c 100644
--- a/src/components/auth/__tests__/LoginMfaStep.spec.ts
+++ b/src/components/auth/__tests__/LoginMfaStep.spec.ts
@@ -9,11 +9,14 @@ vi.mock('vue-i18n', () => ({
const slotStub = { template: '
' }
const buttonStub = {
emits: ['click'],
- props: ['loading'],
- template: '',
+ props: ['disabled', 'loading', 'type'],
+ template: '',
}
-function mountStep(methods: Array<'otp'>) {
+function mountStep(
+ methods: Array<'otp'>,
+ props: Partial<{ errorMessage: string; otpLoading: boolean; otpPassword: string }> = {},
+) {
return shallowMount(LoginMfaStep, {
global: {
stubs: {
@@ -28,6 +31,7 @@ function mountStep(methods: Array<'otp'>) {
methods,
otpLoading: false,
otpPassword: '',
+ ...props,
},
})
}
@@ -44,4 +48,44 @@ describe('LoginMfaStep', () => {
expect(wrapper.find('[data-testid="mfa-otp-form"]').exists()).toBe(false)
})
+
+ it('emits the OTP value and submits the current verification step', async () => {
+ const wrapper = mountStep(['otp'])
+ const input = wrapper.get('input[name="otp"]')
+
+ await input.setValue('123456')
+ await wrapper.setProps({ otpPassword: '123456' })
+ await wrapper.get('[data-testid="mfa-otp-form"]').trigger('submit')
+
+ expect(wrapper.emitted('update:otpPassword')).toEqual([['123456']])
+ expect(wrapper.emitted('otp')).toHaveLength(1)
+ })
+
+ it('disables submission until an OTP is present', () => {
+ const wrapper = mountStep(['otp'])
+
+ expect(wrapper.get('button[type="submit"]').attributes('disabled')).toBeDefined()
+ })
+
+ it('locks input, back and submit actions while OTP verification is pending', () => {
+ const wrapper = mountStep(['otp'], { otpLoading: true, otpPassword: '123456' })
+
+ expect(wrapper.get('input[name="otp"]').attributes('disabled')).toBeDefined()
+ expect(wrapper.get('[data-testid="mfa-back"]').attributes('disabled')).toBeDefined()
+ expect(wrapper.get('button[type="submit"]').attributes('disabled')).toBeDefined()
+ })
+
+ it('emits back when the user returns to password login', async () => {
+ const wrapper = mountStep(['otp'])
+
+ await wrapper.get('[data-testid="mfa-back"]').trigger('click')
+
+ expect(wrapper.emitted('back')).toHaveLength(1)
+ })
+
+ it('shows the current verification error', () => {
+ const wrapper = mountStep(['otp'], { errorMessage: '验证码错误' })
+
+ expect(wrapper.text()).toContain('验证码错误')
+ })
})
diff --git a/src/pages/__tests__/login.spec.ts b/src/pages/__tests__/login.spec.ts
new file mode 100644
index 00000000..9a57f87e
--- /dev/null
+++ b/src/pages/__tests__/login.spec.ts
@@ -0,0 +1,996 @@
+import LoginPage from '@/pages/login.vue'
+import { useAuthStore, useUserStore } from '@/stores'
+import { renderWithProviders } from '@tests/support/render'
+import { fireEvent, screen, waitFor } from '@testing-library/vue'
+import { defineComponent, h, nextTick } from 'vue'
+import { beforeEach, describe, expect, it, vi } from 'vitest'
+
+const mocks = vi.hoisted(() => ({
+ api: {
+ get: vi.fn(),
+ post: vi.fn(),
+ },
+ loadRemoteComponentFromModule: vi.fn(),
+ navMenus: [{ title: 'Home', to: '/home' }],
+ router: {
+ currentRoute: { value: { query: {} } },
+ push: vi.fn(),
+ resolve: vi.fn((target: string) => ({ path: target })),
+ },
+}))
+
+vi.mock('@/api', () => ({
+ default: mocks.api,
+}))
+
+vi.mock('@/router', () => ({
+ default: mocks.router,
+}))
+
+vi.mock('@/router/i18n-menu', () => ({
+ getNavMenus: () => mocks.navMenus,
+}))
+
+vi.mock('@/utils/federationLoader', () => ({
+ loadRemoteComponentFromModule: (...args: unknown[]) => mocks.loadRemoteComponentFromModule(...args),
+}))
+
+function deferred() {
+ let resolve!: (value: T) => void
+ let reject!: (reason?: unknown) => void
+ const promise = new Promise((resolvePromise, rejectPromise) => {
+ resolve = resolvePromise
+ reject = rejectPromise
+ })
+ return { promise, reject, resolve }
+}
+
+function remoteAuthPage(label: string) {
+ return defineComponent({
+ name: `${label}AuthPage`,
+ setup: () => () => h('div', label),
+ })
+}
+
+function ticketAuthPage(ticket?: string) {
+ return defineComponent({
+ name: 'TicketAuthPage',
+ emits: ['authenticated'],
+ setup(_, { emit }) {
+ return () => h('button', { onClick: () => emit('authenticated', { ticket }) }, 'Complete Plugin Login')
+ },
+ })
+}
+
+function failingAuthPage(error: unknown) {
+ return defineComponent({
+ name: 'FailingAuthPage',
+ emits: ['error'],
+ setup(_, { emit }) {
+ return () => h('button', { onClick: () => emit('error', error) }, 'Fail Plugin Login')
+ },
+ })
+}
+
+function loginResponse(overrides: Record = {}) {
+ return {
+ access_token: 'synthetic-token',
+ avatar: 'avatar.png',
+ level: 2,
+ permissions: {
+ discovery: true,
+ features: { 'discovery.recommend': true },
+ manage: true,
+ search: true,
+ subscribe: true,
+ },
+ super_user: false,
+ user_id: 7,
+ user_name: 'alice',
+ wizard: false,
+ ...overrides,
+ }
+}
+
+function mfaRequired(methods: unknown = ['otp']) {
+ return {
+ response: {
+ data: { detail: '需要二次验证', mfa_methods: methods },
+ headers: { 'x-mfa-required': 'true' },
+ status: 401,
+ },
+ }
+}
+
+async function submitPassword(container: Element) {
+ await fireEvent.update(screen.getByRole('textbox', { name: '用户名' }), 'alice')
+ await fireEvent.update(screen.getByLabelText('密码'), 'secret')
+ const form = container.querySelector('form.login-form')
+ expect(form).not.toBeNull()
+ await fireEvent.submit(form!)
+}
+
+async function renderLogin(initialState: Record> = {}) {
+ return renderWithProviders(LoginPage, {
+ global: {
+ stubs: {
+ OpticalLogoLab: { template: '
' },
+ },
+ },
+ initialState,
+ stubActions: false,
+ })
+}
+
+describe('login page orchestration', () => {
+ beforeEach(() => {
+ mocks.api.get.mockReset()
+ mocks.api.post.mockReset()
+ mocks.loadRemoteComponentFromModule.mockReset()
+ mocks.router.push.mockReset()
+ mocks.router.resolve.mockClear()
+ mocks.router.currentRoute.value.query = {}
+ mocks.api.get.mockResolvedValue([])
+ mocks.navMenus = [{ title: 'Home', to: '/home' }]
+ vi.spyOn(console, 'error').mockImplementation(() => {})
+ })
+
+ it('sends only one password request while the current submission is pending', async () => {
+ const pendingLogin = deferred()
+ mocks.api.post.mockReturnValue(pendingLogin.promise)
+ const { container } = await renderLogin()
+
+ await fireEvent.update(screen.getByRole('textbox', { name: '用户名' }), 'alice')
+ await fireEvent.update(screen.getByLabelText('密码'), 'secret')
+ const form = container.querySelector('form.login-form')
+ expect(form).not.toBeNull()
+
+ await fireEvent.submit(form!)
+ await fireEvent.submit(form!)
+
+ expect(mocks.api.post).toHaveBeenCalledTimes(1)
+ })
+
+ it('does not submit an incomplete password form', async () => {
+ const { container } = await renderLogin()
+
+ await fireEvent.submit(container.querySelector('form.login-form')!)
+
+ expect(mocks.api.post).not.toHaveBeenCalled()
+ })
+
+ it('toggles password visibility without submitting the form', async () => {
+ await renderLogin()
+ const password = screen.getByLabelText('密码')
+
+ expect(password).toHaveAttribute('type', 'password')
+ await fireEvent.click(screen.getByRole('button', { name: '显示密码' }))
+ expect(password).toHaveAttribute('type', 'text')
+ await fireEvent.click(screen.getByRole('button', { name: '隐藏密码' }))
+
+ expect(password).toHaveAttribute('type', 'password')
+ expect(mocks.api.post).not.toHaveBeenCalled()
+ })
+
+ it('keeps the latest plugin AuthPage when an earlier provider resolves late', async () => {
+ const alpha = deferred>()
+ const beta = deferred>()
+ mocks.api.get.mockResolvedValue([
+ {
+ id: 'plugin:alpha',
+ type: 'plugin',
+ name: 'Alpha Login',
+ enabled: true,
+ remote: { id: 'alpha', url: '/alpha/remoteEntry.js' },
+ },
+ {
+ id: 'plugin:beta',
+ type: 'plugin',
+ name: 'Beta Login',
+ enabled: true,
+ remote: { id: 'beta', url: '/beta/remoteEntry.js' },
+ },
+ ])
+ mocks.loadRemoteComponentFromModule.mockImplementation((remote: { id: string }) =>
+ remote.id === 'alpha' ? alpha.promise : beta.promise,
+ )
+ await renderLogin()
+
+ await fireEvent.click(await screen.findByRole('button', { name: 'Alpha Login' }))
+ await fireEvent.click(screen.getByRole('button', { name: 'Beta Login' }))
+ expect(mocks.loadRemoteComponentFromModule).toHaveBeenCalledTimes(2)
+
+ beta.resolve(remoteAuthPage('Beta Auth Page'))
+ expect(await screen.findByText('Beta Auth Page')).toBeInTheDocument()
+
+ alpha.resolve(remoteAuthPage('Alpha Auth Page'))
+ await alpha.promise
+ await nextTick()
+ await waitFor(() => {
+ expect(screen.queryByText('Alpha Auth Page')).not.toBeInTheDocument()
+ expect(screen.getByText('Beta Auth Page')).toBeInTheDocument()
+ })
+ })
+
+ it('keeps password login available when provider discovery fails', async () => {
+ mocks.api.get.mockRejectedValue(new Error('provider unavailable'))
+
+ await renderLogin()
+
+ expect(await screen.findByRole('button', { name: '登录' })).toBeInTheDocument()
+ expect(screen.queryByText('provider unavailable')).not.toBeInTheDocument()
+ expect(console.error).toHaveBeenCalledOnce()
+ })
+
+ it('does not let delayed provider discovery cancel an explicit password login', async () => {
+ const providers = deferred()
+ const passwordLogin = deferred()
+ let passwordSignal: AbortSignal | undefined
+ const conditionalAvailability = vi.fn().mockResolvedValue(true)
+ vi.stubGlobal(
+ 'PublicKeyCredential',
+ class PublicKeyCredentialStub {
+ static isConditionalMediationAvailable = conditionalAvailability
+ },
+ )
+ mocks.api.get.mockReturnValue(providers.promise)
+ mocks.api.post.mockImplementation((url: string, _data: unknown, config?: { signal?: AbortSignal }) => {
+ if (url === '/login/access-token') {
+ passwordSignal = config?.signal
+ return passwordLogin.promise
+ }
+ return Promise.resolve({ success: false })
+ })
+ const { container } = await renderLogin()
+
+ await submitPassword(container)
+ providers.resolve([])
+ await providers.promise
+ await nextTick()
+
+ expect(passwordSignal?.aborted).toBe(false)
+ expect(conditionalAvailability).not.toHaveBeenCalled()
+ expect(mocks.api.post).toHaveBeenCalledTimes(1)
+ })
+
+ it('skips provider and Conditional UI startup for a remembered session', async () => {
+ await renderLogin({ auth: { remember: true, token: 'remembered-token' } })
+
+ expect(mocks.router.push).toHaveBeenCalledWith('/')
+ expect(mocks.api.get).not.toHaveBeenCalled()
+ expect(mocks.api.post).not.toHaveBeenCalled()
+ })
+
+ it('stores the password login session and consumes the original destination', async () => {
+ mocks.api.post.mockResolvedValue(loginResponse())
+ const { container } = await renderLogin()
+ const authStore = useAuthStore()
+ authStore.setOriginalPath('/recommend?tab=trending#today')
+
+ await submitPassword(container)
+
+ await waitFor(() => expect(mocks.router.push).toHaveBeenCalledWith('/recommend?tab=trending#today'))
+ expect(authStore.token).toBe('synthetic-token')
+ expect(authStore.originalPath).toBeNull()
+ expect(useUserStore().userName).toBe('alice')
+ expect(useUserStore().permissions.features).toEqual({ 'discovery.recommend': true })
+ const formData = mocks.api.post.mock.calls[0][1] as FormData
+ expect(formData.get('username')).toBe('alice')
+ expect(formData.get('password')).toBe('secret')
+ expect(formData.get('otp_password')).toBe('')
+ })
+
+ it('does not create a session when no navigation entry is permitted', async () => {
+ mocks.navMenus = []
+ mocks.api.post.mockResolvedValue(loginResponse())
+ const { container } = await renderLogin()
+
+ await submitPassword(container)
+
+ expect(await screen.findByText('登录失败,您没有任何功能权限,请联系管理员!')).toBeInTheDocument()
+ expect(useAuthStore().token).toBeNull()
+ expect(mocks.router.push).not.toHaveBeenCalled()
+ })
+
+ it('routes a login that requires setup to the wizard before any menu', async () => {
+ mocks.api.post.mockResolvedValue(loginResponse({ wizard: true }))
+ const { container } = await renderLogin()
+
+ await submitPassword(container)
+
+ await waitFor(() => expect(mocks.router.push).toHaveBeenCalledWith('/setup-wizard'))
+ expect(mocks.router.push).not.toHaveBeenCalledWith('/home')
+ })
+
+ it('maps password HTTP failures to a visible retryable error', async () => {
+ mocks.api.post.mockRejectedValue({ response: { data: {}, status: 403 } })
+ const { container } = await renderLogin()
+
+ await submitPassword(container)
+
+ expect(await screen.findByRole('alert')).toHaveTextContent('登录失败,您没有权限访问!')
+ expect(screen.getByRole('textbox', { name: '用户名' })).toHaveValue('alice')
+ expect(useAuthStore().token).toBeNull()
+ })
+
+ it('maps a password server failure to the dedicated retryable error', async () => {
+ mocks.api.post.mockRejectedValue({ response: { data: {}, status: 500 } })
+ const { container } = await renderLogin()
+
+ await submitPassword(container)
+
+ expect(await screen.findByRole('alert')).toHaveTextContent('登录失败,服务器错误!')
+ expect(screen.getByRole('textbox', { name: '用户名' })).toHaveValue('alice')
+ expect(useAuthStore().token).toBeNull()
+ })
+
+ it('maps a password network failure without leaving the login form', async () => {
+ mocks.api.post.mockRejectedValue(new Error('offline'))
+ const { container } = await renderLogin()
+
+ await submitPassword(container)
+
+ expect(await screen.findByRole('alert')).toHaveTextContent('登录失败,请检查网络连接!')
+ expect(screen.getByLabelText('密码')).toHaveValue('secret')
+ })
+
+ it('prefers a structured backend message for password failures', async () => {
+ mocks.api.post.mockRejectedValue({ response: { data: { message: '账号已被停用' }, status: 403 } })
+ const { container } = await renderLogin()
+
+ await submitPassword(container)
+
+ expect(await screen.findByRole('alert')).toHaveTextContent('账号已被停用')
+ })
+
+ it('includes an unknown HTTP status in the fallback password error', async () => {
+ mocks.api.post.mockRejectedValue({ response: { data: {}, status: 418 } })
+ const { container } = await renderLogin()
+
+ await submitPassword(container)
+
+ expect(await screen.findByRole('alert')).toHaveTextContent('Status: 418')
+ })
+
+ it('switches the login page locale from the language menu', async () => {
+ await renderLogin()
+
+ await fireEvent.click(screen.getByRole('button', { name: /简体中文/ }))
+ await fireEvent.click(await screen.findByText('English'))
+
+ expect(await screen.findByRole('button', { name: /English/ })).toBeInTheDocument()
+ expect(screen.getByRole('button', { name: 'Login' })).toBeInTheDocument()
+ })
+
+ it('enters the OTP step only for a supported server-declared method', async () => {
+ mocks.api.post.mockRejectedValue(mfaRequired(['otp', 'unknown', 'otp']))
+ const { container } = await renderLogin()
+
+ await submitPassword(container)
+
+ expect(await screen.findByTestId('mfa-otp-form')).toBeInTheDocument()
+ expect(screen.queryByRole('textbox', { name: '用户名' })).not.toBeInTheDocument()
+ })
+
+ it('keeps password login visible when the MFA response has no supported method', async () => {
+ mocks.api.post.mockRejectedValue(mfaRequired(['sms']))
+ const { container } = await renderLogin()
+
+ await submitPassword(container)
+
+ expect(await screen.findByText('无法获取验证方式,请重新登录')).toBeInTheDocument()
+ expect(screen.getByRole('textbox', { name: '用户名' })).toBeInTheDocument()
+ expect(screen.queryByTestId('mfa-otp-form')).not.toBeInTheDocument()
+ })
+
+ it('submits the original password with the OTP and completes login', async () => {
+ mocks.api.post.mockRejectedValueOnce(mfaRequired()).mockResolvedValueOnce(loginResponse())
+ const { container } = await renderLogin()
+ await submitPassword(container)
+ const otpInput = await screen.findByRole('textbox', { name: '验证码' })
+
+ await fireEvent.update(otpInput, '123456')
+ await fireEvent.submit(screen.getByTestId('mfa-otp-form'))
+
+ await waitFor(() => expect(useAuthStore().token).toBe('synthetic-token'))
+ const formData = mocks.api.post.mock.calls[1][1] as FormData
+ expect(formData.get('username')).toBe('alice')
+ expect(formData.get('password')).toBe('secret')
+ expect(formData.get('otp_password')).toBe('123456')
+ })
+
+ it('keeps the OTP step retryable and clears an invalid code', async () => {
+ mocks.api.post.mockRejectedValueOnce(mfaRequired()).mockRejectedValueOnce({ response: { data: {}, status: 401 } })
+ const { container } = await renderLogin()
+ await submitPassword(container)
+ const otpInput = await screen.findByRole('textbox', { name: '验证码' })
+
+ await fireEvent.update(otpInput, '654321')
+ await fireEvent.submit(screen.getByTestId('mfa-otp-form'))
+
+ expect(await screen.findByText('验证失败,请检查验证码后重试')).toBeInTheDocument()
+ expect(screen.getByRole('textbox', { name: '验证码' })).toHaveValue('')
+ expect(screen.getByTestId('mfa-otp-form')).toBeInTheDocument()
+ })
+
+ it('keeps the OTP step retryable after a network failure', async () => {
+ mocks.api.post.mockRejectedValueOnce(mfaRequired()).mockRejectedValueOnce(new Error('offline'))
+ const { container } = await renderLogin()
+ await submitPassword(container)
+
+ await fireEvent.update(await screen.findByRole('textbox', { name: '验证码' }), '654321')
+ await fireEvent.submit(screen.getByTestId('mfa-otp-form'))
+
+ expect(await screen.findByText('登录失败,请检查网络连接!')).toBeInTheDocument()
+ expect(screen.getByRole('textbox', { name: '验证码' })).toHaveValue('')
+ expect(screen.getByTestId('mfa-otp-form')).toBeInTheDocument()
+ })
+
+ it('returns from OTP verification to a clean password step', async () => {
+ mocks.api.post.mockRejectedValue(mfaRequired())
+ const { container } = await renderLogin()
+ await submitPassword(container)
+
+ await fireEvent.click(await screen.findByTestId('mfa-back'))
+
+ expect(await screen.findByRole('textbox', { name: '用户名' })).toHaveValue('alice')
+ expect(screen.queryByTestId('mfa-otp-form')).not.toBeInTheDocument()
+ })
+
+ it('ignores an obsolete provider failure after the latest AuthPage loads', async () => {
+ const alpha = deferred>()
+ mocks.api.get.mockResolvedValue([
+ {
+ id: 'plugin:alpha',
+ type: 'plugin',
+ name: 'Alpha Login',
+ enabled: true,
+ remote: { id: 'alpha', url: '/alpha/remoteEntry.js' },
+ },
+ {
+ id: 'plugin:beta',
+ type: 'plugin',
+ name: 'Beta Login',
+ enabled: true,
+ remote: { id: 'beta', url: '/beta/remoteEntry.js' },
+ },
+ ])
+ mocks.loadRemoteComponentFromModule.mockImplementation((remote: { id: string }) =>
+ remote.id === 'alpha' ? alpha.promise : Promise.resolve(remoteAuthPage('Beta Auth Page')),
+ )
+ await renderLogin()
+
+ await fireEvent.click(await screen.findByRole('button', { name: 'Alpha Login' }))
+ await fireEvent.click(screen.getByRole('button', { name: 'Beta Login' }))
+ expect(await screen.findByText('Beta Auth Page')).toBeInTheDocument()
+ alpha.reject(new Error('obsolete failure'))
+ await alpha.promise.catch(() => undefined)
+ await nextTick()
+
+ expect(screen.queryByText('obsolete failure')).not.toBeInTheDocument()
+ expect(screen.getByText('Beta Auth Page')).toBeInTheDocument()
+ expect(console.error).not.toHaveBeenCalled()
+ })
+
+ it('exchanges a plugin ticket through the shared login completion path', async () => {
+ mocks.api.get.mockResolvedValue([
+ {
+ id: 'plugin:ticket',
+ type: 'plugin',
+ name: 'Ticket Login',
+ enabled: true,
+ remote: { id: 'ticket', url: '/ticket/remoteEntry.js' },
+ },
+ ])
+ mocks.loadRemoteComponentFromModule.mockResolvedValue(ticketAuthPage('plugin-ticket'))
+ mocks.api.post.mockResolvedValue(loginResponse({ user_name: 'plugin-user' }))
+ await renderLogin()
+
+ await fireEvent.click(await screen.findByRole('button', { name: 'Ticket Login' }))
+ await fireEvent.click(await screen.findByRole('button', { name: 'Complete Plugin Login' }))
+
+ await waitFor(() => expect(useAuthStore().token).toBe('synthetic-token'))
+ expect(mocks.api.post).toHaveBeenCalledWith('auth/exchange', { ticket: 'plugin-ticket' })
+ expect(useUserStore().userName).toBe('plugin-user')
+ expect(mocks.router.push).toHaveBeenCalledWith('/home')
+ })
+
+ it('exchanges only once when the current AuthPage emits the same completion twice', async () => {
+ const exchange = deferred>()
+ mocks.api.get.mockResolvedValue([
+ {
+ id: 'plugin:ticket',
+ type: 'plugin',
+ name: 'Ticket Login',
+ enabled: true,
+ remote: { id: 'ticket', url: '/ticket/remoteEntry.js' },
+ },
+ ])
+ mocks.loadRemoteComponentFromModule.mockResolvedValue(ticketAuthPage('plugin-ticket'))
+ mocks.api.post.mockReturnValue(exchange.promise)
+ await renderLogin()
+
+ await fireEvent.click(await screen.findByRole('button', { name: 'Ticket Login' }))
+ const complete = await screen.findByRole('button', { name: 'Complete Plugin Login' })
+ await fireEvent.click(complete)
+ await fireEvent.click(complete)
+
+ expect(mocks.api.post).toHaveBeenCalledTimes(1)
+ })
+
+ it('ignores an earlier password response after plugin login completes', async () => {
+ const passwordLogin = deferred>()
+ let passwordSignal: AbortSignal | undefined
+ mocks.api.get.mockResolvedValue([
+ {
+ id: 'plugin:ticket',
+ type: 'plugin',
+ name: 'Ticket Login',
+ enabled: true,
+ remote: { id: 'ticket', url: '/ticket/remoteEntry.js' },
+ },
+ ])
+ mocks.loadRemoteComponentFromModule.mockResolvedValue(ticketAuthPage('plugin-ticket'))
+ mocks.api.post.mockImplementation((url: string, _data: unknown, config?: { signal?: AbortSignal }) => {
+ if (url === '/login/access-token') {
+ passwordSignal = config?.signal
+ return passwordLogin.promise
+ }
+ return Promise.resolve(loginResponse({ access_token: 'plugin-token', user_name: 'plugin-user' }))
+ })
+ const { container } = await renderLogin()
+
+ await submitPassword(container)
+ await fireEvent.click(await screen.findByRole('button', { name: 'Ticket Login' }))
+ await fireEvent.click(await screen.findByRole('button', { name: 'Complete Plugin Login' }))
+ await waitFor(() => expect(useUserStore().userName).toBe('plugin-user'))
+ expect(passwordSignal?.aborted).toBe(true)
+
+ passwordLogin.resolve(loginResponse({ access_token: 'password-token', user_name: 'password-user' }))
+ await passwordLogin.promise
+ await nextTick()
+
+ expect(useAuthStore().token).toBe('plugin-token')
+ expect(useUserStore().userName).toBe('plugin-user')
+ })
+
+ it('shows the current plugin ticket exchange failure without closing the dialog', async () => {
+ mocks.api.get.mockResolvedValue([
+ {
+ id: 'plugin:ticket',
+ type: 'plugin',
+ name: 'Ticket Login',
+ enabled: true,
+ remote: { id: 'ticket', url: '/ticket/remoteEntry.js' },
+ },
+ ])
+ mocks.loadRemoteComponentFromModule.mockResolvedValue(ticketAuthPage('expired-ticket'))
+ mocks.api.post.mockRejectedValue({ response: { data: { detail: '认证票据无效或已过期' }, status: 401 } })
+ await renderLogin()
+
+ await fireEvent.click(await screen.findByRole('button', { name: 'Ticket Login' }))
+ await fireEvent.click(await screen.findByRole('button', { name: 'Complete Plugin Login' }))
+
+ expect(await screen.findByText('认证票据无效或已过期')).toBeInTheDocument()
+ expect(useAuthStore().token).toBeNull()
+ })
+
+ it('shows an error emitted by the current plugin AuthPage', async () => {
+ mocks.api.get.mockResolvedValue([
+ {
+ id: 'plugin:error',
+ type: 'plugin',
+ name: 'Error Login',
+ enabled: true,
+ remote: { id: 'error', url: '/error/remoteEntry.js' },
+ },
+ ])
+ mocks.loadRemoteComponentFromModule.mockResolvedValue(failingAuthPage(new Error('remote authorization failed')))
+ await renderLogin()
+
+ await fireEvent.click(await screen.findByRole('button', { name: 'Error Login' }))
+ await fireEvent.click(await screen.findByRole('button', { name: 'Fail Plugin Login' }))
+
+ expect(await screen.findByText('remote authorization failed')).toBeInTheDocument()
+ expect(mocks.api.post).not.toHaveBeenCalled()
+ })
+
+ it('shows the current plugin AuthPage load failure', async () => {
+ mocks.api.get.mockResolvedValue([
+ {
+ id: 'plugin:broken',
+ type: 'plugin',
+ name: 'Broken Login',
+ enabled: true,
+ remote: { id: 'broken', url: '/broken/remoteEntry.js' },
+ },
+ ])
+ mocks.loadRemoteComponentFromModule.mockRejectedValue(new Error('remote entry unavailable'))
+ await renderLogin()
+
+ await fireEvent.click(await screen.findByRole('button', { name: 'Broken Login' }))
+
+ expect(await screen.findByText('remote entry unavailable')).toBeInTheDocument()
+ expect(console.error).toHaveBeenCalledOnce()
+ })
+
+ it('rejects a plugin authenticated event without a ticket', async () => {
+ mocks.api.get.mockResolvedValue([
+ {
+ id: 'plugin:ticket',
+ type: 'plugin',
+ name: 'Ticket Login',
+ enabled: true,
+ remote: { id: 'ticket', url: '/ticket/remoteEntry.js' },
+ },
+ ])
+ mocks.loadRemoteComponentFromModule.mockResolvedValue(ticketAuthPage())
+ await renderLogin()
+
+ await fireEvent.click(await screen.findByRole('button', { name: 'Ticket Login' }))
+ await fireEvent.click(await screen.findByRole('button', { name: 'Complete Plugin Login' }))
+
+ expect(await screen.findByText('登录失败,请检查用户名、密码或验证码')).toBeInTheDocument()
+ expect(mocks.api.post).not.toHaveBeenCalled()
+ })
+
+ it('does not wait for WebPush subscription before completing an administrator login', async () => {
+ const webPushRequest = deferred()
+ const subscription = { endpoint: 'https://push.invalid/synthetic' }
+ vi.stubGlobal('PushManager', class PushManagerStub {})
+ vi.stubGlobal('navigator', {
+ serviceWorker: {
+ ready: Promise.resolve({
+ pushManager: {
+ getSubscription: vi.fn().mockResolvedValue(subscription),
+ },
+ }),
+ },
+ })
+ mocks.api.post.mockImplementation((url: string) => {
+ if (url === '/message/webpush/subscribe') return webPushRequest.promise
+ return Promise.resolve(loginResponse({ super_user: true }))
+ })
+ const { container } = await renderLogin()
+
+ await submitPassword(container)
+
+ await waitFor(() => expect(mocks.router.push).toHaveBeenCalledWith('/home'))
+ expect(useAuthStore().token).toBe('synthetic-token')
+ await waitFor(() => expect(mocks.api.post).toHaveBeenCalledWith('/message/webpush/subscribe', subscription))
+ })
+
+ it('converts Passkey options and credential buffers for the authentication endpoints', async () => {
+ const credentialGet = vi.fn().mockResolvedValue({
+ id: 'credential-id',
+ rawId: new Uint8Array([1, 2]).buffer,
+ response: {
+ authenticatorData: new Uint8Array([3]).buffer,
+ clientDataJSON: new Uint8Array([4]).buffer,
+ signature: new Uint8Array([5]).buffer,
+ userHandle: new Uint8Array([6]).buffer,
+ },
+ type: 'public-key',
+ })
+ vi.stubGlobal('PublicKeyCredential', class PublicKeyCredentialStub {})
+ vi.stubGlobal('navigator', { credentials: { get: credentialGet } })
+ mocks.api.get.mockResolvedValue([
+ {
+ id: 'system:passkey',
+ type: 'system',
+ method: 'passkey',
+ name: '通行密钥',
+ enabled: true,
+ },
+ ])
+ mocks.api.post.mockImplementation((url: string) => {
+ if (url === '/mfa/passkey/authenticate/start') {
+ return Promise.resolve({
+ success: true,
+ data: {
+ options: JSON.stringify({
+ allowCredentials: [{ id: 'AwQ', type: 'public-key' }],
+ challenge: 'AQI',
+ timeout: 60_000,
+ }),
+ transaction_token: 'transaction-1',
+ },
+ })
+ }
+ return Promise.resolve(loginResponse({ user_name: 'passkey-user' }))
+ })
+ const { container } = await renderLogin()
+
+ await fireEvent.click(await waitFor(() => container.querySelector('.passkey-btn')!))
+
+ await waitFor(() => expect(useAuthStore().token).toBe('synthetic-token'))
+ const credentialOptions = credentialGet.mock.calls[0][0] as CredentialRequestOptions
+ expect(Array.from(new Uint8Array(credentialOptions.publicKey!.challenge as ArrayBuffer))).toEqual([1, 2])
+ expect(Array.from(new Uint8Array(credentialOptions.publicKey!.allowCredentials![0].id as ArrayBuffer))).toEqual([
+ 3, 4,
+ ])
+ expect(credentialOptions.mediation).toBeUndefined()
+ expect(mocks.api.post).toHaveBeenNthCalledWith(
+ 1,
+ '/mfa/passkey/authenticate/start',
+ {},
+ {
+ signal: expect.any(AbortSignal),
+ },
+ )
+ expect(mocks.api.post).toHaveBeenNthCalledWith(
+ 2,
+ '/mfa/passkey/authenticate/finish',
+ {
+ credential: {
+ id: 'credential-id',
+ rawId: 'AQI',
+ response: {
+ authenticatorData: 'Aw',
+ clientDataJSON: 'BA',
+ signature: 'BQ',
+ userHandle: 'Bg',
+ },
+ type: 'public-key',
+ },
+ transaction_token: 'transaction-1',
+ },
+ {
+ signal: expect.any(AbortSignal),
+ },
+ )
+ expect(useUserStore().userName).toBe('passkey-user')
+ })
+
+ it('shows a visible error when a manual Passkey start is rejected', async () => {
+ vi.stubGlobal('PublicKeyCredential', class PublicKeyCredentialStub {})
+ mocks.api.get.mockResolvedValue([
+ {
+ id: 'system:passkey',
+ type: 'system',
+ method: 'passkey',
+ name: '通行密钥',
+ enabled: true,
+ },
+ ])
+ mocks.api.post.mockResolvedValue({ success: false, message: '认证失败' })
+ const { container } = await renderLogin()
+
+ await fireEvent.click(await waitFor(() => container.querySelector('.passkey-btn')!))
+
+ expect(await screen.findByRole('alert')).toHaveTextContent('登录失败,请检查用户名、密码或验证码')
+ expect(useAuthStore().token).toBeNull()
+ })
+
+ it('aborts an in-flight Passkey finish request when password login takes ownership', async () => {
+ const finish = deferred>()
+ let finishSignal: AbortSignal | undefined
+ vi.stubGlobal('PublicKeyCredential', class PublicKeyCredentialStub {})
+ vi.stubGlobal('navigator', {
+ credentials: {
+ get: vi.fn().mockResolvedValue({
+ id: 'credential-id',
+ rawId: new Uint8Array([1]).buffer,
+ response: {
+ authenticatorData: new Uint8Array([2]).buffer,
+ clientDataJSON: new Uint8Array([3]).buffer,
+ signature: new Uint8Array([4]).buffer,
+ userHandle: null,
+ },
+ type: 'public-key',
+ }),
+ },
+ })
+ mocks.api.get.mockResolvedValue([
+ {
+ id: 'system:passkey',
+ type: 'system',
+ method: 'passkey',
+ name: '通行密钥',
+ enabled: true,
+ },
+ ])
+ mocks.api.post.mockImplementation((url: string, _data: unknown, config?: { signal?: AbortSignal }) => {
+ if (url === '/mfa/passkey/authenticate/start') {
+ return Promise.resolve({
+ success: true,
+ data: {
+ options: JSON.stringify({ challenge: 'AQI' }),
+ transaction_token: 'finish-transaction',
+ },
+ })
+ }
+ if (url === '/mfa/passkey/authenticate/finish') {
+ finishSignal = config?.signal
+ return finish.promise
+ }
+ return new Promise(() => {})
+ })
+ const { container } = await renderLogin()
+
+ await fireEvent.click(await waitFor(() => container.querySelector('.passkey-btn')!))
+ await waitFor(() =>
+ expect(mocks.api.post).toHaveBeenCalledWith(
+ '/mfa/passkey/authenticate/finish',
+ expect.any(Object),
+ expect.any(Object),
+ ),
+ )
+ await submitPassword(container)
+
+ expect(finishSignal?.aborted).toBe(true)
+ finish.resolve(loginResponse({ access_token: 'passkey-token', user_name: 'passkey-user' }))
+ await finish.promise
+ await nextTick()
+ expect(useAuthStore().token).toBeNull()
+ expect(useUserStore().userName).toBe('')
+ })
+
+ it('shows a cancellation message when the user dismisses manual Passkey selection', async () => {
+ const canceled = new Error('cancelled')
+ canceled.name = 'NotAllowedError'
+ vi.stubGlobal('PublicKeyCredential', class PublicKeyCredentialStub {})
+ vi.stubGlobal('navigator', { credentials: { get: vi.fn().mockRejectedValue(canceled) } })
+ mocks.api.get.mockResolvedValue([
+ {
+ id: 'system:passkey',
+ type: 'system',
+ method: 'passkey',
+ name: '通行密钥',
+ enabled: true,
+ },
+ ])
+ mocks.api.post.mockResolvedValue({
+ success: true,
+ data: {
+ options: JSON.stringify({ challenge: 'AQI' }),
+ transaction_token: 'cancel-transaction',
+ },
+ })
+ const { container } = await renderLogin()
+
+ await fireEvent.click(await waitFor(() => container.querySelector('.passkey-btn')!))
+
+ expect(await screen.findByRole('alert')).toHaveTextContent('通行密钥认证被取消')
+ expect(useAuthStore().token).toBeNull()
+ })
+
+ it('reports unsupported manual Passkey capability without starting an API request', async () => {
+ mocks.api.get.mockResolvedValue([
+ {
+ id: 'system:passkey',
+ type: 'system',
+ method: 'passkey',
+ name: '通行密钥',
+ enabled: true,
+ },
+ ])
+ const { container } = await renderLogin()
+
+ await fireEvent.click(await waitFor(() => container.querySelector('.passkey-btn')!))
+
+ expect(await screen.findByText('通行密钥需要 HTTPS 安全连接')).toBeInTheDocument()
+ expect(mocks.api.post).not.toHaveBeenCalled()
+ })
+
+ it('aborts an unfinished Conditional UI request when the page unmounts', async () => {
+ let capturedSignal: AbortSignal | undefined
+ const credentialGet = vi.fn((options: CredentialRequestOptions) => {
+ capturedSignal = options.signal
+ return new Promise(() => {})
+ })
+ vi.stubGlobal(
+ 'PublicKeyCredential',
+ class PublicKeyCredentialStub {
+ static isConditionalMediationAvailable = vi.fn().mockResolvedValue(true)
+ },
+ )
+ vi.stubGlobal('navigator', { credentials: { get: credentialGet } })
+ mocks.api.post.mockResolvedValue({
+ success: true,
+ data: {
+ options: JSON.stringify({ challenge: 'AQI' }),
+ transaction_token: 'conditional-transaction',
+ },
+ })
+ const { unmount } = await renderLogin()
+ await waitFor(() => expect(credentialGet).toHaveBeenCalledOnce())
+
+ unmount()
+
+ expect(capturedSignal?.aborted).toBe(true)
+ })
+
+ it('does not start Conditional UI after its capability check resolves on an unmounted page', async () => {
+ const capability = deferred()
+ vi.stubGlobal(
+ 'PublicKeyCredential',
+ class PublicKeyCredentialStub {
+ static isConditionalMediationAvailable = vi.fn(() => capability.promise)
+ },
+ )
+ vi.stubGlobal('navigator', { credentials: { get: vi.fn() } })
+ const { unmount } = await renderLogin()
+ await waitFor(() => expect(PublicKeyCredential.isConditionalMediationAvailable).toHaveBeenCalledOnce())
+
+ unmount()
+ capability.resolve(true)
+ await capability.promise
+ await nextTick()
+
+ expect(mocks.api.post).not.toHaveBeenCalled()
+ })
+
+ it('aborts Conditional UI when the user submits password login', async () => {
+ let capturedSignal: AbortSignal | undefined
+ const credentialGet = vi.fn((options: CredentialRequestOptions) => {
+ capturedSignal = options.signal
+ return new Promise(() => {})
+ })
+ vi.stubGlobal(
+ 'PublicKeyCredential',
+ class PublicKeyCredentialStub {
+ static isConditionalMediationAvailable = vi.fn().mockResolvedValue(true)
+ },
+ )
+ vi.stubGlobal('navigator', { credentials: { get: credentialGet } })
+ mocks.api.post.mockImplementation((url: string) => {
+ if (url === '/mfa/passkey/authenticate/start') {
+ return Promise.resolve({
+ success: true,
+ data: {
+ options: JSON.stringify({ challenge: 'AQI' }),
+ transaction_token: 'conditional-transaction',
+ },
+ })
+ }
+ return new Promise(() => {})
+ })
+ const { container } = await renderLogin()
+ await waitFor(() => expect(credentialGet).toHaveBeenCalledOnce())
+
+ await submitPassword(container)
+
+ expect(capturedSignal?.aborted).toBe(true)
+ expect(mocks.api.post).toHaveBeenCalledWith('/login/access-token', expect.any(FormData), expect.any(Object))
+ })
+
+ it('aborts manual Passkey before password login can take ownership', async () => {
+ let capturedSignal: AbortSignal | undefined
+ const credentialGet = vi.fn((options: CredentialRequestOptions) => {
+ capturedSignal = options.signal
+ return new Promise(() => {})
+ })
+ vi.stubGlobal('PublicKeyCredential', class PublicKeyCredentialStub {})
+ vi.stubGlobal('navigator', { credentials: { get: credentialGet } })
+ mocks.api.get.mockResolvedValue([
+ {
+ id: 'system:passkey',
+ type: 'system',
+ method: 'passkey',
+ name: '通行密钥',
+ enabled: true,
+ },
+ ])
+ mocks.api.post.mockImplementation((url: string) => {
+ if (url === '/mfa/passkey/authenticate/start') {
+ return Promise.resolve({
+ success: true,
+ data: {
+ options: JSON.stringify({ challenge: 'AQI' }),
+ transaction_token: 'manual-transaction',
+ },
+ })
+ }
+ return new Promise(() => {})
+ })
+ const { container } = await renderLogin()
+
+ await fireEvent.click(await waitFor(() => container.querySelector('.passkey-btn')!))
+ await waitFor(() => expect(credentialGet).toHaveBeenCalledOnce())
+ await submitPassword(container)
+
+ expect(capturedSignal?.aborted).toBe(true)
+ expect(mocks.api.post).not.toHaveBeenCalledWith('/mfa/passkey/authenticate/finish', expect.any(Object))
+ })
+})
diff --git a/src/pages/login.vue b/src/pages/login.vue
index 6eb6ddca..03f6168c 100644
--- a/src/pages/login.vue
+++ b/src/pages/login.vue
@@ -53,7 +53,7 @@ function handleLabTap(target: LabTapTarget) {
}
// 国际化
-const { t, te } = useI18n()
+const { t } = useI18n()
// 应用版本号(构建时注入,形如 v2.13.10)
const appVersion = typeof __APP_VERSION__ !== 'undefined' ? __APP_VERSION__ : ''
@@ -111,6 +111,9 @@ let conditionalAbortController: AbortController | null = null
// 手动模式的 AbortController(用于防止重复点击)
let manualAbortController: AbortController | null = null
+// 密码与 OTP 请求必须可取消,认证方式切换后不得接收旧会话的资源 Cookie。
+let passwordAbortController: AbortController | null = null
+
// 标记当前是否有手动模式的 PassKey 请求正在进行
let isManualPassKeyActive = false
@@ -165,6 +168,31 @@ const RemoteAuthView = shallowRef(null)
const pluginAuthDialog = ref(false)
const pluginAuthLoading = ref(false)
const pluginAuthError = ref('')
+// Remote generation 只保护组件加载;login generation 统一保护不同认证方式的会话写入。
+let pluginAuthLoadGeneration = 0
+let loginAttemptGeneration = 0
+let pluginLoginAttemptGeneration = 0
+let isPageActive = true
+
+function beginLoginAttempt() {
+ loginAttemptGeneration += 1
+ return loginAttemptGeneration
+}
+
+function isCurrentLoginAttempt(generation: number) {
+ return generation === loginAttemptGeneration
+}
+
+function abortPasswordLoginRequest() {
+ passwordAbortController?.abort()
+ passwordAbortController = null
+}
+
+function beginPasswordLoginRequest() {
+ abortPasswordLoginRequest()
+ passwordAbortController = new AbortController()
+ return passwordAbortController
+}
const systemPasskeyProvider = computed(() =>
authProviders.value.find(provider => provider.type === 'system' && provider.method === 'passkey'),
@@ -221,6 +249,8 @@ function enterMfaStep(methodsValue: unknown) {
// 用户主动返回账号密码步骤时清理未完成的二次验证。
function leaveMfaStep() {
+ beginLoginAttempt()
+ abortPasswordLoginRequest()
manualAbortController?.abort()
manualAbortController = null
mfaOtpLoading.value = false
@@ -244,27 +274,43 @@ async function loadAuthProviders() {
// 打开插件认证联邦页面。
async function openPluginAuth(provider: LoginAuthProvider) {
if (!provider.remote) return
+ abortPasswordLoginRequest()
+ conditionalAbortController?.abort()
+ conditionalAbortController = null
+ manualAbortController?.abort()
+ manualAbortController = null
+ isManualPassKeyActive = false
+ passkeyLoading.value = false
+ pluginLoginAttemptGeneration = beginLoginAttempt()
+ loading.value = false
+ const loadGeneration = ++pluginAuthLoadGeneration
selectedAuthProvider.value = provider
RemoteAuthView.value = null
pluginAuthError.value = ''
pluginAuthLoading.value = true
pluginAuthDialog.value = true
try {
- RemoteAuthView.value = (await loadRemoteComponentFromModule(
+ const remoteAuthView = (await loadRemoteComponentFromModule(
provider.remote,
provider.component || 'AuthPage',
)) as Component
+ if (loadGeneration !== pluginAuthLoadGeneration) return
+ RemoteAuthView.value = remoteAuthView
} catch (error: unknown) {
+ if (loadGeneration !== pluginAuthLoadGeneration) return
console.error('加载插件认证页面失败:', error)
pluginAuthError.value = getErrorMessage(error) || t('login.authFailure')
} finally {
- pluginAuthLoading.value = false
+ if (loadGeneration === pluginAuthLoadGeneration) pluginAuthLoading.value = false
}
}
// 关闭插件认证弹窗。
function closePluginAuth() {
+ beginLoginAttempt()
+ pluginAuthLoadGeneration += 1
pluginAuthDialog.value = false
+ pluginAuthLoading.value = false
selectedAuthProvider.value = null
RemoteAuthView.value = null
pluginAuthError.value = ''
@@ -272,19 +318,22 @@ function closePluginAuth() {
// 兑换插件认证票据并完成系统登录。
async function exchangePluginAuthTicket(ticket: string) {
+ const loginGeneration = pluginLoginAttemptGeneration
pluginAuthLoading.value = true
try {
const response = (await api.post('auth/exchange', { ticket })) as PassKeyFinishResponse
+ if (!isCurrentLoginAttempt(loginGeneration)) return
closePluginAuth()
await handleLoginSuccess(response)
} catch (error: unknown) {
+ if (!isCurrentLoginAttempt(loginGeneration)) return
console.error('插件认证票据兑换失败:', error)
const apiError = asApiError(error)
const message = apiError.response?.data?.message || apiError.response?.data?.detail
pluginAuthError.value =
(typeof message === 'string' ? message : undefined) || getErrorMessage(error) || t('login.authFailure')
} finally {
- pluginAuthLoading.value = false
+ if (isCurrentLoginAttempt(loginGeneration)) pluginAuthLoading.value = false
}
}
@@ -294,6 +343,7 @@ async function handlePluginAuthenticated(payload: PluginAuthPayload) {
pluginAuthError.value = t('login.authFailure')
return
}
+ if (pluginAuthLoading.value) return
await exchangePluginAuthTicket(payload.ticket)
}
@@ -331,10 +381,9 @@ async function authenticateWithPassKey(options: PassKeyAuthOptions = {}): Promis
const { username, isConditional = false, signal } = options
// 1. 开始认证流程
- const startResponse = (await api.post(
- '/mfa/passkey/authenticate/start',
- username ? { username } : {},
- )) as ApiResponse
+ const startResponse = (await api.post('/mfa/passkey/authenticate/start', username ? { username } : {}, {
+ signal,
+ })) as ApiResponse
if (!startResponse.success) {
throw new Error(startResponse.message || 'PassKey start failed')
@@ -355,13 +404,11 @@ async function authenticateWithPassKey(options: PassKeyAuthOptions = {}): Promis
},
}
- // 如果是 Conditional UI 模式,添加 mediation 和 signal
+ // Conditional UI 额外声明 mediation;两种模式都必须响应认证方式切换产生的取消信号。
if (isConditional) {
credentialRequestOptions.mediation = 'conditional'
- if (signal) {
- credentialRequestOptions.signal = signal
- }
}
+ if (signal) credentialRequestOptions.signal = signal
const credential = await navigator.credentials.get(credentialRequestOptions)
@@ -373,6 +420,7 @@ async function authenticateWithPassKey(options: PassKeyAuthOptions = {}): Promis
if (!credential) {
throw new Error('No credential selected')
}
+ if (signal?.aborted) throw new DOMException('PassKey authentication aborted', 'AbortError')
// 3. 转换credential为可传输格式
const publicKeyCredential = credential as PublicKeyCredential
@@ -390,10 +438,14 @@ async function authenticateWithPassKey(options: PassKeyAuthOptions = {}): Promis
}
// 4. 完成认证
- const finishResponse = (await api.post('/mfa/passkey/authenticate/finish', {
- credential: credentialJSON,
- transaction_token: transactionToken,
- })) as PassKeyFinishResponse
+ const finishResponse = (await api.post(
+ '/mfa/passkey/authenticate/finish',
+ {
+ credential: credentialJSON,
+ transaction_token: transactionToken,
+ },
+ { signal },
+ )) as PassKeyFinishResponse
if (!finishResponse || !finishResponse.access_token) {
throw new Error('PassKey finish failed: No access token')
@@ -408,6 +460,9 @@ async function handlePassKeyAuth(
setLoading: (loading: boolean) => void,
onSuccess: (response: PassKeyFinishResponse) => Promise,
) {
+ abortPasswordLoginRequest()
+ const loginGeneration = beginLoginAttempt()
+ loading.value = false
const { isConditional = false } = authOptions
errorMessage.value = ''
@@ -455,8 +510,10 @@ async function handlePassKeyAuth(
: undefined,
})
+ if (!isCurrentLoginAttempt(loginGeneration)) return
await onSuccess(finishResponse)
} catch (error: unknown) {
+ if (!isCurrentLoginAttempt(loginGeneration)) return
const errorName = error instanceof Error ? error.name : ''
const message = getErrorMessage(error)
@@ -485,16 +542,18 @@ async function handlePassKeyAuth(
errorMessage.value = t('login.authFailure')
}
} finally {
- // 清除 loading 状态
- if (!isConditional) {
- // 手动模式:始终清除,并取消手动活跃标记
- isManualPassKeyActive = false
- setLoading(false)
- manualAbortController = null
- } else {
- // Conditional UI 模式:只有在没有手动请求活跃时才清除
- if (!isManualPassKeyActive && passkeyLoading.value) {
- passkeyLoading.value = false
+ if (isCurrentLoginAttempt(loginGeneration)) {
+ // 清除 loading 状态
+ if (!isConditional) {
+ // 手动模式:始终清除,并取消手动活跃标记
+ isManualPassKeyActive = false
+ setLoading(false)
+ manualAbortController = null
+ } else {
+ // Conditional UI 模式:只有在没有手动请求活跃时才清除
+ if (!isManualPassKeyActive && passkeyLoading.value) {
+ passkeyLoading.value = false
+ }
}
}
}
@@ -601,13 +660,14 @@ async function handleLoginSuccess(response: PassKeyFinishResponse) {
await afterLogin(userPayload.superUser, userPayload, filteredMenus)
}
-async function requestPasswordLogin(): Promise {
+async function requestPasswordLogin(signal: AbortSignal): Promise {
const formData = new FormData()
formData.append('username', form.value.username)
formData.append('password', form.value.password)
formData.append('otp_password', form.value.otp_password)
return (await api.post('/login/access-token', formData, {
+ signal,
headers: {
Accept: 'application/json',
},
@@ -643,17 +703,29 @@ function setLoginError(error: unknown) {
}
async function login() {
+ if (loading.value) return
+
errorMessage.value = ''
syncLoginCredentialValues()
if (!form.value.username || !form.value.password) return
+ conditionalAbortController?.abort()
+ conditionalAbortController = null
+ manualAbortController?.abort()
+ manualAbortController = null
+ isManualPassKeyActive = false
+ passkeyLoading.value = false
+ const loginGeneration = beginLoginAttempt()
+ const passwordController = beginPasswordLoginRequest()
form.value.otp_password = ''
loading.value = true
try {
- const response = await requestPasswordLogin()
+ const response = await requestPasswordLogin(passwordController.signal)
+ if (!isCurrentLoginAttempt(loginGeneration)) return
await handleLoginSuccess(response)
} catch (error: unknown) {
+ if (!isCurrentLoginAttempt(loginGeneration)) return
const apiError = asApiError(error)
if (apiError.response?.headers?.['x-mfa-required'] === 'true') {
enterMfaStep(apiError.response.data?.mfa_methods)
@@ -661,7 +733,8 @@ async function login() {
}
setLoginError(error)
} finally {
- loading.value = false
+ if (passwordAbortController === passwordController) passwordAbortController = null
+ if (isCurrentLoginAttempt(loginGeneration)) loading.value = false
}
}
@@ -669,12 +742,16 @@ async function login() {
async function loginWithOTP() {
if (!form.value.otp_password || mfaOtpLoading.value) return
+ const loginGeneration = beginLoginAttempt()
+ const passwordController = beginPasswordLoginRequest()
errorMessage.value = ''
mfaOtpLoading.value = true
try {
- const response = await requestPasswordLogin()
+ const response = await requestPasswordLogin(passwordController.signal)
+ if (!isCurrentLoginAttempt(loginGeneration)) return
await handleLoginSuccess(response)
} catch (error: unknown) {
+ if (!isCurrentLoginAttempt(loginGeneration)) return
const apiError = asApiError(error)
if (!apiError.response) {
errorMessage.value = t('login.networkError')
@@ -685,7 +762,8 @@ async function loginWithOTP() {
}
form.value.otp_password = ''
} finally {
- mfaOtpLoading.value = false
+ if (passwordAbortController === passwordController) passwordAbortController = null
+ if (isCurrentLoginAttempt(loginGeneration)) mfaOtpLoading.value = false
}
}
@@ -701,8 +779,12 @@ onMounted(async () => {
return
}
+ // Conditional UI 是后台增强,只能在页面仍存活且没有显式认证尝试取得所有权时启动。
+ const startupGeneration = loginAttemptGeneration
+
// 加载系统和插件声明的未登录认证入口
await loadAuthProviders()
+ if (!isPageActive || !isCurrentLoginAttempt(startupGeneration)) return
// 初始化 Conditional UI 的 PassKey 自动填充
await initConditionalPasskey()
@@ -716,8 +798,9 @@ async function initConditionalPasskey() {
}
try {
+ const startupGeneration = loginAttemptGeneration
const available = await PublicKeyCredential.isConditionalMediationAvailable()
- if (!available) {
+ if (!available || !isPageActive || !isCurrentLoginAttempt(startupGeneration)) {
return
}
@@ -739,6 +822,9 @@ async function initConditionalPasskey() {
// 组件卸载时清理
onUnmounted(() => {
+ isPageActive = false
+ beginLoginAttempt()
+ abortPasswordLoginRequest()
if (conditionalAbortController) {
conditionalAbortController.abort()
conditionalAbortController = null
diff --git a/vite.config.ts b/vite.config.ts
index bc40505d..f744f2a5 100644
--- a/vite.config.ts
+++ b/vite.config.ts
@@ -314,6 +314,8 @@ export default defineConfig(({ command, mode, isPreview }) => ({
'src/stores/auth.ts',
'src/stores/user.ts',
'src/stores/pluginSidebarNav.ts',
+ 'src/components/auth/LoginMfaStep.vue',
+ 'src/pages/login.vue',
'src/pages/appcenter.vue',
'src/pages/recommend.vue',
'src/pages/discover.vue',
@@ -640,6 +642,18 @@ export default defineConfig(({ command, mode, isPreview }) => ({
lines: 90,
statements: 90,
},
+ 'src/components/auth/LoginMfaStep.vue': {
+ branches: 85,
+ functions: 90,
+ lines: 90,
+ statements: 90,
+ },
+ 'src/pages/login.vue': {
+ branches: 75,
+ functions: 80,
+ lines: 80,
+ statements: 80,
+ },
'src/stores/pluginSidebarNav.ts': {
branches: 85,
functions: 90,