From 19527cdf42d0e7aa285318cefa6cbe6747640416 Mon Sep 17 00:00:00 2001 From: liangxu Date: Sat, 25 Apr 2026 16:48:15 +0800 Subject: [PATCH] feat(admin-web): proactively refresh access tokens before expiry Track access token expires_at, dedupe concurrent refreshes, retry SSE on 401, and refresh on window focus/visibility so long-lived sessions stop hitting the login redirect mid-task. Co-Authored-By: Claude Opus 4.7 (1M context) --- apps/admin-web/src/lib/api.ts | 122 ++++++++++++++++++++++++----- apps/admin-web/src/lib/errors.ts | 1 + apps/admin-web/src/lib/session.ts | 24 +++++- apps/admin-web/src/main.ts | 13 +++ apps/admin-web/src/stores/auth.ts | 83 ++++++++++++++++++-- packages/shared-types/src/index.ts | 1 + 6 files changed, 218 insertions(+), 26 deletions(-) diff --git a/apps/admin-web/src/lib/api.ts b/apps/admin-web/src/lib/api.ts index 61e5fb6..1c55605 100644 --- a/apps/admin-web/src/lib/api.ts +++ b/apps/admin-web/src/lib/api.ts @@ -115,6 +115,7 @@ import { clearStoredSession, getStoredAccessToken, getStoredRefreshToken, + readStoredSession, setStoredTokens, } from "./session"; import { @@ -126,8 +127,10 @@ import { const baseURL = import.meta.env.VITE_API_BASE_URL ?? ""; const generationStreamEnabled = import.meta.env.VITE_GENERATION_STREAM_ENABLED === "true"; +const accessRefreshSkewMs = 2 * 60 * 1000; const publicClient = createApiClient({ baseURL }); +let storedRefreshPromise: Promise | null = null; export function getApiBaseURL(): string { if (baseURL) { @@ -157,24 +160,88 @@ export function resolveApiURL(value?: string | null): string { return new URL(trimmed, apiBaseURL.endsWith("/") ? apiBaseURL : `${apiBaseURL}/`).toString(); } +function toAuthTokens(data: RefreshResponse): AuthTokens { + return { + accessToken: data.access_token, + refreshToken: data.refresh_token, + expiresAt: data.expires_at, + }; +} + +function authRequiredError(): ApiClientError { + return new ApiClientError({ + message: "not_authenticated", + code: 40100, + status: 401, + }); +} + +function expireStoredSession(): void { + clearStoredSession({ notifyAuthExpired: true }); +} + +export function shouldRefreshAccessToken( + expiresAt: number | null, + minRemainingMs = accessRefreshSkewMs, +): boolean { + return !expiresAt || expiresAt * 1000 - Date.now() <= minRemainingMs; +} + +export async function refreshStoredAuthSession(refreshToken = getStoredRefreshToken()): Promise { + if (!refreshToken) { + expireStoredSession(); + throw authRequiredError(); + } + + if (!storedRefreshPromise) { + storedRefreshPromise = publicClient + .post("/api/auth/refresh", { + refresh_token: refreshToken, + }) + .then((data) => { + const tokens = toAuthTokens(data); + setStoredTokens(tokens); + return tokens; + }) + .catch((error) => { + expireStoredSession(); + throw error; + }) + .finally(() => { + storedRefreshPromise = null; + }); + } + + return storedRefreshPromise; +} + +async function getFreshStoredAccessToken(): Promise { + const snapshot = readStoredSession(); + + if (snapshot.refreshToken && shouldRefreshAccessToken(snapshot.accessTokenExpiresAt)) { + return (await refreshStoredAuthSession(snapshot.refreshToken)).accessToken; + } + + if (snapshot.accessToken) { + return snapshot.accessToken; + } + + if (snapshot.refreshToken) { + return (await refreshStoredAuthSession(snapshot.refreshToken)).accessToken; + } + + expireStoredSession(); + throw authRequiredError(); +} + export const apiClient = createApiClient({ baseURL, auth: { getAccessToken: getStoredAccessToken, getRefreshToken: getStoredRefreshToken, setTokens: setStoredTokens, - clearTokens: clearStoredSession, - async refresh(refreshToken: string): Promise { - const data = await publicClient.post( - "/api/auth/refresh", - { refresh_token: refreshToken }, - ); - - return { - accessToken: data.access_token, - refreshToken: data.refresh_token, - }; - }, + clearTokens: expireStoredSession, + refresh: refreshStoredAuthSession, }, }); @@ -182,6 +249,7 @@ export const authApi = { login(payload: LoginRequest) { return publicClient.post("/api/auth/login", payload); }, + refresh: refreshStoredAuthSession, me() { return apiClient.get("/api/auth/me"); }, @@ -710,23 +778,37 @@ async function subscribeSSE( }, signal: AbortSignal, ): Promise { - const accessToken = getStoredAccessToken(); - if (!accessToken) { - throw new Error("missing access token"); - } - const requestHeaders = new Headers(init.headers); requestHeaders.set("Accept", "text/event-stream"); - requestHeaders.set("Authorization", `Bearer ${accessToken}`); + requestHeaders.set("Authorization", `Bearer ${await getFreshStoredAccessToken()}`); - const response = await fetch(`${baseURL}${path}`, { + let response = await fetch(`${baseURL}${path}`, { ...init, headers: requestHeaders, signal, }); + if (response.status === 401) { + try { + const tokens = await refreshStoredAuthSession(); + requestHeaders.set("Authorization", `Bearer ${tokens.accessToken}`); + response = await fetch(`${baseURL}${path}`, { + ...init, + headers: requestHeaders, + signal, + }); + } catch (error) { + expireStoredSession(); + throw error; + } + } + if (!response.ok) { - throw await buildSSERequestError(response); + const error = await buildSSERequestError(response); + if (response.status === 401) { + expireStoredSession(); + } + throw error; } if (!response.body) { throw new Error("stream response body is empty"); diff --git a/apps/admin-web/src/lib/errors.ts b/apps/admin-web/src/lib/errors.ts index 06c861c..12710a4 100644 --- a/apps/admin-web/src/lib/errors.ts +++ b/apps/admin-web/src/lib/errors.ts @@ -4,6 +4,7 @@ const errorMessageMap: Record = { invalid_credentials: "邮箱或密码错误", no_tenant: "当前账号未关联租户", not_authenticated: "请先登录", + missing_bearer_token: "请先登录", invalid_access_token: "登录状态已失效", invalid_refresh_token: "刷新令牌已失效", refresh_session_expired: "登录已过期,请重新登录", diff --git a/apps/admin-web/src/lib/session.ts b/apps/admin-web/src/lib/session.ts index 5fa0b7d..871a994 100644 --- a/apps/admin-web/src/lib/session.ts +++ b/apps/admin-web/src/lib/session.ts @@ -3,18 +3,22 @@ import type { AuthTokens, UserInfo } from "@geo/shared-types"; export interface SessionSnapshot { accessToken: string | null; refreshToken: string | null; + accessTokenExpiresAt: number | null; user: UserInfo | null; } type SessionListener = (snapshot: SessionSnapshot) => void; +type AuthExpiredListener = () => void; const STORAGE_KEY = "geo.admin-web.session"; const listeners = new Set(); +const authExpiredListeners = new Set(); function emptySession(): SessionSnapshot { return { accessToken: null, refreshToken: null, + accessTokenExpiresAt: null, user: null, }; } @@ -32,6 +36,10 @@ function sanitizeSession(candidate: unknown): SessionSnapshot { return { accessToken: typeof value.accessToken === "string" ? value.accessToken : null, refreshToken: typeof value.refreshToken === "string" ? value.refreshToken : null, + accessTokenExpiresAt: + typeof value.accessTokenExpiresAt === "number" && Number.isFinite(value.accessTokenExpiresAt) + ? value.accessTokenExpiresAt + : null, user: value.user && typeof value.user === "object" ? (value.user as UserInfo) : null, }; } @@ -40,6 +48,10 @@ function notify(snapshot: SessionSnapshot): void { listeners.forEach((listener) => listener(snapshot)); } +function notifyAuthExpired(): void { + authExpiredListeners.forEach((listener) => listener()); +} + export function readStoredSession(): SessionSnapshot { if (!hasStorage()) { return emptySession(); @@ -75,6 +87,7 @@ export function setStoredTokens(tokens: AuthTokens): SessionSnapshot { return updateStoredSession({ accessToken: tokens.accessToken, refreshToken: tokens.refreshToken, + accessTokenExpiresAt: tokens.expiresAt ?? null, }); } @@ -82,12 +95,15 @@ export function setStoredUser(user: UserInfo | null): SessionSnapshot { return updateStoredSession({ user }); } -export function clearStoredSession(): SessionSnapshot { +export function clearStoredSession(options: { notifyAuthExpired?: boolean } = {}): SessionSnapshot { if (hasStorage()) { window.localStorage.removeItem(STORAGE_KEY); } const next = emptySession(); notify(next); + if (options.notifyAuthExpired) { + notifyAuthExpired(); + } return next; } @@ -106,3 +122,9 @@ export function subscribeStoredSession(listener: SessionListener): () => void { }; } +export function subscribeAuthExpired(listener: AuthExpiredListener): () => void { + authExpiredListeners.add(listener); + return () => { + authExpiredListeners.delete(listener); + }; +} diff --git a/apps/admin-web/src/main.ts b/apps/admin-web/src/main.ts index 5bd1df0..3900531 100644 --- a/apps/admin-web/src/main.ts +++ b/apps/admin-web/src/main.ts @@ -51,6 +51,7 @@ import { import App from "./App.vue"; import { i18n } from "./i18n"; +import { subscribeAuthExpired } from "./lib/session"; import { router } from "./router"; import { pinia } from "./stores/pinia"; import "./styles.css"; @@ -70,6 +71,18 @@ const queryClient = new QueryClient({ const app = createApp(App); +subscribeAuthExpired(() => { + const current = router.currentRoute.value; + if (current.name === "login") { + return; + } + + void router.replace({ + name: "login", + query: current.fullPath ? { redirect: current.fullPath } : undefined, + }); +}); + const IconFont = createFromIconfontCN({ // 需要替换为你自己真实项目的 js 地址,才能显示出你的 '#icon-line-global_undo'。 scriptUrl: "//at.alicdn.com/t/c/font_5154382_922oh81rh1.js", diff --git a/apps/admin-web/src/stores/auth.ts b/apps/admin-web/src/stores/auth.ts index 9f76186..20b686d 100644 --- a/apps/admin-web/src/stores/auth.ts +++ b/apps/admin-web/src/stores/auth.ts @@ -3,7 +3,7 @@ import { computed, ref } from "vue"; import type { LoginRequest, UserInfo } from "@geo/shared-types"; -import { authApi } from "@/lib/api"; +import { authApi, refreshStoredAuthSession, shouldRefreshAccessToken } from "@/lib/api"; import { clearStoredSession, readStoredSession, @@ -16,14 +16,79 @@ import { export const useAuthStore = defineStore("auth", () => { const accessToken = ref(null); const refreshToken = ref(null); + const accessTokenExpiresAt = ref(null); const user = ref(null); const initialized = ref(false); let unsubscribe: (() => void) | null = null; + let refreshTimer: number | null = null; + let lifecycleRefreshRegistered = false; + + function clearRefreshTimer(): void { + if (refreshTimer) { + window.clearTimeout(refreshTimer); + refreshTimer = null; + } + } + + function scheduleRefresh(): void { + clearRefreshTimer(); + if (!refreshToken.value || typeof window === "undefined") { + return; + } + + const nextRefreshAt = accessTokenExpiresAt.value + ? accessTokenExpiresAt.value * 1000 - 2 * 60 * 1000 + : Date.now() + 30 * 1000; + const delay = Math.max(30 * 1000, nextRefreshAt - Date.now()); + + refreshTimer = window.setTimeout(() => { + void refreshSession(); + }, delay); + } function sync(snapshot: SessionSnapshot = readStoredSession()): void { accessToken.value = snapshot.accessToken; refreshToken.value = snapshot.refreshToken; + accessTokenExpiresAt.value = snapshot.accessTokenExpiresAt; user.value = snapshot.user; + scheduleRefresh(); + } + + async function refreshSession(): Promise { + if (!refreshToken.value) { + return; + } + + try { + await refreshStoredAuthSession(refreshToken.value); + } catch { + clearStoredSession({ notifyAuthExpired: true }); + } + } + + async function refreshSessionIfNeeded(minRemainingMs = 2 * 60 * 1000): Promise { + if (!refreshToken.value) { + return; + } + if (!accessToken.value || shouldRefreshAccessToken(accessTokenExpiresAt.value, minRemainingMs)) { + await refreshSession(); + } + } + + function registerLifecycleRefresh(): void { + if (lifecycleRefreshRegistered || typeof window === "undefined") { + return; + } + + lifecycleRefreshRegistered = true; + window.addEventListener("focus", () => { + void refreshSessionIfNeeded(); + }); + document.addEventListener("visibilitychange", () => { + if (document.visibilityState === "visible") { + void refreshSessionIfNeeded(); + } + }); } async function bootstrap(): Promise { @@ -32,13 +97,19 @@ export const useAuthStore = defineStore("auth", () => { } sync(); - if (accessToken.value) { - try { + registerLifecycleRefresh(); + + try { + if (refreshToken.value) { + await refreshSessionIfNeeded(Number.POSITIVE_INFINITY); + } + + if (accessToken.value) { const currentUser = await authApi.me(); setStoredUser(currentUser); - } catch { - clearStoredSession(); } + } catch { + clearStoredSession({ notifyAuthExpired: true }); } initialized.value = true; @@ -49,6 +120,7 @@ export const useAuthStore = defineStore("auth", () => { setStoredTokens({ accessToken: response.access_token, refreshToken: response.refresh_token, + expiresAt: response.expires_at, }); setStoredUser(response.user); return response.user; @@ -73,6 +145,7 @@ export const useAuthStore = defineStore("auth", () => { return { accessToken, refreshToken, + accessTokenExpiresAt, user, initialized, isAuthenticated, diff --git a/packages/shared-types/src/index.ts b/packages/shared-types/src/index.ts index 268a8e2..4b920b7 100644 --- a/packages/shared-types/src/index.ts +++ b/packages/shared-types/src/index.ts @@ -17,6 +17,7 @@ export interface ApiErrorEnvelope { export interface AuthTokens { accessToken: string; refreshToken: string; + expiresAt?: number; } export interface LoginRequest {