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

396 lines
15 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;
/// 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;
}
}