mirror of
https://github.com/jxxghp/MoviePilot-Frontend.git
synced 2026-09-05 15:36:49 +08:00
fix(auth): prevent stale login attempts from replacing sessions (#663)
This commit is contained in:
@@ -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('验证码错误')
|
||||
})
|
||||
})
|
||||
|
||||
@@ -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
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user