didvan-app/lib/services/auth/keycloak_auth_service.dart

335 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.

// lib/services/auth/keycloak_auth_service.dart
//
// سرویس مسئول صحبت مستقیم با سرور Keycloak برای:
// - login (Resource Owner Password Credentials)
// - refresh (refresh_token grant)
// - logout (revoke refresh_token)
//
// به دلیل ساده بودن این endpoint ها و عدم وابستگی به ساختار قدیمی RequestService
// (که با JSON کار می‌کند و این endpoint ها form-urlencoded هستند) به صورت
// مستقل از package:http استفاده می‌کنیم.
import 'dart:async';
import 'dart:convert';
import 'dart:developer' as dev;
import 'dart:io' show SocketException;
// ignore: depend_on_referenced_packages
import 'package:http/http.dart' as http;
import 'package:didvan/config/auth_config.dart';
import 'package:didvan/services/auth/jwt_utils.dart';
import 'package:didvan/services/auth/token_storage.dart';
/// نتیجه‌ی یک تلاش احراز هویت با Keycloak.
class KeycloakAuthResult {
final bool success;
final String? errorMessage;
final int? statusCode;
KeycloakAuthResult.ok()
: success = true,
errorMessage = null,
statusCode = 200;
KeycloakAuthResult.fail(this.errorMessage, {this.statusCode})
: success = false;
}
class KeycloakAuthService {
KeycloakAuthService._();
static final KeycloakAuthService instance = KeycloakAuthService._();
/// در حال refresh هستیم؟ (برای جلوگیری از refresh موازی)
Future<void>? _refreshing;
/// لاگین با username/password.
/// در صورت موفقیت، توکن‌ها در [TokenStorage] ذخیره می‌شوند.
Future<KeycloakAuthResult> login({
required String username,
required String password,
}) async {
try {
final body = <String, String>{
'grant_type': 'password',
'client_id': AuthConfig.keycloakClientId,
'username': username,
'password': password,
'scope': 'openid',
};
final secret = AuthConfig.keycloakClientSecret;
if (secret != null && secret.isNotEmpty) {
body['client_secret'] = secret;
}
final response = await _withSocketRetry(
() => http
.post(
Uri.parse(AuthConfig.keycloakTokenUrl),
headers: const {
'Content-Type': 'application/x-www-form-urlencoded',
'Accept': 'application/json',
},
body: body,
)
.timeout(const Duration(seconds: 30)),
);
if (response.statusCode == 200) {
await _persistTokenResponse(response.body);
return KeycloakAuthResult.ok();
}
final message = _extractError(response.body) ??
'نام کاربری یا رمز عبور اشتباه است.';
return KeycloakAuthResult.fail(message, statusCode: response.statusCode);
} catch (e) {
dev.log('Keycloak login error: $e', name: 'KeycloakAuthService');
return KeycloakAuthResult.fail(
'خطا در ارتباط با سرور احراز هویت.',
);
}
}
/// در صورت دریافت SocketException، تا 2 بار با تاخیر کوتاه تلاش مجدد می‌کنیم.
/// عمدتا برای جبران DNS negative cache در لحظه‌ی boot emulator مفید است.
Future<T> _withSocketRetry<T>(Future<T> Function() body) async {
for (var attempt = 0; attempt < 3; attempt++) {
try {
return await body();
} on SocketException {
if (attempt == 2) rethrow;
await Future.delayed(Duration(milliseconds: 250 * (attempt + 1)));
} on http.ClientException {
if (attempt == 2) rethrow;
await Future.delayed(Duration(milliseconds: 250 * (attempt + 1)));
}
}
throw StateError('retry loop exited without return');
}
/// تلاش برای تمدید توکن. در صورت موفقیت true.
/// اگر چند درخواست همزمان نیاز به refresh داشته باشند فقط یک‌بار اجرا می‌شود.
Future<bool> refresh() async {
final inProgress = _refreshing;
if (inProgress != null) {
await inProgress;
return TokenStorage.isAccessTokenValid;
}
final completer = _refreshing = _doRefresh();
try {
await completer;
} finally {
_refreshing = null;
}
return TokenStorage.isAccessTokenValid;
}
Future<void> _doRefresh() async {
final refreshToken = TokenStorage.refreshToken;
if (refreshToken == null || refreshToken.isEmpty) {
// ignore: avoid_print
print('🔐 refresh: NO refresh token -> abort');
return;
}
// 🔐 PROBE موقت
final now = DateTime.now().millisecondsSinceEpoch;
// ignore: avoid_print
print('🔐 refresh START accessValid=${TokenStorage.isAccessTokenValid} '
'refreshValid=${TokenStorage.isRefreshTokenValid} '
'accessExpiresInMs=${TokenStorage.accessExpiresAt != null ? TokenStorage.accessExpiresAt! - now : "?"} '
'refreshExpiresInMs=${TokenStorage.refreshExpiresAt != null ? TokenStorage.refreshExpiresAt! - now : "?"}');
if (!TokenStorage.isRefreshTokenValid) {
// refresh token هم منقضی شده => پاک می‌کنیم تا کاربر دوباره login کند.
// ignore: avoid_print
print('🔐 refresh: refresh token EXPIRED -> clearing tokens (logout)');
await TokenStorage.clear();
return;
}
try {
final body = <String, String>{
'grant_type': 'refresh_token',
'client_id': AuthConfig.keycloakClientId,
'refresh_token': refreshToken,
};
final secret = AuthConfig.keycloakClientSecret;
if (secret != null && secret.isNotEmpty) {
body['client_secret'] = secret;
}
final response = await _withSocketRetry(
() => http
.post(
Uri.parse(AuthConfig.keycloakTokenUrl),
headers: const {
'Content-Type': 'application/x-www-form-urlencoded',
'Accept': 'application/json',
},
body: body,
)
.timeout(const Duration(seconds: 30)),
);
if (response.statusCode == 200) {
await _persistTokenResponse(response.body);
// ignore: avoid_print
print('🔐 refresh OK 200 -> tokens renewed');
return;
}
// فقط در شکست‌های "قطعی" (400 invalid_grant / 401 unauthorized) که
// یعنی refresh token واقعا نامعتبر شده، state را پاک می‌کنیم.
// بقیه‌ی خطاها (5xx، شبکه، CORS، ...) ممکن است گذرا باشند و نباید
// باعث logout اجباری شوند.
if (response.statusCode == 400 || response.statusCode == 401) {
// ignore: avoid_print
print('🔐 refresh FAIL ${response.statusCode} body=${response.body} '
'-> clearing tokens (logout)');
dev.log(
'Refresh permanent failure ${response.statusCode}: ${response.body}',
name: 'KeycloakAuthService',
);
await TokenStorage.clear();
} else {
// ignore: avoid_print
print('🔐 refresh transient ${response.statusCode} '
'body=${response.body} -> keeping tokens');
dev.log(
'Refresh transient failure ${response.statusCode}: '
'${response.body} — keeping tokens',
name: 'KeycloakAuthService',
);
}
} catch (e) {
// خطای transient (شبکه/SSL/timeout) — token پاک نمی‌شود.
dev.log('Refresh exception (transient): $e',
name: 'KeycloakAuthService');
}
}
/// دریافت اطلاعات استاندارد OpenID Connect کاربر جاری.
/// این endpoint برای هر کاربر authenticated در دسترس است و نیازی به
/// scope خاصی ندارد.
///
/// پاسخ معمول شامل: sub, preferred_username, email, given_name,
/// family_name, name. در صورت تنظیم Protocol Mapper در client،
/// attributes اضافی (مثل phone یا avatar) نیز در پاسخ ظاهر می‌شوند.
Future<Map<String, dynamic>?> fetchUserInfo() async {
final token = TokenStorage.accessToken;
if (token == null || token.isEmpty) return null;
try {
final response = await _withSocketRetry(
() => http.get(
Uri.parse(AuthConfig.keycloakUserInfoUrl),
headers: {
'Authorization': 'Bearer $token',
'Accept': 'application/json',
},
).timeout(const Duration(seconds: 30)),
);
if (response.statusCode == 200) {
final data = json.decode(response.body);
if (data is Map<String, dynamic>) return data;
if (data is Map) return Map<String, dynamic>.from(data);
}
dev.log(
'userinfo failed ${response.statusCode}: ${response.body}',
name: 'KeycloakAuthService',
);
return null;
} catch (e) {
dev.log('userinfo error: $e', name: 'KeycloakAuthService');
return null;
}
}
/// revoke یک refresh token مشخص روی سرور Keycloak.
/// بدون تاثیر روی state لوکال. خطاها silently log می‌شوند.
Future<void> revokeRefreshToken(String refreshToken) async {
if (refreshToken.isEmpty) return;
try {
final body = <String, String>{
'client_id': AuthConfig.keycloakClientId,
'refresh_token': refreshToken,
};
final secret = AuthConfig.keycloakClientSecret;
if (secret != null && secret.isNotEmpty) {
body['client_secret'] = secret;
}
await http
.post(
Uri.parse(AuthConfig.keycloakLogoutUrl),
headers: const {
'Content-Type': 'application/x-www-form-urlencoded',
},
body: body,
)
.timeout(const Duration(seconds: 10));
} catch (e) {
dev.log('Keycloak revoke error: $e', name: 'KeycloakAuthService');
}
}
/// logout کامل: revoke روی سرور + پاکسازی state لوکال.
/// در هر حالت state لوکال پاک می‌شود.
Future<void> logout() async {
final refreshToken = TokenStorage.refreshToken;
await TokenStorage.clear();
if (refreshToken != null) {
await revokeRefreshToken(refreshToken);
}
}
// -------------------------------------------------------------
// Helpers
// -------------------------------------------------------------
Future<void> _persistTokenResponse(String responseBody) async {
final Map<String, dynamic> data = json.decode(responseBody);
final access = data['access_token'] as String?;
final refresh = data['refresh_token'] as String?;
if (access == null || refresh == null) {
throw StateError('Token response missing required fields');
}
final userId = JwtUtils.subjectOf(access);
// ignore: avoid_print
print('🔐 token lifetimes: expires_in=${data['expires_in']} '
'refresh_expires_in=${data['refresh_expires_in']}');
await TokenStorage.saveTokens(
accessToken: access,
refreshToken: refresh,
idToken: data['id_token'] as String?,
expiresIn: (data['expires_in'] as num?)?.toInt(),
refreshExpiresIn: (data['refresh_expires_in'] as num?)?.toInt(),
userId: userId,
);
}
/// تلاش برای استخراج پیام خطای قابل نمایش از body پاسخ Keycloak.
String? _extractError(String body) {
if (body.isEmpty) return null;
try {
final Map<String, dynamic> data = json.decode(body);
final desc = data['error_description'] ?? data['error'];
if (desc is String && desc.isNotEmpty) {
// ترجمه‌ی چند خطای متداول Keycloak
switch (desc) {
case 'Invalid user credentials':
return 'نام کاربری یا رمز عبور اشتباه است.';
case 'Account disabled':
return 'حساب کاربری غیرفعال است.';
case 'Account is not fully set up':
return 'حساب شما کامل نیست. لطفا ابتدا رمز خود را تنظیم کنید.';
}
return desc;
}
} catch (_) {}
return null;
}
}