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'); + }); +}