fix(client): refresh token single-flight — 修跨平台「放久了/更新后被踢下线」
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 <noreply@anthropic.com>
This commit is contained in:
@@ -33,6 +33,13 @@ class AuthNotifier extends StateNotifier<AuthState> {
|
||||
final TokenStore _store;
|
||||
final AuthApi _api;
|
||||
|
||||
/// 进行中的刷新(single-flight)。refresh token 是「单次使用 + 轮换」的——
|
||||
/// 服务端每次刷新会作废旧 JTI、发新的。冷启动/进页面会并发打多个接口,过期后
|
||||
/// 会同时多次 401 → 各自拿同一个旧 refresh token 去刷新:第一个成功轮换,其余
|
||||
/// 拿已作废的 JTI 必被拒 → 误触发 logout 把用户踢下线。用一个共享的 in-flight
|
||||
/// future 合并并发刷新,旧 token 只被消费一次。
|
||||
Future<bool>? _refreshInFlight;
|
||||
|
||||
Future<void> _loadFromStore() async {
|
||||
try {
|
||||
final token = await _store.loadAccessToken();
|
||||
@@ -54,7 +61,13 @@ class AuthNotifier extends StateNotifier<AuthState> {
|
||||
|
||||
/// 用存储的 refresh token 刷新令牌(供 ApiClient 在 401 时调用)。
|
||||
/// 成功写入新令牌并更新状态返回 true;无 refresh token / 刷新失败返回 false。
|
||||
Future<bool> refresh() async {
|
||||
/// single-flight:并发调用共用同一次刷新,避免单次使用的 refresh token 被多个
|
||||
/// 并发 401 重复消费 → 误判失效把用户踢下线。
|
||||
Future<bool> refresh() {
|
||||
return _refreshInFlight ??= _doRefresh().whenComplete(() => _refreshInFlight = null);
|
||||
}
|
||||
|
||||
Future<bool> _doRefresh() async {
|
||||
final rt = await _store.loadRefreshToken();
|
||||
if (rt == null || rt.isEmpty) return false;
|
||||
try {
|
||||
|
||||
@@ -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<String?> loadAccessToken() async => access;
|
||||
@override
|
||||
Future<String?> loadRefreshToken() async => refresh;
|
||||
@override
|
||||
Future<void> saveTokens({required String access, required String refresh}) async {
|
||||
this.access = access;
|
||||
this.refresh = refresh;
|
||||
}
|
||||
@override
|
||||
Future<void> clear() async {
|
||||
cleared = true;
|
||||
access = null;
|
||||
refresh = null;
|
||||
}
|
||||
@override
|
||||
Future<void> markOnboarded() async {}
|
||||
@override
|
||||
Future<bool> isOnboarded() async => true;
|
||||
@override
|
||||
Future<void> saveLastEmail(String email) async {}
|
||||
@override
|
||||
Future<String?> 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<String, dynamic>;
|
||||
// 模拟服务端单次使用:旧 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<void>.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<String, dynamic>;
|
||||
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<void>.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');
|
||||
});
|
||||
}
|
||||
Reference in New Issue
Block a user