成为全栈·Flutter App 篇·Refresh Token 旋转与并发 401:安全存储与会话代次

成为全栈·Flutter App 篇·Refresh Token 旋转与并发 401:安全存储与会话代次

上一篇文章末尾留了个伏笔:缓存键里那个 session:${api.userId}:${api.epoch},我一直没解释 epoch 是什么。今天补上------顺便讲一个我在接入真实后端时被"教育"的问题。

先说结论:Refresh Token 轮换这件事,难的不是旋转,是并发。

设想一个很自然的场景。用户打开 App 首页,首页同时发起 5 个请求;这 5 个请求几乎同时到达服务器,而 token 恰好在这一刻过期。服务器会对其中每一个都返回 401。

如果每个 401 都独立去刷新,会发生什么?5 个刷新请求同时打出去,服务端的 Refresh Token 是有状态、有轮换的(用一次就作废发下一个),于是 5 个里有 4 个拿着已经作废的 token 去换,全部失败,账号被踢下线。

用户看到的是:刚登录,一进首页就掉线。这个 bug 在开发环境几乎复现不出来------因为 token 没过期。

这篇讲怎么在 Dart 里把这三道栅栏垒起来:单飞刷新 、迟到响应跨会话隔离 、重放限制 。代码全部来自 flutter-app/lib/core/network/api_client.dart(344 行)。

动手之前有个前提得说清楚,它决定了这套代码的形态。

M1 讲后端时提过,本项目的认证是 JWT access token + 有状态 refresh token ,接口认证靠 Authorization: Bearer,不是 Cookie。这在浏览器里有个坑------但在 Flutter 里这个坑不存在。

原生客户端没有浏览器自动携带 Cookie 的机制,也没有同源策略那套东西。所以 Flutter 端不需要 CSRF token、不需要 withCredentials、不需要考虑"第三方站点会不会自动带上我的会话"。它要做的只是:

dart 复制代码
headers: {
  if (!anonymous && accessToken != null)
    'Authorization': 'Bearer $accessToken',
},

就这么一行,比 Web 那边干净得多。但省下来的不是复杂度,是"防错的空间"------Web 上那堆 Cookie 防御,正是为了防止别人冒用你的身份;原生端没人能自动带 cookie,风险就低。

代价是:职责从框架转移到了我们手上。Web 上 Cookie 由浏览器管理并发和过期;原生端这些全部要自己实现。后面三道栅栏就是这笔债。

令牌存哪:不是 SharedPreferences

access token 短效,refresh token 长效,直接丢进内存不行(App 一杀就没了)。那存哪?

我一开始用的是 shared_preferences。后来改成了 flutter_secure_storage,理由是这个 App 里 refresh token 的有效期是以天计的,而且它在移动端------设备可能被 root、可能被备份、可能被别的 App 读。

dart 复制代码
/// 令牌存储接口允许测试替换为内存实现,业务代码不接触存储细节。
abstract interface class TokenVault {
  Future<String?> read();
  Future<void> write(String? token);
}

/// 按接口环境隔离安全存储中的刷新令牌,避免开发与线上会话互相覆盖。
class SecureTokenVault implements TokenVault {
  SecureTokenVault({this.namespace = "local"});
  final String namespace;
  String get key => "reader.refresh.$namespace";
  final FlutterSecureStorage storage = const FlutterSecureStorage();

  @override
  Future<String?> read() => storage.read(key: key);

  @override
  Future<void> write(String? token) => token == null
      ? storage.delete(key: key)
      : storage.write(key: key, value: token);
}

两个细节值得单独拎出来:

抽象成接口是为了测试。 TokenVault 只有读和写两个方法。测试里换成内存实现,就能在毫秒级模拟"存储里有/没有 token""写入失败"这些真实设备上很难构造的场景,而不用真的去戳系统密钥链。业务代码完全不接触存储细节。

namespace 是为了隔离环境。 键是 reader.refresh.local,打的是 reader.refresh.production。同一台机器上开发环境连本地后端、线上版本连真实服务,如果共用一个键,两个 App 会互相覆盖对方的 token ------表现是开发版偶尔莫名其妙掉线。namespace 一个参数省掉了一整类玄学问题。

