diff --git a/lib/features/base/reloadable/reloadable_controller.dart b/lib/features/base/reloadable/reloadable_controller.dart index cadb53b64..5ce8d2601 100644 --- a/lib/features/base/reloadable/reloadable_controller.dart +++ b/lib/features/base/reloadable/reloadable_controller.dart @@ -63,7 +63,7 @@ abstract class ReloadableController extends BaseController { void _setUpInterceptors(GetCredentialViewState credentialViewState) { _dynamicUrlInterceptors.changeBaseUrl(credentialViewState.baseUrl.origin); - _authorizationInterceptors.changeAuthorization( + _authorizationInterceptors.setBasicAuthorization( credentialViewState.userName.userName, credentialViewState.password.value, ); diff --git a/lib/features/home/presentation/home_controller.dart b/lib/features/home/presentation/home_controller.dart index 1cb0f95fd..9975f6b13 100644 --- a/lib/features/home/presentation/home_controller.dart +++ b/lib/features/home/presentation/home_controller.dart @@ -119,13 +119,15 @@ class HomeController extends BaseController { void _goToSessionWithTokenOidc(GetStoredTokenOidcSuccess storedTokenOidcSuccess) { _dynamicUrlInterceptors.changeBaseUrl(storedTokenOidcSuccess.baseUrl.toString()); - _authorizationInterceptors.setToken(storedTokenOidcSuccess.tokenOidc.toToken()); + _authorizationInterceptors.setTokenAndAuthorityOidc( + newToken: storedTokenOidcSuccess.tokenOidc.toToken(), + newConfig: storedTokenOidcSuccess.oidcConfiguration); pushAndPop(AppRoutes.SESSION); } void _goToSessionWithBasicAuth(GetCredentialViewState credentialViewState) { _dynamicUrlInterceptors.changeBaseUrl(credentialViewState.baseUrl.origin); - _authorizationInterceptors.changeAuthorization( + _authorizationInterceptors.setBasicAuthorization( credentialViewState.userName.userName, credentialViewState.password.value, ); diff --git a/lib/features/login/data/extensions/token_response_extension.dart b/lib/features/login/data/extensions/token_response_extension.dart new file mode 100644 index 000000000..3807bdc7a --- /dev/null +++ b/lib/features/login/data/extensions/token_response_extension.dart @@ -0,0 +1,14 @@ + +import 'package:flutter_appauth/flutter_appauth.dart'; +import 'package:model/model.dart'; + +extension TokenResponseExtension on TokenResponse { + + TokenOIDC toTokenOIDC() { + return TokenOIDC( + accessToken ?? '', + TokenId(idToken ?? ''), + refreshToken ?? '', + expiredTime: accessTokenExpirationDateTime ?? DateTime.now()); + } +} \ No newline at end of file diff --git a/lib/features/login/data/local/account_cache_manager.dart b/lib/features/login/data/local/account_cache_manager.dart index d0cf5086f..86494822c 100644 --- a/lib/features/login/data/local/account_cache_manager.dart +++ b/lib/features/login/data/local/account_cache_manager.dart @@ -25,4 +25,9 @@ class AccountCacheManager { log('AccountCacheManager::setSelectedAccount(): $_accountCacheClient'); return _accountCacheClient.insertItem(account.id, account.toCache()); } + + Future deleteSelectedAccount(String accountId) { + log('AccountCacheManager::deleteSelectedAccount(): $accountId'); + return _accountCacheClient.deleteItem(accountId); + } } \ No newline at end of file diff --git a/lib/features/login/data/local/token_oidc_cache_manager.dart b/lib/features/login/data/local/token_oidc_cache_manager.dart index 8b92405a7..6b5bc9cd8 100644 --- a/lib/features/login/data/local/token_oidc_cache_manager.dart +++ b/lib/features/login/data/local/token_oidc_cache_manager.dart @@ -26,8 +26,8 @@ class TokenOidcCacheManager { await _tokenOidcCacheClient.clearAllData(); } log('TokenOidcCacheManager::persistOneTokenOidc(): key: ${tokenOIDC.tokenId.uuid}'); - log('TokenOidcCacheManager::persistOneTokenOidc(): key\'s hash: ${tokenOIDC.tokenId.hashCode.toString()}'); + log('TokenOidcCacheManager::persistOneTokenOidc(): key\'s hash: ${tokenOIDC.tokenIdHash}'); log('TokenOidcCacheManager::persistOneTokenOidc(): token: ${tokenOIDC.token}'); - await _tokenOidcCacheClient.insertItem(tokenOIDC.tokenId.hashCode.toString(), tokenOIDC.toTokenOidcCache()); + await _tokenOidcCacheClient.insertItem(tokenOIDC.tokenIdHash, tokenOIDC.toTokenOidcCache()); } } \ No newline at end of file diff --git a/lib/features/login/data/network/config/authorization_interceptors.dart b/lib/features/login/data/network/config/authorization_interceptors.dart index 51b3df32a..0c6a14ed9 100644 --- a/lib/features/login/data/network/config/authorization_interceptors.dart +++ b/lib/features/login/data/network/config/authorization_interceptors.dart @@ -1,32 +1,141 @@ import 'dart:convert'; import 'dart:io'; +import 'package:core/utils/app_logger.dart'; import 'package:dio/dio.dart'; -import 'package:model/model.dart'; +import 'package:model/account/account.dart'; +import 'package:model/account/authentication_type.dart'; +import 'package:model/oidc/oidc_configuration.dart'; +import 'package:model/oidc/token.dart'; +import 'package:model/oidc/token_oidc.dart'; +import 'package:tmail_ui_user/features/login/data/local/account_cache_manager.dart'; +import 'package:tmail_ui_user/features/login/data/local/token_oidc_cache_manager.dart'; +import 'package:tmail_ui_user/features/login/data/network/oidc_http_client.dart'; class AuthorizationInterceptors extends InterceptorsWrapper { - String? _authorization; - Token? _token; - void changeAuthorization(String? userName, String? password) { + final Dio _dio; + final OIDCHttpClient _oidcHttpClient; + final TokenOidcCacheManager _tokenOidcCacheManager; + final AccountCacheManager _accountCacheManager; + + AuthenticationType _authenticationType = AuthenticationType.none; + OIDCConfiguration? _configOIDC; + Token? _token; + String? _authorization; + + AuthorizationInterceptors( + this._dio, + this._oidcHttpClient, + this._tokenOidcCacheManager, + this._accountCacheManager + ); + + void setBasicAuthorization(String? userName, String? password) { _authorization = base64Encode(utf8.encode('$userName:$password')); + _authenticationType = AuthenticationType.basic; } - void setToken(Token? newToken) { + void setTokenAndAuthorityOidc({Token? newToken, OIDCConfiguration? newConfig}) { + _token = newToken; + _configOIDC = newConfig; + _authenticationType = AuthenticationType.oidc; + log('AuthorizationInterceptors::setToken(): newToken: $newToken'); + log('AuthorizationInterceptors::setToken(): tokenId: ${newToken?.tokenIdHash}'); + log('AuthorizationInterceptors::setToken(): EXPIRE_DATE: ${newToken?.expiredTime?.toIso8601String()}'); + } + + void _updateNewToken(Token newToken) { _token = newToken; } @override void onRequest(RequestOptions options, RequestInterceptorHandler handler) { - if (_authorization != null) { - options.headers[HttpHeaders.authorizationHeader] = _getAuthorizationAsBasicHeader(_authorization); - } else if (_token != null && _token?.isTokenValid() == true) { - options.headers[HttpHeaders.authorizationHeader] = _getTokenAsBearerHeader(_token!.token); + switch(_authenticationType) { + case AuthenticationType.basic: + if (_authorization != null) { + options.headers[HttpHeaders.authorizationHeader] = _getAuthorizationAsBasicHeader(_authorization); + } + break; + case AuthenticationType.oidc: + if (_token != null && _token?.isTokenValid() == true) { + options.headers[HttpHeaders.authorizationHeader] = _getTokenAsBearerHeader(_token!.token); + } + break; + case AuthenticationType.none: + break; } super.onRequest(options, handler); } + @override + void onError(DioError err, ErrorInterceptorHandler handler) async { + final requestOptions = err.requestOptions; + log('AuthorizationInterceptors::onError(): $err'); + if ((_isTokenExpired() || err.response?.statusCode == 401) && + _isAuthenticationOidcValid()) { + try { + final newToken = await _oidcHttpClient.refreshingTokensOIDC( + _configOIDC!.clientId, + _configOIDC!.redirectUrl, + _configOIDC!.discoveryUrl, + _configOIDC!.scopes, + _token!.refreshToken); + + await Future.wait([ + _tokenOidcCacheManager.persistOneTokenOidc(newToken), + _accountCacheManager.deleteSelectedAccount(_token!.tokenIdHash), + _accountCacheManager.setSelectedAccount(Account( + newToken.tokenIdHash, + AuthenticationType.oidc, + isSelected: true)), + ]); + + log('AuthorizationInterceptors::onError(): refreshToken: $newToken'); + log('AuthorizationInterceptors::setToken(): refreshTokenId: ${newToken.tokenIdHash}'); + + _updateNewToken(newToken.toToken()); + + requestOptions.headers[HttpHeaders.authorizationHeader] = + _getTokenAsBearerHeader(newToken.token); + + final response = await _dio.fetch(requestOptions); + return handler.resolve(response); + } catch(e) { + log('AuthorizationInterceptors::onError(): $e'); + super.onError(err, handler); + } + } else { + super.onError(err, handler); + } + } + + bool _isTokenExpired() { + if (_token?.isExpired == true) { + log('AuthorizationInterceptors::_isTokenExpired(): TOKE_EXPIRED'); + return true; + } + return false; + } + + bool _isAuthenticationOidcValid() { + if (_authenticationType == AuthenticationType.oidc && + _configOIDC != null && + _token != null) { + log('AuthorizationInterceptors::_isAuthenticationOidcValid()'); + return true; + } + return false; + } + String _getAuthorizationAsBasicHeader(String? authorization) => 'Basic $authorization'; String _getTokenAsBearerHeader(String token) => 'Bearer $token'; + + void clear() { + _authorization = null; + _token = null; + _configOIDC = null; + _authenticationType = AuthenticationType.none; + } } diff --git a/lib/features/login/data/network/oidc_http_client.dart b/lib/features/login/data/network/oidc_http_client.dart index e8cb10032..c98474757 100644 --- a/lib/features/login/data/network/oidc_http_client.dart +++ b/lib/features/login/data/network/oidc_http_client.dart @@ -1,14 +1,21 @@ import 'dart:convert'; -import 'package:core/core.dart'; +import 'package:core/data/model/query/query_parameter.dart'; +import 'package:core/data/network/dio_client.dart'; +import 'package:core/utils/app_logger.dart'; import 'package:flutter_appauth/flutter_appauth.dart'; -import 'package:model/model.dart'; +import 'package:model/oidc/oidc_configuration.dart'; +import 'package:model/oidc/request/oidc_request.dart'; +import 'package:model/oidc/response/oidc_response.dart'; +import 'package:model/oidc/token_oidc.dart'; import 'package:tmail_ui_user/features/login/data/extensions/authentication_token_extension.dart'; import 'package:tmail_ui_user/features/login/data/extensions/service_path_extension.dart'; +import 'package:tmail_ui_user/features/login/data/extensions/token_response_extension.dart'; import 'package:tmail_ui_user/features/login/data/network/config/oidc_constant.dart'; import 'package:tmail_ui_user/features/login/data/network/endpoint.dart'; import 'package:tmail_ui_user/features/login/data/network/oidc_error.dart'; +import 'package:tmail_ui_user/features/login/domain/exceptions/authentication_exception.dart'; class OIDCHttpClient { @@ -48,20 +55,47 @@ class OIDCHttpClient { } Future getTokenOIDC(String clientId, String redirectUrl, String discoveryUrl, List scopes) async { - final tokenResponse = await _appAuth.authorizeAndExchangeCode(AuthorizationTokenRequest( + final authorizationTokenResponse = await _appAuth.authorizeAndExchangeCode(AuthorizationTokenRequest( clientId, redirectUrl, discoveryUrl: discoveryUrl, scopes: scopes, preferEphemeralSession: true)); - log('OIDCHttpClient::getTokenOIDC(): token: ${tokenResponse?.accessToken}'); + log('OIDCHttpClient::getTokenOIDC(): token: ${authorizationTokenResponse?.accessToken}'); + + if (authorizationTokenResponse != null) { + final tokenOIDC = authorizationTokenResponse.toTokenOIDC(); + if (tokenOIDC.isTokenValid()) { + return tokenOIDC; + } else { + throw AccessTokenInvalidException(); + } + } else { + throw NotFoundAccessTokenException(); + } + } + + Future refreshingTokensOIDC(String clientId, String redirectUrl, + String discoveryUrl, List scopes, String refreshToken) async { + final tokenResponse = await _appAuth.token(TokenRequest( + clientId, + redirectUrl, + discoveryUrl: discoveryUrl, + refreshToken: refreshToken, + scopes: scopes)); + + log('OIDCHttpClient::refreshingTokensOIDC(): refreshToken: ${tokenResponse?.accessToken}'); if (tokenResponse != null) { final tokenOIDC = tokenResponse.toTokenOIDC(); - return tokenOIDC.isTokenValid() ? tokenOIDC : TokenOIDC.empty(); + if (tokenOIDC.isTokenValid()) { + return tokenOIDC; + } else { + throw AccessTokenInvalidException(); + } } else { - return TokenOIDC.empty(); + throw NotFoundAccessTokenException(); } } } \ No newline at end of file diff --git a/lib/features/login/domain/exceptions/authentication_exception.dart b/lib/features/login/domain/exceptions/authentication_exception.dart index a783929c6..b8b60a911 100644 --- a/lib/features/login/domain/exceptions/authentication_exception.dart +++ b/lib/features/login/domain/exceptions/authentication_exception.dart @@ -27,4 +27,12 @@ class InvalidBaseUrl extends AuthenticationException { @override List get props => []; +} + +class NotFoundAccessTokenException implements Exception { + NotFoundAccessTokenException(); +} + +class AccessTokenInvalidException implements Exception { + AccessTokenInvalidException(); } \ No newline at end of file diff --git a/lib/features/login/domain/usecases/get_token_oidc_interactor.dart b/lib/features/login/domain/usecases/get_token_oidc_interactor.dart index 9cee86c06..b148aa354 100644 --- a/lib/features/login/domain/usecases/get_token_oidc_interactor.dart +++ b/lib/features/login/domain/usecases/get_token_oidc_interactor.dart @@ -1,8 +1,12 @@ -import 'package:core/core.dart'; +import 'package:core/presentation/state/failure.dart'; +import 'package:core/presentation/state/success.dart'; +import 'package:core/utils/app_logger.dart'; import 'package:dartz/dartz.dart'; import 'package:model/account/account.dart'; import 'package:model/account/authentication_type.dart'; +import 'package:model/oidc/oidc_configuration.dart'; +import 'package:model/oidc/token_oidc.dart'; import 'package:tmail_ui_user/features/login/domain/repository/account_repository.dart'; import 'package:tmail_ui_user/features/login/domain/repository/authentication_oidc_repository.dart'; import 'package:tmail_ui_user/features/login/domain/repository/credential_repository.dart'; @@ -16,24 +20,19 @@ class GetTokenOIDCInteractor { GetTokenOIDCInteractor(this._credentialRepository, this.authenticationOIDCRepository, this._accountRepository); - Future> execute( - Uri baseUrl, - String clientId, - String redirectUrl, - String discoveryUrl, - List scopes - ) async { + Future> execute(Uri baseUrl, OIDCConfiguration config) async { try { log('GetTokenOIDCInteractor::execute(): baseUrl: $baseUrl'); final tokenOIDC = await authenticationOIDCRepository - .getTokenOIDC(clientId, redirectUrl, discoveryUrl, scopes); + .getTokenOIDC(config.clientId, config.redirectUrl, config.discoveryUrl, config.scopes); await Future.wait([ _credentialRepository.saveBaseUrl(baseUrl), _accountRepository.setCurrentAccount(Account( - tokenOIDC.tokenId.hashCode.toString(), + tokenOIDC.tokenIdHash, AuthenticationType.oidc, isSelected: true)), - authenticationOIDCRepository.persistTokenOIDC(tokenOIDC) + authenticationOIDCRepository.persistTokenOIDC(tokenOIDC), + authenticationOIDCRepository.persistAuthorityOidc(config.authority), ]); return Right(GetTokenOIDCSuccess(tokenOIDC)); } catch (e) { diff --git a/lib/features/login/presentation/login_controller.dart b/lib/features/login/presentation/login_controller.dart index 0a709418c..2bbea9622 100644 --- a/lib/features/login/presentation/login_controller.dart +++ b/lib/features/login/presentation/login_controller.dart @@ -164,7 +164,7 @@ class LoginController extends GetxController { final baseUri = kIsWeb ? _parseUri(AppConfig.baseUrl) : _parseUri(_urlText); if (baseUri != null) { await _getTokenOIDCInteractor - .execute(baseUri, config.clientId, config.redirectUrl, config.discoveryUrl, config.scopes) + .execute(baseUri, config) .then((response) => response.fold( (failure) { @@ -176,7 +176,7 @@ class LoginController extends GetxController { }, (success) { if (success is GetTokenOIDCSuccess) { - _getTokenOIDCSuccess(success); + _getTokenOIDCSuccess(success, config); } else { loginState.value = LoginState(Left(LoginCanNotGetTokenAction())); } @@ -186,11 +186,13 @@ class LoginController extends GetxController { } } - void _getTokenOIDCSuccess(GetTokenOIDCSuccess success) { + void _getTokenOIDCSuccess(GetTokenOIDCSuccess success, OIDCConfiguration config) { log('LoginController::_getTokenOIDCSuccess(): ${success.tokenOIDC.toString()}'); loginState.value = LoginState(Right(success)); _dynamicUrlInterceptors.changeBaseUrl(kIsWeb ? AppConfig.baseUrl : _urlText); - _authorizationInterceptors.setToken(success.tokenOIDC.toToken()); + _authorizationInterceptors.setTokenAndAuthorityOidc( + newToken: success.tokenOIDC.toToken(), + newConfig: config); pushAndPop(AppRoutes.SESSION); } @@ -205,7 +207,7 @@ class LoginController extends GetxController { void _loginSuccessAction(AuthenticationUserViewState success) { loginState.value = LoginState(Right(success)); _dynamicUrlInterceptors.changeBaseUrl(kIsWeb ? AppConfig.baseUrl : _urlText); - _authorizationInterceptors.changeAuthorization(_userNameText, _passwordText); + _authorizationInterceptors.setBasicAuthorization(_userNameText, _passwordText); pushAndPop(AppRoutes.SESSION); } diff --git a/lib/main/bindings/main_bindings.dart b/lib/main/bindings/main_bindings.dart index 4f0da37b9..e6462be7f 100644 --- a/lib/main/bindings/main_bindings.dart +++ b/lib/main/bindings/main_bindings.dart @@ -9,8 +9,8 @@ class MainBindings extends Bindings { @override Future dependencies() async { await CoreBindings().dependencies(); - NetworkBindings().dependencies(); LocalBindings().dependencies(); + NetworkBindings().dependencies(); CredentialBindings().dependencies(); SessionBindings().dependencies(); } diff --git a/lib/main/bindings/network/network_bindings.dart b/lib/main/bindings/network/network_bindings.dart index 13c844020..53de83565 100644 --- a/lib/main/bindings/network/network_bindings.dart +++ b/lib/main/bindings/network/network_bindings.dart @@ -9,6 +9,8 @@ import 'package:get/get.dart'; import 'package:jmap_dart_client/http/http_client.dart' as JmapHttpClient; import 'package:tmail_ui_user/features/composer/data/network/composer_api.dart'; import 'package:tmail_ui_user/features/email/data/network/email_api.dart'; +import 'package:tmail_ui_user/features/login/data/local/account_cache_manager.dart'; +import 'package:tmail_ui_user/features/login/data/local/token_oidc_cache_manager.dart'; import 'package:tmail_ui_user/features/login/data/network/config/authorization_interceptors.dart'; import 'package:tmail_ui_user/features/login/data/network/oidc_http_client.dart'; import 'package:tmail_ui_user/features/mailbox/data/network/mailbox_api.dart'; @@ -35,7 +37,20 @@ class NetworkBindings extends Bindings { void _bindingDio() { _bindingBaseOption(); Get.put(Dio(Get.find())); + Get.put(DioClient(Get.find())); + Get.put(const FlutterAppAuth()); + Get.put(OIDCHttpClient(Get.find(), Get.find())); _bindingInterceptors(); + } + + void _bindingInterceptors() { + Get.put(DynamicUrlInterceptors()); + Get.put(AuthorizationInterceptors( + Get.find(), + Get.find(), + Get.find(), + Get.find() + )); Get.find().interceptors.add(Get.find()); Get.find().interceptors.add(Get.find()); if (kDebugMode) { @@ -43,13 +58,7 @@ class NetworkBindings extends Bindings { } } - void _bindingInterceptors() { - Get.put(DynamicUrlInterceptors()); - Get.put(AuthorizationInterceptors()); - } - void _bindingApi() { - Get.put(DioClient(Get.find())); Get.put(JmapHttpClient.HttpClient(Get.find())); Get.put(DownloadClient(Get.find())); Get.put(DownloadManager(Get.find())); @@ -60,8 +69,6 @@ class NetworkBindings extends Bindings { Get.find(), Get.find())); Get.put(ComposerAPI(Get.find())); - Get.put(const FlutterAppAuth()); - Get.put(OIDCHttpClient(Get.find(), Get.find())); } void _bindingConnection() { diff --git a/model/lib/oidc/token.dart b/model/lib/oidc/token.dart index 2ac83f732..4481d13d9 100644 --- a/model/lib/oidc/token.dart +++ b/model/lib/oidc/token.dart @@ -1,17 +1,33 @@ +import 'package:core/utils/app_logger.dart'; import 'package:equatable/equatable.dart'; import 'package:model/oidc/token_id.dart'; -class Token extends Equatable { - const Token(this.token, this.tokenId); +class Token with EquatableMixin { final String token; final TokenId tokenId; + final String refreshToken; + final DateTime? expiredTime; + + const Token(this.token, this.tokenId, this.refreshToken, {this.expiredTime}); @override - List get props => [token, tokenId]; + List get props => [token, tokenId, refreshToken, expiredTime]; } extension TokenExtension on Token { bool isTokenValid() => token.isNotEmpty && tokenId.uuid.isNotEmpty; -} + + bool get isExpired { + if (expiredTime != null) { + final now = DateTime.now(); + log('TokenExtension::isExpired(): TIME_NOW: $now'); + log('TokenExtension::isExpired(): EXPIRED_DATE: $expiredTime'); + return expiredTime!.isBefore(now); + } + return false; + } + + String get tokenIdHash => tokenId.uuid.hashCode.toString(); +} \ No newline at end of file diff --git a/model/lib/oidc/token_oidc.dart b/model/lib/oidc/token_oidc.dart index 45f854fed..b9dc0253b 100644 --- a/model/lib/oidc/token_oidc.dart +++ b/model/lib/oidc/token_oidc.dart @@ -17,10 +17,6 @@ class TokenOIDC with EquatableMixin { {this.expiredTime} ); - factory TokenOIDC.empty() { - return TokenOIDC('', TokenId(''), ''); - } - @override List get props => [token, tokenId, expiredTime, refreshToken]; } @@ -30,6 +26,8 @@ extension TokenOIDCExtension on TokenOIDC { bool isTokenValid() => token.isNotEmpty && tokenId.uuid.isNotEmpty; Token toToken() { - return Token(token, tokenId); + return Token(token, tokenId, refreshToken, expiredTime: expiredTime); } + + String get tokenIdHash => tokenId.uuid.hashCode.toString(); } \ No newline at end of file