mirror of
https://github.com/mauriceboe/TREK.git
synced 2026-06-20 13:51:45 +00:00
fix(security): stop cross-user offline data leak on shared devices (#1176)
Closes BLOCKER B4 — three reinforcing paths could serve one account's cached data to the next user on a shared device: - The Workbox 'api-data' cache keyed trip/user-scoped GETs by URL only (cookie-blind). Changed to NetworkOnly; offline reads come from the per-user IndexedDB cache via the repo layer instead. - IndexedDB had no per-user scoping. The Dexie connection is now scoped per user (trek-offline-u<id>) behind a Proxy so the ~19 importers keep a stable binding; login opens the user DB, logout deletes it and returns to the anonymous DB. - logout() was fire-and-forget and racy: background flush/syncAll could re-seed the DB after the wipe. It is now async and ordered — close an auth gate, unregister sync triggers, disconnect, clear caches, delete the user DB — and flush()/syncAll() bail when the gate is closed.
This commit is contained in:
@@ -5,7 +5,9 @@ import { connect, disconnect } from '../api/websocket'
|
||||
import type { User } from '../types'
|
||||
import { getApiErrorMessage } from '../types'
|
||||
import { tripSyncManager } from '../sync/tripSyncManager'
|
||||
import { clearAll } from '../db/offlineDb'
|
||||
import { reopenForUser, deleteCurrentUserDb } from '../db/offlineDb'
|
||||
import { setAuthed } from '../sync/authGate'
|
||||
import { unregisterSyncTriggers } from '../sync/syncTriggers'
|
||||
import { useSystemNoticeStore } from './systemNoticeStore.js'
|
||||
|
||||
interface AuthResponse {
|
||||
@@ -40,7 +42,7 @@ interface AuthState {
|
||||
login: (email: string, password: string) => Promise<LoginResult>
|
||||
completeMfaLogin: (mfaToken: string, code: string) => Promise<AuthResponse>
|
||||
register: (username: string, email: string, password: string, invite_token?: string) => Promise<AuthResponse>
|
||||
logout: () => void
|
||||
logout: () => Promise<void>
|
||||
/** Pass `{ silent: true }` to refresh the user without toggling global isLoading (avoids unmounting protected routes). */
|
||||
loadUser: (opts?: { silent?: boolean }) => Promise<void>
|
||||
updateMapsKey: (key: string | null) => Promise<void>
|
||||
@@ -65,6 +67,19 @@ interface AuthState {
|
||||
// Sequence counter to prevent stale loadUser responses from overwriting fresh auth state
|
||||
let authSequence = 0
|
||||
|
||||
/**
|
||||
* Mark the session authenticated and point the offline DB at this user's scoped
|
||||
* database before any background sync runs, so cached data never crosses users.
|
||||
*/
|
||||
async function onAuthSuccess(userId: number): Promise<void> {
|
||||
setAuthed(true)
|
||||
try {
|
||||
await reopenForUser(userId)
|
||||
} catch (err) {
|
||||
console.error('[auth] failed to open user-scoped offline DB', err)
|
||||
}
|
||||
}
|
||||
|
||||
export const useAuthStore = create<AuthState>()(
|
||||
persist(
|
||||
(set, get) => ({
|
||||
@@ -99,6 +114,7 @@ export const useAuthStore = create<AuthState>()(
|
||||
isLoading: false,
|
||||
error: null,
|
||||
})
|
||||
await onAuthSuccess(data.user.id)
|
||||
connect()
|
||||
tripSyncManager.syncAll().catch(console.error)
|
||||
if (!data.user?.must_change_password) {
|
||||
@@ -123,6 +139,7 @@ export const useAuthStore = create<AuthState>()(
|
||||
isLoading: false,
|
||||
error: null,
|
||||
})
|
||||
await onAuthSuccess(data.user.id)
|
||||
connect()
|
||||
tripSyncManager.syncAll().catch(console.error)
|
||||
if (!data.user?.must_change_password) {
|
||||
@@ -147,6 +164,7 @@ export const useAuthStore = create<AuthState>()(
|
||||
isLoading: false,
|
||||
error: null,
|
||||
})
|
||||
await onAuthSuccess(data.user.id)
|
||||
connect()
|
||||
tripSyncManager.syncAll().catch(console.error)
|
||||
useSystemNoticeStore.getState().fetch()
|
||||
@@ -158,18 +176,27 @@ export const useAuthStore = create<AuthState>()(
|
||||
}
|
||||
},
|
||||
|
||||
logout: () => {
|
||||
logout: async () => {
|
||||
// 1. Gate first so any in-flight flush/syncAll bails before we wipe the DB.
|
||||
setAuthed(false)
|
||||
set({ isAuthenticated: false })
|
||||
// 2. Stop background sync triggers (30s interval, WS pre-reconnect hook, listeners).
|
||||
unregisterSyncTriggers()
|
||||
// 3. Tear down the live connection.
|
||||
disconnect()
|
||||
useSystemNoticeStore.getState().reset()
|
||||
// Tell server to clear the httpOnly cookie
|
||||
fetch('/api/auth/logout', { method: 'POST', credentials: 'include' }).catch(() => {})
|
||||
// Clear service worker caches containing sensitive data
|
||||
// 4. Tell server to clear the httpOnly cookie (best-effort).
|
||||
await fetch('/api/auth/logout', { method: 'POST', credentials: 'include' }).catch(() => {})
|
||||
// 5. Clear service worker caches containing sensitive data.
|
||||
if ('caches' in window) {
|
||||
caches.delete('api-data').catch(() => {})
|
||||
caches.delete('user-uploads').catch(() => {})
|
||||
await Promise.all([
|
||||
caches.delete('api-data').catch(() => {}),
|
||||
caches.delete('user-uploads').catch(() => {}),
|
||||
])
|
||||
}
|
||||
// Purge all cached trip data from IndexedDB
|
||||
clearAll().catch(console.error)
|
||||
// 6. Delete this user's scoped IndexedDB and return to the anonymous DB.
|
||||
await deleteCurrentUserDb().catch(console.error)
|
||||
// 7. Finish clearing auth state.
|
||||
set({
|
||||
user: null,
|
||||
isAuthenticated: false,
|
||||
@@ -189,6 +216,7 @@ export const useAuthStore = create<AuthState>()(
|
||||
isAuthenticated: true,
|
||||
isLoading: false,
|
||||
})
|
||||
await onAuthSuccess(data.user.id)
|
||||
connect()
|
||||
} catch (err: unknown) {
|
||||
if (seq !== authSequence) return // stale response — ignore
|
||||
@@ -282,6 +310,7 @@ export const useAuthStore = create<AuthState>()(
|
||||
demoMode: true,
|
||||
error: null,
|
||||
})
|
||||
await onAuthSuccess(data.user.id)
|
||||
connect()
|
||||
return data
|
||||
} catch (err: unknown) {
|
||||
|
||||
Reference in New Issue
Block a user