顺便注意 write(null) 被实现成 delete 而不是"写入空字符串"。这两种语义在后续的"是否已登出"判断里是等价的,但在存储层含义不同------把登出表达成一个动作,而不是一个空值,比每次调用点都判断一遍要可靠。

第一道栅栏:单飞刷新

现在回到开头那个场景:5 个请求同时 401。

最直觉的写法是在拦截器里收到 401 就去刷新。Dart 里最自然的实现是:

dart 复制代码
// 错误示范:5 个 401 → 5 次刷新
if (response.statusCode == 401) {
  await refresh();          // 每个请求各刷一次
  return retry(request);
}

问题就在 await refresh() 拦不住并发。五个请求几乎同时进来,各自看到 _refresh == null,于是各自启动了一次刷新。这不是"写错了",是这段逻辑在并发下天然如此------检查和赋值之间没有原子性。

修法是共享同一个 Future。看真实实现:

dart 复制代码
Future<void> refresh() {
  if (_refresh != null) return _refresh!;     // ① 有人正在刷,直接用它的结果
  final start = epoch;
  final task = () async {
    try {
      final token = await vault.read();
      if (start != epoch) throw SessionChanged();
      if (token == null) {
        throw const ApiFailure('请登录后继续', code: 1004, status: 401);
      }
      final data = await request(
        Endpoints.refresh,
        method: 'POST',
        data: {'refreshToken': token},
        refreshAllowed: false,               // ② 刷新请求本身不再触发刷新
      );
      await install(
        Map<String, dynamic>.from(data as Map),
        expectedEpoch: start,
      );
    } on ApiFailure catch (e) {
      if ([1002, 1003, 1004, 1005].contains(e.code) && start == epoch) {
        await clear();
        onExpired?.call();
      }
      rethrow;
    }
  }();
  _refresh = task;
  return task.whenComplete(() {
    if (identical(_refresh, task)) _refresh = null;   // ③ 只清自己那一个
  });
}

三个点合起来才是完整的单飞:

① _refresh != null 时直接复用。 第一个 401 触发刷新并把 Future 存进 _refresh,后面 4 个 401 进来时看到非空,直接 return _refresh! 挂在这个 Future 上等结果。只有一个网络请求,其余四个等它。

② refreshAllowed: false。 刷新请求自己也会带 Authorization,也可能拿到 401。如果不关掉这个开关,/auth/refresh 返回 401 → 触发刷新 → 刷新请求又返回 401 → 无限递归直到栈溢出。这个开关是硬性安全边界。

③ identical(_refresh, task) 这个判断不能省。 清理时如果不确认"存进去的还是不是我自己这个",就会出这种问题:A 的刷新结束了,此时恰好 B 刚开始刷新,A 的 whenComplete 把 _refresh 清成 null ------ B 的刷新还在飞,但这个字段已经空了,第三个 401 就会又启动一次刷新。identical 保证了只有持有者的清理才生效。

Dart 的单飞和 Java 不太一样:Java 里通常靠 synchronized 包住"检查+赋值"这两步;Dart 是单线程事件循环,_refresh = task 之前不会被抢占,所以裸赋值就是原子的。但 whenComplete 回调是异步的,所以第 ③ 点依然必要。

第二道栅栏:epoch 挡住迟到响应

单飞解决了"别重复刷",但引出了新问题:什么时候清掉那个正在飞的刷新?

朴素做法是"谁先发起谁负责清"。但用户可能在刷新进行到一半时点了退出登录------这时那个刷新还会成功返回 (服务器并不知道你已登出),然后它把新 token 写回去。登出之后,token 又活过来了。

epoch 就是为这个场景准备的。它是一个单调递增的会话代次计数器 ,每次会话状态变化就 +1:

dart 复制代码
/// 请求期间账号发生变化的控制信号;页面应忽略旧响应而非提示网络错误。
class SessionChanged implements Exception {}

int epoch = 0;

