- ApiClient: 添加 onRefreshToken 回调,401 时自动调用刷新 - ApiClient: 并发保护(_isRefreshing),防止多个 401 触发多次刷新 - ApiClient: 跳过 /auth/refresh 自身的 401(避免无限循环) - ApiClient: 刷新成功后自动重试原始请求 - AuthRepository: 注册 _handleAutoRefresh 回调 - 使用回调模式避免 ApiClient ↔ AuthRepository 循环依赖 审计 ID: 9a-AUTH-01
291 lines
8.4 KiB
Dart
291 lines
8.4 KiB
Dart
// 认证仓库 — 登录/注册/令牌管理的统一入口
|
||
//
|
||
// 职责:
|
||
// - 封装后端认证 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);
|
||
}
|
||
}
|