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:
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user