diff --git a/apps/app/src/components/shared/__tests__/ContextSwitcher.spec.ts b/apps/app/src/components/shared/__tests__/ContextSwitcher.spec.ts index acca214e..d9e96a55 100644 --- a/apps/app/src/components/shared/__tests__/ContextSwitcher.spec.ts +++ b/apps/app/src/components/shared/__tests__/ContextSwitcher.spec.ts @@ -2,7 +2,7 @@ import { describe, expect, it, vi } from 'vitest' import { mount } from '@vue/test-utils' const mockSetLastContext = vi.fn() -const mockResolveLandingRoute = vi.fn(() => ({ path: '/portal/evenementen' })) +const mockResolveLandingRoute = vi.fn(() => '/portal/evenementen') const mockPush = vi.fn() const authStoreState: Record = { @@ -67,6 +67,6 @@ describe('ContextSwitcher', () => { expect(mockSetLastContext).toHaveBeenCalledWith('portal') expect(mockResolveLandingRoute).toHaveBeenCalledWith('portal') - expect(mockPush).toHaveBeenCalledWith({ path: '/portal/evenementen' }) + expect(mockPush).toHaveBeenCalledWith('/portal/evenementen') }) }) diff --git a/apps/app/src/pages/login.vue b/apps/app/src/pages/login.vue index cd245461..009535f6 100644 --- a/apps/app/src/pages/login.vue +++ b/apps/app/src/pages/login.vue @@ -9,11 +9,10 @@ import authV2MaskDark from '@images/pages/misc-mask-dark.png' import authV2MaskLight from '@images/pages/misc-mask-light.png' import { VNodeRenderer } from '@layouts/components/VNodeRenderer' import { themeConfig } from '@themeConfig' -import { apiClient } from '@/lib/axios' import { useAuthStore } from '@/stores/useAuthStore' import { emailValidator, requiredValidator } from '@core/utils/validators' -import { generateDeviceFingerprint } from '@/utils/deviceFingerprint' -import type { LoginCredentials, LoginResponse } from '@/types/auth' +import { resolvePostLoginTarget as resolvePostLoginPath } from '@/utils/postLoginRedirect' +import type { LoginCredentials } from '@/types/auth' import type { MfaMethod } from '@/types/mfa' definePage({ @@ -56,58 +55,64 @@ const authThemeImg = useGenerateImageVariant( const authThemeMask = useGenerateImageVariant(authV2MaskLight, authV2MaskDark) +function resolvePostLoginTarget(): string { + const rawTo = route.query.to ? String(route.query.to) : '' + + return resolvePostLoginPath(rawTo, () => authStore.resolveLandingRoute()) +} + async function handleLogin() { errors.value = {} isPending.value = true try { - const fingerprint = generateDeviceFingerprint() - - const { data } = await apiClient.post('/auth/login', { + const credentials: LoginCredentials = { email: form.value.email, password: form.value.password, - }, { - headers: { 'X-Device-Fingerprint': fingerprint }, - }) - - if (data.mfa_required) { - mfaSessionToken.value = data.mfa_session_token! - mfaMethods.value = (data.methods ?? []) as MfaMethod[] - mfaPreferredMethod.value = data.preferred_method ?? 'totp' - mfaExpiresIn.value = data.expires_in ?? 600 - showMfaChallenge.value = true - - return } - // Normal login success - authStore.setUser(data.data.user) + const result = await authStore.login(credentials) - if (data.mfa_setup_required) { - router.replace('/account-settings?tab=security') + switch (result.kind) { + case 'mfa-required': + mfaSessionToken.value = result.sessionToken + mfaMethods.value = result.methods + mfaPreferredMethod.value = result.preferredMethod + mfaExpiresIn.value = result.expiresIn + showMfaChallenge.value = true - return - } + return - const rawTo = route.query.to ? String(route.query.to) : '' - const redirectTo = rawTo.startsWith('/') ? rawTo : '/dashboard' + case 'authenticated': + if (authStore.mfaSetupRequired) { + router.replace('/account-settings?tab=security') - router.replace(redirectTo) - } - catch (err: unknown) { - const errorData = (err as { response?: { data?: { message?: string; errors?: Record } } }).response?.data + return + } + router.replace(resolvePostLoginTarget()) - if (errorData?.errors) { - errors.value = { - email: errorData.errors.email?.[0] ?? '', - password: errorData.errors.password?.[0] ?? '', - } - } - else if (errorData?.message) { - errors.value = { email: errorData.message } - } - else { - errors.value = { email: 'Er is een fout opgetreden. Probeer het opnieuw.' } + return + + case 'must-set-password': + // Forward-compatible — backend doesn't surface this kind today. + // Placeholder route until the must-set-password flow is wired. + router.replace('/account-settings?tab=security') + + return + + case 'failed': + if (result.errors) { + errors.value = { + email: result.errors.email?.[0] ?? '', + password: result.errors.password?.[0] ?? '', + } + } + else if (result.reason === 'rate_limited') { + errors.value = { email: 'Te veel pogingen. Wacht even en probeer opnieuw.' } + } + else { + errors.value = { email: result.reason || 'Er is een fout opgetreden. Probeer het opnieuw.' } + } } } finally { @@ -116,14 +121,10 @@ async function handleLogin() { } function onMfaVerified() { - // After MFA verify, the response sets the auth cookie. Use refreshUser() - // (not initialize() — that's guarded by isInitialized and returns immediately) - // to call GET /auth/me with the new cookie, populating the store. + // After MFA verify, the response sets the auth cookie. refreshUser() + // hydrates the store from /auth/me with the new cookie. authStore.refreshUser().then(() => { - const rawTo = route.query.to ? String(route.query.to) : '' - const redirectTo = rawTo.startsWith('/') ? rawTo : '/dashboard' - - router.replace(redirectTo) + router.replace(resolvePostLoginTarget()) }) } diff --git a/apps/app/src/plugins/1.router/__tests__/guards.spec.ts b/apps/app/src/plugins/1.router/__tests__/guards.spec.ts index 8d9231b8..8fc58299 100644 --- a/apps/app/src/plugins/1.router/__tests__/guards.spec.ts +++ b/apps/app/src/plugins/1.router/__tests__/guards.spec.ts @@ -246,7 +246,7 @@ describe('router guards (WS-3 PR-B2a)', () => { () => {}, ) - expect(result).toEqual({ path: '/portal/evenementen' }) + expect(result).toBe('/portal/evenementen') }) it('MFA setup gate redirects to /account-settings when mfaSetupRequired', async () => { diff --git a/apps/app/src/stores/__tests__/useAuthStore.spec.ts b/apps/app/src/stores/__tests__/useAuthStore.spec.ts index 8d929520..d26290ab 100644 --- a/apps/app/src/stores/__tests__/useAuthStore.spec.ts +++ b/apps/app/src/stores/__tests__/useAuthStore.spec.ts @@ -126,7 +126,7 @@ describe('useAuthStore — context-aware additions (WS-3 PR-B2a)', () => { const target = store.resolveLandingRoute() - expect(target).toEqual({ path: '/portal/evenementen' }) + expect(target).toBe('/portal/evenementen') }) it('routes to organizer dashboard when only organizer context is available', () => { @@ -138,7 +138,7 @@ describe('useAuthStore — context-aware additions (WS-3 PR-B2a)', () => { contexts: { available: ['organizer'], default: 'organizer' }, })) - expect(store.resolveLandingRoute()).toEqual({ name: 'dashboard' }) + expect(store.resolveLandingRoute()).toBe('/dashboard') }) it('routes to platform dashboard for super_admin without an active org', () => { @@ -150,7 +150,7 @@ describe('useAuthStore — context-aware additions (WS-3 PR-B2a)', () => { contexts: { available: ['organizer'], default: 'organizer' }, })) - expect(store.resolveLandingRoute()).toEqual({ name: 'platform' }) + expect(store.resolveLandingRoute()).toBe('/platform') }) it('multi-role user — defaultContext wins when no lastContext is set', () => { @@ -162,7 +162,7 @@ describe('useAuthStore — context-aware additions (WS-3 PR-B2a)', () => { contexts: { available: ['portal', 'organizer'], default: 'organizer' }, })) - expect(store.resolveLandingRoute()).toEqual({ name: 'dashboard' }) + expect(store.resolveLandingRoute()).toBe('/dashboard') }) it('multi-role user — lastContext overrides defaultContext', () => { @@ -176,7 +176,7 @@ describe('useAuthStore — context-aware additions (WS-3 PR-B2a)', () => { contexts: { available: ['portal', 'organizer'], default: 'organizer' }, })) - expect(store.resolveLandingRoute()).toEqual({ path: '/portal/evenementen' }) + expect(store.resolveLandingRoute()).toBe('/portal/evenementen') }) it('forceContext overrides both lastContext and defaultContext', () => { @@ -189,13 +189,13 @@ describe('useAuthStore — context-aware additions (WS-3 PR-B2a)', () => { })) store.setLastContext('organizer') - expect(store.resolveLandingRoute('portal')).toEqual({ path: '/portal/evenementen' }) + expect(store.resolveLandingRoute('portal')).toBe('/portal/evenementen') }) it('returns forbidden when user has no contexts available', () => { const store = useAuthStore() - expect(store.resolveLandingRoute()).toEqual({ name: 'forbidden' }) + expect(store.resolveLandingRoute()).toBe('/forbidden') }) }) diff --git a/apps/app/src/stores/useAuthStore.ts b/apps/app/src/stores/useAuthStore.ts index 6420df7f..3522490b 100644 --- a/apps/app/src/stores/useAuthStore.ts +++ b/apps/app/src/stores/useAuthStore.ts @@ -1,6 +1,5 @@ import { defineStore } from 'pinia' import { computed, ref } from 'vue' -import type { RouteLocationRaw } from 'vue-router' import { apiClient } from '@/lib/axios' import { useOrganisationStore } from '@/stores/useOrganisationStore' import { generateDeviceFingerprint } from '@/utils/deviceFingerprint' @@ -135,29 +134,32 @@ export const useAuthStore = defineStore('auth', () => { } /** - * Resolve the post-login or post-context-switch landing route. A + * Resolve the post-login or post-context-switch landing path. A * `forceContext` overrides the lastContext + defaultContext precedence * (used by the context-switcher when the user explicitly chooses). + * + * Returns a string path (not a RouteLocationRaw object) so consumers + * can pass it directly to the typed router without casting. */ - function resolveLandingRoute(forceContext?: AuthContext): RouteLocationRaw { + function resolveLandingRoute(forceContext?: AuthContext): string { const ctx = forceContext ?? lastContext.value ?? defaultContext.value if (ctx === 'portal' && availableContexts.value.includes('portal')) - return { path: '/portal/evenementen' } + return '/portal/evenementen' if (ctx === 'organizer' && availableContexts.value.includes('organizer')) { if (isSuperAdmin.value && organisations.value.length === 0) - return { name: 'platform' } + return '/platform' - return { name: 'dashboard' } + return '/dashboard' } if (availableContexts.value.includes('organizer')) - return { name: 'dashboard' } + return '/dashboard' if (availableContexts.value.includes('portal')) - return { path: '/portal/evenementen' } + return '/portal/evenementen' - return { name: 'forbidden' } + return '/forbidden' } function clearState() { diff --git a/apps/app/src/utils/__tests__/postLoginRedirect.spec.ts b/apps/app/src/utils/__tests__/postLoginRedirect.spec.ts new file mode 100644 index 00000000..343df18e --- /dev/null +++ b/apps/app/src/utils/__tests__/postLoginRedirect.spec.ts @@ -0,0 +1,38 @@ +import { describe, expect, it, vi } from 'vitest' +import { resolvePostLoginTarget } from '@/utils/postLoginRedirect' + +describe('resolvePostLoginTarget — A13-3 minimum open-redirect guard', () => { + it('returns the relative ?to= path when it starts with /', () => { + const fallback = vi.fn(() => '/dashboard') + const result = resolvePostLoginTarget('/events/01ABC', fallback) + + expect(result).toBe('/events/01ABC') + expect(fallback).not.toHaveBeenCalled() + }) + + it('falls back to the resolver when ?to= is missing', () => { + expect(resolvePostLoginTarget('', () => '/dashboard')).toBe('/dashboard') + expect(resolvePostLoginTarget(null, () => '/dashboard')).toBe('/dashboard') + expect(resolvePostLoginTarget(undefined, () => '/dashboard')).toBe('/dashboard') + }) + + it('rejects absolute URLs (open-redirect attempt)', () => { + expect(resolvePostLoginTarget('https://evil.com', () => '/dashboard')).toBe('/dashboard') + expect(resolvePostLoginTarget('http://evil.com/x', () => '/dashboard')).toBe('/dashboard') + }) + + it('rejects protocol-relative URLs (//evil.com)', () => { + expect(resolvePostLoginTarget('//evil.com', () => '/dashboard')).toBe('/dashboard') + expect(resolvePostLoginTarget('//evil.com/path', () => '/dashboard')).toBe('/dashboard') + }) + + it('rejects javascript: and data: schemes', () => { + expect(resolvePostLoginTarget('javascript:alert(1)', () => '/dashboard')).toBe('/dashboard') + expect(resolvePostLoginTarget('data:text/html,