feat(client): 实时授权状态与会话失效处理
- 心跳读取 /auth/ping 回带的授权概况,直接刷新横幅/状态栏/只读门禁,省去单独轮询 /license/info - 账号被停用/删除(401 USER_DISABLED)即强制重新登录 - 令牌持久化经串行队列 + 会话代号守卫,杜绝续期写入与登出交叉把失效 token 写回 - 写操作门禁(write_guard)+ 授权文案(license_copy)按 grace/readonly/locked 分阶段降级 Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
@@ -3,8 +3,10 @@ import 'package:flutter/foundation.dart';
|
||||
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
||||
import '../auth/auth_state.dart';
|
||||
import '../config/app_config.dart';
|
||||
import '../config/license_copy.dart';
|
||||
import '../errors/error_reporter.dart';
|
||||
import '../../providers/connectivity_provider.dart';
|
||||
import '../../providers/license_provider.dart';
|
||||
import 'retry_interceptor.dart';
|
||||
|
||||
/// Public Dio instance for unauthenticated calls (login / refresh)
|
||||
@@ -30,8 +32,10 @@ final apiClientProvider = Provider<ApiClient>((ref) {
|
||||
final client = ApiClient(
|
||||
token: user?.accessToken,
|
||||
refreshToken: user?.refreshToken,
|
||||
onTokenRefreshed: (newToken) {
|
||||
ref.read(authStateProvider.notifier).updateAccessToken(newToken);
|
||||
onTokensRefreshed: (accessToken, refreshToken) {
|
||||
ref
|
||||
.read(authStateProvider.notifier)
|
||||
.updateAccessToken(accessToken, refreshToken);
|
||||
},
|
||||
onAuthFailed: (reason) {
|
||||
if (ref.read(authStateProvider).isLoggedIn) {
|
||||
@@ -44,6 +48,27 @@ final apiClientProvider = Provider<ApiClient>((ref) {
|
||||
onConnectionError: () {
|
||||
ref.read(connectivityProvider.notifier).forceCheck();
|
||||
},
|
||||
onForbidden: (body) {
|
||||
final map = body is Map ? body : const <String, dynamic>{};
|
||||
final code = map['code'];
|
||||
final phase = map['phase'];
|
||||
// 只读角色写被拒:直接提示(无需刷新授权)。
|
||||
if (code == 'READONLY_USER') {
|
||||
ref.read(apiMessageProvider.notifier).state =
|
||||
LicenseCopy.readonlyUserToast;
|
||||
return;
|
||||
}
|
||||
// 授权过期(readonly/locked)写被拒:刷新授权令横幅/按钮即时降级,并提示。
|
||||
if (phase == 'readonly' || phase == 'locked') {
|
||||
ref.read(licenseProvider.notifier).refresh().then((_) {
|
||||
final lic = ref.read(licenseProvider).valueOrNull;
|
||||
ref.read(apiMessageProvider.notifier).state = lic != null
|
||||
? LicenseCopy.writeBlockedToast(lic)
|
||||
: '授权已过期,无法执行写操作';
|
||||
});
|
||||
}
|
||||
// 其它 403(管理员/超管权限不足等)不在此统一处理,交由调用方。
|
||||
},
|
||||
);
|
||||
ref.onDispose(client.dispose);
|
||||
return client;
|
||||
@@ -53,13 +78,28 @@ class ApiClient {
|
||||
late final Dio _dio;
|
||||
bool _disposed = false;
|
||||
|
||||
/// 当前 refresh token。续期会轮换它,故必须可变并随响应更新——
|
||||
/// provider 不会因 token 变化重建本实例(只监听 isLoggedIn)。
|
||||
String? _refreshToken;
|
||||
|
||||
/// 单飞:并发 401 共享同一次刷新。否则每个 401 各发一次 /auth/refresh,
|
||||
/// 各自轮换 jti,后到的请求重放已被取代的 jti → 触发盗用检测吊销整条会话。
|
||||
Future<String?>? _refreshing;
|
||||
|
||||
void Function(String accessToken, String refreshToken)? _onTokensRefreshed;
|
||||
void Function(String? reason)? _onAuthFailed;
|
||||
|
||||
ApiClient({
|
||||
String? token,
|
||||
String? refreshToken,
|
||||
void Function(String newToken)? onTokenRefreshed,
|
||||
void Function(String accessToken, String refreshToken)? onTokensRefreshed,
|
||||
void Function(String? reason)? onAuthFailed,
|
||||
void Function()? onConnectionError,
|
||||
void Function(dynamic body)? onForbidden,
|
||||
}) {
|
||||
_refreshToken = refreshToken;
|
||||
_onTokensRefreshed = onTokensRefreshed;
|
||||
_onAuthFailed = onAuthFailed;
|
||||
_dio = Dio(BaseOptions(
|
||||
baseUrl: AppConfig.apiBaseUrl,
|
||||
connectTimeout: const Duration(seconds: 8),
|
||||
@@ -98,29 +138,37 @@ class ApiClient {
|
||||
);
|
||||
}
|
||||
|
||||
if (e.response?.statusCode == 401 && (refreshToken ?? '').isNotEmpty) {
|
||||
// 403:写权限被拒(只读角色 / 授权过期)。交给上层刷新授权 + 提示。
|
||||
// 后端契约:body 带 code=READONLY_USER 或 phase=readonly|locked。
|
||||
if (e.response?.statusCode == 403) {
|
||||
if (!_disposed) onForbidden?.call(e.response?.data);
|
||||
return handler.next(e);
|
||||
}
|
||||
|
||||
// 账号被停用/删除:中间件返回 401 + code=USER_DISABLED。这是终态,
|
||||
// 续期也救不回(refresh 会因 is_active=0 再次失败),直接强制重新登录。
|
||||
if (e.response?.statusCode == 401 &&
|
||||
e.response?.data is Map &&
|
||||
e.response?.data['code'] == 'USER_DISABLED') {
|
||||
if (!_disposed) {
|
||||
_onAuthFailed?.call('您的账号已被停用或删除,请重新登录或联系管理员');
|
||||
}
|
||||
return handler.next(e);
|
||||
}
|
||||
|
||||
if (e.response?.statusCode == 401 && (_refreshToken ?? '').isNotEmpty) {
|
||||
debugPrint('[ApiClient] got 401 on ${e.requestOptions.path}, trying refresh...');
|
||||
final newToken = await _refreshAccessToken();
|
||||
if (newToken == null) {
|
||||
return handler.next(e);
|
||||
}
|
||||
try {
|
||||
final resp = await _publicDio.post('/auth/refresh', data: {
|
||||
'refresh_token': refreshToken,
|
||||
});
|
||||
final newToken = resp.data['data']['access_token'] as String;
|
||||
_dio.options.headers['Authorization'] = 'Bearer $newToken';
|
||||
if (!_disposed) onTokenRefreshed?.call(newToken);
|
||||
final opts = e.requestOptions;
|
||||
opts.headers['Authorization'] = 'Bearer $newToken';
|
||||
final retryResp = await _dio.fetch(opts);
|
||||
return handler.resolve(retryResp);
|
||||
} catch (refreshErr) {
|
||||
debugPrint('[ApiClient] refresh failed: $refreshErr');
|
||||
// 区分「被踢/会话失效」与普通登录过期,便于登录页给出明确提示
|
||||
String? reason;
|
||||
if (refreshErr is DioException &&
|
||||
refreshErr.response?.data is Map &&
|
||||
refreshErr.response?.data['code'] == 'SESSION_REVOKED') {
|
||||
reason = '您的账号已在其他设备登录,或登录已失效,请重新登录';
|
||||
}
|
||||
if (!_disposed) onAuthFailed?.call(reason);
|
||||
} catch (retryErr) {
|
||||
return handler.next(retryErr is DioException ? retryErr : e);
|
||||
}
|
||||
} else if (e.response?.statusCode == 401) {
|
||||
debugPrint('[ApiClient] got 401 on ${e.requestOptions.path}, no refresh token');
|
||||
@@ -131,6 +179,45 @@ class ApiClient {
|
||||
);
|
||||
}
|
||||
|
||||
/// 单飞刷新:并发 401 复用同一次 /auth/refresh,避免多次轮换 jti 触发盗用检测。
|
||||
/// 成功返回新的 access token;失败(含会话已撤销)返回 null 并通知 onAuthFailed。
|
||||
Future<String?> _refreshAccessToken() {
|
||||
return _refreshing ??= _doRefresh().whenComplete(() => _refreshing = null);
|
||||
}
|
||||
|
||||
Future<String?> _doRefresh() async {
|
||||
try {
|
||||
final resp = await _publicDio.post('/auth/refresh', data: {
|
||||
'refresh_token': _refreshToken,
|
||||
});
|
||||
final data = resp.data['data'] as Map;
|
||||
final newAccess = data['access_token'] as String;
|
||||
// 后端轮换 refresh token,必须采纳新值(无则沿用旧值,兼容老后端)。
|
||||
final newRefresh = (data['refresh_token'] as String?) ?? _refreshToken;
|
||||
_refreshToken = newRefresh;
|
||||
_dio.options.headers['Authorization'] = 'Bearer $newAccess';
|
||||
if (!_disposed && newRefresh != null) {
|
||||
_onTokensRefreshed?.call(newAccess, newRefresh);
|
||||
}
|
||||
return newAccess;
|
||||
} catch (refreshErr) {
|
||||
debugPrint('[ApiClient] refresh failed: $refreshErr');
|
||||
// 区分「被踢/会话失效」与普通登录过期,便于登录页给出明确提示
|
||||
String? reason;
|
||||
if (refreshErr is DioException &&
|
||||
refreshErr.response?.data is Map &&
|
||||
refreshErr.response?.data['code'] == 'SESSION_REVOKED') {
|
||||
reason = '您的账号已在其他设备登录,或登录已失效,请重新登录';
|
||||
}
|
||||
if (!_disposed) _onAuthFailed?.call(reason);
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
/// 仅供测试:访问内部 Dio,以便挂载 mock adapter 走真实拦截器(401/403 等)。
|
||||
@visibleForTesting
|
||||
Dio get dioForTest => _dio;
|
||||
|
||||
/// 取消所有进行中的请求,标记实例为已废弃
|
||||
void dispose() {
|
||||
_disposed = true;
|
||||
|
||||
Reference in New Issue
Block a user