Files
AI-Health/health_app/lib/providers/auth_provider.dart
MingNian 9cea41705e feat: 文件存储安全加固 + 认证增强 + 媒体URL保护 + provider 重构
## 后端安全加固
- 新增 UserUploadPathResolver: 用户上传文件路径安全解析, 防目录穿越
- LocalReportFileStorage: 文件存储路径安全加固
- local_account_file_cleanup: 账号删除时文件清理逻辑增强
- AuthService: 认证逻辑增强
- file_endpoints / report_endpoints: 文件访问接口安全加固
- ai_chat_endpoints / doctor_endpoints: 接口安全调整
- Program.cs: 服务注册调整

## 前端认证与媒体
- 新增 authenticated_network_image.dart: 带认证的图片加载组件
- auth_provider: 认证状态管理大幅增强(+173)
- api_client: 网络客户端增强(+124)
- chat_provider: 聊天 provider 重构(+76)
- omron_device_provider: 蓝牙设备 provider 增强(+53)
- sse_handler: SSE 处理增强(+35)
- consultation_provider / data_providers / conversation_history_provider: 调整

## 页面调整
- remaining_pages: 健康档案/饮食记录等页面增强(+115)
- home_page / chat_messages_view: 主页微调
- doctor 端多页微调(consultations/dashboard/followups/patient_detail/profile/report_detail/reports)
- report_pages / settings_pages / notification_prefs_page: 微调
- device_scan_page / diet_capture_page / admin_home_page: 微调

## 测试
- 新增 file_path_security_tests: 文件路径安全测试
- 新增 protected_media_url_test: 媒体URL保护测试
- 新增 user_session_identity_test: 用户会话身份测试
- account_deletion_tests / application_service_tests / auth_tests: 更新
2026-07-20 10:19:01 +08:00

428 lines
12 KiB
Dart
Raw 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.

