feat: replace token-based impersonation with enterprise-grade header-based system

Replaces the insecure token-in-localStorage approach with a header-based
impersonation system backed by cache sessions and MFA verification.

Key changes:
- New impersonation_sessions audit table (immutable, ULID PK)
- MFA verification required to start impersonation (TOTP/email/backup)
- X-Impersonate-User header + HandleImpersonation middleware
- Per-request auth context swap (admin session never modified)
- IP pinning, sensitive route blocking, no nesting, sliding 60-min TTL
- Activity log auto-tagged with impersonated_by during sessions
- Frontend: sessionStorage, BroadcastChannel sync, countdown timer
- ImpersonateDialog with reason + MFA verification flow
- 26 comprehensive tests covering core, middleware, audit, lifecycle

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-04-16 02:42:53 +02:00
parent 47cb6b83d4
commit 4df668b5b8
25 changed files with 1813 additions and 269 deletions

View File

@@ -67,6 +67,7 @@ declare module 'vue' {
EventMetricCards: typeof import('./src/components/events/EventMetricCards.vue')['default']
EventTabsNav: typeof import('./src/components/events/EventTabsNav.vue')['default']
I18n: typeof import('./src/@core/components/I18n.vue')['default']
ImpersonateDialog: typeof import('./src/components/platform/ImpersonateDialog.vue')['default']
ImpersonationBanner: typeof import('./src/components/platform/ImpersonationBanner.vue')['default']
ImportFromEventDialog: typeof import('./src/components/event/ImportFromEventDialog.vue')['default']
InfoTooltip: typeof import('./src/components/common/InfoTooltip.vue')['default']

View File

@@ -5,6 +5,7 @@ import initCore from '@core/initCore'
import { initConfigStore, useConfigStore } from '@core/stores/config'
import { hexToRgb } from '@core/utils/colorConverter'
import { useAuthStore } from '@/stores/useAuthStore'
import { useImpersonationStore } from '@/stores/useImpersonationStore'
import { useNotificationStore } from '@/stores/useNotificationStore'
const { global } = useTheme()
@@ -14,8 +15,13 @@ initConfigStore()
const configStore = useConfigStore()
const authStore = useAuthStore()
const impersonationStore = useImpersonationStore()
const notificationStore = useNotificationStore()
// Restore impersonation state and listen for cross-tab sync
impersonationStore.restoreFromStorage()
impersonationStore.listenForBroadcasts()
// Validate stored token on app startup — must complete before rendering protected content
authStore.initialize()
</script>

View File

@@ -0,0 +1,183 @@
<script setup lang="ts">
import { useImpersonationStore } from '@/stores/useImpersonationStore'
import type { AdminUser, StartImpersonationPayload } from '@/types/admin'
import { apiClient } from '@/lib/axios'
const props = defineProps<{
modelValue: boolean
user: AdminUser | null
}>()
const emit = defineEmits<{
'update:modelValue': [value: boolean]
}>()
const impersonationStore = useImpersonationStore()
const isOpen = computed({
get: () => props.modelValue,
set: (value: boolean) => emit('update:modelValue', value),
})
const reason = ref('')
const mfaCode = ref('')
const mfaMethod = ref<StartImpersonationPayload['mfa_method']>('totp')
const errorMessage = ref('')
const isSubmitting = ref(false)
const isSendingEmailCode = ref(false)
const emailCodeSent = ref(false)
const mfaMethodOptions = [
{ title: 'Authenticator app', value: 'totp' as const },
{ title: 'E-mailcode', value: 'email' as const },
{ title: 'Backup code', value: 'backup_code' as const },
]
const isFormValid = computed(() =>
reason.value.length >= 5 && mfaCode.value.length > 0,
)
function resetForm() {
reason.value = ''
mfaCode.value = ''
mfaMethod.value = 'totp'
errorMessage.value = ''
emailCodeSent.value = false
}
watch(isOpen, (open) => {
if (!open) {
resetForm()
}
})
async function sendEmailCode() {
isSendingEmailCode.value = true
errorMessage.value = ''
try {
await apiClient.post('/admin/impersonate/send-mfa-code')
emailCodeSent.value = true
}
catch (err: unknown) {
const error = err as { response?: { data?: { message?: string } } }
errorMessage.value = error.response?.data?.message ?? 'Kon e-mailcode niet versturen.'
}
finally {
isSendingEmailCode.value = false
}
}
async function submit() {
if (!props.user || !isFormValid.value) return
isSubmitting.value = true
errorMessage.value = ''
try {
await impersonationStore.start(props.user.id, {
reason: reason.value,
mfa_code: mfaCode.value,
mfa_method: mfaMethod.value,
})
// start() triggers page reload — no further action needed
}
catch (err: unknown) {
const error = err as { response?: { data?: { message?: string } } }
errorMessage.value = error.response?.data?.message ?? 'Impersonation mislukt.'
isSubmitting.value = false
}
}
</script>
<template>
<VDialog
v-model="isOpen"
max-width="500"
>
<VCard title="Inloggen als gebruiker">
<VCardText>
<!-- Target user info -->
<VAlert
type="info"
variant="tonal"
class="mb-4"
density="comfortable"
>
Je gaat het platform bekijken als
<strong>{{ user?.full_name }}</strong>
({{ user?.email }}).
Verificatie met tweestapsverificatie is vereist.
</VAlert>
<!-- Error -->
<VAlert
v-if="errorMessage"
type="error"
variant="tonal"
class="mb-4"
density="comfortable"
>
{{ errorMessage }}
</VAlert>
<!-- Reason -->
<AppTextField
v-model="reason"
label="Reden"
placeholder="Waarom log je in als deze gebruiker?"
:rules="[(v: string) => v.length >= 5 || 'Minimaal 5 tekens']"
class="mb-4"
/>
<!-- MFA method -->
<AppSelect
v-model="mfaMethod"
:items="mfaMethodOptions"
label="Verificatiemethode"
class="mb-4"
/>
<!-- Send email code button -->
<VBtn
v-if="mfaMethod === 'email'"
variant="tonal"
size="small"
:loading="isSendingEmailCode"
:disabled="emailCodeSent"
class="mb-4"
@click="sendEmailCode"
>
{{ emailCodeSent ? 'Code verstuurd' : 'Verstuur e-mailcode' }}
</VBtn>
<!-- MFA code -->
<AppTextField
v-model="mfaCode"
:label="mfaMethod === 'backup_code' ? 'Backup code' : 'Verificatiecode'"
:placeholder="mfaMethod === 'backup_code' ? 'XXXX-XXXX' : '000000'"
autocomplete="one-time-code"
@keyup.enter="submit"
/>
</VCardText>
<VCardActions>
<VSpacer />
<VBtn
variant="tonal"
@click="isOpen = false"
>
Annuleren
</VBtn>
<VBtn
color="warning"
:loading="isSubmitting"
:disabled="!isFormValid"
@click="submit"
>
Inloggen als gebruiker
</VBtn>
</VCardActions>
</VCard>
</VDialog>
</template>

View File

@@ -4,46 +4,115 @@ import { useImpersonationStore } from '@/stores/useImpersonationStore'
const impersonationStore = useImpersonationStore()
const isStopping = ref(false)
const remainingSeconds = ref(0)
let timerInterval: ReturnType<typeof setInterval> | null = null
const remainingFormatted = computed(() => {
const mins = Math.floor(remainingSeconds.value / 60)
const secs = remainingSeconds.value % 60
return `${String(mins).padStart(2, '0')}:${String(secs).padStart(2, '0')}`
})
function updateCountdown() {
if (!impersonationStore.expiresAt) {
remainingSeconds.value = 0
return
}
const diff = Math.max(0, Math.floor((impersonationStore.expiresAt.getTime() - Date.now()) / 1000))
remainingSeconds.value = diff
if (diff <= 0) {
handleExpired()
}
}
function handleExpired() {
if (timerInterval) {
clearInterval(timerInterval)
timerInterval = null
}
impersonationStore.clearState()
window.location.href = '/platform'
}
async function handleStop() {
isStopping.value = true
await impersonationStore.stopImpersonation()
await impersonationStore.stop()
}
watch(() => impersonationStore.isImpersonating, (active) => {
if (active) {
updateCountdown()
timerInterval = setInterval(updateCountdown, 1000)
}
else if (timerInterval) {
clearInterval(timerInterval)
timerInterval = null
}
}, { immediate: true })
onUnmounted(() => {
if (timerInterval) {
clearInterval(timerInterval)
}
})
</script>
<template>
<VBanner
<VSystemBar
v-if="impersonationStore.isImpersonating"
color="warning"
sticky
class="impersonation-banner"
>
<template #prepend>
<VIcon icon="tabler-user-exclamation" />
</template>
<VBannerText>
<VIcon
icon="tabler-user-exclamation"
class="me-2"
/>
<span>
Je bekijkt het platform als
<strong>{{ impersonationStore.impersonatedUser?.full_name }}</strong>
({{ impersonationStore.impersonatedUser?.email }})
</VBannerText>
</span>
<template #actions>
<VBtn
variant="tonal"
color="warning"
:loading="isStopping"
prepend-icon="tabler-arrow-back"
@click="handleStop"
>
Terug naar admin
</VBtn>
</template>
</VBanner>
<VSpacer />
<VChip
size="small"
variant="tonal"
color="warning"
class="me-3"
>
<VIcon
icon="tabler-clock"
size="14"
class="me-1"
/>
{{ remainingFormatted }}
</VChip>
<VBtn
variant="tonal"
color="warning"
size="small"
:loading="isStopping"
prepend-icon="tabler-arrow-back"
@click="handleStop"
>
Terug naar admin
</VBtn>
</VSystemBar>
</template>
<style scoped>
/* VSystemBar uses a fixed height by default; override to accommodate the button */
.impersonation-banner {
z-index: 1050;
z-index: 9999;
block-size: auto;
min-block-size: 36px;
padding-block: 4px;
padding-inline: 16px;
}
</style>

View File

@@ -8,7 +8,6 @@ import type {
AdminOrganisationMember,
AdminUser,
CreateOrganisationPayload,
ImpersonationResponse,
InviteMemberPayload,
PlatformStats,
UpdateAdminOrganisationPayload,
@@ -245,25 +244,4 @@ export function useAdminActivityLog(params: Ref<Record<string, string | number |
}
// ─── Impersonation ──────────────────────────────────────────
export function useStartImpersonation() {
return useMutation({
mutationFn: async (userId: string) => {
const { data } = await apiClient.post<ApiResponse<ImpersonationResponse>>(
`/admin/impersonate/${userId}`,
)
return data.data
},
})
}
export function useStopImpersonation() {
return useMutation({
mutationFn: async () => {
const { data } = await apiClient.post<ApiResponse<{ user: AdminUser }>>(
'/admin/stop-impersonation',
)
return data.data
},
})
}
// Impersonation API calls are now handled directly by useImpersonationStore.

View File

@@ -21,6 +21,21 @@ apiClient.interceptors.request.use(
config.headers['X-Organisation-Id'] = orgStore.activeOrganisationId
}
// Add impersonation header when active
// Lazy import to avoid circular dependency with store
const impersonationData = sessionStorage.getItem('crewli_impersonation')
if (impersonationData) {
try {
const parsed = JSON.parse(impersonationData) as { targetUserId?: string }
if (parsed.targetUserId) {
config.headers['X-Impersonate-User'] = parsed.targetUserId
}
}
catch {
// Invalid data — ignore
}
}
if (import.meta.env.DEV) {
console.log(`🚀 ${config.method?.toUpperCase()} ${config.url}`, config.data)
}
@@ -46,6 +61,17 @@ apiClient.interceptors.response.use(
const status = error.response?.status
const notificationStore = useNotificationStore()
// Handle impersonation session expiry
if (status === 403 && error.response?.data?.impersonation_ended) {
import('@/stores/useImpersonationStore').then(({ useImpersonationStore }) => {
const impersonationStore = useImpersonationStore()
impersonationStore.clearState()
window.location.href = '/platform'
})
return Promise.reject(error)
}
if (status === 401) {
// Lazy import to avoid circular dependency
import('@/stores/useAuthStore').then(({ useAuthStore }) => {

View File

@@ -2,11 +2,10 @@
import {
useAdminUser,
useUpdateAdminUser,
useStartImpersonation,
} from '@/composables/api/useAdmin'
import { useAdminResetMfa } from '@/composables/api/useMfa'
import { useImpersonationStore } from '@/stores/useImpersonationStore'
import type { AdminUser, UpdateAdminUserPayload } from '@/types/admin'
import ImpersonateDialog from '@/components/platform/ImpersonateDialog.vue'
import type { UpdateAdminUserPayload } from '@/types/admin'
definePage({
meta: {
@@ -16,7 +15,6 @@ definePage({
const route = useRoute()
const router = useRouter()
const impersonationStore = useImpersonationStore()
const userId = computed(() => String((route.params as { id: string }).id))
@@ -76,16 +74,6 @@ function submitEdit() {
// Impersonation
const isImpersonateDialogOpen = ref(false)
const { mutate: startImpersonation, isPending: isImpersonating } = useStartImpersonation()
function confirmImpersonate() {
startImpersonation(userId.value, {
onSuccess: (result) => {
isImpersonateDialogOpen.value = false
impersonationStore.startImpersonation(result.token, result.user, result.admin_id)
},
})
}
// MFA Reset
const isMfaResetDialogOpen = ref(false)
@@ -416,33 +404,10 @@ function getInitials(name: string): string {
</VDialog>
<!-- Impersonate Dialog -->
<VDialog
<ImpersonateDialog
v-model="isImpersonateDialogOpen"
max-width="400"
>
<VCard title="Inloggen als gebruiker">
<VCardText>
Je gaat inloggen als <strong>{{ user?.full_name }}</strong>
({{ user?.email }}). Wil je doorgaan?
</VCardText>
<VCardActions>
<VSpacer />
<VBtn
variant="text"
@click="isImpersonateDialogOpen = false"
>
Annuleren
</VBtn>
<VBtn
color="warning"
:loading="isImpersonating"
@click="confirmImpersonate"
>
Doorgaan
</VBtn>
</VCardActions>
</VCard>
</VDialog>
:user="user ?? null"
/>
<!-- MFA Reset Dialog -->
<VDialog

View File

@@ -1,10 +1,9 @@
<script setup lang="ts">
import {
useAdminUsers,
useStartImpersonation,
useDeleteAdminUser,
} from '@/composables/api/useAdmin'
import { useImpersonationStore } from '@/stores/useImpersonationStore'
import ImpersonateDialog from '@/components/platform/ImpersonateDialog.vue'
import type { AdminUser } from '@/types/admin'
definePage({
@@ -14,7 +13,6 @@ definePage({
})
const router = useRouter()
const impersonationStore = useImpersonationStore()
const search = ref('')
const searchDebounced = refDebounced(search, 400)
@@ -57,24 +55,12 @@ const headers = [
// Impersonation
const isImpersonateDialogOpen = ref(false)
const userToImpersonate = ref<AdminUser | null>(null)
const { mutate: startImpersonation, isPending: isImpersonating } = useStartImpersonation()
function openImpersonateDialog(user: AdminUser) {
userToImpersonate.value = user
isImpersonateDialogOpen.value = true
}
function confirmImpersonate() {
if (!userToImpersonate.value) return
startImpersonation(userToImpersonate.value.id, {
onSuccess: (result) => {
isImpersonateDialogOpen.value = false
impersonationStore.startImpersonation(result.token, result.user, result.admin_id)
},
})
}
// Delete
const isDeleteDialogOpen = ref(false)
const userToDelete = ref<AdminUser | null>(null)
@@ -296,33 +282,10 @@ function onUpdateOptions(options: { page: number; itemsPerPage: number }) {
</VCard>
<!-- Impersonate Dialog -->
<VDialog
<ImpersonateDialog
v-model="isImpersonateDialogOpen"
max-width="400"
>
<VCard title="Inloggen als gebruiker">
<VCardText>
Je gaat inloggen als <strong>{{ userToImpersonate?.full_name }}</strong>
({{ userToImpersonate?.email }}). Wil je doorgaan?
</VCardText>
<VCardActions>
<VSpacer />
<VBtn
variant="text"
@click="isImpersonateDialogOpen = false"
>
Annuleren
</VBtn>
<VBtn
color="warning"
:loading="isImpersonating"
@click="confirmImpersonate"
>
Doorgaan
</VBtn>
</VCardActions>
</VCard>
</VDialog>
:user="userToImpersonate"
/>
<!-- Delete Dialog -->
<VDialog

View File

@@ -1,74 +1,176 @@
import { defineStore } from 'pinia'
import { computed, ref } from 'vue'
import { apiClient } from '@/lib/axios'
import { useAuthStore } from '@/stores/useAuthStore'
import type { AdminUser } from '@/types/admin'
import type { AdminUser, ImpersonationSession, ImpersonationStartResponse, ImpersonationStatusResponse, StartImpersonationPayload } from '@/types/admin'
const IMPERSONATION_KEY = 'crewli_impersonation'
const SESSION_STORAGE_KEY = 'crewli_impersonation'
const BROADCAST_CHANNEL_NAME = 'crewli_impersonation_sync'
interface ImpersonationState {
sessionId: string
adminId: string
originalToken: string
targetUserId: string
impersonatedUser: AdminUser
expiresAt: string
}
export const useImpersonationStore = defineStore('impersonation', () => {
const stored = localStorage.getItem(IMPERSONATION_KEY)
const stored = sessionStorage.getItem(SESSION_STORAGE_KEY)
const state = ref<ImpersonationState | null>(stored ? JSON.parse(stored) : null)
let broadcastChannel: BroadcastChannel | null = null
const isImpersonating = computed(() => !!state.value)
const originalAdminId = computed(() => state.value?.adminId ?? null)
const impersonatedUser = computed(() => state.value?.impersonatedUser ?? null)
const sessionId = computed(() => state.value?.sessionId ?? null)
const targetUserId = computed(() => state.value?.targetUserId ?? null)
const expiresAt = computed(() => state.value?.expiresAt ? new Date(state.value.expiresAt) : null)
function startImpersonation(token: string, user: AdminUser, adminId: string) {
// Store the current cookie token reference (we'll restore it on stop)
// Since the app uses httpOnly cookies, we store the admin ID to know we're impersonating
state.value = {
adminId,
originalToken: '', // httpOnly cookie — we can't read it, but we track the state
impersonatedUser: user,
function persistState(): void {
if (state.value) {
sessionStorage.setItem(SESSION_STORAGE_KEY, JSON.stringify(state.value))
}
else {
sessionStorage.removeItem(SESSION_STORAGE_KEY)
}
localStorage.setItem(IMPERSONATION_KEY, JSON.stringify(state.value))
// The impersonation token from the API is a plain Sanctum token.
// Set it as a Bearer token header for subsequent requests.
apiClient.defaults.headers.common.Authorization = `Bearer ${token}`
// Reload user state to reflect the impersonated user
const authStore = useAuthStore()
authStore.initialize()
}
async function stopImpersonation() {
async function start(
userId: string,
payload: StartImpersonationPayload,
): Promise<ImpersonationStartResponse> {
const { data } = await apiClient.post<{ data: ImpersonationStartResponse }>(
`/admin/impersonate/${userId}`,
payload,
)
const result = data.data
const session = result.session
state.value = {
sessionId: session.id,
adminId: session.admin_id,
targetUserId: session.target_user_id,
impersonatedUser: result.user,
expiresAt: session.expires_at,
}
persistState()
broadcastChange('started')
// Reload to apply impersonated context
window.location.href = '/'
return result
}
async function stop(): Promise<void> {
try {
await apiClient.post('/admin/stop-impersonation')
// Call stop WITHOUT the X-Impersonate-User header
// The interceptor won't add it because we clear state first
const currentState = state.value
state.value = null
persistState()
if (currentState) {
await apiClient.post('/admin/stop-impersonation')
}
}
catch {
// Even if the API call fails, restore local state
// Even if API call fails, state is already cleared
}
// Remove the Bearer token so httpOnly cookie takes over again
delete apiClient.defaults.headers.common.Authorization
broadcastChange('stopped')
state.value = null
localStorage.removeItem(IMPERSONATION_KEY)
// Full reload to restore admin session from httpOnly cookie
// Full reload to restore admin session
window.location.href = '/platform'
}
function clearWithoutReload() {
function clearState(): void {
state.value = null
localStorage.removeItem(IMPERSONATION_KEY)
delete apiClient.defaults.headers.common.Authorization
persistState()
}
async function checkStatus(): Promise<void> {
try {
const { data } = await apiClient.get<{ data: ImpersonationStatusResponse }>(
'/admin/impersonate/status',
)
if (!data.data.active) {
if (state.value) {
clearState()
window.location.href = '/platform'
}
}
else if (data.data.session) {
// Update expiry from server
if (state.value) {
state.value.expiresAt = data.data.session.expires_at
persistState()
}
}
}
catch {
// If status check fails, don't clear — might be a network issue
}
}
function restoreFromStorage(): void {
const stored = sessionStorage.getItem(SESSION_STORAGE_KEY)
if (stored) {
try {
state.value = JSON.parse(stored)
}
catch {
sessionStorage.removeItem(SESSION_STORAGE_KEY)
state.value = null
}
}
}
function listenForBroadcasts(): void {
if (broadcastChannel) return
try {
broadcastChannel = new BroadcastChannel(BROADCAST_CHANNEL_NAME)
broadcastChannel.onmessage = (event: MessageEvent<{ type: string }>) => {
if (event.data.type === 'stopped') {
state.value = null
persistState()
window.location.href = '/platform'
}
else if (event.data.type === 'started') {
restoreFromStorage()
}
}
}
catch {
// BroadcastChannel not supported — no cross-tab sync
}
}
function broadcastChange(type: string): void {
try {
broadcastChannel?.postMessage({ type })
}
catch {
// Ignore broadcast errors
}
}
return {
isImpersonating,
originalAdminId,
impersonatedUser,
startImpersonation,
stopImpersonation,
clearWithoutReload,
sessionId,
targetUserId,
expiresAt,
start,
stop,
clearState,
checkStatus,
restoreFromStorage,
listenForBroadcasts,
}
})

View File

@@ -67,10 +67,34 @@ export interface ActivityLogEntry {
created_at: string
}
export interface ImpersonationResponse {
token: string
user: AdminUser
export interface ImpersonationSession {
id: string
admin_id: string
target_user_id: string
target_user?: AdminUser
reason: string
mfa_method: string
started_at: string
expires_at: string
ended_at: string | null
end_reason: string | null
actions_count: number
}
export interface ImpersonationStartResponse {
session: ImpersonationSession
user: AdminUser
}
export interface ImpersonationStatusResponse {
active: boolean
session?: ImpersonationSession
}
export interface StartImpersonationPayload {
reason: string
mfa_code: string
mfa_method: 'totp' | 'email' | 'backup_code'
}
export interface AdminOrganisationMember {