Files
nj/app/lib/data/repositories/auth_repository.dart
iven 45949e3ed0 fix(app): Token 自动刷新拦截器 — 401 时自动刷新 + 重试原请求
- ApiClient: 添加 onRefreshToken 回调,401 时自动调用刷新
- ApiClient: 并发保护(_isRefreshing),防止多个 401 触发多次刷新
- ApiClient: 跳过 /auth/refresh 自身的 401(避免无限循环)
- ApiClient: 刷新成功后自动重试原始请求
- AuthRepository: 注册 _handleAutoRefresh 回调
- 使用回调模式避免 ApiClient ↔ AuthRepository 循环依赖

审计 ID: 9a-AUTH-01
2026-06-03 10:07:33 +08:00

291 lines
8.4 KiB
Dart
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
// 认证仓库 — 登录/注册/令牌管理的统一入口
//
// 职责:
// - 封装后端认证 API 调用(登录/注册/刷新令牌/登出)
// - 通过 SecureTokenStore 安全持久化 JWT 令牌PIPL 合规)
// - 为 AuthBloc 提供干净的认证数据访问接口
import 'dart:convert';
import 'package:logger/logger.dart';
import '../local/secure_token_store.dart';
import '../models/auth_token.dart';
import '../models/user.dart';
import '../remote/api_client.dart';
/// 安全存储键名
const _keyAccessToken = 'auth_access_token';
const _keyRefreshToken = 'auth_refresh_token';
const _keyExpiresAt = 'auth_expires_at';
const _keyUserJson = 'auth_user_json';
/// 认证异常 — 认证流程中出现的错误
class AuthException implements Exception {
final String message;
final int? statusCode;
const AuthException(this.message, {this.statusCode});
@override
String toString() => 'AuthException: $message';
}
/// 认证仓库 — 管理用户登录状态和令牌
///
/// 使用 [ApiClient] 与后端通信,使用 [SecureTokenStore] 持久化令牌。
/// 原生平台使用加密存储Web 平台使用 shared_preferences。
class AuthRepository {
final ApiClient _apiClient;
final SecureTokenStore _tokenStore;
final Logger _logger = Logger(printer: PrettyPrinter(methodCount: 0));
AuthToken? _currentToken;
User? _currentUser;
AuthRepository({
required ApiClient apiClient,
required SecureTokenStore tokenStore,
}) : _apiClient = apiClient,
_tokenStore = tokenStore {
// 注册 token 自动刷新回调 — 401 时 ApiClient 自动调用
_apiClient.onRefreshToken = _handleAutoRefresh;
}
/// 当前用户(可能为 null
User? get currentUser => _currentUser;
/// 当前令牌(可能为 null
AuthToken? get currentToken => _currentToken;
/// 是否已登录
bool get isAuthenticated => _currentToken != null && !_currentToken!.isExpired;
// ===== 登录 =====
/// 用户名密码登录
///
/// 调用后端 `POST /auth/login`,成功后保存令牌和用户信息。
Future<User> login({
required String username,
required String password,
}) async {
_logger.i('登录请求: $username');
final response = await _apiClient.post('/auth/login', data: {
'username': username,
'password': password,
'client_type': 'mobile',
});
final data = _extractData(response.data);
final token = AuthToken.fromJson(data);
final user = User.fromJson(data['user'] as Map<String, dynamic>);
await _saveAuth(token, user);
_apiClient.setToken(token.accessToken);
_logger.i('登录成功: ${user.displayLabel}');
return user;
}
// ===== 注册 =====
/// 注册新用户
///
/// 调用后端 `POST /users`,成功后自动登录。
Future<User> register({
required String username,
required String password,
String? displayName,
}) async {
_logger.i('注册请求: $username');
await _apiClient.post('/users', data: {
'username': username,
'password': password,
if (displayName != null) 'display_name': displayName,
});
// 注册成功后自动登录
return await login(username: username, password: password);
}
// ===== 刷新令牌 =====
/// 刷新访问令牌
///
/// 调用后端 `POST /auth/refresh`,使用 refresh_token 获取新的 access_token。
Future<AuthToken> refreshToken() async {
if (_currentToken == null) {
throw const AuthException('无可用令牌,请重新登录');
}
_logger.d('刷新令牌');
final response = await _apiClient.post('/auth/refresh', data: {
'refresh_token': _currentToken!.refreshToken,
});
final data = _extractData(response.data);
final token = AuthToken.fromJson(data);
await _saveToken(token);
_apiClient.setToken(token.accessToken);
return token;
}
// ===== 登出 =====
/// 登出
///
/// 调用后端 `POST /auth/logout` 并清除本地存储。
Future<void> logout() async {
_logger.i('登出');
try {
if (isAuthenticated) {
await _apiClient.post('/auth/logout');
}
} catch (e) {
_logger.w('登出 API 调用失败(忽略): $e');
}
await _clearAuth();
_apiClient.clearToken();
}
// ===== 本地恢复 =====
/// 从安全存储恢复认证状态
///
/// App 启动时调用,检查是否有有效的持久化令牌。
/// 如果令牌即将过期,自动刷新。
Future<User?> restoreAuth() async {
_logger.d('恢复认证状态');
try {
final accessToken = await _tokenStore.read(_keyAccessToken);
final refreshTokenStr = await _tokenStore.read(_keyRefreshToken);
final expiresAtStr = await _tokenStore.read(_keyExpiresAt);
final userJsonStr = await _tokenStore.read(_keyUserJson);
if (accessToken == null || refreshTokenStr == null || userJsonStr == null) {
_logger.d('无存储的认证信息');
return null;
}
final expiresAt = DateTime.parse(expiresAtStr ?? DateTime.now().subtract(const Duration(hours: 1)).toIso8601String());
_currentToken = AuthToken(
accessToken: accessToken,
refreshToken: refreshTokenStr,
expiresIn: 0,
expiresAt: expiresAt,
);
_currentUser = User.fromJson(
jsonDecode(userJsonStr) as Map<String, dynamic>,
);
// 令牌已过期 → 尝试刷新
if (_currentToken!.isExpired) {
_logger.d('令牌已过期,尝试刷新');
try {
await refreshToken();
} catch (e) {
_logger.w('令牌刷新失败,需要重新登录: $e');
await _clearAuth();
return null;
}
} else if (_currentToken!.isExpiringSoon) {
// 即将过期 → 后台刷新(不阻塞)
_logger.d('令牌即将过期,后台刷新');
refreshToken().catchError((e) {
_logger.w('后台刷新失败: $e');
return _currentToken!; // 返回当前令牌作为 fallback
});
}
_apiClient.setToken(_currentToken!.accessToken);
_logger.i('认证恢复成功: ${_currentUser!.displayLabel}');
return _currentUser;
} catch (e) {
_logger.e('认证恢复失败: $e');
await _clearAuth();
return null;
}
}
// ===== Token 自动刷新 =====
/// ApiClient 401 拦截器调用的自动刷新处理
///
/// 使用 refresh_token 获取新 access_token更新 ApiClient 的 token
/// 返回新 access_token失败返回 null
Future<String?> _handleAutoRefresh() async {
if (_currentToken == null) return null;
_logger.d('自动刷新令牌401 触发)');
try {
final response = await _apiClient.post('/auth/refresh', data: {
'refresh_token': _currentToken!.refreshToken,
});
final data = _extractData(response.data);
final token = AuthToken.fromJson(data);
await _saveToken(token);
_logger.i('自动刷新令牌成功');
return token.accessToken;
} catch (e) {
_logger.w('自动刷新令牌失败: $e');
return null;
}
}
// ===== 私有方法 =====
/// 从 API 响应中提取 data 字段
Map<String, dynamic> _extractData(dynamic responseData) {
if (responseData is Map<String, dynamic>) {
// 后端 ApiResponse 格式: { success: bool, data: T, message: String? }
if (responseData.containsKey('data')) {
return responseData['data'] as Map<String, dynamic>;
}
return responseData;
}
throw const AuthException('服务器响应格式异常');
}
/// 保存令牌和用户到安全存储
Future<void> _saveAuth(AuthToken token, User user) async {
_currentToken = token;
_currentUser = user;
await _saveToken(token);
await _tokenStore.write(
_keyUserJson,
jsonEncode(user.toJson()),
);
}
/// 仅保存令牌到安全存储
Future<void> _saveToken(AuthToken token) async {
_currentToken = token;
await _tokenStore.write(_keyAccessToken, token.accessToken);
await _tokenStore.write(_keyRefreshToken, token.refreshToken);
await _tokenStore.write(_keyExpiresAt, token.expiresAt.toIso8601String());
}
/// 清除所有认证数据
Future<void> _clearAuth() async {
_currentToken = null;
_currentUser = null;
await _tokenStore.delete(_keyAccessToken);
await _tokenStore.delete(_keyRefreshToken);
await _tokenStore.delete(_keyExpiresAt);
await _tokenStore.delete(_keyUserJson);
}
}