187 lines
5.5 KiB
Dart
187 lines
5.5 KiB
Dart
import 'dart:convert';
|
|
import 'package:flutter_secure_storage/flutter_secure_storage.dart';
|
|
import 'package:http/http.dart' as http;
|
|
import 'package:http_interceptor/http_interceptor.dart';
|
|
import 'package:jwt_decoder/jwt_decoder.dart';
|
|
import '../models/user_profile.dart';
|
|
import '../utils/logging_interceptor.dart';
|
|
|
|
class AuthService {
|
|
static const String _tokenKey = 'auth_token';
|
|
static const String _refreshTokenKey = 'refresh_token';
|
|
static const String _tokenEndpoint = 'https://auth.goutezplanb.com/realms/planb-internal/protocol/openid-connect/token';
|
|
static const String _clientId = 'delivery-mobile-app';
|
|
|
|
final FlutterSecureStorage _secureStorage;
|
|
final http.Client _httpClient;
|
|
|
|
AuthService({
|
|
FlutterSecureStorage? secureStorage,
|
|
http.Client? httpClient,
|
|
}) : _secureStorage = secureStorage ?? const FlutterSecureStorage(
|
|
aOptions: AndroidOptions(
|
|
encryptedSharedPreferences: true,
|
|
),
|
|
iOptions: IOSOptions(
|
|
accessibility: KeychainAccessibility.first_unlock,
|
|
),
|
|
mOptions: MacOsOptions(
|
|
accessibility: KeychainAccessibility.first_unlock,
|
|
),
|
|
),
|
|
_httpClient = httpClient ?? InterceptedClient.build(
|
|
interceptors: [LoggingInterceptor()],
|
|
);
|
|
|
|
Future<AuthResult> login({
|
|
required String username,
|
|
required String password,
|
|
}) async {
|
|
try {
|
|
final response = await _httpClient.post(
|
|
Uri.parse(_tokenEndpoint),
|
|
headers: {
|
|
'Content-Type': 'application/x-www-form-urlencoded',
|
|
},
|
|
body: {
|
|
'grant_type': 'password',
|
|
'client_id': _clientId,
|
|
'username': username,
|
|
'password': password,
|
|
'scope': 'openid profile offline_access',
|
|
},
|
|
);
|
|
|
|
if (response.statusCode == 200) {
|
|
final data = json.decode(response.body) as Map<String, dynamic>;
|
|
final accessToken = data['access_token'] as String;
|
|
final refreshToken = data['refresh_token'] as String?;
|
|
|
|
await _secureStorage.write(key: _tokenKey, value: accessToken);
|
|
if (refreshToken != null) {
|
|
await _secureStorage.write(key: _refreshTokenKey, value: refreshToken);
|
|
}
|
|
|
|
return AuthResult.success(token: accessToken);
|
|
} else if (response.statusCode == 401) {
|
|
return AuthResult.error(error: 'Invalid username or password');
|
|
} else {
|
|
return AuthResult.error(error: 'Authentication failed: ${response.statusCode}');
|
|
}
|
|
} catch (e) {
|
|
return AuthResult.error(error: 'Network error: ${e.toString()}');
|
|
}
|
|
}
|
|
|
|
Future<AuthResult> refreshAccessToken() async {
|
|
try {
|
|
final refreshToken = await getRefreshToken();
|
|
if (refreshToken == null) {
|
|
return AuthResult.error(error: 'No refresh token available');
|
|
}
|
|
|
|
final response = await _httpClient.post(
|
|
Uri.parse(_tokenEndpoint),
|
|
headers: {
|
|
'Content-Type': 'application/x-www-form-urlencoded',
|
|
},
|
|
body: {
|
|
'grant_type': 'refresh_token',
|
|
'client_id': _clientId,
|
|
'refresh_token': refreshToken,
|
|
},
|
|
);
|
|
|
|
if (response.statusCode == 200) {
|
|
final data = json.decode(response.body) as Map<String, dynamic>;
|
|
final accessToken = data['access_token'] as String;
|
|
final newRefreshToken = data['refresh_token'] as String?;
|
|
|
|
await _secureStorage.write(key: _tokenKey, value: accessToken);
|
|
if (newRefreshToken != null) {
|
|
await _secureStorage.write(key: _refreshTokenKey, value: newRefreshToken);
|
|
}
|
|
|
|
return AuthResult.success(token: accessToken);
|
|
} else {
|
|
await logout();
|
|
return AuthResult.error(error: 'Token refresh failed');
|
|
}
|
|
} catch (e) {
|
|
return AuthResult.error(error: 'Token refresh error: ${e.toString()}');
|
|
}
|
|
}
|
|
|
|
Future<void> logout() async {
|
|
await Future.wait([
|
|
_secureStorage.delete(key: _tokenKey),
|
|
_secureStorage.delete(key: _refreshTokenKey),
|
|
]);
|
|
}
|
|
|
|
Future<String?> getToken() async {
|
|
return await _secureStorage.read(key: _tokenKey);
|
|
}
|
|
|
|
Future<String?> getRefreshToken() async {
|
|
return await _secureStorage.read(key: _refreshTokenKey);
|
|
}
|
|
|
|
bool isTokenValid(String? token) {
|
|
if (token == null || token.isEmpty) return false;
|
|
try {
|
|
return !JwtDecoder.isExpired(token);
|
|
} catch (e) {
|
|
return false;
|
|
}
|
|
}
|
|
|
|
UserProfile? decodeToken(String token) {
|
|
try {
|
|
final decodedToken = JwtDecoder.decode(token);
|
|
return UserProfile.fromJwtClaims(decodedToken);
|
|
} catch (e) {
|
|
return null;
|
|
}
|
|
}
|
|
|
|
Future<bool> isAuthenticated() async {
|
|
final token = await getToken();
|
|
return isTokenValid(token);
|
|
}
|
|
}
|
|
|
|
sealed class AuthResult {
|
|
const AuthResult();
|
|
|
|
factory AuthResult.success({required String token}) => _Success(token);
|
|
factory AuthResult.error({required String error}) => _Error(error);
|
|
const factory AuthResult.cancelled() = _Cancelled;
|
|
|
|
R when<R>({
|
|
required R Function(String token) success,
|
|
required R Function(String error) onError,
|
|
required R Function() cancelled,
|
|
}) {
|
|
return switch (this) {
|
|
_Success(:final token) => success(token),
|
|
_Error(:final error) => onError(error),
|
|
_Cancelled() => cancelled(),
|
|
};
|
|
}
|
|
}
|
|
|
|
final class _Success extends AuthResult {
|
|
final String token;
|
|
const _Success(this.token);
|
|
}
|
|
|
|
final class _Error extends AuthResult {
|
|
final String error;
|
|
const _Error(this.error);
|
|
}
|
|
|
|
final class _Cancelled extends AuthResult {
|
|
const _Cancelled();
|
|
}
|