Skip to content

Commit

Permalink
fix: add error handling to recover user page (#604)
Browse files Browse the repository at this point in the history
## Description
This PR adds proper error handling to recover identity key page

## Additional Notes
<!-- Add any extra context or relevant information here. -->

## Type of Change
- [x] Bug fix
- [ ] New feature
- [ ] Breaking change
- [ ] Refactoring
- [ ] Documentation
- [ ] Chore

## Screenshots (if applicable)


https://github.com/user-attachments/assets/019942a3-d17f-4c49-894e-f755c028b367
  • Loading branch information
ice-ajax authored Jan 24, 2025
1 parent e444a44 commit 6e141b4
Show file tree
Hide file tree
Showing 7 changed files with 151 additions and 108 deletions.
Original file line number Diff line number Diff line change
Expand Up @@ -7,12 +7,14 @@ import 'package:ion/app/extensions/extensions.dart';
import 'package:ion/app/features/auth/data/models/twofa_type.dart';
import 'package:ion/app/features/auth/views/pages/recover_user_page/components/recovery_creds_step.dart';
import 'package:ion/app/features/auth/views/pages/recover_user_page/models/recover_user_step.dart';
import 'package:ion/app/features/auth/views/pages/recover_user_twofa_page/components/twofa_try_again_page.dart';
import 'package:ion/app/features/auth/views/pages/two_fa/twofa_input_step.dart';
import 'package:ion/app/features/auth/views/pages/two_fa/twofa_options_step.dart';
import 'package:ion/app/features/components/verify_identity/verify_identity_prompt_dialog_helper.dart';
import 'package:ion/app/features/protect_account/backup/providers/recover_user_action_notifier.c.dart';
import 'package:ion/app/features/protect_account/secure_account/providers/selected_two_fa_types_provider.c.dart';
import 'package:ion/app/router/app_routes.c.dart';
import 'package:ion/app/router/utils/show_simple_bottom_sheet.dart';
import 'package:ion_identity_client/ion_identity.dart';

typedef RecoveryCreds = ({String name, String id, String code});
Expand Down Expand Up @@ -93,14 +95,26 @@ class RecoverUserPage extends HookConsumerWidget {
required ValueNotifier<RecoverUserStep> step,
}) {
ref
..listenError(initUserRecoveryActionNotifierProvider, (error) async {
..listenError(initUserRecoveryActionNotifierProvider, (error) {
switch (error) {
case TwoFARequiredException(:final twoFAOptionsCount):
twoFAOptionsCountRef.value = twoFAOptionsCount;
step.value = RecoverUserStep.twoFAOptions;
case InvalidTwoFaCodeException():
showSimpleBottomSheet<void>(
context: ref.context,
child: const TwoFaTryAgainPage(),
);
default:
}
})
..displayErrors(
initUserRecoveryActionNotifierProvider,
excludedExceptions: {
TwoFARequiredException,
InvalidTwoFaCodeException,
},
)
..listenSuccess(initUserRecoveryActionNotifierProvider, (value) {
final challenge = value?.whenOrNull(success: (challenge) => challenge);

Expand Down
69 changes: 46 additions & 23 deletions lib/app/features/auth/views/pages/two_fa/twofa_input_step.dart
Original file line number Diff line number Diff line change
Expand Up @@ -20,6 +20,7 @@ import 'package:ion/app/features/components/verify_identity/hooks/use_on_get_pas
import 'package:ion/app/features/protect_account/secure_account/providers/request_twofa_code_notifier.c.dart';
import 'package:ion/app/features/protect_account/secure_account/providers/selected_two_fa_types_provider.c.dart';
import 'package:ion/app/features/user/providers/user_verify_identity_provider.c.dart';
import 'package:ion/app/hooks/use_on_init.dart';
import 'package:ion/app/router/components/sheet_content/sheet_content.dart';
import 'package:ion/app/router/utils/show_simple_bottom_sheet.dart';
import 'package:ion/generated/assets.gen.dart';
Expand Down Expand Up @@ -50,14 +51,18 @@ class TwoFAInputStep extends HookConsumerWidget {
_listenRequestTwoFaErrorResult(context, ref);

final twoFaTypes = ref.watch(selectedTwoFaOptionsProvider);
final onGetPassword = useOnGetPassword();

useOnInit(
() => _requestRecoveryCodes(ref, twoFaTypes, onGetPassword),
[twoFaTypes],
);

final formKey = useRef(GlobalKey<FormState>());
final controllers = {
for (final type in twoFaTypes) type: useTextEditingController(),
};

final onGetPassword = useOnGetPassword();

return SheetContent(
body: AuthScrollContainer(
title: context.i18n.two_fa_title,
Expand All @@ -80,27 +85,8 @@ class TwoFAInputStep extends HookConsumerWidget {
child: TwoFaCodeInput(
controller: controllers[twoFaType]!,
twoFaType: twoFaType,
onRequestCode: () async {
await ref
.read(requestTwoFaCodeNotifierProvider.notifier)
.requestRecoveryTwoFaCode(twoFaType, identityKeyName, ({
required OnPasswordFlow<GenerateSignatureResponse> onPasswordFlow,
required OnPasskeyFlow<GenerateSignatureResponse> onPasskeyFlow,
required OnBiometricsFlow<GenerateSignatureResponse>
onBiometricsFlow,
}) {
return ref.read(
verifyUserIdentityProvider(
onGetPassword: onGetPassword,
onPasswordFlow: onPasswordFlow,
onPasskeyFlow: onPasskeyFlow,
onBiometricsFlow: onBiometricsFlow,
localisedReasonForBiometricsDialog:
context.i18n.verify_with_biometrics_title,
).future,
);
});
},
onRequestCode: () =>
_requestRecoveryCode(ref, twoFaType, onGetPassword),
isSending: isRequesting,
),
),
Expand Down Expand Up @@ -152,4 +138,41 @@ class TwoFAInputStep extends HookConsumerWidget {
}
});
}

Future<void> _requestRecoveryCodes(
WidgetRef ref,
Set<TwoFaType> twoFaTypes,
Future<T> Function<T>(OnPasswordFlow<T> onPasswordFlow) onGetPassword,
) async {
for (final twoFaType in twoFaTypes) {
await _requestRecoveryCode(ref, twoFaType, onGetPassword);
}
}

Future<void> _requestRecoveryCode(
WidgetRef ref,
TwoFaType twoFaType,
Future<T> Function<T>(OnPasswordFlow<T> onPasswordFlow) onGetPassword,
) async {
if (twoFaType == TwoFaType.auth) {
return;
}
await ref
.read(requestTwoFaCodeNotifierProvider.notifier)
.requestRecoveryTwoFaCode(twoFaType, identityKeyName, ({
required OnPasswordFlow<GenerateSignatureResponse> onPasswordFlow,
required OnPasskeyFlow<GenerateSignatureResponse> onPasskeyFlow,
required OnBiometricsFlow<GenerateSignatureResponse> onBiometricsFlow,
}) {
return ref.read(
verifyUserIdentityProvider(
onGetPassword: onGetPassword,
onPasswordFlow: onPasswordFlow,
onPasskeyFlow: onPasskeyFlow,
onBiometricsFlow: onBiometricsFlow,
localisedReasonForBiometricsDialog: ref.context.i18n.verify_with_biometrics_title,
).future,
);
});
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -144,12 +144,8 @@ class AuthenticatorSetupCodeConfirmPage extends HookConsumerWidget {
WidgetRef ref,
ValueNotifier<bool> failedCodeNotifier,
) {
ref.listen(validateTwoFaCodeNotifierProvider, (prev, next) {
if (prev?.isLoading != true) {
return;
}

if (next.hasError && next.error is InvalidTwoFaCodeException) {
ref.listenError(validateTwoFaCodeNotifierProvider, (error) {
if (error is InvalidTwoFaCodeException) {
failedCodeNotifier.value = true;
showSimpleBottomSheet<void>(
context: ref.context,
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -35,13 +35,17 @@ class RecoverUserDataSource {
decoder: (result) => parseJsonObject(result, fromJson: UserRegistrationChallenge.fromJson),
);
} on RequestExecutionException catch (e) {
final dioException = e.error is DioException ? e.error as DioException : null;
if (e.error is! DioException) rethrow;

if (dioException?.response?.statusCode == 403 &&
dioException?.response?.data['error']['message'] == '2FA_REQUIRED') {
final twoFAOptionsCount = dioException?.response?.data['data']['n'] as int;
final exception = e.error as DioException;

if (TwoFARequiredException.isMatch(exception)) {
final twoFAOptionsCount = exception.response?.data['data']['n'] as int;
throw TwoFARequiredException(twoFAOptionsCount);
}
if (InvalidTwoFaCodeException.isMatch(exception)) {
throw InvalidTwoFaCodeException();
}
rethrow;
}
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -74,17 +74,22 @@ class TwoFADataSource {
required String twoFAOption,
required String code,
}) async {
final token = tokenStorage.getToken(username: username)?.token;
if (token == null) {
throw const UnauthenticatedException();
}
try {
final token = tokenStorage.getToken(username: username)?.token;
if (token == null) {
throw const UnauthenticatedException();
}

return networkClient.patch(
sprintf(twoFaPath, [userId, twoFAOption]),
queryParams: {'code': code},
headers: RequestHeaders.getAuthorizationHeaders(token: token, username: username),
decoder: (json) => parseJsonObject(json, fromJson: (json) => json),
);
return networkClient.patch(
sprintf(twoFaPath, [userId, twoFAOption]),
queryParams: {'code': code},
headers: RequestHeaders.getAuthorizationHeaders(token: token, username: username),
decoder: (json) => parseJsonObject(json, fromJson: (json) => json),
);
} on RequestExecutionException catch (e) {
final exception = _mapException(e);
throw exception;
}
}

Future<void> deleteTwoFA({
Expand All @@ -94,34 +99,39 @@ class TwoFADataSource {
required TwoFAType twoFAType,
List<TwoFAType> verificationCodes = const [],
}) async {
final token = tokenStorage.getToken(username: username)?.token;
if (token == null) {
throw const UnauthenticatedException();
}
try {
final token = tokenStorage.getToken(username: username)?.token;
if (token == null) {
throw const UnauthenticatedException();
}

final query = verificationCodes
.map(
(e) => '$queryOption=${e.option}&$queryValue=${e.value!}',
)
.join('&');
final twoFaValue = twoFAType.value ?? 0;
final uri = Uri.parse(networkClient.dio.options.baseUrl)
.resolveUri(
Uri(
path: sprintf(deleteTwoFaPath, [userId, twoFAType.option, twoFaValue]),
query: query,
),
)
.toString();

return networkClient.delete<void>(
uri,
headers: {
...RequestHeaders.getAuthorizationHeaders(token: token, username: username),
RequestHeaders.ionIdentityUserAction: signature,
},
decoder: (response) => response,
);
final query = verificationCodes
.map(
(e) => '$queryOption=${e.option}&$queryValue=${e.value!}',
)
.join('&');
final twoFaValue = twoFAType.value ?? 0;
final uri = Uri.parse(networkClient.dio.options.baseUrl)
.resolveUri(
Uri(
path: sprintf(deleteTwoFaPath, [userId, twoFAType.option, twoFaValue]),
query: query,
),
)
.toString();

return networkClient.delete<void>(
uri,
headers: {
...RequestHeaders.getAuthorizationHeaders(token: token, username: username),
RequestHeaders.ionIdentityUserAction: signature,
},
decoder: (response) => response,
);
} on RequestExecutionException catch (e) {
final exception = _mapException(e);
throw exception;
}
}

Exception _mapException(RequestExecutionException e) {
Expand All @@ -131,6 +141,9 @@ class TwoFADataSource {
if (TwoFaMethodNotConfiguredException.isMatch(exception)) {
return TwoFaMethodNotConfiguredException();
}
if (InvalidTwoFaCodeException.isMatch(exception)) {
return InvalidTwoFaCodeException();
}

return e;
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -3,11 +3,9 @@
import 'dart:convert';

import 'package:crypto/crypto.dart';
import 'package:dio/dio.dart';
import 'package:ion_identity_client/ion_identity.dart';
import 'package:ion_identity_client/src/auth/services/extract_user_id/extract_user_id_service.dart';
import 'package:ion_identity_client/src/auth/services/twofa/data_sources/twofa_data_source.dart';
import 'package:ion_identity_client/src/core/network/network_exception.dart';
import 'package:ion_identity_client/src/wallets/ion_identity_wallets.dart';

class TwoFAService {
Expand Down Expand Up @@ -59,17 +57,12 @@ class TwoFAService {
Future<void> verifyTwoFA(TwoFAType twoFAType) async {
final userId = _extractUserIdService.extractUserId(username: username);

try {
await _dataSource.verifyTwoFA(
username: username,
userId: userId,
twoFAOption: twoFAType.option,
code: twoFAType.value!,
);
} on RequestExecutionException catch (e) {
final exception = _mapException(e);
throw exception;
}
await _dataSource.verifyTwoFA(
username: username,
userId: userId,
twoFAOption: twoFAType.option,
code: twoFAType.value!,
);
}

Future<void> deleteTwoFA(
Expand All @@ -80,18 +73,13 @@ class TwoFAService {
final userId = _extractUserIdService.extractUserId(username: username);
final base64Signature = await _generateSignature(userId, onVerifyIdentity);

try {
await _dataSource.deleteTwoFA(
signature: base64Signature,
username: username,
userId: userId,
twoFAType: twoFAType,
verificationCodes: verificationCodes,
);
} on RequestExecutionException catch (e) {
final exception = _mapException(e);
throw exception;
}
await _dataSource.deleteTwoFA(
signature: base64Signature,
username: username,
userId: userId,
twoFAType: twoFAType,
verificationCodes: verificationCodes,
);
}

Future<String> _generateSignature(
Expand Down Expand Up @@ -145,15 +133,4 @@ class TwoFAService {

return Uri.parse(url).queryParameters['secret'];
}

Exception _mapException(RequestExecutionException e) {
if (e.error is! DioException) return e;

final exception = e.error as DioException;
if (InvalidTwoFaCodeException.isMatch(exception)) {
return InvalidTwoFaCodeException();
}

return e;
}
}
Loading

0 comments on commit 6e141b4

Please sign in to comment.