/// 清除会话先提升代际,使刷新和旧请求不能重新写入已退出的令牌。
Future<void> clear() async {
  epoch++;
  accessToken = null;
  userId = null;
  onSessionChanged?.call();
  await _persist(null);
}

顺序是关键:先 epoch++,再清别的。 如果反过来先清 token 再加代次,中间那个窗口里可能已经有别的协程读到了"token 已清、代次未变"的中间状态。

然后看 epoch 是怎么被检查的。整个 api_client.dart 里有五处 if (start != epoch) throw SessionChanged();,它们构成一道网:

dart 复制代码
final start = epoch;                       // 请求发起时记下当前代次
final originalToken = accessToken;
try {
  Response<dynamic> response = await dio.request(...);
  if (start != epoch) throw SessionChanged();        // ① 响应回来时代次已变
  if (refreshAllowed && !anonymous && _shouldRefresh(response)) {
    if (originalToken == accessToken) await refresh();
    if (start != epoch) throw SessionChanged();      // ② 刷新完再确认一次
    return await _request(..., refreshAllowed: false);
  }
  response = await _retryAfter(...);
  if (start != epoch) throw SessionChanged();        // ③ 重试后
  final result = await _decode(response, start);
  if (start != epoch) throw SessionChanged();        // ④ 解码后
  return result;
} on DioException catch (e) {
  if (start != epoch) throw SessionChanged();        // ⑤ 失败路径同样要查
  ...
}

每一处 await 之后都查一次。 这看起来啰嗦到有点不专业------_decode 是纯本地解码,按理说不会跨越会话边界。但 await 就是一道可能被打断的边界,用户可以在任何一格退出登录。

我一度想只保留最前面那一处。后来想明白:这种"多加一行"的成本和漏掉的代价完全不成比例。 漏掉第 ④ 处,_decode 内部就会拿到一个属于旧账号的响应,然后用旧数据渲染页面------用户已经登录成 B 账号了,屏幕上却显示 A 的文章。这种 bug 复现一次就够记很久。

SessionChanged 是个空类,不带任何字段。这是刻意的:页面拿到它只需要知道"这条响应当废",具体原因页面不需要知道。带一堆字段反而会诱导页面去 switch 判断,最后写出"如果是 A 原因就显示 X,如果是 B 原因就显示 Y"------而那本该在数据层就已经处理掉了。

第三道栅栏:什么时候该刷,什么时候只是重放

回到那个并发场景。现在 _request 里处理 401 的部分是这样的:

dart 复制代码
if (refreshAllowed && !anonymous && _shouldRefresh(response)) {
  if (originalToken == accessToken) await refresh();
  ...
  return await _request(..., refreshAllowed: false, ...);
}

注意 if (originalToken == accessToken) await refresh(); 这一行------它经常被误读成"检查令牌有没有变"。

它的真实作用是避免重复触发刷新 。想一下:请求 A 用旧 token 发出去,在路上的时候用户手动下拉刷新触发了另一次 refresh(),token 已经换成新的。此时 A 的响应回来了,带着 401(因为它带的是旧 token)。

如果不判断就 await refresh(),A 会触发第二次刷新------而 A 的 _refresh 复用逻辑虽然能挡住并发情况下的重复,但这里 A 看到的是"token 已经和发起时不同",说明确实有一次刷新刚成功完成,再刷一次是纯浪费,而且是拿新 token 去换,可能又触发一轮轮换。

originalToken == accessToken 表达的是:"如果我发这个请求时用的 token 还在用,那说明还没人替我刷新过,我得自己刷;如果已经被刷新过了,直接用新的重放就行。"

而 _shouldRefresh 又从另一个方向收窄了刷新范围:

dart 复制代码
// 刷新只处理 access token 过期;禁用账号等错误交给解码处理。
bool _shouldRefresh(Response<dynamic> response) =>
    response.statusCode == 401 &&
    response.data is Map &&
    response.data['code'] == 1002;

只认 1002。 这个细节上一篇文章提过一句,这里说完整:401 在本项目里至少有三种含义------access token 过期(1002)、refresh token 也失效了(1003/1004)、账号被停用(1005)。只有第一种该刷新。

