From 6a766f2c5ad1f61bdc4a87691d61d4f34a624a91 Mon Sep 17 00:00:00 2001 From: Nikita Sirovskiy Date: Thu, 5 Jan 2023 17:59:54 +0300 Subject: [PATCH] chore(#80): Introduce `Authenticator` --- .../exceptions/authenticator_exception.dart | 13 ++ .../api/new/authenticator/authenticator.dart | 163 ++++++++++++++++++ 2 files changed, 176 insertions(+) create mode 100644 lib/data/api/exceptions/authenticator_exception.dart create mode 100644 lib/data/api/new/authenticator/authenticator.dart diff --git a/lib/data/api/exceptions/authenticator_exception.dart b/lib/data/api/exceptions/authenticator_exception.dart new file mode 100644 index 0000000..74ba687 --- /dev/null +++ b/lib/data/api/exceptions/authenticator_exception.dart @@ -0,0 +1,13 @@ +import 'package:freezed_annotation/freezed_annotation.dart'; + +part 'authenticator_exception.freezed.dart'; + +@freezed +class AuthenticatorException + with _$AuthenticatorException + implements Exception { + const factory AuthenticatorException.unauthorized() = _Unauthorized; + const factory AuthenticatorException.noRefreshToken() = _NoRefreshToken; + const factory AuthenticatorException.reauthenticationFailed() = + _ReauthenticationFailed; +} diff --git a/lib/data/api/new/authenticator/authenticator.dart b/lib/data/api/new/authenticator/authenticator.dart new file mode 100644 index 0000000..cab6723 --- /dev/null +++ b/lib/data/api/new/authenticator/authenticator.dart @@ -0,0 +1,163 @@ +import 'dart:async'; + +import 'package:dio/dio.dart'; +import 'package:flutter_template/data/api/api_config.dart'; +import 'package:flutter_template/data/api/exceptions/authenticator_exception.dart'; +import 'package:flutter_template/data/api/new/auth_token_storage/auth_token_storage.dart'; +import 'package:flutter_template/data/api/new/interceptor/api_auth_interceptor.dart'; +import 'package:flutter_template/data/interceptor/meta_interceptor.dart'; +import 'package:flutter_template/data/model/auth/auth_tokens.dart'; +import 'package:flutter_template/data/services/http_client/dio_http_client.dart'; +import 'package:flutter_template/data/services/response_objects/tokens_response.dart'; +import 'package:flutter_template/injection/network_module.dart'; +import 'package:injectable/injectable.dart'; + +/// Is responsible for making the [accessToken] & [refreshToken] accessible +/// in the [ApiAuthInterceptor] and also for reauthenticating the user. +/// +/// --- +/// +/// ### Signing the user in +/// +/// You must call [setNewTokens] method when you sign in the user. +/// This way you'll configure the authenticator and store the tokens to the storage. +/// +/// ### Signing the user out +/// +/// You must call [clear] method when you sign out the user. +/// This way you'll configure the authenticator and clear the tokens from the storage. +/// +/// ### Unauthenticated callback +/// +/// When [Authenticator] fails to reauthenticate, it will notify by adding +/// an event to the [onUnauthenticated] stream. +/// +/// So you can listen to it, for example, in your cubit: +/// +/// ```dart +/// // auth_cubit.dart: +/// +/// ... +/// +/// final Authenticator _authenticator; +/// +/// void setup() { +/// _unauthenticatedSubscription = _authenticator +/// .onUnauthenticated +/// .listen(_onUnauthenticated); +/// } +/// +/// void _onUnauthenticated(_) { +/// // Do whatever you need to sign the user out, notify the UI ... +/// } +/// +/// ... +/// ``` +/// +/// ### Throws +/// - [AuthenticatorException.noRefreshToken] if the refresh token is missing. +/// - [AuthenticatorException.reauthenticationFailed] if the reauth response was null +/// - [AuthenticatorException.unauthorized] if the reauth request ended up in a 401 response. +@lazySingleton +class Authenticator { + Authenticator( + this._storage, + @Named(authDioClient) this._refreshTokenClient, + this._apiConfig, + ); + + final AuthTokenStorage _storage; + final DioHttpClient _refreshTokenClient; + final ApiConfig _apiConfig; + + final StreamController _unauthenticatedController = + StreamController.broadcast(); + + AuthTokens? _tokens; + + String? get accessToken => _tokens?.accessToken; + String? get refreshToken => _tokens?.refreshToken; + bool get expiresSoon => _tokens?.expiresSoon ?? true; + + /// Fires an event when [reauthenticate] fails. + Stream get onUnauthenticated => _unauthenticatedController.stream; + + /// Clears tokens from the storage and adds an event to [_unauthenticatedController]. + Future _clearTokensAndNotify() { + return clear().then(_unauthenticatedController.add); + } + + /// Retrieves new auth tokens and saves them to the storage. + /// + /// If reauthentication fails in one of the expected "unauthenticated" reasons, + /// this method will clear the stored tokens & add an event to [_unauthenticatedController]. + /// + /// [RequestOptions] is needed to get N-Meta information. + Future reauthenticate(RequestOptions requestOptions) async { + try { + final refreshToken = this.refreshToken; + + if (refreshToken == null) { + await _clearTokensAndNotify(); + throw const AuthenticatorException.noRefreshToken(); + } + + final response = await _refreshTokenClient.get>( + '${_apiConfig.apiUrl}/v1/auth/token', + headers: { + 'Authorization': 'Bearer $refreshToken', + MetaInterceptor.nMetaHeaderKey: + requestOptions.headers[MetaInterceptor.nMetaHeaderKey] + }, + ); + + if (response == null) { + await _clearTokensAndNotify(); + throw const AuthenticatorException.reauthenticationFailed(); + } + + final newAuthTokens = + TokensResponse.fromJson(response['data']).getEntity(); + + await _storage.set(newAuthTokens); + + return newAuthTokens; + } on DioError catch (e) { + if (e.response?.statusCode == 401) { + await _clearTokensAndNotify(); + throw const AuthenticatorException.unauthorized(); + } + + rethrow; + } + } + + /// When a user signs in, this must be called in order to set & save the auth tokens. + Future setNewTokens(AuthTokens tokens) async { + return _storage.set(tokens).then((value) => _tokens = tokens); + } + + /// When a user signs in, this must be called in order to set & save the auth tokens. + Future clear() async { + _tokens = null; + return _storage.clear(); + } + + @preResolve + @lazySingleton + static Future restore( + AuthTokenStorage storage, + DioHttpClient refreshDioClient, + ApiConfig apiConfig, + ) async { + final tokens = await storage.get(); + + final authenticator = Authenticator( + storage, + refreshDioClient, + apiConfig, + ).._tokens = tokens; + + return authenticator; + } +}