前面我们已经学习过一次无感刷新,当时介绍的是 admin 子站里的做法:在 middleware 里提前试探 access token,必要时用 refresh token 续签,再把新的登录态写回 cookie。
这一篇要介绍的是 web 子站里的无感刷新。它的重点不再是 middleware 和 cookie,而是浏览器本地 session、统一 HTTP 请求模块、Authorization 请求头、refresh 接口,以及刷新成功以后自动重试刚才失败的业务请求。
Web 登录一般会同时拿到两个 token:accessToken 和 refreshToken。
accessToken 用来访问接口,有效期会短一些。refreshToken 用来续签,有效期会长一些。这样做是为了让安全和体验都能顾上。
如果 accessToken 有效期太长,泄露后的风险会持续很久。如果它很快过期,又没有续签机制,用户就会频繁回到登录页,体验会很差。
无感刷新要做的事很明确:用户发起正常请求时,如果当前 accessToken 过期了,前端自动用 refreshToken 换一组新 token,然后把刚才失败的请求重新发一遍。
用户看到的是页面继续可用,操作没有被打断。开发侧要处理的是:登录后保存会话,请求时带上 token,过期时续签,续签成功后重试,续签失败后退出登录。
我们可以先把这条链路梳理清楚。
用户登录成功后,浏览器保存 accessToken、refreshToken 和 session。之后需要登录态的接口,都从统一的 HTTP 模块发出。
HTTP 模块会在请求前读取本地会话,把 accessToken 放进 Authorization 请求头。接口正常返回时,业务代码直接拿数据,不需要知道 token 是怎么处理的。
当接口返回 AUTH.UNAUTHORIZED,前端就认为当前 accessToken 可能已经不可用,于是调用 /auth/web/token/refresh。刷新成功后,新 token 覆盖旧 token,刚才失败的请求再重新发送一次。
这套逻辑要收在 HTTP 模块里,不要散落到每个页面。页面只负责请求业务数据,刷新和重试由统一入口处理。
对应到代码文件,大致是这样的关系:
01apps/web/src/auth/login-client.ts02登录成功后保存会话0304apps/web/src/auth/client-session.ts05管理浏览器侧 session0607apps/web/src/lib/http.ts08自动带 token、识别过期、刷新、重试0910apps/web/src/components/web-dashboard-guard.tsx11进入受保护页面时校验登录态1213apps/api/src/auth/services/web-token-refresh.ts14后端处理 refresh token 续签1516apps/api/src/auth/jwt.ts17负责 JWT 签发和校验
这个结构的好处是边界清楚。登录页只负责登录,页面保护只负责守页面,HTTP 模块处理请求过程,后端 refresh 服务处理续签安全。
登录成功以后,接口返回的是一份完整登录结果,里面包含 accessToken、refreshToken 和 session。
登录表单拿到结果后,直接交给客户端会话模块保存。它不直接操作 localStorage,也不自己拼接 token。
01import type { WebPasswordLoginResponse } from '@repo/contracts'02import { saveClientSession } from '@/auth/client-session'03import { http } from '@/lib/http'0405export type WebLoginInput = {06email: string07password: string08}0910export async function loginByApi(input: WebLoginInput) {11const response = await http.post<WebPasswordLoginResponse, WebLoginInput>(12'/auth/web/password/login',13input,14)1516saveClientSession(response)17}
会话模块通常会同时用内存变量和 localStorage。内存变量让当前页面读取更快,localStorage 让用户刷新页面后还能恢复登录态。
01const storageKey = 'web:client-session'02const sessionChangedEventName = 'web-client-session-changed'0304type StoredWebSession = {05accessToken: string06refreshToken: string07session: WebAuthSession08}0910let currentSession: StoredWebSession | null = null
读取会话时,先看内存里有没有。没有的话,再从 localStorage 里恢复。
01export function readClientSession(): StoredWebSession | null {02if (currentSession) {03return currentSession04}0506if (!canUseStorage()) {07return null08}0910const rawValue = window.localStorage.getItem(storageKey)1112if (!rawValue) {13return null14}1516try {17currentSession = JSON.parse(rawValue) as StoredWebSession18return currentSession19} catch {20window.localStorage.removeItem(storageKey)21return null22}23}
保存会话时,内存和 localStorage 都要更新,并且要发出会话变化事件。
01export function saveClientSession(input: Pick<WebPasswordLoginResponse, 'accessToken' | 'refreshToken' | 'session'>) {02currentSession = {03accessToken: input.accessToken,04refreshToken: input.refreshToken,05session: input.session,06}0708if (canUseStorage()) {09window.localStorage.setItem(storageKey, JSON.stringify(currentSession))10}1112notifySessionChanged()13}
这个事件很有用。登录、刷新、登出都会改变 session。其他组件如果需要感知这件事,比如页面保护组件重新加载 profile,就可以监听它。
刷新成功后,也要保存完整的新会话,不能只替换 accessToken。
1export function saveClientRefreshSession(input: Pick<WebTokenRefreshResponse, 'accessToken' | 'refreshToken' | 'session'>) {2saveClientSession(input)3}
原因是后端一般会做 refresh token rotation。每次刷新成功后,refreshToken 也会换成新的。前端如果还保留旧 refresh token,下次续签就会失败。
业务页面不需要每次都手动读取 token。登录态请求统一走 HTTP 模块,让它自动补 Authorization。
对外暴露的 get 和 post 可以保持简单:
01export const http = {02get<T>(url: string, config?: AxiosRequestConfig): Promise<T> {03return request<T>(createRequestConfig(url, {04...config,05method: 'GET',06}))07},08post<TResponse, TRequest = unknown>(url: string, data?: TRequest, config?: AxiosRequestConfig): Promise<TResponse> {09return request<TResponse>(createRequestConfig(url, {10...config,11method: 'POST',12data,13}))14},15}
真正补 token 的地方在 createRequestConfig。
01function createRequestConfig(url: string, config: AxiosRequestConfig = {}): AxiosRequestConfig {02const isFormData = typeof FormData !== 'undefined' && config.data instanceof FormData03const headers: RawAxiosRequestHeaders = {04...(isFormData ? {} : { 'content-type': 'application/json' }),05...(config.headers as RawAxiosRequestHeaders | undefined),06}0708if (typeof window !== 'undefined' && shouldAttachAccessToken(config.headers)) {09const storedSession = readClientSession()1011if (storedSession) {12headers.authorization = `Bearer ${storedSession.accessToken}`13}14}1516return {17...config,18baseURL: config.baseURL ?? resolveBaseURL(),19headers,20validateStatus: () => true,21url,22}23}
这里只在浏览器环境读取 session,因为 localStorage 只存在于浏览器。
如果调用方已经传了 authorization,HTTP 模块不要覆盖它。这样可以给特殊请求留下空间。
1function shouldAttachAccessToken(headers: AxiosRequestConfig['headers']) {2if (!headers || typeof headers !== 'object') {3return true4}56const normalizedHeaders = headers as Record<string, unknown>7return !('authorization' in normalizedHeaders || 'Authorization' in normalizedHeaders)8}
做到这一步,业务代码正常调用 http.get 或 http.post 就行,不用关心 token 从哪里来。
无感刷新不是一进页面就刷新 token。我们先用当前 accessToken 请求接口,只有后端返回 AUTH.UNAUTHORIZED,才尝试续签。
1const unauthorizedBizCodes: Set<BizCodeValue> = new Set([2'AUTH.UNAUTHORIZED',3])
判断是否尝试刷新,集中在 shouldTryRefresh。
01function shouldTryRefresh(config: AxiosRequestConfig, payload: ApiResponse<unknown>) {02if (typeof window === 'undefined') {03return false04}0506if (config.url?.includes('/auth/web/token/refresh')) {07return false08}0910return !payload.ok && unauthorizedBizCodes.has(payload.error.code)11}
refresh 接口自己不能再触发 refresh。/auth/web/token/refresh 都失败了,说明 refresh token 或 session 已经不可用了,继续递归刷新只会制造死循环。
普通业务错误也不应该触发刷新。参数校验失败、业务冲突、权限不足,都不是 access token 过期。
所有接口返回都会经过 unwrapApiResponse。成功时返回 data,失败时抛出错误,并把是否需要刷新写到错误对象上。
01function unwrapApiResponse<T>(config: AxiosRequestConfig, payload: ApiResponse<T>): T {02if (payload.ok) {03return payload.data04}0506const error = new Error(payload.error.message) as Error & {07status?: number08code?: BizCodeValue09shouldRefresh?: boolean10}1112error.code = payload.error.code13error.shouldRefresh = shouldTryRefresh(config, payload)1415throw error16}
这样后面的 request 函数不用反复分析错误码,只看 error.shouldRefresh 就能知道是否进入续签流程。
需要续签时,前端从本地会话里取出 refreshToken,请求 /auth/web/token/refresh。刷新成功后,把新的 accessToken、refreshToken 和 session 一起保存。
01async function refreshClientSession() {02const storedSession = readClientSession()0304if (!storedSession) {05throw new Error('Session refresh failed')06}0708const response = await axios.request<ApiResponse<WebTokenRefreshResponse>>({09baseURL: resolveBaseURL(),10url: '/auth/web/token/refresh',11method: 'POST',12headers: {13'content-type': 'application/json',14},15data: {16refreshToken: storedSession.refreshToken,17},18validateStatus: () => true,19})2021const data = unwrapApiResponse<WebTokenRefreshResponse>({22url: '/auth/web/token/refresh',23method: 'POST',24}, response.data)2526saveClientRefreshSession(data)27}
这里没有复用当前请求的 Authorization。refresh 接口真正需要的是请求体里的 refreshToken。
页面上常常会同时发出多个请求。如果 access token 刚好过期,这些请求可能一起收到 AUTH.UNAUTHORIZED。
这里不能让每个请求都刷新一次。refresh token rotation 通常要求旧 refresh token 只能使用一次。第一个刷新请求成功后,旧 token 就会被标记为已使用。后面的请求如果继续拿旧 token 刷新,后端可能会把它当成重放风险。
所以前端需要一个并发锁。
01let refreshPromise: Promise<void> | null = null0203async function ensureClientRefresh() {04if (!refreshPromise) {05refreshPromise = refreshClientSession().finally(() => {06refreshPromise = null07})08}0910return refreshPromise11}
第一个发现 token 过期的请求创建 refreshPromise,后续请求等待同一个 promise。这样同时有很多请求时,真正发到后端的 refresh 请求也只有一个。
刷新成功后,还要重试原请求。这一步做完,用户才真正感觉不到 token 过期。
01async function request<T>(config: AxiosRequestConfig): Promise<T> {02try {03const response = await axios.request<ApiResponse<T>>(config)04return unwrapApiResponse(config, response.data)05} catch (error) {06const appError = error as Error & { shouldRefresh?: boolean }0708if (appError.shouldRefresh) {09try {10await ensureClientRefresh()11const { authorization: _authorization, Authorization: _Authorization, ...retryHeaders } = (config.headers ?? {}) as RawAxiosRequestHeaders12const retryConfig = createRequestConfig(config.url ?? '', {13...config,14headers: retryHeaders,15})16const retryResponse = await axios.request<ApiResponse<T>>(retryConfig)17return unwrapApiResponse(retryConfig, retryResponse.data)18} catch {19clearClientSession()20throw new Error('Session refresh failed')21}22}2324if (error instanceof AxiosError) {25throw new Error(error.message || 'Request failed')26}2728throw error29}30}
重试前要移除旧的 Authorization,再重新创建请求配置。否则重试请求可能仍然带着过期的 access token。
1const { authorization: _authorization, Authorization: _Authorization, ...retryHeaders } = (config.headers ?? {}) as RawAxiosRequestHeaders
如果刷新失败,前端清空本地会话,回到登录流程。无效 token 留在本地,只会让页面反复失败。
前端能做到无感刷新,后端也要配合把 refresh 过程管住。
后端会先校验 refresh token 的 JWT,并确认它属于 web 应用。
1claims = await verifyRefreshToken({2token: payload.refreshToken,3secret: env.JWT_REFRESH_SECRET,4expectedApp: 'web',5})
JWT 校验通过后,还要查数据库,确认这枚 refresh token 是否存在,以及它是不是属于当前 session。
1const currentToken = await findRefreshTokenForSession({2db: db,3jtiHash,4sessionId: claims.sid,5})67if (!currentToken || currentToken.applicationCode !== 'web') {8throw refreshTokenInvalidError()9}
session 已经撤销,刷新失败。
1if (currentToken.sessionRevokedAtMs !== null) {2throw sessionRevokedError()3}
refresh token 自己已经撤销或者过期,也要失败。
1if (currentToken.revokedAtMs !== null || currentToken.expiresAtMs <= nowMs) {2throw refreshTokenInvalidError()3}
旧 refresh token 已经使用过,又被拿来刷新,后端应该把它当作重放风险,并撤销整条 session。
01if (currentToken.usedAtMs !== null) {02await revokeSession({03db: db,04sessionId: currentToken.sessionId,05revokedAtMs: nowMs,06reason: 'refresh_token_replay',07})0809throw refreshTokenReplayedError()10}
接着,后端会把当前 refresh token 标记为已使用。
1const markedUsed = await markRefreshTokenUsed({2db: db,3tokenId: currentToken.tokenId,4usedAtMs: nowMs,5})
然后重新查询用户的 web 角色。这样权限变化可以在下一次刷新时生效。如果用户已经没有 web_user 角色,就不能继续续签。
01const roles = await getWebRolesForUser(db, claims.sub)0203if (!roles.includes('web_user')) {04await revokeSession({05db: db,06sessionId: currentToken.sessionId,07revokedAtMs: nowMs,08reason: 'web_role_missing',09})1011throw adminRoleRequiredError()12}
最后,后端创建新的 session 视图并签发新的 token。
01const session = {02sessionId: currentToken.sessionId,03userId: claims.sub,04app: 'web' as const,05roles,06expiresAtMs: refreshExpiresAtMs,07}0809const tokenPair = await issueTokenPair({10session,11accessSecret: env.JWT_ACCESS_SECRET,12refreshSecret: env.JWT_REFRESH_SECRET,13accessTtlSec: env.ACCESS_TOKEN_TTL_SEC,14refreshTtlSec: env.REFRESH_TOKEN_TTL_SEC,15})
新的 refresh token 要写入数据库,并且和旧 token 串起来。
01await insertRefreshToken({02db: db,03tokenId: tokenPair.refreshJti,04sessionId: currentToken.sessionId,05jtiHash: await hashTokenJti(tokenPair.refreshJti),06parentTokenId: currentToken.tokenId,07issuedAtMs: nowMs,08expiresAtMs: refreshExpiresAtMs,09})1011await updateRefreshRotation({12db: db,13oldTokenId: currentToken.tokenId,14newTokenId: tokenPair.refreshJti,15sessionId: currentToken.sessionId,16lastSeenAtMs: nowMs,17})
这就是 refresh token rotation。旧 token 被标记为用过,新 token 被写入,旧 token 指向新 token。这样后端既能追踪续签链路,也能识别旧 token 被重复使用的风险。
web 和 admin 可以共用 JWT 签发能力,但 token 里必须带上 app 字段。这个字段用来防止 web token 和 admin token 混用。
access token 中会写入 sid、app、roles。
1return new SignJWT({2sid: params.claims.sid,3app: params.claims.app,4roles: params.claims.roles,5})
refresh token 中也会写入 sid、app、jti。
1const token = await new SignJWT({2sid: params.claims.sid,3app: params.claims.app,4jti,5})
校验时检查 expectedApp。
01export async function verifyRefreshToken(params: {02token: string03secret: string04expectedApp?: ExpectedApp05}): Promise<RefreshTokenClaims> {06const expectedApp = params.expectedApp ?? 'admin'0708if (!isExpectedApp(app, expectedApp)) {09throw new Error('Invalid refresh token claims')10}11}
默认值是 admin,这样系统新增 web 登录后,也不会意外放宽 admin 接口。web refresh 明确传 expectedApp: 'web',共享接口则可以传 ['admin', 'web']。
无感刷新也会影响路由保护。进入受保护页面时,web-dashboard-guard.tsx 会先读取本地 session,然后请求 profile。
01async function loadProfile() {02const storedSession = readClientSession()0304if (!storedSession) {05setIsLoading(false)06router.replace('/login')07return08}0910try {11const nextProfile = await getWebUserProfile()12const latestSession = readClientSession()1314if (!latestSession) {15setIsLoading(false)16router.replace('/login')17return18}1920setContext({ profile: nextProfile, session: latestSession.session, refreshProfile: loadProfile })21} catch {22clearClientSession()23router.replace('/login')24} finally {25setIsLoading(false)26}27}
getWebUserProfile() 同样走统一 HTTP 模块。所以用户刷新页面时,如果 access token 已经过期,profile 请求会先失败,然后 HTTP 模块自动 refresh,再重试 profile。guard 最后仍然能拿到 profile,页面就不会跳回登录页。
只有 refresh token 也失效、session 被撤销,或者用户失去了 web_user 角色时,guard 才会清空会话并回到 /login。
无感刷新不是永远不重新登录。它的边界是 refresh token 和服务端 session 仍然有效。
如果 refresh 失败,前端应该清空本地 session。
1clearClientSession()2throw new Error('Session refresh failed')
常见失败原因包括 refresh token 过期、refresh token 已撤销、session 已撤销、旧 refresh token 被重复使用、用户不再拥有 web_user 角色。
验证时可以先做类型检查,确认前后端没有破坏编译。
1pnpm --filter web check-types2pnpm --filter @repo/api check-types
然后用 API 验证登录和刷新。
1curl -X POST http://127.0.0.1:8787/auth/web/password/login \2-H 'content-type: application/json' \3-d '{"email":"user01@example.com","password":"Admin123456!"}'
拿到 refresh token 后,请求刷新接口。
1curl -X POST http://127.0.0.1:8787/auth/web/token/refresh \2-H 'content-type: application/json' \3-d '{"refreshToken":"<refreshToken>"}'
最后用 access token 访问 profile。
1curl http://127.0.0.1:8787/rpc/user/profile \2-H 'authorization: Bearer <accessToken>'
浏览器里可以登录后查看 localStorage 中是否存在 web:client-session。为了更快验证无感刷新,可以临时缩短 access token 的 TTL,等它过期后触发一次需要登录态的请求。Network 面板中应该能看到 /auth/web/token/refresh,并且原业务请求会在刷新成功后继续完成,页面不会跳回登录页。
无感刷新是一套前后端协作机制,不只是一个 refresh 接口。
前端负责保存会话、请求时自动带上 access token、识别认证失败、调用 refresh、保存新 token,并重试原请求。后端负责校验 refresh token、维护 rotation、识别重放风险、重新签发 token,并保证 web/admin 应用边界不混在一起。
这样既能让 accessToken 保持较短有效期,降低泄露风险,也能用 refreshToken 完成续签,减少用户反复登录。