import 'dart:convert';
import 'dart:developer';
import 'package:flutter_riverpod/flutter_riverpod.dart';
import 'package:dio/dio.dart';
import '../core/api_client.dart';
import '../core/local_database.dart';
class UserInfo {
final String id;
final String phone;
final String role;
final String? name;
final String? avatarUrl;
final String? gender;
final String? birthDate;
UserInfo({
required this.id,
required this.phone,
this.role = 'User',
this.name,
this.avatarUrl,
this.gender,
this.birthDate,
});
}
class AuthState {
final UserInfo? user;
final bool isLoggedIn;
final bool isLoading;
const AuthState({this.user, this.isLoggedIn = false, this.isLoading = true});
}
final authProvider = NotifierProvider<AuthNotifier, AuthState>(
AuthNotifier.new,
);
final localDbProvider = Provider<LocalDatabase>(
(ref) => LocalDatabase.instance,
);
final authExpiredNotifierProvider = Provider<AuthExpiredNotifier>((ref) {
return AuthExpiredNotifier();
});
final apiClientProvider = Provider<ApiClient>((ref) {
return ApiClient(
db: ref.watch(localDbProvider),
authExpiredNotifier: ref.watch(authExpiredNotifierProvider),
);
});
class AuthNotifier extends Notifier<AuthState> {
static const _cachedUserKey = 'session_user';
@override
AuthState build() {
final removeListener = ref.read(authExpiredNotifierProvider).addListener(
() {
ref.read(localDbProvider).delete(_cachedUserKey);
state = const AuthState(isLoggedIn: false, isLoading: false);
_publishSessionIdentity(null);
},
);
ref.onDispose(removeListener);
_checkAuth();
// 初始为"加载中",让启动闸门显示 Splash 盖住登录页/首页初始态
return const AuthState(isLoggedIn: false, isLoading: true);
}
Future<void> _checkAuth() async {
final db = ref.read(localDbProvider);
final refresh = await db.read('refresh_token');
if (refresh == null) {
// 无 token判定完成、未登录
state = const AuthState(isLoggedIn: false, isLoading: false);
_publishSessionIdentity(null);
return;
}
state = const AuthState(isLoading: true);
try {
final response = await Dio(
BaseOptions(
baseUrl: baseUrl,
connectTimeout: const Duration(seconds: 5),
receiveTimeout: const Duration(seconds: 8),
),
).post('/api/auth/refresh', data: {'refreshToken': refresh});
final body = response.data;
final data = body is Map ? body['data'] : null;
final code = body is Map ? body['code'] : null;
if (data is Map) {
await db.write('access_token', data['accessToken']);
await db.write('refresh_token', data['refreshToken']);
final u = data['user'] as Map<String, dynamic>?;
final cachedUser = await _readCachedUser();
final user = _userFromMap(u, fallback: cachedUser);
state = AuthState(isLoggedIn: true, isLoading: false, user: user);
_publishSessionIdentity(user);
await _cacheUser(user);
_loadProfile();
} else if (code != null && code != 0) {
await _clearSessionStorage();
state = const AuthState(isLoggedIn: false, isLoading: false);
_publishSessionIdentity(null);
} else {
await _restoreOfflineSession();
}
} on DioException catch (error) {
if (error.response?.statusCode == 400 ||
error.response?.statusCode == 401) {
await _clearSessionStorage();
state = const AuthState(isLoggedIn: false, isLoading: false);
_publishSessionIdentity(null);
} else {
await _restoreOfflineSession();
}
} catch (error) {
log('[Auth] startup refresh: $error');
await _restoreOfflineSession();
}
}
Future<void> _loadProfile() async {
// Admin 不查 profile无 User 记录)
if (state.user?.role == 'Admin') return;
try {
final api = ref.read(apiClientProvider);
final response = await api.get('/api/user/profile');
final user = response.data['data'];
if (user != null) {
state = AuthState(
isLoggedIn: true,
isLoading: false,
user: UserInfo(
id: user['id'] ?? '',
phone: user['phone'] ?? '',
role: user['role'] ?? state.user?.role ?? 'User',
name: user['name'],
avatarUrl: user['avatarUrl'],
gender: user['gender'],
birthDate: user['birthDate']?.toString(),
),
);
await _cacheUser(state.user!);
_publishSessionIdentity(state.user);
}
} catch (e) {
log('[Auth] loadProfile: $e');
}
}
Future<void> refreshProfile() => _loadProfile();
/// 发送验证码
Future<({String? error, String? devCode})> sendSms(String phone) async {
try {
final api = ref.read(apiClientProvider);
final response = await api.post(
'/api/auth/send-sms',
data: {'phone': phone},
);
return (
error: null,
devCode: response.data['data']?['devCode'] as String?,
);
} catch (e) {
return (error: '发送失败: $e', devCode: null);
}
}
/// 注册(新用户,需选身份)
Future<String?> register(
String phone,
String code,
String name,
String doctorId,
) async {
try {
final api = ref.read(apiClientProvider);
final response = await api.post(
'/api/auth/register',
data: {
'phone': phone,
'smsCode': code,
'name': name,
'doctorId': doctorId,
},
);
final data = response.data['data'];
if (data == null) return response.data['message'] ?? '注册失败';
await api.saveTokens(data['accessToken'], data['refreshToken']);
final user = data['user'];
state = AuthState(
isLoggedIn: true,
isLoading: false,
user: UserInfo(
id: user['id'] ?? '',
phone: user['phone'] ?? '',
role: user['role'] ?? 'User',
),
);
await _cacheUser(state.user!);
_publishSessionIdentity(state.user);
return null;
} catch (e) {
return '注册失败: $e';
}
}
/// 登录(已有账号)
Future<String?> login(String phone, String code) async {
try {
final api = ref.read(apiClientProvider);
final response = await api.post(
'/api/auth/login',
data: {'phone': phone, 'smsCode': code},
);
final data = response.data['data'];
if (data == null) return response.data['message'] ?? '登录失败';
await api.saveTokens(data['accessToken'], data['refreshToken']);
final user = data['user'];
state = AuthState(
isLoggedIn: true,
isLoading: false,
user: UserInfo(
id: user['id'] ?? '',
phone: user['phone'] ?? '',
role: user['role'] ?? 'User',
name: user['name'],
avatarUrl: user['avatarUrl'],
),
);
await _cacheUser(state.user!);
_publishSessionIdentity(state.user);
return null;
} catch (e) {
return '登录失败: $e';
}
}
/// Apple 登录
Future<String?> appleLogin(
String identityToken,
String? authorizationCode,
String? name,
) async {
try {
final api = ref.read(apiClientProvider);
final response = await api.post(
'/api/auth/apple-login',
data: {
'identityToken': identityToken,
'authorizationCode': authorizationCode,
'name': name,
},
);
final data = response.data['data'];
if (data == null) return response.data['message'] ?? 'Apple 登录失败';
await api.saveTokens(data['accessToken'], data['refreshToken']);
final user = data['user'];
state = AuthState(
isLoggedIn: true,
isLoading: false,
user: UserInfo(
id: user['id'] ?? '',
phone: user['phone'] ?? '',
role: user['role'] ?? 'User',
name: user['name'],
avatarUrl: user['avatarUrl'],
),
);
await _cacheUser(state.user!);
_publishSessionIdentity(state.user);
return null;
} catch (_) {
return 'Apple 登录失败,请稍后重试';
}
}
/// 登出
Future<void> logout() async {
final api = ref.read(apiClientProvider);
final db = ref.read(localDbProvider);
final refresh = await db.read('refresh_token');
if (refresh != null) {
try {
await api.post('/api/auth/logout', data: {'refreshToken': refresh});
} catch (e) {
log('[Auth] logout: $e');
}
}
await api.clearTokens();
await db.delete(_cachedUserKey);
state = const AuthState(isLoggedIn: false, isLoading: false);
_publishSessionIdentity(null);
}
Future<void> _restoreOfflineSession() async {
final cached = await _readCachedUser() ?? await _userFromStoredToken();
if (cached == null) {
state = const AuthState(isLoggedIn: false, isLoading: false);
_publishSessionIdentity(null);
return;
}
state = AuthState(user: cached, isLoggedIn: true, isLoading: false);
_publishSessionIdentity(cached);
}
UserInfo _userFromMap(Map<String, dynamic>? value, {UserInfo? fallback}) {
return UserInfo(
id: value?['id']?.toString() ?? fallback?.id ?? '',
phone: value?['phone']?.toString() ?? fallback?.phone ?? '',
role: value?['role']?.toString() ?? fallback?.role ?? 'User',
name: value?['name']?.toString() ?? fallback?.name,
avatarUrl: value?['avatarUrl']?.toString() ?? fallback?.avatarUrl,
gender: value?['gender']?.toString() ?? fallback?.gender,
birthDate: value?['birthDate']?.toString() ?? fallback?.birthDate,
);
}
Future<void> _cacheUser(UserInfo user) {
return ref
.read(localDbProvider)
.write(
_cachedUserKey,
jsonEncode({
'id': user.id,
'phone': user.phone,
'role': user.role,
'name': user.name,
'avatarUrl': user.avatarUrl,
'gender': user.gender,
'birthDate': user.birthDate,
}),
);
}
Future<UserInfo?> _readCachedUser() async {
final raw = await ref.read(localDbProvider).read(_cachedUserKey);
if (raw == null || raw.isEmpty) return null;
try {
final decoded = jsonDecode(raw);
if (decoded is! Map) return null;
return _userFromMap(Map<String, dynamic>.from(decoded));
} catch (_) {
return null;
}
}
Future<UserInfo?> _userFromStoredToken() async {
final token = await ref.read(localDbProvider).read('access_token');
if (token == null) return null;
try {
final parts = token.split('.');
if (parts.length != 3) return null;
final payload = jsonDecode(
utf8.decode(base64Url.decode(base64Url.normalize(parts[1]))),
);
if (payload is! Map) return null;
final map = Map<String, dynamic>.from(payload);
String? claim(String shortName, String longName) =>
map[shortName]?.toString() ?? map[longName]?.toString();
return UserInfo(
id:
claim(
'sub',
'http://schemas.xmlsoap.org/ws/2005/05/identity/claims/nameidentifier',
) ??
'',
phone:
claim(
'phone',
'http://schemas.xmlsoap.org/ws/2005/05/identity/claims/mobilephone',
) ??
'',
role:
claim(
'role',
'http://schemas.microsoft.com/ws/2008/06/identity/claims/role',
) ??
'User',
);
} catch (_) {
return null;
}
}
Future<void> _clearSessionStorage() async {
await ref.read(apiClientProvider).clearTokens();
await ref.read(localDbProvider).delete(_cachedUserKey);
}
void _publishSessionIdentity(UserInfo? user) {
ref.read(userSessionIdentityProvider.notifier).setUser(user);
}
}
/// 便捷:当前用户角色
final userRoleProvider = Provider<String>((ref) {
return ref.watch(authProvider).user?.role ?? 'User';
});
final userSessionIdentityProvider =
NotifierProvider<UserSessionIdentityNotifier, String?>(
UserSessionIdentityNotifier.new,
);
class UserSessionIdentityNotifier extends Notifier<String?> {
@override
String? build() => null;
void setUser(UserInfo? user) {
if (user == null || user.id.isEmpty) {
state = null;
return;
}
state = user.id;
}
}