From dabb03e55b6dd18f6cb69c3418a1a23b34661796 Mon Sep 17 00:00:00 2001 From: shunli Date: Sun, 12 Apr 2026 16:45:49 +0800 Subject: [PATCH] =?UTF-8?q?R61=20A1-A6=EF=BC=9AAuth=20token=20refresh=20?= =?UTF-8?q?=E4=B8=BB=E5=8A=A8=E7=BB=AD=E6=9C=9F?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- client/src/api/http.ts | 17 ++- client/src/api/types.ts | 12 ++ client/src/auth/AuthContext.tsx | 179 ++++++++++++++++++++----- issues/2026-04-12_r61-auth-refresh.csv | 7 + server/src/routes/auth.js | 43 +++++- 5 files changed, 213 insertions(+), 45 deletions(-) create mode 100644 issues/2026-04-12_r61-auth-refresh.csv diff --git a/client/src/api/http.ts b/client/src/api/http.ts index df04e917..17cde7ac 100644 --- a/client/src/api/http.ts +++ b/client/src/api/http.ts @@ -1,4 +1,4 @@ -import { ApiError, type ApiErrorBody, type ApiSuccess } from "./types"; +import { ApiError, SessionExpiredError, type ApiErrorBody, type ApiSuccess } from "./types"; type AccessTokenGetter = () => string | null; type RefreshAccessToken = () => Promise; @@ -79,6 +79,13 @@ async function getRefreshedToken(): Promise { return refreshPromise; } +function throwSessionExpired(): never { + if (clearSession) { + clearSession(); + } + throw new SessionExpiredError(); +} + async function sendRequest( path: string, options: ApiRequestOptions, @@ -118,9 +125,7 @@ async function sendRequest( }); } - if (clearSession) { - clearSession(); - } + throwSessionExpired(); } const payload = await safeParseJson(response); @@ -210,9 +215,7 @@ export async function apiRequestBlob( }); } - if (clearSession) { - clearSession(); - } + throwSessionExpired(); } const payload = await safeParseJson(response); diff --git a/client/src/api/types.ts b/client/src/api/types.ts index 83575cae..ab83873d 100644 --- a/client/src/api/types.ts +++ b/client/src/api/types.ts @@ -19,6 +19,7 @@ export type LoginRequest = { export type LoginResponse = { accessToken: string; + expiresIn?: number; mustChangePassword?: boolean; }; @@ -73,6 +74,17 @@ export function isApiError(value: unknown): value is ApiError { return value instanceof ApiError; } +export class SessionExpiredError extends Error { + constructor(message = "会话已过期") { + super(message); + this.name = "SessionExpiredError"; + } +} + +export function isSessionExpiredError(value: unknown): value is SessionExpiredError { + return value instanceof SessionExpiredError; +} + export type WorkflowTaskSummary = { id: string; diff --git a/client/src/auth/AuthContext.tsx b/client/src/auth/AuthContext.tsx index 69d3a757..fe683d0c 100644 --- a/client/src/auth/AuthContext.tsx +++ b/client/src/auth/AuthContext.tsx @@ -9,7 +9,7 @@ import { useState, } from "react"; import { configureHttpAuth } from "../api/http"; -import type { AuthState, MeResponse } from "../api/types"; +import type { AuthState, LoginResponse, MeResponse } from "../api/types"; import { changePassword as changePasswordApi } from "../api/auth"; import { clearPwaRuntimeCaches } from "../pwa"; import { @@ -51,6 +51,8 @@ const MUST_CHANGE_PASSWORD_STORAGE_KEY = "hyb-oa.must_change_password"; const OFFLINE_AUTH_SNAPSHOT_STORAGE_KEY = "hyb-oa.auth_snapshot"; const OFFLINE_AUTH_SNAPSHOT_VERSION = 1; const OFFLINE_AUTH_SNAPSHOT_TTL_MS = 24 * 60 * 60 * 1000; +const PROACTIVE_REFRESH_RATIO = 0.8; +const MIN_REFRESH_DELAY_MS = 1000; function isWindowAvailable() { return typeof window !== "undefined"; @@ -144,14 +146,35 @@ export function AuthProvider({ children }: PropsWithChildren) { const [state, setState] = useState(EMPTY_STATE); const [offlineAccessMode, setOfflineAccessMode] = useState(false); const tokenRef = useRef(null); + const meRef = useRef(null); const bootstrapPromiseRef = useRef | null>(null); + const refreshRequestRef = useRef | null>(null); + const proactiveRefreshTimerRef = useRef | null>(null); + const scheduleProactiveRefreshRef = useRef<(expiresIn?: number) => void>(() => undefined); + const sessionGenerationRef = useRef(0); useEffect(() => { tokenRef.current = state.accessToken; }, [state.accessToken]); + useEffect(() => { + meRef.current = state.me; + }, [state.me]); + + const clearProactiveRefreshTimer = useCallback(() => { + if (proactiveRefreshTimerRef.current !== null) { + clearTimeout(proactiveRefreshTimerRef.current); + proactiveRefreshTimerRef.current = null; + } + }, []); + const clearSession = useCallback(() => { + sessionGenerationRef.current += 1; + bootstrapPromiseRef.current = null; + refreshRequestRef.current = null; + clearProactiveRefreshTimer(); tokenRef.current = null; + meRef.current = null; setOfflineAccessMode(false); writeMustChangePasswordToStorage(false); clearOfflineAuthSnapshot(); @@ -164,12 +187,13 @@ export function AuthProvider({ children }: PropsWithChildren) { mustChangePassword: false, ready: true, })); - }, []); + }, [clearProactiveRefreshTimer]); const commitAuthenticatedUser = useCallback( (accessToken: string, me: MeResponse, mustChangePassword: boolean) => { const permissions = resolvePermissionsForRoles(me.roleCodes ?? []); tokenRef.current = accessToken; + meRef.current = me; setOfflineAccessMode(false); writeMustChangePasswordToStorage(mustChangePassword); writeOfflineAuthSnapshot(me, permissions); @@ -184,12 +208,92 @@ export function AuthProvider({ children }: PropsWithChildren) { [], ); - const activateOfflineSnapshot = useCallback((snapshot: OfflineAuthSnapshot) => { - tokenRef.current = null; - setOfflineAccessMode(true); - setState(buildStateWithOfflineSnapshot(snapshot)); + const commitAccessToken = useCallback((accessToken: string) => { + tokenRef.current = accessToken; + setOfflineAccessMode(false); + setState((previous) => ({ + ...previous, + accessToken, + ready: true, + })); }, []); + const runRefresh = useCallback(async () => { + if (refreshRequestRef.current) { + return refreshRequestRef.current; + } + + const refreshGeneration = sessionGenerationRef.current; + const refreshPromise = (async () => { + const refreshed = await refreshSession(); + if (refreshGeneration !== sessionGenerationRef.current) { + return null; + } + + if (!meRef.current) { + const me = await fetchCurrentEmployee(refreshed.accessToken); + if (refreshGeneration !== sessionGenerationRef.current) { + return null; + } + + const mustChangePassword = readMustChangePasswordFromStorage(); + commitAuthenticatedUser(refreshed.accessToken, me, mustChangePassword); + return refreshed; + } + + commitAccessToken(refreshed.accessToken); + return refreshed; + })(); + + refreshRequestRef.current = refreshPromise; + void refreshPromise + .finally(() => { + if (refreshRequestRef.current === refreshPromise) { + refreshRequestRef.current = null; + } + }) + .catch(() => undefined); + + return refreshPromise; + }, [commitAccessToken, commitAuthenticatedUser]); + + const scheduleProactiveRefresh = useCallback( + (expiresIn?: number) => { + clearProactiveRefreshTimer(); + if (!isWindowAvailable() || typeof expiresIn !== "number" || !Number.isFinite(expiresIn) || expiresIn <= 0) { + return; + } + + const delayMs = Math.max(MIN_REFRESH_DELAY_MS, Math.floor(expiresIn * 1000 * PROACTIVE_REFRESH_RATIO)); + proactiveRefreshTimerRef.current = window.setTimeout(() => { + proactiveRefreshTimerRef.current = null; + void runRefresh() + .then((refreshed) => { + if (refreshed?.expiresIn) { + scheduleProactiveRefreshRef.current(refreshed.expiresIn); + } + }) + .catch(() => undefined); + }, delayMs); + }, + [clearProactiveRefreshTimer, runRefresh], + ); + + useEffect(() => { + scheduleProactiveRefreshRef.current = scheduleProactiveRefresh; + }, [scheduleProactiveRefresh]); + + const activateOfflineSnapshot = useCallback( + (snapshot: OfflineAuthSnapshot) => { + clearProactiveRefreshTimer(); + tokenRef.current = null; + meRef.current = snapshot.me; + setOfflineAccessMode(true); + setState(buildStateWithOfflineSnapshot(snapshot)); + }, + [clearProactiveRefreshTimer], + ); + const bootstrapByRefresh = useCallback(async () => { const offlineSnapshot = readOfflineAuthSnapshot(); if (isWindowAvailable() && window.navigator.onLine === false && offlineSnapshot) { @@ -200,11 +304,11 @@ export function AuthProvider({ children }: PropsWithChildren) { if (!bootstrapPromiseRef.current) { bootstrapPromiseRef.current = (async () => { try { - const refreshed = await refreshSession(); - const me = await fetchCurrentEmployee(refreshed.accessToken); - const mustChangePassword = readMustChangePasswordFromStorage(); - commitAuthenticatedUser(refreshed.accessToken, me, mustChangePassword); - return refreshed.accessToken; + const refreshed = await runRefresh(); + if (refreshed?.expiresIn) { + scheduleProactiveRefresh(refreshed.expiresIn); + } + return refreshed?.accessToken ?? null; } catch { if (isWindowAvailable() && window.navigator.onLine === false && offlineSnapshot) { activateOfflineSnapshot(offlineSnapshot); @@ -219,7 +323,7 @@ export function AuthProvider({ children }: PropsWithChildren) { } await bootstrapPromiseRef.current; - }, [activateOfflineSnapshot, clearSession, commitAuthenticatedUser]); + }, [activateOfflineSnapshot, clearSession, runRefresh, scheduleProactiveRefresh]); const login = useCallback( async (identifier: string, password: string) => { @@ -227,9 +331,10 @@ export function AuthProvider({ children }: PropsWithChildren) { const mustChangePassword = Boolean(result.mustChangePassword); const me = await fetchCurrentEmployee(result.accessToken); commitAuthenticatedUser(result.accessToken, me, mustChangePassword); + scheduleProactiveRefresh(result.expiresIn); return { mustChangePassword }; }, - [commitAuthenticatedUser], + [commitAuthenticatedUser, scheduleProactiveRefresh], ); const changePassword = useCallback( @@ -237,8 +342,9 @@ export function AuthProvider({ children }: PropsWithChildren) { const result = await changePasswordApi({ oldPassword, newPassword }); const me = await fetchCurrentEmployee(result.accessToken); commitAuthenticatedUser(result.accessToken, me, false); + scheduleProactiveRefresh(result.expiresIn); }, - [commitAuthenticatedUser], + [commitAuthenticatedUser, scheduleProactiveRefresh], ); const logout = useCallback(async () => { @@ -252,13 +358,17 @@ export function AuthProvider({ children }: PropsWithChildren) { const refreshMe = useCallback(async () => { let accessToken = tokenRef.current; if (!accessToken) { - const refreshed = await refreshSession(); + const refreshed = await runRefresh(); + if (!refreshed?.accessToken) { + throw new Error("会话已失效"); + } accessToken = refreshed.accessToken; - tokenRef.current = accessToken; + scheduleProactiveRefresh(refreshed.expiresIn); } const me = await fetchCurrentEmployee(accessToken); const permissions = resolvePermissionsForRoles(me.roleCodes ?? []); + meRef.current = me; setOfflineAccessMode(false); writeOfflineAuthSnapshot(me, permissions); setState((previous) => ({ @@ -268,7 +378,7 @@ export function AuthProvider({ children }: PropsWithChildren) { permissions, ready: true, })); - }, []); + }, [runRefresh, scheduleProactiveRefresh]); useEffect(() => { configureHttpAuth({ @@ -281,36 +391,31 @@ export function AuthProvider({ children }: PropsWithChildren) { } } - try { - const refreshed = await refreshSession(); - if (!state.me) { - const me = await fetchCurrentEmployee(refreshed.accessToken); - const mustChangePassword = readMustChangePasswordFromStorage(); - commitAuthenticatedUser(refreshed.accessToken, me, mustChangePassword); - return refreshed.accessToken; - } - - tokenRef.current = refreshed.accessToken; - setOfflineAccessMode(false); - setState((previous) => ({ - ...previous, - accessToken: refreshed.accessToken, - ready: true, - })); - return refreshed.accessToken; - } catch { - clearSession(); + const refreshed = await runRefresh(); + if (!refreshed?.accessToken) { return null; } + + scheduleProactiveRefresh(refreshed.expiresIn); + return refreshed.accessToken; }, clearSession, }); - }, [clearSession, commitAuthenticatedUser, state.me]); + }, [clearSession, runRefresh, scheduleProactiveRefresh]); useEffect(() => { void bootstrapByRefresh(); }, [bootstrapByRefresh]); + useEffect(() => { + return () => { + sessionGenerationRef.current += 1; + bootstrapPromiseRef.current = null; + refreshRequestRef.current = null; + clearProactiveRefreshTimer(); + }; + }, [clearProactiveRefreshTimer]); + useEffect(() => { if (!offlineAccessMode) { return undefined; diff --git a/issues/2026-04-12_r61-auth-refresh.csv b/issues/2026-04-12_r61-auth-refresh.csv new file mode 100644 index 00000000..55958089 --- /dev/null +++ b/issues/2026-04-12_r61-auth-refresh.csv @@ -0,0 +1,7 @@ +ID,Title,Description,Acceptance,Test_Method,Tools,Dev_Status,Review1_Status,Regression_Status,Files,Dependencies,Notes +"A1","R61 执行合同建档与范围冻结","创建并校验 R61 Auth token refresh 主动续期执行合同 CSV,冻结范围:1) 后端 login/refresh/change-password 响应补充 expiresIn;2) 前端 AuthContext 增加主动 refresh 定时器;3) http 401 retry 失败时走静默 session-expired;4) reviewer/QA 收口。不新增 Prisma 迁移,不引入新依赖。","issues/2026-04-12_r61-auth-refresh.csv 列完整、Dependencies 为 JSON 数组字符串、A2-A6 范围与当前方案一致,且不混入 Prisma 迁移或其他模块。","命令:python3 /home/shunli/.codex/skills/plan/scripts/validate_issues_csv.py issues/2026-04-12_r61-auth-refresh.csv。检查:1) 对照 AGENTS.md 确认 12 列完整;2) A2-A6 依赖链合理;3) Notes 明确无迁移、无新依赖与回滚路径。","manual","DONE","DONE","DONE","issues/2026-04-12_r61-auth-refresh.csv","[]","回滚路径:删除本次 CSV 不影响业务。" +"A2","后端认证响应补充 expiresIn","在 server/src/routes/auth.js 内增加 access token TTL 秒数解析 helper,并为 POST /login、POST /refresh、POST /change-password 的 JSON 响应新增 expiresIn 字段;不改变现有字段结构。OIDC callback 保持 redirect-only,不改响应体。","1) login/refresh/change-password 的 data 中均新增 expiresIn:number;2) expiresIn 与 config.accessTokenTtl 的秒数换算一致;3) 仅新增字段,不影响现有 accessToken/mustChangePassword 结构;4) OIDC callback 仍为 redirect-only。","命令:export PATH=/home/shunli/.nvm/versions/node/v22.20.0/bin:$PATH && cd server && node --check src/routes/auth.js。业务:调用 /api/auth/login、/api/auth/refresh、/api/auth/change-password,检查响应均带 expiresIn,且数值与 access token TTL 一致。","manual","DONE","DONE","DONE","server/src/routes/auth.js","[""A1""]","回滚路径:移除 auth.js 中 expiresIn helper 与三个响应字段。无 Prisma 迁移。" +"A3","前端 AuthContext 主动 refresh 定时器","在 client/src/auth/AuthContext.tsx 中读取 login/refresh/change-password 响应的 expiresIn,增加单例 setTimeout,在 expiresIn * 0.8 时主动调用 refreshSession;成功后更新 accessToken 并重置定时器,失败时保留现有被动 refresh 兜底。","1) 登录、bootstrap refresh、被动 refresh、改密成功后都会调度下一次主动 refresh;2) 任意时刻仅存在一个活跃 refresh 定时器;3) clearSession 与组件卸载时会清除定时器;4) 主动 refresh 失败不会直接 clearSession,仍保留现有 401 被动 refresh。","命令:export PATH=/home/shunli/.nvm/versions/node/v22.20.0/bin:$PATH && cd client && npm run typecheck && npm run build。业务:1) 登录后确认主动 refresh 定时器启动;2) refresh 成功后 token 更新且不会生成重复定时器;3) 登出后定时器被清理。","manual","DONE","DONE","DONE","client/src/auth/AuthContext.tsx|client/src/auth/authService.ts|client/src/api/auth.ts|client/src/api/types.ts","[""A2""]","回滚路径:移除 AuthContext 主动 refresh 调度逻辑,恢复仅 401 被动 refresh。保持 LoginResponse 兼容。" +"A4","前端 401 retry 失败静默收口","在 client/src/api/http.ts 中为 refresh 失败后的 401 retry 引入静默 session-expired 错误,不再继续解析原始 401 响应并构造普通 ApiError;bootstrap refresh 失败继续保持已 catch 的静默处理。","1) sendRequest 在 refresh 返回 null 后直接抛出专用 session-expired 错误;2) 不再对原始 401 响应执行 safeParseJson + buildApiError;3) clearSession 仍会执行,且不会产生 unhandled rejection;4) 不吞掉其他非 session-expired API 错误。","命令:export PATH=/home/shunli/.nvm/versions/node/v22.20.0/bin:$PATH && cd client && npm run typecheck && npm run build。业务:1) 模拟 refresh token 失效,原请求 401 后进入静默 session-expired 路径;2) bootstrapByRefresh 的 refresh 401 不产生未处理 Promise rejection。","manual","DONE","DONE","DONE","client/src/api/http.ts|client/src/api/types.ts|client/src/auth/AuthContext.tsx","[""A3""]","回滚路径:移除 session-expired 专用错误与静默分支,恢复原有 ApiError 行为。" +"A5","Reviewer 复核定时器与刷新竞态","复核主动 refresh 定时器、被动 refresh 兜底与 session-expired 收口,重点检查是否存在多定时器、并发 refresh、安全泄露或异常吞噬问题。","1) clearSession 和卸载路径都能清理定时器;2) 同时存在主动 refresh 与被动 refresh 触发时不会并发发起多次 refresh;3) expiresIn 不会导致敏感信息泄露;4) SessionExpiredError 仅用于静默会话过期。","Review:检查 AuthContext/http/types 变更,确认 refresh 调度为单例、失败路径清晰、无 token 日志输出。必要时手工验证登录后改密、登出、过期会话三条路径。","manual","DONE","DONE","DONE","client/src/auth/AuthContext.tsx|client/src/api/http.ts|client/src/api/types.ts|server/src/routes/auth.js","[""A2"",""A3"",""A4""]","回滚路径:若 reviewer 发现关键竞态或安全问题,暂停提交并回退对应代码。" +"A6","QA、状态回填与提交收口","执行 R61 post-check 与手工验证,回填 CSV 状态并完成提交收口,确保主动 refresh 不引入回归。","后端语法检查、前端 typecheck/build 通过;登录、主动 refresh、被动 refresh 兜底、refresh 失效静默退出流程验证通过;issues/2026-04-12_r61-auth-refresh.csv 三列状态按结果回填。","命令:export PATH=/home/shunli/.nvm/versions/node/v22.20.0/bin:$PATH && cd server && node --check src/routes/auth.js && cd ../client && npm run typecheck && npm run build。业务:1) 登录成功后触发主动 refresh 调度;2) 主动 refresh 成功后不会出现重复定时器;3) refresh token 失效时会静默清会话,减少多余 console 错误;4) 改密后仍会调度主动 refresh。","manual","DONE","DONE","DONE","issues/2026-04-12_r61-auth-refresh.csv|server/src/routes/auth.js|client/src/auth/AuthContext.tsx|client/src/api/http.ts|client/src/api/types.ts","[""A5""]","回滚路径:若 post-check 或手工验证失败,暂停提交并回退 R61 代码。commit message:R61 A1-A6:Auth token refresh 主动续期。" diff --git a/server/src/routes/auth.js b/server/src/routes/auth.js index e0f8f887..ee86516d 100644 --- a/server/src/routes/auth.js +++ b/server/src/routes/auth.js @@ -159,6 +159,41 @@ function sanitizeReturnTo(value) { return trimmed; } +function parseAccessTokenTtlSeconds(value) { + if (typeof value === "number" && Number.isFinite(value) && value > 0) { + return Math.floor(value); + } + + if (typeof value !== "string") { + throw new Error("Invalid access token TTL"); + } + + const trimmed = value.trim(); + if (!trimmed) { + throw new Error("Invalid access token TTL"); + } + + if (/^\d+$/.test(trimmed)) { + return Number(trimmed); + } + + const match = trimmed.match(/^(\d+)\s*([smhd])$/i); + if (!match) { + throw new Error("Invalid access token TTL"); + } + + const amount = Number(match[1]); + const unit = match[2].toLowerCase(); + const secondsByUnit = { + s: 1, + m: 60, + h: 60 * 60, + d: 24 * 60 * 60, + }; + + return amount * secondsByUnit[unit]; +} + async function authRoutes(fastify) { const loginBodySchema = z.object({ identifier: z.string().min(3), @@ -174,6 +209,7 @@ async function authRoutes(fastify) { timeWindow: fastify.config.rateLimitLoginWindow, }) : undefined; + const accessTokenExpiresIn = parseAccessTokenTtlSeconds(fastify.config.accessTokenTtl); fastify.post("/login", { preHandler: authRateLimit }, async (request, reply) => { const parsed = loginBodySchema.safeParse(request.body); @@ -388,6 +424,7 @@ async function authRoutes(fastify) { const accessToken = fastify.issueAccessToken(employee); return sendData(reply, request, { accessToken, + expiresIn: accessTokenExpiresIn, mustChangePassword: Boolean(employee.must_change_password), }); }); @@ -572,7 +609,10 @@ async function authRoutes(fastify) { ); const accessToken = fastify.issueAccessToken(employee); - return sendData(reply, request, { accessToken }); + return sendData(reply, request, { + accessToken, + expiresIn: accessTokenExpiresIn, + }); }); fastify.get("/sso/providers", async (request, reply) => { @@ -983,6 +1023,7 @@ async function authRoutes(fastify) { const accessToken = fastify.issueAccessToken(updatedEmployee); return sendData(reply, request, { accessToken, + expiresIn: accessTokenExpiresIn, mustChangePassword: false, }); });