Taro 小程序认证竞态:一次修错方向的复盘

太阳作者太阳
原创内容采用 CC-4.0 协议发布,转载请注明出处

先看最初的日志:

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.showToastTaro.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       200

Access Token 过期、Refresh Token 有效:

GET  /resources/42     401
POST /auth/refresh     200
GET  /resources/42     200

同时发出多个业务请求时,只能看到一次 /auth/refresh。微信登录返回空 code 时,应当停在登录阶段并正常显示错误。资料保存后,客户端用户快照和新 Access Token 中的角色应当一致。

这次真正改动的边界很小:页面照常挂载,请求等待认证;Access Token 只做无状态校验,Refresh Token 只在需要时轮换。之前两次绕远,分别是把请求竞态变成页面生命周期问题,又把角色同步变成了持续的数据库写入。