diff --git a/lib/cubit/account_cubit.dart b/lib/cubit/account_cubit.dart index b0d363d..18d60ff 100644 --- a/lib/cubit/account_cubit.dart +++ b/lib/cubit/account_cubit.dart @@ -10,6 +10,7 @@ import 'package:keevault/vault_backend/user.dart'; import 'package:meta/meta.dart'; import 'package:shared_preferences/shared_preferences.dart'; import '../logging/logger.dart'; +import '../vault_backend/utils.dart'; part 'account_state.dart'; @@ -325,6 +326,65 @@ class AccountCubit extends Cubit { } } + startEmailChange() { + emit(AccountEmailChangeRequested(currentUser, null)); + } + + Future changeEmailAddress(String password, String newEmailAddress) async { + l.d('starting the changeEmailAddress procedure'); + User user = currentUser; + try { + final newEmailHashed = await hashString(newEmailAddress, EMAIL_ID_SALT); + + final protectedValue = ProtectedValue.fromString(password); + final key = protectedValue.hash; + final passKeyConfirmation = await derivePassKey(user.email!, key); + + await _userRepo.changeEmailAddress(user, newEmailAddress, newEmailHashed, passKeyConfirmation); + final prefs = await SharedPreferences.getInstance(); + await prefs.setString('user.current.email', newEmailAddress); + if (user.id?.isNotEmpty ?? false) { + await prefs.setString('user.authMaterialUserIdMap.${user.emailHashed}', user.id!); + } + final newUser = await User.fromEmail(newEmailAddress); + //TODO: verify that we can only get here if Vault is already locked. Throw exception earlier if we can detect that state? + emit(AccountChosen(newUser)); + //await BlocProvider.of(context).forgetUser(vc.signout); + } on KeeLoginFailedMITMException { + rethrow; + } on KeeLoginRequiredException { + l.w('Unable to changeEmailAddress due to a 403.'); + emit(AccountEmailChangeRequested(user, + 'Due to an authentication problem, we were unable to change your email address. Probably it has been too long since you last signed in with your previous email address. We have left you signed in using your old email address but you may find that you are signed out soon. Please sign out and then sign in again with your previous email address and try again when you have enough time to complete the operation within 10 minutes.')); + } on KeeInvalidRequestException { + l.i('Unable to changeEmailAddress due to 400 response.'); + emit(AccountEmailChangeRequested(user, + 'Please double check that you have entered the correct password for your existing Kee Vault account. Also check that you have entered a valid email address of no more than 70 characters.')); + } on KeeServerConflictException { + l.i('Unable to changeEmailAddress due to 409 response.'); + emit(AccountEmailChangeRequested(user, + 'Sorry, that email address is already associated with a different Kee Vault account (or is reserved due to earlier use by a deleted account). Try signing in to that account, and consider importing your exported KDBX file from this account if you wish to transfer your data to the other account. If you have access to the requested email address but are unable to remember your password, you could use the account reset feature to delete the contents of the other account and assign it a new password that you will remember.')); + } on KeeNotFoundException { + l.i('Unable to changeEmailAddress due to 404 response.'); + emit(AccountEmailChangeRequested(user, 'We cannot find your account. Have you recently deleted it?')); + } on KeeServiceTransportException catch (e) { + l.w('Unable to changeEmailAddress due to a transport error. Cannot be sure if the request was successful or not. Details: $e'); + emit(AccountEmailChangeRequested(user, + 'Due to a network failure, we cannot say whether your request succeeded or not. We have left you signed in using your old email address but if the operation did eventually succeed, you may find that you are signed out soon. Please check your new email address for a verification request. It might take a moment to arrive but if it does, that suggests the process did work so just verify your new address, sign out of the app and then sign-in using the new email address. If unsure if it worked, sign in with your previous email address next time and try again when you have a more stable network connection.')); + + //} on KeeMaybeOfflineException { + //TODO: confirm if can get a KeeMaybeOfflineException here + // l.i('Unable to authenticate since initial identification failed, probably due to a transport error. App should continue to work offline if user has previously stored their Vault.'); + // final prefs = await SharedPreferences.getInstance(); + // await prefs.setString('user.current.email', user.email!); + // if (user.id?.isNotEmpty ?? false) { + // await prefs.setString('user.authMaterialUserIdMap.${user.emailHashed}', user.id!); + // } + // emit(AccountAuthenticationBypassed(user)); + } + return; + } + Future signout() async { final AccountState currentState = state; if (currentState is AccountChosen && (currentState.user.email?.isNotEmpty ?? false)) { diff --git a/lib/cubit/account_state.dart b/lib/cubit/account_state.dart index e636b9f..8cfa74e 100644 --- a/lib/cubit/account_state.dart +++ b/lib/cubit/account_state.dart @@ -60,6 +60,11 @@ class AccountEmailNotVerified extends AccountAuthenticated { const AccountEmailNotVerified(super.user); } +class AccountEmailChangeRequested extends AccountAuthenticated { + final String? error; + const AccountEmailChangeRequested(super.user, this.error); +} + class AccountTrialRestartStarted extends AccountExpired { const AccountTrialRestartStarted(super.user, super.trialAvailable); } diff --git a/lib/user_repository.dart b/lib/user_repository.dart index 7c6fd82..3d536c0 100644 --- a/lib/user_repository.dart +++ b/lib/user_repository.dart @@ -56,6 +56,11 @@ class UserRepository { return user; } + Future changeEmailAddress(User user, String newEmailAddress, String newEmailHashed, String newPassKey) async { + await userService.changeEmailAddress(user, newEmailAddress, newEmailHashed, newPassKey); + return; + } + Future associate(User user, int subscriptionSource, String validationData) async { return await subscriptionService.associate(user, subscriptionSource, validationData); } diff --git a/lib/vault_backend/user_service.dart b/lib/vault_backend/user_service.dart index 2ad700f..d290493 100644 --- a/lib/vault_backend/user_service.dart +++ b/lib/vault_backend/user_service.dart @@ -1,4 +1,6 @@ import 'dart:convert'; +import 'dart:typed_data'; +import 'package:convert/convert.dart'; import 'package:keevault/logging/logger.dart'; import 'package:keevault/vault_backend/exceptions.dart'; import 'package:keevault/vault_backend/login_parameters.dart'; @@ -105,7 +107,8 @@ class UserService { Future createAccount(User user, int marketingEmailStatus, int subscriptionSource) async { final hexSalt = generateSalt(); user.salt = hex2base64(hexSalt); - final privateKey = derivePrivateKey(hexSalt, user.id!, user.passKey!); + //TODO: Verify that changing from user.id to user.emailHashed has expected effect (nothing since we just defaulted the id to emailHashed anyway and the server will tell us the final random user ID after registration succeeds) + final privateKey = derivePrivateKey(hexSalt, user.emailHashed!, user.passKey!); final verifier = deriveVerifier(privateKey); final response = await _service.postRequest('register', { 'emailHashed': user.emailHashed, @@ -202,6 +205,31 @@ class UserService { return true; } + // We make no changes to the User model since we will sign the user out and ask them to + // sign in again, partly so that we can ensure they have verified their new email address. + Future changeEmailAddress(User user, String newEmailAddress, String newEmailHashed, String newPassKey) async { + final newHexSalt = generateSalt(); + final newSalt = hex2base64(newHexSalt); + final newPrivateKey = derivePrivateKey(newHexSalt, newEmailHashed, newPassKey); + final newVerifier = deriveVerifier(newPrivateKey); + + final oldPrivateKey = derivePrivateKey(user.salt!, user.emailHashed!, user.passKey!); + final oldVerifier = deriveVerifier(oldPrivateKey); + final oldVerifierHashed = await hashBytes(Uint8List.fromList(hex.decode(oldVerifier))); + + await _service.postRequest( + 'changeEmailAddress', + { + 'emailHashed': newEmailHashed, + 'verifier': hex2base64(newVerifier), + 'salt': newSalt, + 'email': newEmailAddress, + 'oldVerifierHashed': oldVerifierHashed, + }, + user.tokens!.identity); + return; + } + Future _parseJWTs(User user, List jwts, {bool notifyListeners = false}) async { user.tokens = Tokens(); diff --git a/lib/widgets/account_email_change.dart b/lib/widgets/account_email_change.dart new file mode 100644 index 0000000..be82707 --- /dev/null +++ b/lib/widgets/account_email_change.dart @@ -0,0 +1,220 @@ +import 'dart:async'; + +import 'package:flutter/material.dart'; +import 'package:flutter_bloc/flutter_bloc.dart'; +import 'package:keevault/cubit/account_cubit.dart'; +import '../generated/l10n.dart'; +import '../vault_backend/exceptions.dart'; + +typedef SubmitCallback = Future Function(String string); + +class AccountEmailChangeWidget extends StatefulWidget { + const AccountEmailChangeWidget({ + super.key, + }); + + @override + State createState() => _AccountEmailChangeWidgetState(); +} +// Very rare technical failures outside of our control could leave you unable to sign-in to your account. We therefore recommend that you export your Vault to a "kdbx" file before proceding. +// You will be signed out immediately and will have to reverify your new email address before signing in again. + +// above only relevant for when signed in to vault. + +//Your password will remain the same throughout the process. If you want to change that too, we recommend waiting at least an hour before doing so. +// Enter your current password and new email address::: + +// Dialog: Please double-check this is your new email address. Typos or auto-correct mistakes can be very difficult and slow to recover from. +// Cancel / Change to displayed address + +class _AccountEmailChangeWidgetState extends State { + Future resendEmail() async { + setState(() { + disableResending = true; + resending = true; + }); + final accountCubit = BlocProvider.of(context); + final wasSent = await accountCubit.resendVerificationEmail(); + // Keep spinning for a couple of seconds to help user email deliverability + // experience, unless we know it failed. + // Keep disabled for 60 seconds, or 5 if it failed. + if (!wasSent) { + if (mounted) { + setState(() { + resending = false; + }); + } + await Future.delayed(Duration(seconds: 5)); + if (mounted) { + setState(() { + disableResending = false; + }); + } + } else { + await Future.delayed(Duration(seconds: 2)); + if (mounted) { + setState(() { + resending = false; + }); + } + await Future.delayed(Duration(seconds: 58)); + if (mounted) { + setState(() { + disableResending = false; + }); + } + } + } + + Future refreshUserAndTokens() async { + setState(() { + disableRefreshing = true; + refreshing = true; + }); + try { + for (var i = 0; i < 3; i++) { + try { + final accountCubit = BlocProvider.of(context); + await accountCubit.refreshUserAndTokens(); + break; + } on KeeAccountUnverifiedException { + // retry automatically so user can't keep spamming the button in case dynamodb + // is taking some time to update after their verification or they are + // verifying an old email address + await Future.delayed(Duration(seconds: 2 * (i + 1))); + } + } + } finally { + if (mounted) { + setState(() { + refreshing = false; + }); + } + await Future.delayed(Duration(seconds: 5)); + if (mounted) { + setState(() { + disableRefreshing = false; + }); + } + } + } + + bool disableResending = true; + bool disableRefreshing = true; + bool resending = false; + bool refreshing = false; + + @override + void initState() { + super.initState(); + unawaited(_enableButtonsAfterDelay()); + } + + Future _enableButtonsAfterDelay() async { + await Future.delayed(Duration(seconds: 5)); + if (mounted) { + setState(() { + disableRefreshing = false; + disableResending = false; + }); + } + } + + @override + Widget build(BuildContext context) { + final str = S.of(context); + final theme = Theme.of(context); + return BlocBuilder(builder: (context, state) { + if (state is AccountEmailNotVerified) { + final userEmail = state.user.email; + return Padding( + padding: const EdgeInsets.all(16.0), + child: Column(crossAxisAlignment: CrossAxisAlignment.center, children: [ + Padding( + padding: const EdgeInsets.only(bottom: 16.0), + child: Text( + str.emailVerification, + style: theme.textTheme.titleLarge, + ), + ), + Padding( + padding: const EdgeInsets.only(bottom: 16.0), + child: Text( + str.verificationRequest(userEmail ?? 'error - unknown email address - contact us for help'), + textAlign: TextAlign.center, + style: theme.textTheme.bodyLarge, + ), + ), + OutlinedButton( + onPressed: disableResending + ? null + : () async { + await resendEmail(); + }, + child: resending + ? Container( + width: 24, + height: 24, + padding: const EdgeInsets.all(2.0), + child: const CircularProgressIndicator( + strokeWidth: 3, + ), + ) + : Text(str.resendVerification), + ), + Padding( + padding: const EdgeInsets.symmetric(vertical: 16.0), + child: Text( + str.signInAgainWhenVerified, + textAlign: TextAlign.center, + ), + ), + ElevatedButton( + onPressed: disableRefreshing + ? null + : () async { + await refreshUserAndTokens(); + }, + child: refreshing + ? Container( + width: 24, + height: 24, + padding: const EdgeInsets.all(2.0), + child: const CircularProgressIndicator( + strokeWidth: 3, + ), + ) + : Text('Continue signing in'), + ), + Padding( + padding: const EdgeInsets.symmetric(vertical: 16.0), + child: Text( + str.wrongAddress, + textAlign: TextAlign.center, + ), + ), + OutlinedButton( + onPressed: disableRefreshing + ? null + : () async { + await startEmailChange(); + }, + child: refreshing + ? Container( + width: 24, + height: 24, + padding: const EdgeInsets.all(2.0), + child: const CircularProgressIndicator( + strokeWidth: 3, + ), + ) + : Text('Change email address'), + ), + ]), + ); + } else { + return Text(str.unexpected_error('Account in invalid state for AccountEmailNotVerifiedWidget')); + } + }); + } +} diff --git a/lib/widgets/account_email_not_verified.dart b/lib/widgets/account_email_not_verified.dart index b8ac68a..c986313 100644 --- a/lib/widgets/account_email_not_verified.dart +++ b/lib/widgets/account_email_not_verified.dart @@ -89,6 +89,11 @@ class _AccountEmailNotVerifiedWidgetState extends State startEmailChange() async { + final accountCubit = BlocProvider.of(context); + await accountCubit.startEmailChange(); + } + bool disableResending = true; bool disableRefreshing = true; bool resending = false; @@ -176,6 +181,30 @@ class _AccountEmailNotVerifiedWidgetState extends State { final user = await accountCubit.finishSignin(password); if ((accountCubit.state is AccountAuthenticated && + accountCubit.state is! AccountEmailChangeRequested && accountCubit.state is! AccountEmailNotVerified && accountCubit.state is! AccountExpired) || accountCubit.state is AccountAuthenticationBypassed) { // No point going through the startup process if the user has just ended up // back in Accountidentified state due to an incorrect password or their - // account is expired or unverified + // account is expired, unverified or going through an email address change. await vaultCubit.startup(user, password); } } else { @@ -120,6 +122,9 @@ class AccountWrapperState extends State { return LoadingSpinner(tooltip: str.authenticating); } else if (state is AccountExpired) { return AccountExpiredWidget(trialAvailable: state.trialAvailable); + } else if (state is AccountEmailChangeRequested) { + // Success, failure and cancel all will sign out user, remove QuickUnlock data, and thus bump them back to the sign-in page for fresh authentication. + return AccountEmailChangeWidget(); } else if (state is AccountEmailNotVerified) { return AccountEmailNotVerifiedWidget(); } else if (state is AccountChosen || state is AccountLocalOnly) { diff --git a/lib/widgets/vault_loader.dart b/lib/widgets/vault_loader.dart index 8e86cb2..2f993f0 100644 --- a/lib/widgets/vault_loader.dart +++ b/lib/widgets/vault_loader.dart @@ -41,6 +41,7 @@ class VaultLoaderState extends State { final latestState = accountCubit.state; if ((latestState is AccountAuthenticated && + latestState is! AccountEmailChangeRequested && latestState is! AccountEmailNotVerified && latestState is! AccountExpired) || latestState is AccountAuthenticationBypassed) {