如果是 1005(账号停用),也去刷新会怎样?刷新请求带着一个已经被服务端拒绝的 refresh token 去换,失败,触发 clear(),用户被踢出登录------但正确行为是告诉他"账号已被停用,请联系管理员"。这个差别很关键:一个误解成"登录过期,请重新登录"的用户会反复登录失败,而真实原因他根本无从知道。

重放本身也有限制。重放时传的是 refreshAllowed: false(不再二次刷新),FormData 要 clone()(Dart 里 FormData 被消费一次就不能重用):

dart 复制代码
data: data is FormData ? data.clone() : data,

还有一个容易被忽略的约束:重放只发生在 GET 上 。因为 _shouldRefresh 只对 code == 1002 响应生效,而写请求一旦真的到达服务端执行了,即使我们收到 401 再重放,也可能重复创建。文章后面聊 429 的时候会讲为什么写请求宁可失败也不自动重试------那是同一条原则的两面。

会话隔离:缓存键里的那个 epoch

现在可以补上开头那个伏笔了。M4-07 里那个缓存键:

dart 复制代码
String key(String path, Map<String, dynamic> query, {bool private = false}) {
  return CacheKey(
    api.baseUrl,
    private ? 'session:${api.userId}:${api.epoch}' : 'public',
    path, query,
  ).encode();
}

为什么私有键里要带 epoch,光有 userId 不够?

场景:用户 U1 登录,进会员中心,产生一堆私有缓存(收藏、点赞、通知)。然后他退出登录,U2 登录。

如果键只有 userId,那 U1 和 U2 是不同用户,键本来就不同,似乎没问题。但危险在于同一个用户重新登录的情况 :U1 退出后又登录了一次,userId 相同,epoch 不同。此时如果键只含 userId,U1 退出前的老缓存会被 U1 登录后的新会话读到。

听起来无害(同一个人)?不一定。上一次的会话可能留下了"已读的通知列表""已上传的稿件草稿",而这次登录的用户看到的是过期的会话状态。更糟的情况是:上一次会话是管理员,这次是普通会员------权限已经变了,缓存里却还是管理员的数据。

epoch 让每次会话都拿到全新的键空间。onSessionChanged 回调进一步触发清理:

dart 复制代码
// 会话隔离清理不影响公开资源,也不触及按账号保存的投稿草稿。
void resetPrivate() {
  CodeCache.clear();
  images.resetPrivate();
  favoriteIndex.resetFavoriteIndex();
  reactions.clear();
  favoriteIndex.overrides.clear();
  snapshots.removeWhere((_, s) => s.tags.contains('private'));
  cache.invalidate({'private'});
  reactionStore.events.add(-1);
}

注意这里只清私有部分:公开文章缓存不动,投稿草稿也不动(草稿按账号存,不是按会话存)。这个区分要是搞混了,用户每次登录都要重新下载一遍文章列表,体验很差。

顺带解决的一个顺序问题

刷新成功后要把新 token 写进安全存储。如果连续两次刷新(比如用户快速切换环境),两次写入可能并发,而 FlutterSecureStorage 的写入不是原子的。

dart 复制代码
Future<void> _persist(String? token) {
  final next = _storageQueue
      .catchError((Object _) {})      // 上一次失败不影响这一次
      .then((_) => vault.write(token));
  _storageQueue = next;
  return next;
}

用一条 Future 链把写入串行化 ,并且用 catchError 吞掉上一次的错误------否则前一次写失败会把整条链废掉,后面的写入全都执行不了。

install() 里还夹了一处代次检查,位置很讲究:

dart 复制代码
Future<void> install(Map<String, dynamic> auth, {int? expectedEpoch}) async {
  if (expectedEpoch != null && expectedEpoch != epoch) throw SessionChanged();
  final token = auth['refreshToken'] as String?;
  if (token == null || token.isEmpty) {
    throw const ApiFailure('服务未返回刷新令牌,请重新登录');
  }
  await _persist(token);                                          // 落盘
  if (expectedEpoch != null && expectedEpoch != epoch) throw SessionChanged();
  accessToken = auth['accessToken'] as String;                     // 改内存
  ...
}

