R61 A1-A6:Auth token refresh 主动续期
这个提交包含在:
+10
-7
@@ -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 AccessTokenGetter = () => string | null;
|
||||||
type RefreshAccessToken = () => Promise<string | null>;
|
type RefreshAccessToken = () => Promise<string | null>;
|
||||||
@@ -79,6 +79,13 @@ async function getRefreshedToken(): Promise<string | null> {
|
|||||||
return refreshPromise;
|
return refreshPromise;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function throwSessionExpired(): never {
|
||||||
|
if (clearSession) {
|
||||||
|
clearSession();
|
||||||
|
}
|
||||||
|
throw new SessionExpiredError();
|
||||||
|
}
|
||||||
|
|
||||||
async function sendRequest<T>(
|
async function sendRequest<T>(
|
||||||
path: string,
|
path: string,
|
||||||
options: ApiRequestOptions,
|
options: ApiRequestOptions,
|
||||||
@@ -118,9 +125,7 @@ async function sendRequest<T>(
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
if (clearSession) {
|
throwSessionExpired();
|
||||||
clearSession();
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
const payload = await safeParseJson(response);
|
const payload = await safeParseJson(response);
|
||||||
@@ -210,9 +215,7 @@ export async function apiRequestBlob(
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
if (clearSession) {
|
throwSessionExpired();
|
||||||
clearSession();
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
const payload = await safeParseJson(response);
|
const payload = await safeParseJson(response);
|
||||||
|
|||||||
@@ -19,6 +19,7 @@ export type LoginRequest = {
|
|||||||
|
|
||||||
export type LoginResponse = {
|
export type LoginResponse = {
|
||||||
accessToken: string;
|
accessToken: string;
|
||||||
|
expiresIn?: number;
|
||||||
mustChangePassword?: boolean;
|
mustChangePassword?: boolean;
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -73,6 +74,17 @@ export function isApiError(value: unknown): value is ApiError {
|
|||||||
return value instanceof 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 = {
|
export type WorkflowTaskSummary = {
|
||||||
id: string;
|
id: string;
|
||||||
|
|||||||
+140
-35
@@ -9,7 +9,7 @@ import {
|
|||||||
useState,
|
useState,
|
||||||
} from "react";
|
} from "react";
|
||||||
import { configureHttpAuth } from "../api/http";
|
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 { changePassword as changePasswordApi } from "../api/auth";
|
||||||
import { clearPwaRuntimeCaches } from "../pwa";
|
import { clearPwaRuntimeCaches } from "../pwa";
|
||||||
import {
|
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_STORAGE_KEY = "hyb-oa.auth_snapshot";
|
||||||
const OFFLINE_AUTH_SNAPSHOT_VERSION = 1;
|
const OFFLINE_AUTH_SNAPSHOT_VERSION = 1;
|
||||||
const OFFLINE_AUTH_SNAPSHOT_TTL_MS = 24 * 60 * 60 * 1000;
|
const OFFLINE_AUTH_SNAPSHOT_TTL_MS = 24 * 60 * 60 * 1000;
|
||||||
|
const PROACTIVE_REFRESH_RATIO = 0.8;
|
||||||
|
const MIN_REFRESH_DELAY_MS = 1000;
|
||||||
|
|
||||||
function isWindowAvailable() {
|
function isWindowAvailable() {
|
||||||
return typeof window !== "undefined";
|
return typeof window !== "undefined";
|
||||||
@@ -144,14 +146,35 @@ export function AuthProvider({ children }: PropsWithChildren) {
|
|||||||
const [state, setState] = useState<AuthState>(EMPTY_STATE);
|
const [state, setState] = useState<AuthState>(EMPTY_STATE);
|
||||||
const [offlineAccessMode, setOfflineAccessMode] = useState(false);
|
const [offlineAccessMode, setOfflineAccessMode] = useState(false);
|
||||||
const tokenRef = useRef<string | null>(null);
|
const tokenRef = useRef<string | null>(null);
|
||||||
|
const meRef = useRef<MeResponse | null>(null);
|
||||||
const bootstrapPromiseRef = useRef<Promise<string | null> | null>(null);
|
const bootstrapPromiseRef = useRef<Promise<string | null> | null>(null);
|
||||||
|
const refreshRequestRef = useRef<Promise<LoginResponse | null> | null>(null);
|
||||||
|
const proactiveRefreshTimerRef = useRef<ReturnType<typeof setTimeout> | null>(null);
|
||||||
|
const scheduleProactiveRefreshRef = useRef<(expiresIn?: number) => void>(() => undefined);
|
||||||
|
const sessionGenerationRef = useRef(0);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
tokenRef.current = state.accessToken;
|
tokenRef.current = state.accessToken;
|
||||||
}, [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(() => {
|
const clearSession = useCallback(() => {
|
||||||
|
sessionGenerationRef.current += 1;
|
||||||
|
bootstrapPromiseRef.current = null;
|
||||||
|
refreshRequestRef.current = null;
|
||||||
|
clearProactiveRefreshTimer();
|
||||||
tokenRef.current = null;
|
tokenRef.current = null;
|
||||||
|
meRef.current = null;
|
||||||
setOfflineAccessMode(false);
|
setOfflineAccessMode(false);
|
||||||
writeMustChangePasswordToStorage(false);
|
writeMustChangePasswordToStorage(false);
|
||||||
clearOfflineAuthSnapshot();
|
clearOfflineAuthSnapshot();
|
||||||
@@ -164,12 +187,13 @@ export function AuthProvider({ children }: PropsWithChildren) {
|
|||||||
mustChangePassword: false,
|
mustChangePassword: false,
|
||||||
ready: true,
|
ready: true,
|
||||||
}));
|
}));
|
||||||
}, []);
|
}, [clearProactiveRefreshTimer]);
|
||||||
|
|
||||||
const commitAuthenticatedUser = useCallback(
|
const commitAuthenticatedUser = useCallback(
|
||||||
(accessToken: string, me: MeResponse, mustChangePassword: boolean) => {
|
(accessToken: string, me: MeResponse, mustChangePassword: boolean) => {
|
||||||
const permissions = resolvePermissionsForRoles(me.roleCodes ?? []);
|
const permissions = resolvePermissionsForRoles(me.roleCodes ?? []);
|
||||||
tokenRef.current = accessToken;
|
tokenRef.current = accessToken;
|
||||||
|
meRef.current = me;
|
||||||
setOfflineAccessMode(false);
|
setOfflineAccessMode(false);
|
||||||
writeMustChangePasswordToStorage(mustChangePassword);
|
writeMustChangePasswordToStorage(mustChangePassword);
|
||||||
writeOfflineAuthSnapshot(me, permissions);
|
writeOfflineAuthSnapshot(me, permissions);
|
||||||
@@ -184,11 +208,91 @@ export function AuthProvider({ children }: PropsWithChildren) {
|
|||||||
[],
|
[],
|
||||||
);
|
);
|
||||||
|
|
||||||
const activateOfflineSnapshot = useCallback((snapshot: OfflineAuthSnapshot) => {
|
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;
|
tokenRef.current = null;
|
||||||
|
meRef.current = snapshot.me;
|
||||||
setOfflineAccessMode(true);
|
setOfflineAccessMode(true);
|
||||||
setState(buildStateWithOfflineSnapshot(snapshot));
|
setState(buildStateWithOfflineSnapshot(snapshot));
|
||||||
}, []);
|
},
|
||||||
|
[clearProactiveRefreshTimer],
|
||||||
|
);
|
||||||
|
|
||||||
const bootstrapByRefresh = useCallback(async () => {
|
const bootstrapByRefresh = useCallback(async () => {
|
||||||
const offlineSnapshot = readOfflineAuthSnapshot();
|
const offlineSnapshot = readOfflineAuthSnapshot();
|
||||||
@@ -200,11 +304,11 @@ export function AuthProvider({ children }: PropsWithChildren) {
|
|||||||
if (!bootstrapPromiseRef.current) {
|
if (!bootstrapPromiseRef.current) {
|
||||||
bootstrapPromiseRef.current = (async () => {
|
bootstrapPromiseRef.current = (async () => {
|
||||||
try {
|
try {
|
||||||
const refreshed = await refreshSession();
|
const refreshed = await runRefresh();
|
||||||
const me = await fetchCurrentEmployee(refreshed.accessToken);
|
if (refreshed?.expiresIn) {
|
||||||
const mustChangePassword = readMustChangePasswordFromStorage();
|
scheduleProactiveRefresh(refreshed.expiresIn);
|
||||||
commitAuthenticatedUser(refreshed.accessToken, me, mustChangePassword);
|
}
|
||||||
return refreshed.accessToken;
|
return refreshed?.accessToken ?? null;
|
||||||
} catch {
|
} catch {
|
||||||
if (isWindowAvailable() && window.navigator.onLine === false && offlineSnapshot) {
|
if (isWindowAvailable() && window.navigator.onLine === false && offlineSnapshot) {
|
||||||
activateOfflineSnapshot(offlineSnapshot);
|
activateOfflineSnapshot(offlineSnapshot);
|
||||||
@@ -219,7 +323,7 @@ export function AuthProvider({ children }: PropsWithChildren) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
await bootstrapPromiseRef.current;
|
await bootstrapPromiseRef.current;
|
||||||
}, [activateOfflineSnapshot, clearSession, commitAuthenticatedUser]);
|
}, [activateOfflineSnapshot, clearSession, runRefresh, scheduleProactiveRefresh]);
|
||||||
|
|
||||||
const login = useCallback(
|
const login = useCallback(
|
||||||
async (identifier: string, password: string) => {
|
async (identifier: string, password: string) => {
|
||||||
@@ -227,9 +331,10 @@ export function AuthProvider({ children }: PropsWithChildren) {
|
|||||||
const mustChangePassword = Boolean(result.mustChangePassword);
|
const mustChangePassword = Boolean(result.mustChangePassword);
|
||||||
const me = await fetchCurrentEmployee(result.accessToken);
|
const me = await fetchCurrentEmployee(result.accessToken);
|
||||||
commitAuthenticatedUser(result.accessToken, me, mustChangePassword);
|
commitAuthenticatedUser(result.accessToken, me, mustChangePassword);
|
||||||
|
scheduleProactiveRefresh(result.expiresIn);
|
||||||
return { mustChangePassword };
|
return { mustChangePassword };
|
||||||
},
|
},
|
||||||
[commitAuthenticatedUser],
|
[commitAuthenticatedUser, scheduleProactiveRefresh],
|
||||||
);
|
);
|
||||||
|
|
||||||
const changePassword = useCallback(
|
const changePassword = useCallback(
|
||||||
@@ -237,8 +342,9 @@ export function AuthProvider({ children }: PropsWithChildren) {
|
|||||||
const result = await changePasswordApi({ oldPassword, newPassword });
|
const result = await changePasswordApi({ oldPassword, newPassword });
|
||||||
const me = await fetchCurrentEmployee(result.accessToken);
|
const me = await fetchCurrentEmployee(result.accessToken);
|
||||||
commitAuthenticatedUser(result.accessToken, me, false);
|
commitAuthenticatedUser(result.accessToken, me, false);
|
||||||
|
scheduleProactiveRefresh(result.expiresIn);
|
||||||
},
|
},
|
||||||
[commitAuthenticatedUser],
|
[commitAuthenticatedUser, scheduleProactiveRefresh],
|
||||||
);
|
);
|
||||||
|
|
||||||
const logout = useCallback(async () => {
|
const logout = useCallback(async () => {
|
||||||
@@ -252,13 +358,17 @@ export function AuthProvider({ children }: PropsWithChildren) {
|
|||||||
const refreshMe = useCallback(async () => {
|
const refreshMe = useCallback(async () => {
|
||||||
let accessToken = tokenRef.current;
|
let accessToken = tokenRef.current;
|
||||||
if (!accessToken) {
|
if (!accessToken) {
|
||||||
const refreshed = await refreshSession();
|
const refreshed = await runRefresh();
|
||||||
|
if (!refreshed?.accessToken) {
|
||||||
|
throw new Error("会话已失效");
|
||||||
|
}
|
||||||
accessToken = refreshed.accessToken;
|
accessToken = refreshed.accessToken;
|
||||||
tokenRef.current = accessToken;
|
scheduleProactiveRefresh(refreshed.expiresIn);
|
||||||
}
|
}
|
||||||
|
|
||||||
const me = await fetchCurrentEmployee(accessToken);
|
const me = await fetchCurrentEmployee(accessToken);
|
||||||
const permissions = resolvePermissionsForRoles(me.roleCodes ?? []);
|
const permissions = resolvePermissionsForRoles(me.roleCodes ?? []);
|
||||||
|
meRef.current = me;
|
||||||
setOfflineAccessMode(false);
|
setOfflineAccessMode(false);
|
||||||
writeOfflineAuthSnapshot(me, permissions);
|
writeOfflineAuthSnapshot(me, permissions);
|
||||||
setState((previous) => ({
|
setState((previous) => ({
|
||||||
@@ -268,7 +378,7 @@ export function AuthProvider({ children }: PropsWithChildren) {
|
|||||||
permissions,
|
permissions,
|
||||||
ready: true,
|
ready: true,
|
||||||
}));
|
}));
|
||||||
}, []);
|
}, [runRefresh, scheduleProactiveRefresh]);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
configureHttpAuth({
|
configureHttpAuth({
|
||||||
@@ -281,36 +391,31 @@ export function AuthProvider({ children }: PropsWithChildren) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
try {
|
const refreshed = await runRefresh();
|
||||||
const refreshed = await refreshSession();
|
if (!refreshed?.accessToken) {
|
||||||
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();
|
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
scheduleProactiveRefresh(refreshed.expiresIn);
|
||||||
|
return refreshed.accessToken;
|
||||||
},
|
},
|
||||||
clearSession,
|
clearSession,
|
||||||
});
|
});
|
||||||
}, [clearSession, commitAuthenticatedUser, state.me]);
|
}, [clearSession, runRefresh, scheduleProactiveRefresh]);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
void bootstrapByRefresh();
|
void bootstrapByRefresh();
|
||||||
}, [bootstrapByRefresh]);
|
}, [bootstrapByRefresh]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
return () => {
|
||||||
|
sessionGenerationRef.current += 1;
|
||||||
|
bootstrapPromiseRef.current = null;
|
||||||
|
refreshRequestRef.current = null;
|
||||||
|
clearProactiveRefreshTimer();
|
||||||
|
};
|
||||||
|
}, [clearProactiveRefreshTimer]);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!offlineAccessMode) {
|
if (!offlineAccessMode) {
|
||||||
return undefined;
|
return undefined;
|
||||||
|
|||||||
@@ -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 主动续期。"
|
||||||
|
+42
-1
@@ -159,6 +159,41 @@ function sanitizeReturnTo(value) {
|
|||||||
return trimmed;
|
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) {
|
async function authRoutes(fastify) {
|
||||||
const loginBodySchema = z.object({
|
const loginBodySchema = z.object({
|
||||||
identifier: z.string().min(3),
|
identifier: z.string().min(3),
|
||||||
@@ -174,6 +209,7 @@ async function authRoutes(fastify) {
|
|||||||
timeWindow: fastify.config.rateLimitLoginWindow,
|
timeWindow: fastify.config.rateLimitLoginWindow,
|
||||||
})
|
})
|
||||||
: undefined;
|
: undefined;
|
||||||
|
const accessTokenExpiresIn = parseAccessTokenTtlSeconds(fastify.config.accessTokenTtl);
|
||||||
|
|
||||||
fastify.post("/login", { preHandler: authRateLimit }, async (request, reply) => {
|
fastify.post("/login", { preHandler: authRateLimit }, async (request, reply) => {
|
||||||
const parsed = loginBodySchema.safeParse(request.body);
|
const parsed = loginBodySchema.safeParse(request.body);
|
||||||
@@ -388,6 +424,7 @@ async function authRoutes(fastify) {
|
|||||||
const accessToken = fastify.issueAccessToken(employee);
|
const accessToken = fastify.issueAccessToken(employee);
|
||||||
return sendData(reply, request, {
|
return sendData(reply, request, {
|
||||||
accessToken,
|
accessToken,
|
||||||
|
expiresIn: accessTokenExpiresIn,
|
||||||
mustChangePassword: Boolean(employee.must_change_password),
|
mustChangePassword: Boolean(employee.must_change_password),
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
@@ -572,7 +609,10 @@ async function authRoutes(fastify) {
|
|||||||
);
|
);
|
||||||
|
|
||||||
const accessToken = fastify.issueAccessToken(employee);
|
const accessToken = fastify.issueAccessToken(employee);
|
||||||
return sendData(reply, request, { accessToken });
|
return sendData(reply, request, {
|
||||||
|
accessToken,
|
||||||
|
expiresIn: accessTokenExpiresIn,
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
fastify.get("/sso/providers", async (request, reply) => {
|
fastify.get("/sso/providers", async (request, reply) => {
|
||||||
@@ -983,6 +1023,7 @@ async function authRoutes(fastify) {
|
|||||||
const accessToken = fastify.issueAccessToken(updatedEmployee);
|
const accessToken = fastify.issueAccessToken(updatedEmployee);
|
||||||
return sendData(reply, request, {
|
return sendData(reply, request, {
|
||||||
accessToken,
|
accessToken,
|
||||||
|
expiresIn: accessTokenExpiresIn,
|
||||||
mustChangePassword: false,
|
mustChangePassword: false,
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
在新工单中引用
屏蔽一个用户