Skip to content

Commit

Permalink
wip
Browse files Browse the repository at this point in the history
  • Loading branch information
luckyrat committed Aug 18, 2024
1 parent f7fc4d3 commit b2dc719
Show file tree
Hide file tree
Showing 8 changed files with 355 additions and 2 deletions.
60 changes: 60 additions & 0 deletions lib/cubit/account_cubit.dart
Original file line number Diff line number Diff line change
Expand Up @@ -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';

Expand Down Expand Up @@ -325,6 +326,65 @@ class AccountCubit extends Cubit<AccountState> {
}
}

startEmailChange() {
emit(AccountEmailChangeRequested(currentUser, null));
}

Future<void> 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<AccountCubit>(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<void> signout() async {
final AccountState currentState = state;
if (currentState is AccountChosen && (currentState.user.email?.isNotEmpty ?? false)) {
Expand Down
5 changes: 5 additions & 0 deletions lib/cubit/account_state.dart
Original file line number Diff line number Diff line change
Expand Up @@ -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);
}
Expand Down
5 changes: 5 additions & 0 deletions lib/user_repository.dart
Original file line number Diff line number Diff line change
Expand Up @@ -56,6 +56,11 @@ class UserRepository {
return user;
}

Future<void> changeEmailAddress(User user, String newEmailAddress, String newEmailHashed, String newPassKey) async {
await userService.changeEmailAddress(user, newEmailAddress, newEmailHashed, newPassKey);
return;
}

Future<bool> associate(User user, int subscriptionSource, String validationData) async {
return await subscriptionService.associate(user, subscriptionSource, validationData);
}
Expand Down
30 changes: 29 additions & 1 deletion lib/vault_backend/user_service.dart
Original file line number Diff line number Diff line change
@@ -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';
Expand Down Expand Up @@ -105,7 +107,8 @@ class UserService {
Future<User> 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<String>('register', {
'emailHashed': user.emailHashed,
Expand Down Expand Up @@ -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<void> 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<String>(
'changeEmailAddress',
{
'emailHashed': newEmailHashed,
'verifier': hex2base64(newVerifier),
'salt': newSalt,
'email': newEmailAddress,
'oldVerifierHashed': oldVerifierHashed,
},
user.tokens!.identity);
return;
}

Future<void> _parseJWTs(User user, List<String> jwts, {bool notifyListeners = false}) async {
user.tokens = Tokens();

Expand Down
220 changes: 220 additions & 0 deletions lib/widgets/account_email_change.dart
Original file line number Diff line number Diff line change
@@ -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<void> Function(String string);

class AccountEmailChangeWidget extends StatefulWidget {
const AccountEmailChangeWidget({
super.key,
});

@override
State<AccountEmailChangeWidget> 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<AccountEmailChangeWidget> {
Future<void> resendEmail() async {
setState(() {
disableResending = true;
resending = true;
});
final accountCubit = BlocProvider.of<AccountCubit>(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<void> refreshUserAndTokens() async {
setState(() {
disableRefreshing = true;
refreshing = true;
});
try {
for (var i = 0; i < 3; i++) {
try {
final accountCubit = BlocProvider.of<AccountCubit>(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<void> _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<AccountCubit, AccountState>(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'));
}
});
}
}
Loading

0 comments on commit b2dc719

Please sign in to comment.