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

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