feat(10-01): harden the admin cookie session and prefix boot guards
- refresh and logout read the Bearer header first, then the summer_admin cookie; a cookie refresh rotates the cookie without a token in the body and logout always expires the cookie - backend.cookie_secure (default true) may drop Secure outside production only - activation rejects controller vendor segments api, assets, login, settings - BuildRouter rejects non-cabana routes at or under the admin prefix - SPA single-flights refresh on 401, replays once, and refreshes proactively at 80 percent of expires_in; dist rebuilt - scripts/check-admin-dist.sh rebuilds the SPA and fails on dist drift - tests: TestPhase10CookieAuth, TestPhase10CSRF, TestPhase10Prefix, TestPhase10AdminPrefixCollision, boardwalk serving and header tests
This commit is contained in:
@@ -1,11 +1,34 @@
|
||||
// Admin session state. The JWT travels in an HttpOnly cookie (D-19): the SPA
|
||||
// keeps only the profile and the access lifetime, never a token.
|
||||
import { readonly, ref } from 'vue'
|
||||
import { api } from '../api/client'
|
||||
import { api, onRefreshed, refreshSession } from '../api/client'
|
||||
import type { AdminProfile } from '../api/types'
|
||||
|
||||
const user = ref<AdminProfile | null>(null)
|
||||
const expiresIn = ref<number | null>(null)
|
||||
let refreshTimer: ReturnType<typeof setTimeout> | null = null
|
||||
|
||||
/** Refreshes proactively at 80 percent of the access token lifetime. */
|
||||
function scheduleRefresh(seconds: number | null): void {
|
||||
cancelRefresh()
|
||||
expiresIn.value = seconds
|
||||
if (seconds === null || seconds <= 0) {
|
||||
return
|
||||
}
|
||||
refreshTimer = setTimeout(() => {
|
||||
refreshTimer = null
|
||||
void refreshSession()
|
||||
}, seconds * 800)
|
||||
}
|
||||
|
||||
function cancelRefresh(): void {
|
||||
if (refreshTimer !== null) {
|
||||
clearTimeout(refreshTimer)
|
||||
refreshTimer = null
|
||||
}
|
||||
}
|
||||
|
||||
onRefreshed(scheduleRefresh)
|
||||
|
||||
export const currentUser = readonly(user)
|
||||
|
||||
@@ -17,7 +40,7 @@ export async function login(identifier: string, password: string): Promise<boole
|
||||
if (!response.ok || !data) {
|
||||
return false
|
||||
}
|
||||
expiresIn.value = typeof data.data.expires_in === 'number' ? data.data.expires_in : null
|
||||
scheduleRefresh(typeof data.data.expires_in === 'number' ? data.data.expires_in : null)
|
||||
return true
|
||||
}
|
||||
|
||||
@@ -29,6 +52,7 @@ export async function me(): Promise<AdminProfile | null> {
|
||||
}
|
||||
|
||||
export function clearUser(): void {
|
||||
cancelRefresh()
|
||||
user.value = null
|
||||
expiresIn.value = null
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user