R61 A1-A6:Auth token refresh 主动续期
这个提交包含在:
+142
-37
@@ -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<AuthState>(EMPTY_STATE);
|
||||
const [offlineAccessMode, setOfflineAccessMode] = useState(false);
|
||||
const tokenRef = useRef<string | null>(null);
|
||||
const meRef = useRef<MeResponse | 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(() => {
|
||||
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;
|
||||
|
||||
在新工单中引用
屏蔽一个用户