396 lines
15 KiB
Dart
396 lines
15 KiB
Dart
// 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;
|
||
|
||
/// Public wrapper so the identifier→username lookup can be reused by
|
||
/// pre-login flows (e.g. required-actions check on the auth screen).
|
||
Future<String?> resolveIdentifier(String input) => _resolveUsername(input);
|
||
|
||
/// If [input] looks like an Iran phone number (0 followed by 10 digits),
|
||
/// ask the api gateway to look up the corresponding Keycloak username.
|
||
/// Otherwise return [input] unchanged (assume it's already the username).
|
||
/// Returns null when the phone doesn't match any user.
|
||
Future<String?> _resolveUsername(String input) async {
|
||
final trimmed = input.trim();
|
||
final isPhone = RegExp(r'^0\d{10}$').hasMatch(trimmed);
|
||
if (!isPhone) return trimmed;
|
||
|
||
try {
|
||
final resp = await http
|
||
.get(
|
||
Uri.parse(
|
||
'${AuthConfig.apiBaseUrl}/auth/resolve-username?identifier=$trimmed',
|
||
),
|
||
headers: const {'Accept': 'application/json'},
|
||
)
|
||
.timeout(const Duration(seconds: 10));
|
||
if (resp.statusCode == 200) {
|
||
final data = jsonDecode(resp.body) as Map<String, dynamic>;
|
||
final u = data['username'];
|
||
if (u is String && u.isNotEmpty) return u;
|
||
}
|
||
// 404 or any non-200 -> treat as "no user"; caller returns fail.
|
||
return null;
|
||
} catch (e) {
|
||
dev.log('resolve-username failed: $e', name: 'KeycloakAuthService');
|
||
return null;
|
||
}
|
||
}
|
||
|
||
/// لاگین با username/password.
|
||
/// [username] can be a Keycloak username OR an Iran phone number
|
||
/// (0XXXXXXXXXX). Phone gets resolved to username server-side before the
|
||
/// password grant so Keycloak sees a real username either way.
|
||
///
|
||
/// در صورت موفقیت، توکنها در [TokenStorage] ذخیره میشوند.
|
||
Future<KeycloakAuthResult> login({
|
||
required String username,
|
||
required String password,
|
||
}) async {
|
||
try {
|
||
final resolved = await _resolveUsername(username);
|
||
if (resolved == null) {
|
||
return KeycloakAuthResult.fail(
|
||
'نام کاربری یا رمز عبور اشتباه است.',
|
||
statusCode: 401,
|
||
);
|
||
}
|
||
final body = <String, String>{
|
||
'grant_type': 'password',
|
||
'client_id': AuthConfig.keycloakClientId,
|
||
'username': resolved,
|
||
'password': password,
|
||
// `offline_access` requests an OFFLINE refresh token that is NOT bound
|
||
// to the SSO session idle timeout. Result: the app effectively never
|
||
// logs the user out on its own — the offline session persists until
|
||
// the user or an admin explicitly revokes it.
|
||
'scope': 'openid offline_access',
|
||
};
|
||
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(
|
||
'خطا در ارتباط با سرور احراز هویت.',
|
||
);
|
||
}
|
||
}
|
||
|
||
/// Retry on transient network failures. Covers:
|
||
/// - SocketException (DNS glitch at emulator boot, transient network drop)
|
||
/// - http.ClientException (connection reset, TLS handshake fail)
|
||
/// - TimeoutException (Keycloak cold-start; first call can exceed 30s
|
||
/// while the JVM warms up, next call reuses the warm connection).
|
||
/// Backoff: 250ms, 500ms.
|
||
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)));
|
||
} on TimeoutException {
|
||
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,
|
||
// Keep offline_access on rotation so the renewed refresh token is
|
||
// also an offline token — otherwise a normal refresh would downgrade
|
||
// us to a session-bound refresh that expires with SSO idle.
|
||
'scope': 'openid offline_access',
|
||
};
|
||
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;
|
||
}
|
||
}
|