feat(auth): post-login landing route resolution per context
login.vue is rewritten to consume useAuthStore.login()'s discriminated
union — no more direct apiClient calls or branching on raw API response
shapes. The page maps result.kind to UI/routing decisions only:
- mfa-required → swap to MfaChallengeCard with the typed payload
- authenticated → resolvePostLoginTarget() (?to= relative, else
auth.resolveLandingRoute())
- must-set-password → forward-compatible placeholder route
- failed → field-level errors + rate_limit message branch
resolveLandingRoute() now returns a string path instead of
RouteLocationRaw — the typed router accepts string-paths cleanly,
removes the cast at every call site, and lets useAuthStore.spec.ts +
guards.spec.ts assert the resolved path directly.
A13-3 minimum precaution lives in a new utility:
src/utils/postLoginRedirect.ts. The relative-only check
(`startsWith('/') && !startsWith('//')`) rejects absolute, protocol-
relative, javascript:, and data: schemes. Full domain validation lands
in WS-3 PR-B2b.
6 vitest specs in utils/__tests__/postLoginRedirect.spec.ts cover the
six rejection / passthrough scenarios.
Test count 192 → 198. Lint + typecheck clean.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -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<string, unknown> = {
|
||||
@@ -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')
|
||||
})
|
||||
})
|
||||
|
||||
@@ -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<LoginResponse>('/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
|
||||
const result = await authStore.login(credentials)
|
||||
|
||||
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
|
||||
}
|
||||
|
||||
// Normal login success
|
||||
authStore.setUser(data.data.user)
|
||||
|
||||
if (data.mfa_setup_required) {
|
||||
case 'authenticated':
|
||||
if (authStore.mfaSetupRequired) {
|
||||
router.replace('/account-settings?tab=security')
|
||||
|
||||
return
|
||||
}
|
||||
router.replace(resolvePostLoginTarget())
|
||||
|
||||
const rawTo = route.query.to ? String(route.query.to) : ''
|
||||
const redirectTo = rawTo.startsWith('/') ? rawTo : '/dashboard'
|
||||
return
|
||||
|
||||
router.replace(redirectTo)
|
||||
}
|
||||
catch (err: unknown) {
|
||||
const errorData = (err as { response?: { data?: { message?: string; errors?: Record<string, string[]> } } }).response?.data
|
||||
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')
|
||||
|
||||
if (errorData?.errors) {
|
||||
return
|
||||
|
||||
case 'failed':
|
||||
if (result.errors) {
|
||||
errors.value = {
|
||||
email: errorData.errors.email?.[0] ?? '',
|
||||
password: errorData.errors.password?.[0] ?? '',
|
||||
email: result.errors.email?.[0] ?? '',
|
||||
password: result.errors.password?.[0] ?? '',
|
||||
}
|
||||
}
|
||||
else if (errorData?.message) {
|
||||
errors.value = { email: errorData.message }
|
||||
else if (result.reason === 'rate_limited') {
|
||||
errors.value = { email: 'Te veel pogingen. Wacht even en probeer opnieuw.' }
|
||||
}
|
||||
else {
|
||||
errors.value = { email: 'Er is een fout opgetreden. Probeer het opnieuw.' }
|
||||
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())
|
||||
})
|
||||
}
|
||||
|
||||
|
||||
@@ -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 () => {
|
||||
|
||||
@@ -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')
|
||||
})
|
||||
})
|
||||
|
||||
|
||||
@@ -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() {
|
||||
|
||||
38
apps/app/src/utils/__tests__/postLoginRedirect.spec.ts
Normal file
38
apps/app/src/utils/__tests__/postLoginRedirect.spec.ts
Normal file
@@ -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,<script>', () => '/dashboard')).toBe('/dashboard')
|
||||
})
|
||||
|
||||
it('preserves query parameters and fragments on relative paths', () => {
|
||||
expect(resolvePostLoginTarget('/events?tab=overview#info', () => '/x'))
|
||||
.toBe('/events?tab=overview#info')
|
||||
})
|
||||
})
|
||||
19
apps/app/src/utils/postLoginRedirect.ts
Normal file
19
apps/app/src/utils/postLoginRedirect.ts
Normal file
@@ -0,0 +1,19 @@
|
||||
/**
|
||||
* Resolve the post-login redirect target. If the caller supplied a `?to=`
|
||||
* query that's a same-origin relative path, honour it; otherwise fall back
|
||||
* to the auth-store's resolveLandingRoute().
|
||||
*
|
||||
* The `startsWith('/')` + `!startsWith('//')` guard is the **minimum**
|
||||
* A13-3 (open-redirect) precaution. Full domain-validation lands in
|
||||
* WS-3 PR-B2b.
|
||||
*/
|
||||
export function resolvePostLoginTarget(
|
||||
rawTo: string | null | undefined,
|
||||
fallback: () => string,
|
||||
): string {
|
||||
const to = rawTo ?? ''
|
||||
if (to.startsWith('/') && !to.startsWith('//'))
|
||||
return to
|
||||
|
||||
return fallback()
|
||||
}
|
||||
Reference in New Issue
Block a user