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:
183
apps/app/src/components/platform/ImpersonateDialog.vue
Normal file
183
apps/app/src/components/platform/ImpersonateDialog.vue
Normal 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>
|
||||
@@ -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>
|
||||
|
||||
Reference in New Issue
Block a user