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