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

@@ -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>