317 lines
9.2 KiB
Dart
317 lines
9.2 KiB
Dart
import 'package:flutter/foundation.dart';
|
|
import 'package:mileograph_flutter/objects/objects.dart';
|
|
import 'package:mileograph_flutter/services/api_service.dart';
|
|
import 'package:mileograph_flutter/services/token_storage_service.dart';
|
|
|
|
class AuthService extends ChangeNotifier {
|
|
final ApiService api;
|
|
bool _restoring = false;
|
|
String? _accessToken;
|
|
Future<bool>? _refreshFuture;
|
|
|
|
final TokenStorageService _tokenStorage = TokenStorageService();
|
|
|
|
AuthService({required this.api}) {
|
|
api.setTokenProvider(() => token);
|
|
api.setUnauthorizedHandler(_handleUnauthorized);
|
|
}
|
|
|
|
AuthenticatedUserData? _user;
|
|
|
|
bool get isLoggedIn => _user != null;
|
|
String? get token => _accessToken;
|
|
String? get userId => _user?.userId;
|
|
String? get username => _user?.username;
|
|
String? get fullName => _user?.fullName;
|
|
String get entriesVisibility => _user?.entriesVisibility ?? 'private';
|
|
String get mileageVisibility => _user?.mileageVisibility ?? 'private';
|
|
bool get isElevated => _user?.isElevated ?? false;
|
|
bool get isAdmin => isElevated; // alias for old name
|
|
bool get isDisabled => _user?.disabled ?? false;
|
|
|
|
void setLoginData({
|
|
required String userId,
|
|
required String username,
|
|
required String fullName,
|
|
required String accessToken,
|
|
required String email,
|
|
String? refreshToken,
|
|
String entriesVisibility = 'private',
|
|
String mileageVisibility = 'private',
|
|
bool isElevated = false,
|
|
bool isDisabled = false,
|
|
}) {
|
|
_accessToken = accessToken;
|
|
_user = AuthenticatedUserData(
|
|
userId: userId,
|
|
username: username,
|
|
fullName: fullName,
|
|
accessToken: accessToken,
|
|
email: email,
|
|
entriesVisibility: entriesVisibility,
|
|
mileageVisibility: mileageVisibility,
|
|
isElevated: isElevated,
|
|
disabled: isDisabled,
|
|
);
|
|
_persistTokens(accessToken, refreshToken);
|
|
notifyListeners();
|
|
}
|
|
|
|
Future<void> login(String username, String password) async {
|
|
final formData = {
|
|
'grant_type': 'password',
|
|
'username': username,
|
|
'password': password,
|
|
'scope': '',
|
|
'client_id': 'string',
|
|
'client_secret': 'string',
|
|
};
|
|
|
|
// 1. Get token
|
|
final tokenResponse = await api.postForm('/token', formData, includeAuth: false);
|
|
final accessToken = tokenResponse['access_token'];
|
|
final refreshToken = tokenResponse['refresh_token'];
|
|
|
|
// 2. Get user details
|
|
final userResponse = await api.get(
|
|
'/users/me',
|
|
headers: {
|
|
'Authorization': 'Bearer $accessToken',
|
|
'accept': 'application/json',
|
|
},
|
|
);
|
|
|
|
// 3. Populate state
|
|
setLoginData(
|
|
userId: userResponse['user_id'],
|
|
username: userResponse['username'],
|
|
fullName: userResponse['full_name'],
|
|
accessToken: accessToken,
|
|
email: userResponse['email'],
|
|
refreshToken: refreshToken,
|
|
entriesVisibility: _parseVisibility(
|
|
userResponse['user_entries_visibility'] ?? userResponse['entries_visibility'],
|
|
'private',
|
|
),
|
|
mileageVisibility: _parseVisibility(
|
|
userResponse['user_mileage_visibility'] ?? userResponse['mileage_visibility'],
|
|
'private',
|
|
),
|
|
isElevated: _parseIsElevated(userResponse),
|
|
isDisabled: _parseIsDisabled(userResponse),
|
|
);
|
|
}
|
|
|
|
Future<void> tryRestoreSession() async {
|
|
if (_restoring || _user != null) return;
|
|
_restoring = true;
|
|
try {
|
|
// read token from secure storage (with fallback)
|
|
final token = await _tokenStorage.getToken();
|
|
if (token == null || token.isEmpty) return;
|
|
_accessToken = token;
|
|
|
|
final userResponse = await api.get(
|
|
'/users/me',
|
|
);
|
|
|
|
final restoredAccessToken = _accessToken ?? token;
|
|
setLoginData(
|
|
userId: userResponse['user_id'],
|
|
username: userResponse['username'],
|
|
fullName: userResponse['full_name'],
|
|
accessToken: restoredAccessToken,
|
|
email: userResponse['email'],
|
|
entriesVisibility: _parseVisibility(
|
|
userResponse['user_entries_visibility'] ?? userResponse['entries_visibility'],
|
|
'private',
|
|
),
|
|
mileageVisibility: _parseVisibility(
|
|
userResponse['user_mileage_visibility'] ?? userResponse['mileage_visibility'],
|
|
'private',
|
|
),
|
|
isElevated: _parseIsElevated(userResponse),
|
|
isDisabled: _parseIsDisabled(userResponse),
|
|
);
|
|
} catch (_) {
|
|
await _clearToken();
|
|
} finally {
|
|
_restoring = false;
|
|
}
|
|
}
|
|
|
|
Future<bool> validateStoredToken() async {
|
|
final token = await _tokenStorage.getToken();
|
|
if (token == null || token.isEmpty) return false;
|
|
try {
|
|
_accessToken = token;
|
|
await api.get(
|
|
'/validate',
|
|
);
|
|
return true;
|
|
} catch (_) {
|
|
await _clearToken();
|
|
return false;
|
|
}
|
|
}
|
|
|
|
Future<void> _persistTokens(String accessToken, String? refreshToken) async {
|
|
await _tokenStorage.setToken(accessToken);
|
|
if (refreshToken != null && refreshToken.isNotEmpty) {
|
|
await _tokenStorage.setRefreshToken(refreshToken);
|
|
}
|
|
}
|
|
|
|
Future<void> _clearToken() async {
|
|
_accessToken = null;
|
|
await _tokenStorage.clearToken();
|
|
}
|
|
|
|
Future<void> register({
|
|
required String username,
|
|
required String email,
|
|
required String fullName,
|
|
required String password,
|
|
String inviteCode = '',
|
|
}) async {
|
|
final formData = {
|
|
'user_name': username,
|
|
'email': email,
|
|
'full_name': fullName,
|
|
'password': password,
|
|
'invitation_code': inviteCode,
|
|
'empty': '',
|
|
'empty2': '',
|
|
};
|
|
await api.postForm('/register', formData);
|
|
}
|
|
|
|
Future<bool> _handleUnauthorized() async {
|
|
if (_refreshFuture != null) {
|
|
return _refreshFuture!;
|
|
}
|
|
_refreshFuture = _refreshTokens();
|
|
final refreshed = await _refreshFuture!;
|
|
_refreshFuture = null;
|
|
return refreshed;
|
|
}
|
|
|
|
Future<bool> _refreshTokens() async {
|
|
final refreshToken = await _tokenStorage.getRefreshToken();
|
|
if (refreshToken == null || refreshToken.isEmpty) {
|
|
await handleTokenExpired();
|
|
return false;
|
|
}
|
|
try {
|
|
final response = await api.post(
|
|
'/token/refresh',
|
|
{'refresh_token': refreshToken},
|
|
includeAuth: false,
|
|
allowRetry: false,
|
|
);
|
|
final accessToken = response['access_token'];
|
|
final newRefreshToken = response['refresh_token'];
|
|
if (accessToken is! String ||
|
|
accessToken.isEmpty ||
|
|
newRefreshToken is! String ||
|
|
newRefreshToken.isEmpty) {
|
|
await handleTokenExpired();
|
|
return false;
|
|
}
|
|
_accessToken = accessToken;
|
|
await _persistTokens(accessToken, newRefreshToken);
|
|
if (_user != null) {
|
|
_user = AuthenticatedUserData(
|
|
userId: _user!.userId,
|
|
username: _user!.username,
|
|
fullName: _user!.fullName,
|
|
accessToken: accessToken,
|
|
email: _user!.email,
|
|
entriesVisibility: _user!.entriesVisibility,
|
|
mileageVisibility: _user!.mileageVisibility,
|
|
isElevated: _user!.isElevated,
|
|
isDisabled: _user!.disabled,
|
|
);
|
|
}
|
|
notifyListeners();
|
|
return true;
|
|
} catch (_) {
|
|
await handleTokenExpired();
|
|
return false;
|
|
}
|
|
}
|
|
|
|
Future<void> handleTokenExpired() async {
|
|
_user = null;
|
|
await _clearToken();
|
|
notifyListeners();
|
|
}
|
|
|
|
void logout() {
|
|
handleTokenExpired(); // reuse
|
|
}
|
|
|
|
bool _parseIsElevated(Map<String, dynamic> json) {
|
|
dynamic value = json['is_elevated'] ??
|
|
json['elevated'] ??
|
|
json['is_admin'] ??
|
|
json['admin'] ??
|
|
json['isAdmin'] ??
|
|
json['admin_user'] ??
|
|
json['role'] ??
|
|
json['roles'] ??
|
|
json['permissions'] ??
|
|
json['scopes'] ??
|
|
json['is_staff'] ??
|
|
json['staff'] ??
|
|
json['is_superuser'] ??
|
|
json['superuser'] ??
|
|
json['groups'];
|
|
|
|
bool parseBoolish(dynamic v) {
|
|
if (v is bool) return v;
|
|
if (v is num) return v != 0;
|
|
final str = v?.toString().toLowerCase().trim();
|
|
if (str == null || str.isEmpty) return false;
|
|
if (['1', 'true', 'yes', 'y', 'admin', 'superuser', 'staff', 'elevated'].contains(str)) {
|
|
return true;
|
|
}
|
|
return str.contains('admin') || str.contains('superuser') || str.contains('staff');
|
|
}
|
|
|
|
if (value is List) {
|
|
for (final entry in value) {
|
|
if (parseBoolish(entry)) return true;
|
|
final s = entry?.toString().toLowerCase();
|
|
if (s != null &&
|
|
(s.contains('admin') ||
|
|
s.contains('superuser') ||
|
|
s.contains('staff') ||
|
|
s.contains('elevated') ||
|
|
s == 'root')) {
|
|
return true;
|
|
}
|
|
}
|
|
return false;
|
|
}
|
|
|
|
return parseBoolish(value);
|
|
}
|
|
|
|
bool _parseIsDisabled(Map<String, dynamic> json) {
|
|
dynamic value = json['disabled'] ?? json['is_disabled'];
|
|
if (value is bool) return value;
|
|
if (value is num) return value != 0;
|
|
final str = value?.toString().toLowerCase().trim();
|
|
if (str == null || str.isEmpty) return false;
|
|
return ['1', 'true', 'yes', 'y', 'disabled'].contains(str);
|
|
}
|
|
|
|
String _parseVisibility(dynamic value, String fallback) {
|
|
const allowed = ['private', 'friends', 'public'];
|
|
final str = value?.toString().toLowerCase().trim();
|
|
if (str != null && allowed.contains(str)) return str;
|
|
return fallback;
|
|
}
|
|
}
|