fix(auth): prevent stale login attempts from replacing sessions (#663)

This commit is contained in:
InfinityPacer
2026-08-11 11:33:00 +08:00
committed by GitHub
parent bf0e57a92f
commit 202235d88a
5 changed files with 1175 additions and 40 deletions
@@ -9,11 +9,14 @@ vi.mock('vue-i18n', () => ({
const slotStub = { template: '<div><slot /></div>' }
const buttonStub = {
emits: ['click'],
props: ['loading'],
template: '<button :disabled="loading" @click="$emit(\'click\')"><slot /></button>',
props: ['disabled', 'loading', 'type'],
template: '<button :type="type" :disabled="loading || disabled" @click="$emit(\'click\')"><slot /></button>',
}
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('验证码错误')
})
})
+996
View File
@@ -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<T>() {
let resolve!: (value: T) => void
let reject!: (reason?: unknown) => void
const promise = new Promise<T>((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<string, unknown> = {}) {
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<HTMLFormElement>('form.login-form')
expect(form).not.toBeNull()
await fireEvent.submit(form!)
}
async function renderLogin(initialState: Record<string, Record<string, unknown>> = {}) {
return renderWithProviders(LoginPage, {
global: {
stubs: {
OpticalLogoLab: { template: '<div><slot /></div>' },
},
},
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<never>()
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<HTMLFormElement>('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<HTMLFormElement>('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<ReturnType<typeof remoteAuthPage>>()
const beta = deferred<ReturnType<typeof remoteAuthPage>>()
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<unknown[]>()
const passwordLogin = deferred<never>()
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<ReturnType<typeof remoteAuthPage>>()
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<ReturnType<typeof loginResponse>>()
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<ReturnType<typeof loginResponse>>()
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<never>()
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<HTMLButtonElement>('.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<HTMLButtonElement>('.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<ReturnType<typeof loginResponse>>()
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<HTMLButtonElement>('.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<HTMLButtonElement>('.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<HTMLButtonElement>('.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<Credential | null>(() => {})
})
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<boolean>()
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<Credential | null>(() => {})
})
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<Credential | null>(() => {})
})
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<HTMLButtonElement>('.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))
})
})
+118 -32
View File
@@ -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<Component | null>(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<PassKeyStartResponse>
const startResponse = (await api.post('/mfa/passkey/authenticate/start', username ? { username } : {}, {
signal,
})) as ApiResponse<PassKeyStartResponse>
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<void>,
) {
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<PassKeyFinishResponse> {
async function requestPasswordLogin(signal: AbortSignal): Promise<PassKeyFinishResponse> {
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