先看最初的日志:
17:27:41 GET /resources/42 401 missing_auth_header
17:27:44 GET /resources/42 200第一次请求没有 Authorization,几秒后同一接口恢复正常。原因是页面和登录同时启动,页面跑得更快:
// AuthProvider
useEffect(() => {
void recoverAuthSession()
}, [])
// 分享页
useEffect(() => {
void loadResource()
}, [])这两段 useEffect 互不等待。用户从首页进入详情页时,Token 通常已经存在;从分享卡片冷启动时,详情页会直接挂载,于是受保护请求抢在微信登录之前发出。
竞态判断是对的,第一次修复却错了。
错误修复:在 useLaunch 中登录
我最初把认证移到 useLaunch,认证完成前不渲染页面:
function App({ children }: PropsWithChildren) {
const [authReady, setAuthReady] = useState(false)
useLaunch(() => {
void recoverAuthSession()
.then(() => setAuthReady(true))
})
return authReady ? children : null
}这能挡住页面请求,但会在登录失败时遇到另一个错误:
Error: 没有找到页面实例。useLaunch 执行时,第一个页面可能还没创建。认证代码一旦调用 Taro.showToast 或 Taro.showModal,Taro 找不到可以承载提示的页面实例。
不能靠删除提示绕过去。Taro.login() 返回空 code 时,客户端必须停止请求并告诉用户:
async function getLoginCode() {
const result = await Taro.login()
const code = result.code?.trim()
if (!code) {
realtimeLog.warn('login_code_missing', {
errMsg: result.errMsg || null,
codeType: typeof result.code,
})
Taro.showToast({ title: '微信登录失败', icon: 'error' })
throw new Error('微信登录失败')
}
return code
}修复位置应该放在请求层:页面正常挂载,受保护请求等待认证。
建立一个认证屏障
屏障初始为未完成状态。认证恢复结束后调用 completeAuthRefresh(),等待中的请求才会继续:
let resolveAuthReady: (() => void) | null = null
const createAuthBarrier = () => new Promise<void>(resolve => {
resolveAuthReady = resolve
})
let authReadyPromise = createAuthBarrier()
export const beginAuthRefresh = () => {
if (!resolveAuthReady) {
authReadyPromise = createAuthBarrier()
}
}
export const waitForAuthReady = () => authReadyPromise
export const completeAuthRefresh = () => {
resolveAuthReady?.()
resolveAuthReady = null
}AuthProvider 已经挂载在页面树外层,因此可以在 useEffect 中安全恢复认证:
useEffect(() => {
// 有 Access Token 时直接使用本地用户快照。
// 是否过期交给业务请求的 401 恢复处理。
if (getAccessToken()) {
completeAuthRefresh()
return
}
// 此时页面实例已经存在,登录失败可以正常显示提示。
void recoverAuthSession('check_auth')
.finally(completeAuthRefresh)
}, [])请求封装在读取 Token 之前等待屏障:
type HttpMethod = 'GET' | 'POST' | 'PUT' | 'DELETE' | 'PATCH'
async function customFetch(url: string, method: HttpMethod, data?: unknown) {
const bypassAuth = /\/(?:config|auth\/(?:login|refresh))(?:[/?]|$)/.test(url)
if (!bypassAuth) {
await waitForAuthReady()
}
const header: Record<string, string> = {}
const token = getAccessToken()
if (token) {
header.authorization = `Bearer ${token}`
}
const send = () => Taro.request({
url,
method,
header,
data,
})
let response = await send()
const isAuthEndpoint = /\/auth\/(?:login|refresh)(?:[/?]|$)/.test(url)
// 认证接口禁止递归恢复;普通请求换取新 Token 后只重试一次。
if (response.statusCode === 401 && !isAuthEndpoint) {
await recoverAuthSession()
const renewedToken = getAccessToken()
if (renewedToken) {
header.authorization = `Bearer ${renewedToken}`
response = await send()
}
}
return response
}关键顺序只有一句:先 await waitForAuthReady(),再读取 Token。请求如果先读取 Token、随后再等待,拿到的仍然可能是登录前的空值。
401 只恢复一次
Access Token 过期后,第一笔业务请求会返回 401。多个并发请求可能同时遇到它,所以刷新过程必须共用一个 Promise:
let recoveryPromise: Promise<AuthSession> | null = null
export function recoverAuthSession(scene = 'request_recovery') {
if (recoveryPromise) return recoveryPromise
recoveryPromise = (async () => {
try {
return await refreshAuthSession()
} catch {
clearAuthSession()
return loginAuthSession(scene)
}
})().finally(() => {
recoveryPromise = null
})
return recoveryPromise
}请求层拿到新 Token 后,只重试原请求一次。认证接口必须排除在外,否则 /auth/refresh 返回 401 后会递归刷新自己。
为什么不在 App.onShow 中刷新
后来为了及时更新角色,我又在每次进入前台时刷新:
useEffect(() => {
const handleAppShow = () => {
beginAuthRefresh()
void recoverAuthSession().finally(completeAuthRefresh)
}
Taro.onAppShow(handleAppShow)
return () => Taro.offAppShow(handleAppShow)
}, [])这段代码会轮换 Refresh Token。切回小程序一次,就查询并更新一次服务端会话。此前认证中间件还会读写 Redis,并在缓存未命中时查询数据库,普通 JWT 请求也因此带上了存储开销。
最终删除了 App.onShow 刷新。已有 Access Token 时直接进入应用,过期后再由 401 恢复。
服务端只校验 Access Token
Access Token 有效期为 24 小时。普通请求只验证签名、过期时间和必要载荷,不读取会话表,也不访问 Redis:
export const authMiddleware = createMiddleware(async (c, next) => {
const authorization = c.req.header('Authorization')
if (!authorization?.startsWith('Bearer ')) {
return c.json({ error: '请重新登录', code: 'UNAUTHORIZED' }, 401)
}
try {
const token = authorization.slice(7)
const payload = await verify(token, JWT_SECRET, 'HS256')
if (typeof payload.id !== 'number') {
return c.json({ error: '请重新登录', code: 'UNAUTHORIZED' }, 401)
}
if (typeof payload.sid !== 'number' || !Number.isInteger(payload.sid)) {
return c.json({ error: '请重新登录', code: 'UNAUTHORIZED' }, 401)
}
c.set('jwtPayload', payload)
await next()
} catch {
return c.json({ error: '请重新登录', code: 'UNAUTHORIZED' }, 401)
}
})Refresh Token 仍然是有状态的。刷新接口查询有效会话,并通过“旧摘要必须仍然匹配”的条件完成原子轮换:
const currentHash = await hashRefreshToken(refreshToken)
const session = await findActiveRefreshSession(userId, currentHash, now)
if (!session) {
return c.json({ error: '登录已过期' }, 401)
}
const nextRefreshToken = createRefreshToken(session.userId)
const nextHash = await hashRefreshToken(nextRefreshToken)
const rotated = await rotateRefreshSession(
session.id,
currentHash,
nextHash,
expiresAt,
now,
)
if (!rotated) {
return c.json({ error: '登录已过期' }, 401)
}
return c.json({
token: await signAccessToken(session.user, session.id),
refreshToken: nextRefreshToken,
user: publicUser(session.user),
})数据库只在登录和真正刷新时写入,不再因为普通请求或每次进入前台而更新。
角色信息只认 Token 快照
旧实现有两个角色来源:界面从用户接口读取数据库,服务端从 Access Token 授权。角色修改后,界面已经显示新按钮,旧 Token 却仍然返回 403。
现在刷新接口同时返回新 Token 和用户快照。客户端保存两者:
function saveAuthSession(session: AuthSession) {
Taro.setStorageSync('token', session.token)
Taro.setStorageSync('refreshToken', session.refreshToken)
Taro.setStorageSync('user', session.user)
Taro.eventCenter.trigger('auth-session-updated', session.user)
return session
}资料更新接口不签发 Token。客户端保存资料后,再明确刷新一次:
async function saveProfile(data: ProfileInput) {
await unwrap(client.auth.profile.$patch({ json: data }))
// 重新签发 Access Token,同时更新角色快照。
await refreshUser()
}这样令牌只由登录和刷新接口签发。用户没有更新资料时,角色最长延迟一个 Access Token 周期生效;对于不要求即时权限回收的小程序,这个代价可以接受。
请求顺序应该是什么样
无 Token 冷启动:
页面挂载
POST /auth/login 200
GET /resources/42 200不能再出现:
GET /resources/42 401 missing_auth_header
POST /auth/login 200Access Token 过期、Refresh Token 有效:
GET /resources/42 401
POST /auth/refresh 200
GET /resources/42 200同时发出多个业务请求时,只能看到一次 /auth/refresh。微信登录返回空 code 时,应当停在登录阶段并正常显示错误。资料保存后,客户端用户快照和新 Access Token 中的角色应当一致。
这次真正改动的边界很小:页面照常挂载,请求等待认证;Access Token 只做无状态校验,Refresh Token 只在需要时轮换。之前两次绕远,分别是把请求竞态变成页面生命周期问题,又把角色同步变成了持续的数据库写入。