29a6aa9112
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>
102 lines
3.9 KiB
Dart
102 lines
3.9 KiB
Dart
// auth_provider.dart — 认证状态(JWT 令牌生命周期)
|
|
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
|
|
|
import '../services/api_config.dart';
|
|
import '../services/auth_api.dart';
|
|
import '../services/token_store.dart';
|
|
|
|
// ── 状态 ────────────────────────────────────────────────────────────
|
|
|
|
class AuthState {
|
|
const AuthState({this.accessToken, this.isLoading = false});
|
|
|
|
final String? accessToken;
|
|
final bool isLoading;
|
|
|
|
bool get isLoggedIn => accessToken != null && accessToken!.isNotEmpty;
|
|
|
|
AuthState copyWith({String? accessToken, bool? isLoading}) => AuthState(
|
|
accessToken: accessToken ?? this.accessToken,
|
|
isLoading: isLoading ?? this.isLoading,
|
|
);
|
|
}
|
|
|
|
// ── 状态机 ───────────────────────────────────────────────────────────
|
|
|
|
class AuthNotifier extends StateNotifier<AuthState> {
|
|
AuthNotifier(this._store, {AuthApi? api})
|
|
: _api = api ?? AuthApi(baseUrl: kApiBaseUrl),
|
|
super(const AuthState(isLoading: true)) {
|
|
_loadFromStore();
|
|
}
|
|
|
|
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();
|
|
state = AuthState(accessToken: token);
|
|
} catch (_) {
|
|
// FlutterSecureStorage 在测试环境 / 未初始化时抛异常,视为未登录。
|
|
state = const AuthState();
|
|
}
|
|
}
|
|
|
|
/// 登录 / 注册成功后保存令牌。
|
|
Future<void> saveTokens(AuthTokens tokens) async {
|
|
await _store.saveTokens(
|
|
access: tokens.accessToken,
|
|
refresh: tokens.refreshToken,
|
|
);
|
|
state = AuthState(accessToken: tokens.accessToken);
|
|
}
|
|
|
|
/// 用存储的 refresh token 刷新令牌(供 ApiClient 在 401 时调用)。
|
|
/// 成功写入新令牌并更新状态返回 true;无 refresh token / 刷新失败返回 false。
|
|
/// 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 {
|
|
await saveTokens(await _api.refresh(rt));
|
|
return true;
|
|
} on AuthApiException catch (e) {
|
|
// 服务端明确拒绝(refresh 过期/失效,超出 7 天)→ 退出登录回登录页;
|
|
// 网络错误(statusCode<=0)则保留会话,稍后重试。
|
|
if (e.statusCode > 0) await logout();
|
|
return false;
|
|
} catch (_) {
|
|
return false;
|
|
}
|
|
}
|
|
|
|
/// 退出登录:清除本地令牌。
|
|
Future<void> logout() async {
|
|
await _store.clear();
|
|
state = const AuthState();
|
|
}
|
|
}
|
|
|
|
// ── Providers ────────────────────────────────────────────────────────
|
|
|
|
/// 可在测试中 override,注入 stub TokenStore(避免 FlutterSecureStorage 平台依赖)。
|
|
final tokenStoreProvider = Provider<TokenStore>((_) => const TokenStore());
|
|
|
|
final authProvider =
|
|
StateNotifierProvider<AuthNotifier, AuthState>(
|
|
(ref) => AuthNotifier(ref.watch(tokenStoreProvider)),
|
|
);
|