写盘前后各查一次。 因为 _persist 里的 await 是一个真实的挂起点------用户在落盘期间退出登录是完全可能的。前置检查挡住"进来时已经换代",后置检查挡住"存盘期间被登出"。只要少一个,就有一个窗口让旧 token 写进已经登出的会话。

三道栅栏的分工

最后收一下:

栅栏 防的是什么 手段
单飞刷新 并发 401 触发多次刷新,轮换 token 互相作废 共享 _refresh Future + identical 清理
epoch 隔离 迟到响应把旧会话数据写回来 每次 await 后查 start != epoch
重放限制 不该刷新的错误也刷、写请求重复执行 只认 code == 1002 + refreshAllowed 一次性

三者的共同点是都不试图"更快",只试图"更少出错" 。特别是第二条------多写五行检查,换来的是一个"加了 await 就必须重新检查"的纪律。这类代码的正确性不靠聪明的推理,靠每次都照做。

也正因为如此,它需要测试。项目里 TokenVault 可以替换成内存实现,于是"两个并发 401 只触发一次刷新""刷新期间登出导致 token 不复活""连续两次刷新不互相覆盖"这几条都能写成断言。能让并发路径被断言覆盖,前提是存储和时钟都能被替换 ------这也是把 TokenVault 抽象成接口的真正理由。

小结

Refresh Token 轮换的实现难度不在轮换本身,在于它是这个项目里唯一一处"同一时刻有多个协程在改同一份状态"的地方。列表加载、缓存读写都是各自独立的,只有这里必须回答:谁可以刷新、刷新的结果给谁、什么时候作废。

三个答案对应三道栅栏:同一时刻只允许一个刷新(单飞 Future)、令牌属于哪个会话(epoch)、哪些错误才配触发刷新(错误码 + 一次性重放标志)。

顺带一个规律:凡是需要 await 的地方,后面都要重新确认前置假设还成立。 Dart 没有多线程数据竞争,不代表没有这类逻辑竞争------await 本身就是一次可能被用户行为打断的边界。这条纪律在 Web 端同样适用,只是 JS 单线程下更容易被忘掉,因为大家习惯假设代码是连续执行的。

下一篇讲错误与限流:429 的 Retry-After 该不该等、seconds <= 3 那个武断的上限怎么来的,以及为什么写请求宁可报错也不自动重试。

延伸阅读


如果这篇文章对你有帮助,欢迎订阅我的 CSDN 专栏 「成为全栈」:

🔗 专栏地址:https://blog.csdn.net/fungleo/category_13204651.html

📦 本系列配套代码仓库:https://github.com/fengcms/become-a-full-stack-developer

相关推荐
豆角焖肉2 小时前
SpringBoot+JWT 实现前后端分离登录鉴权
java·开发语言·spring boot·jwt·前后端分离·登录认证
m0_738185822 小时前
Flutter 鸿蒙化实战:simple_circular_progress_bar 适配 OpenHarmony,环形进度条
flutter·华为·harmonyos·鸿蒙
大龄秃头程序员17 小时前
Riverpod 深度解析:从响应式本质到最小可运行闭环
flutter
曹小强wxx19 小时前
让 AI 写代码不“翻车”:用 Claude Code + Codex 给 Flutter 项目搭一套工程规范
flutter·ai编程·claude
西西学代码1 天前
像素和颜色之间的转换(Flutter)
前端·javascript·flutter
曹小强wxx1 天前
Flutter 局域网设备发现:UDP 广播 + mDNS 双路合并,以及靠谱的在线判定与断线重连
物联网·网络协议·flutter
FungLeo1 天前
成为全栈·Flutter App 篇·移动端错误与限流:429、重试和写请求边界
flutter·限流·dart·dio·错误处理·成为全栈
恋猫de小郭1 天前
Kotlin Toolchain 0.13,JetBrains 推荐 KMP 都切换到到全新工具链
android·前端·flutter
m0_738185821 天前
Flutter 鸿蒙化实战:rive 适配 OpenHarmony,Rive 动画
flutter·华为·harmonyos·鸿蒙