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

@@ -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,
}
})