From 29a6aa9112c3664bd99258e1b28ed44b3c87f622 Mon Sep 17 00:00:00 2001 From: wangjia <809946525@qq.com> Date: Mon, 29 Jun 2026 11:05:16 +0800 Subject: [PATCH] =?UTF-8?q?fix(client):=20refresh=20token=20single-flight?= =?UTF-8?q?=20=E2=80=94=20=E4=BF=AE=E8=B7=A8=E5=B9=B3=E5=8F=B0=E3=80=8C?= =?UTF-8?q?=E6=94=BE=E4=B9=85=E4=BA=86/=E6=9B=B4=E6=96=B0=E5=90=8E?= =?UTF-8?q?=E8=A2=AB=E8=B8=A2=E4=B8=8B=E7=BA=BF=E3=80=8D?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit refresh token 是单次使用+轮换(服务端每次刷新 DEL 旧 JTI 发新的)。冷启动/进 页面会并发打多个接口,access(15min)过期后同时多次 401,各自拿同一个旧 refresh token 去刷新:第一个成功轮换,其余拿已作废的 JTI 必被拒 → 误触发 logout 把用户 踢回登录页。与平台无关(共用 auth 逻辑),表现为「放久了/覆盖更新后要重新登录」。 修法:AuthNotifier.refresh() 用共享 in-flight future(single-flight)合并并发刷新, 旧 token 只被消费一次。新增 auth_refresh_singleflight_test 覆盖并发只刷一次 + 刷新后可再刷。 Co-Authored-By: Claude Opus 4.8 --- client/lib/state/auth_provider.dart | 15 ++- .../unit/auth_refresh_singleflight_test.dart | 98 +++++++++++++++++++ 2 files changed, 112 insertions(+), 1 deletion(-) create mode 100644 client/test/unit/auth_refresh_singleflight_test.dart diff --git a/client/lib/state/auth_provider.dart b/client/lib/state/auth_provider.dart index 7886385..6e04e4b 100644 --- a/client/lib/state/auth_provider.dart +++ b/client/lib/state/auth_provider.dart @@ -33,6 +33,13 @@ class AuthNotifier extends StateNotifier { final TokenStore _store; final AuthApi _api; + /// 进行中的刷新(single-flight)。refresh token 是「单次使用 + 轮换」的—— + /// 服务端每次刷新会作废旧 JTI、发新的。冷启动/进页面会并发打多个接口,过期后 + /// 会同时多次 401 → 各自拿同一个旧 refresh token 去刷新:第一个成功轮换,其余 + /// 拿已作废的 JTI 必被拒 → 误触发 logout 把用户踢下线。用一个共享的 in-flight + /// future 合并并发刷新,旧 token 只被消费一次。 + Future? _refreshInFlight; + Future _loadFromStore() async { try { final token = await _store.loadAccessToken(); @@ -54,7 +61,13 @@ class AuthNotifier extends StateNotifier { /// 用存储的 refresh token 刷新令牌(供 ApiClient 在 401 时调用)。 /// 成功写入新令牌并更新状态返回 true;无 refresh token / 刷新失败返回 false。 - Future refresh() async { + /// single-flight:并发调用共用同一次刷新,避免单次使用的 refresh token 被多个 + /// 并发 401 重复消费 → 误判失效把用户踢下线。 + Future refresh() { + return _refreshInFlight ??= _doRefresh().whenComplete(() => _refreshInFlight = null); + } + + Future _doRefresh() async { final rt = await _store.loadRefreshToken(); if (rt == null || rt.isEmpty) return false; try { diff --git a/client/test/unit/auth_refresh_singleflight_test.dart b/client/test/unit/auth_refresh_singleflight_test.dart new file mode 100644 index 0000000..94ea566 --- /dev/null +++ b/client/test/unit/auth_refresh_singleflight_test.dart @@ -0,0 +1,98 @@ +// auth_refresh_singleflight_test.dart — refresh token 是「单次使用 + 轮换」, +// 并发 401 必须合并为一次刷新,否则旧 JTI 被多次消费 → 误判失效把用户踢下线 +// (跨平台「放久了/更新后要重新登录」的根因)。 +import 'dart:convert'; + +import 'package:flutter_test/flutter_test.dart'; +import 'package:http/http.dart' as http; +import 'package:http/testing.dart'; +import 'package:pangolin_vpn/services/auth_api.dart'; +import 'package:pangolin_vpn/services/token_store.dart'; +import 'package:pangolin_vpn/state/auth_provider.dart'; + +class _MemStore implements TokenStore { + _MemStore({this.access, this.refresh}); + String? access; + String? refresh; + bool cleared = false; + @override + Future loadAccessToken() async => access; + @override + Future loadRefreshToken() async => refresh; + @override + Future saveTokens({required String access, required String refresh}) async { + this.access = access; + this.refresh = refresh; + } + @override + Future clear() async { + cleared = true; + access = null; + refresh = null; + } + @override + Future markOnboarded() async {} + @override + Future isOnboarded() async => true; + @override + Future saveLastEmail(String email) async {} + @override + Future loadLastEmail() async => null; +} + +void main() { + test('并发刷新 single-flight:多个并发 refresh 只消费一次 refresh token', () async { + var hits = 0; + final client = MockClient((req) async { + if (req.url.path == '/v1/auth/refresh') { + hits++; + final body = jsonDecode(req.body) as Map; + // 模拟服务端单次使用:旧 JTI 一旦被轮换,再用就 401。 + if (body['refresh_token'] != 'rt-1') { + return http.Response('{"error":"invalid_token"}', 401, + headers: {'content-type': 'application/json'}); + } + return http.Response('{"access_token":"at-2","refresh_token":"rt-2"}', 200, + headers: {'content-type': 'application/json'}); + } + return http.Response('not found', 404); + }); + + final store = _MemStore(access: 'at-1', refresh: 'rt-1'); + final n = AuthNotifier(store, api: AuthApi(baseUrl: 'http://test.local', client: client)); + await Future.delayed(Duration.zero); // 等构造里的 _loadFromStore + + final results = await Future.wait([ + n.refresh(), n.refresh(), n.refresh(), n.refresh(), n.refresh(), + ]); + + expect(hits, 1, reason: '并发刷新应只打一次 /v1/auth/refresh,旧 token 只消费一次'); + expect(results.every((r) => r), isTrue, reason: '所有并发调用都应拿到同一次成功刷新'); + expect(store.refresh, 'rt-2', reason: '应轮换到新 refresh token'); + expect(store.cleared, isFalse, reason: '不应误触发 logout'); + expect(n.state.accessToken, 'at-2'); + }); + + test('刷新后可再次刷新(in-flight 完成后释放)', () async { + var current = 'rt-1'; + final client = MockClient((req) async { + final body = jsonDecode(req.body) as Map; + if (body['refresh_token'] != current) { + return http.Response('{"error":"invalid_token"}', 401, + headers: {'content-type': 'application/json'}); + } + final next = current == 'rt-1' ? 'rt-2' : 'rt-3'; + current = next; + return http.Response('{"access_token":"at-$next","refresh_token":"$next"}', 200, + headers: {'content-type': 'application/json'}); + }); + final store = _MemStore(access: 'at-1', refresh: 'rt-1'); + final n = AuthNotifier(store, api: AuthApi(baseUrl: 'http://test.local', client: client)); + await Future.delayed(Duration.zero); + + expect(await n.refresh(), isTrue); + expect(store.refresh, 'rt-2'); + expect(await n.refresh(), isTrue); // in-flight 已释放,用新 token 再刷 + expect(store.refresh, 'rt-3'); + }); +}