Optimizing Authentication Flows with TOTP using AWS Amplify and Flutter for Improved Security

Muhammed Salih Guler
Flutter Community
Published in
10 min readSep 7, 2023
Photo by Scott Webb on Unsplash

In today’s digital world, protecting user data is crucial. To enhance security, many applications are adding extra security steps like MFA (Multi-factor Authentication) by using SMS or TOTP (Time-based One-Time Passwords).

AWS Amplify has already supported SMS MFA for some time now but with the version 1.4.0, it now also supports TOTP with Flutter libraries.

Screenshot of version 1.4.0 of Amplify Flutter libraries.

In this tutorial, you’ll discover how to create a sign-in/up process using TOTP. We’ll be implementing this on the starter project available on GitHub.

To simplify things, the AWS Amplify team has developed a package called Amplify Authenticator. With this tool, you just need to set up authentication details using the Amplify CLI, and the UI will handle the rest for you.

You can find more information in this blog post on creating a sign-in/up process with TOTP using the UI library.

You can watch the video below to see how the final implementation behaves. You can also review the completed code on GitHub.

For this project, you should have an AWS Account and have the Amplify CLI set up. You can follow the instructions in this documentation to setup the Amplify CLI.

Let’s get started!

Initializing the Amplify Project

To begin the implementation, start by cloning the starter project from GitHub.

git clone -b starter_project https://github.com/salihgueler/totp_sample_app.git

Once you’ve cloned the project, navigate to its root folder and run the command amplify init.

cd path/to/project/totp_sample_app
amplify init

Running amplify init will initialize your project. Follow the CLI prompts and accept the default settings to proceed:

msalihg totp_sample_app % amplify init
Note: It is recommended to run this command from the root of your app directory
? Enter a name for the project totpsampleapp
The following configuration will be applied:

Project information
| Name: totpsampleapp
| Environment: dev
| Default editor: Visual Studio Code
| App type: flutter
| Configuration file location: ./lib/

? Initialize the project with the above configuration? Yes
Using default provider awscloudformation
? Select the authentication method you want to use: AWS profile

✅ Initialized your environment successfully.
✅ Your project has been successfully initialized and connected to the cloud!

Now, let’s add authentication capabilities using the Amplify CLI. Run the command amplify add authand select the Manual Configuration option. Follow the prompts and choose your answers as follows:

msalihg totp_sample_app % amplify add auth
Using service: Cognito, provided by: awscloudformation

The current configured provider is Amazon Cognito.

Do you want to use the default authentication and security configuration? Manual configuration
Select the authentication/authorization services that you want to use: User Sign-Up, Sign-In, connected with AWS IAM controls (Enables per-user Stora
ge features for images or other content, Analytics, and more)
Provide a friendly name for your resource that will be used to label this category in the project: totpsampleappf1c0d737f1c0d737
Enter a name for your identity pool. totpsampleappf1c0d737_identitypool_f1c0d737
Allow unauthenticated logins? (Provides scoped down permissions that you can control via AWS IAM) No
Do you want to enable 3rd party authentication providers in your identity pool? No
Provide a name for your user pool: totpsampleappf1c0d737_userpool_f1c0d737
Warning: you will not be able to edit these selections.
How do you want users to be able to sign in? Username
Do you want to add User Pool Groups? No
Do you want to add an admin queries API? No
Multifactor authentication (MFA) user login options: ON (Required for all logins, can not be enabled later)
For user login, select the MFA types: Time-Based One-Time Password (TOTP)
Specify an SMS authentication message: Your authentication code is {####}
Email based user registration/forgot password: Enabled (Requires per-user email entry at registration)
Specify an email verification subject: Your verification code
Specify an email verification message: Your verification code is {####}
Do you want to override the default password policy for this User Pool? No
Warning: you will not be able to edit these selections.
What attributes are required for signing up? Email
Specify the apps refresh token expiration period (in days): 30
Do you want to specify the user attributes this app can read and write? No
Do you want to enable any of the following capabilities?
Do you want to use an OAuth flow? No
? Do you want to configure Lambda Triggers for Cognito? No
✅ Successfully added auth resource totpsampleappf1c0d737f1c0d737 locally

In the provided CLI flow:

  • Username sign-in is enabled.
  • Email is required for activating accounts and managing forgot password options.
  • Multi-factor authentication for sign-in is enabled using TOTP.
  • All other custom options are rejected, keeping the authentication process straightforward and secure.

Now, run amplify push -y to push the changes to the cloud.

msalihg totp_sample_app % amplify push -y
✔ Successfully pulled backend environment dev from the cloud.

Current Environment: dev

┌──────────┬───────────────────────────────┬───────────┬───────────────────┐
│ Category │ Resource name │ Operation │ Provider plugin │
├──────────┼───────────────────────────────┼───────────┼───────────────────┤
│ Auth │ totpsampleappf1c0d737f1c0d737 │ Create │ awscloudformation │
└──────────┴───────────────────────────────┴───────────┴───────────────────┘

Deployment state saved successfully.

Now it is time to write some Dart code.

Configure Amplify Libraries

The first step in configuring Amplify Libraries is to add the required libraries. To do this, add the following to your pubspec.yaml file and then run flutter pub get:

dependencies:
amplify_auth_cognito: ^1.4.0
amplify_flutter: ^1.4.0

Once you’ve added the required libraries, navigate to your main.dart file and update the //TODO: Configure Amplify with the following:

Future<void> _configureAmplify() async {
try {
Amplify.addPlugin(AmplifyAuthCognito());
Amplify.configure(amplifyconfig);
} on AmplifyAlreadyConfiguredException catch (_) {
safePrint('Amplify was already configured. Was the app restarted?');
} on AmplifyException catch (e) {
safePrint('Failed to configure Amplify: $e');
}
}

Last, update the main function by replacing // TODO: Call _configureAmplify line with the following:

await _configureAmplify();

Now you are ready to use the libraries.

Implementing Sign Up

To begin, open the sign_up_user.dart file and replace the // TODO: Implement signUpUser function:

Future<void> signUpUser({
required String username,
required String password,
required String email,
}) async {
final userAttributes = {
AuthUserAttributeKey.email: email,
};
final result = await Amplify.Auth.signUp(
username: username,
password: password,
options: SignUpOptions(
userAttributes: userAttributes,
),
);
await _handleSignUpResult(result);
}

Future<void> _handleSignUpResult(SignUpResult result) async {
switch (result.nextStep.signUpStep) {
case AuthSignUpStep.confirmSignUp:
_handleCodeDelivery(result.nextStep.codeDeliveryDetails);
break;
case AuthSignUpStep.done:
_showSnackBar('Sign up is complete');
break;
}
}

void _handleCodeDelivery(AuthCodeDeliveryDetails? codeDeliveryDetails) {
_showSnackBar(
'A confirmation code has been sent to ${codeDeliveryDetails?.destination}. '
'Please check your ${codeDeliveryDetails?.deliveryMedium.name} for the code.',
);
}

With the provided code:

  • You are signing up the user.
  • Providing feedback on the sign-up state.

To use the created functions, find the // TODO: Call signUpUser function section and replace all the content of the anonymous function with the following code:

final isSignUpFormValid =
_signUpFormKey.currentState?.validate() ??
false;
if (isSignUpFormValid) {
final username = _usernameController.text;
final email = _emailController.text;
final password = _passwordController.text;

try {
setState(() {
_isSigningUp = true;
});
await signUpUser(
username: username,
password: password,
email: email,
);
if (mounted) {
context.go(
Routes.emailVerification,
extra: username,
);
}
} on AuthException catch (e) {
switch (e) {
case UsernameExistsException _:
_showSnackBar(
'Username already exists');
_usernameController.clear();
break;
case InvalidPasswordException _:
_showSnackBar(
'Invalid password format');
_passwordController.clear();
break;
default:
_showSnackBar('Sign up failed. $e');
break;
}
} finally {
setState(() {
_isSigningUp = false;
});
}
}

The code above performs the following tasks:

  • Calls the previously created functions.
  • In case of an exception, it notifies the user with a snack bar.
  • It updates the state of the button during and after the processes.

Now that you have the sign-up capability, you also need to implement account verification. Open the email_verification_page.dart file and locate the // TODO: Call confirmSignUp function section. Replace all the content of the anonymous function with the following code:

final isFormValid = _verificationFormKey.currentState?.validate() ?? false;
if (isFormValid) {
try {
final confirmationCode = [
_digit1Controller.text,
_digit2Controller.text,
_digit3Controller.text,
_digit4Controller.text,
_digit5Controller.text,
_digit6Controller.text,
].join();

await Amplify.Auth.confirmSignUp(
username: widget.username,
confirmationCode: confirmationCode,
);
if (mounted) {
context.go(Routes.verificationSuccessful);
}
} on AuthException catch (e) {
switch (e) {
case CodeMismatchException _:
_showSnackBar('Invalid verification code');
break;
default:
_showSnackBar('Verification failed. $e');
break;
}
} finally {
_digit1Controller.clear();
_digit2Controller.clear();
_digit3Controller.clear();
_digit4Controller.clear();
_digit5Controller.clear();
_digit6Controller.clear();
}
}

The provided code will confirm the user if the confirmation code is correct, ensuring that the user’s account is properly verified.

In case the user loses the code, it’s a common practice to provide a button to resend the code. To implement this feature, find the // TODO: Call resendSignUpCode function section in your code and replace it with the following:

final resendResult = await Amplify.Auth.resendSignUpCode(
username: widget.username,
);
_showSnackBar(
'Verification code sent to ${resendResult.codeDeliveryDetails.destination}',
);

The provided code will resend the sign-up code to the user’s email address if needed. The sign-up process is now complete, and it’s time to move on to the sign-in functionality.

Implementing Sign In

To proceed with the sign-in implementation, open the sign_in_page.dart file and locate the // TODO: Add signInWithCognito section. Replace it with the following code:

Future<void> signInWithCognito(
String username,
String password,
) async {
final result = await Amplify.Auth.signIn(
username: username,
password: password,
);
await _handleSignInResult(result);
}

Future<void> _handleSignInResult(SignInResult result) async {
safePrint(result);
switch (result.nextStep.signInStep) {
case AuthSignInStep.continueSignInWithMfaSelection:
// Handle select from MFA methods case
case AuthSignInStep.continueSignInWithTotpSetup:
if (mounted) {
final totpSetupDetails = result.nextStep.totpSetupDetails!;
final setupUri = totpSetupDetails.getSetupUri(
appName: 'TOTP_SAMPLE_APP',
);
context.go(
Routes.setupTotp,
extra: <String, String>{
'username': _usernameController.text,
'totp_uri': setupUri.toString(),
},
);
}
case AuthSignInStep.confirmSignInWithTotpMfaCode:
context.go(
Routes.verifyTotp,
extra: _usernameController.text,
);
case AuthSignInStep.confirmSignInWithSmsMfaCode:
// Handle SMS MFA case
case AuthSignInStep.confirmSignInWithNewPassword:
// Handle new password case
case AuthSignInStep.confirmSignInWithCustomChallenge:
// Handle custom challenge case
case AuthSignInStep.resetPassword:
// Handle reset password case
case AuthSignInStep.confirmSignUp:
final resendResult = await Amplify.Auth.resendSignUpCode(
username: _usernameController.text,
);
_showSnackBar(
'Verification code sent to ${resendResult.codeDeliveryDetails.destination}',
);
if (mounted) {
context.go(
Routes.emailVerification,
extra: _usernameController.text,
);
}
case AuthSignInStep.done:
if (mounted) {
context.go(
Routes.home,
extra: _usernameController.text,
);
}
}
}

These are the three important points to note:

  • If the user hasn’t set up TOTP yet, they will be directed to a page to set it up.
  • If the user has TOTP set up, they will be directed to the verification code page before signing in.
  • If the user’s account hasn’t been verified yet, they will be directed to the verification page.

Now, to continue with the sign-in implementation, find the // TODO: Call signInWithCognito function section and replace all the content of the anonymous function with the following code:

final isFormVilled = _signInFormKey.currentState?.validate() ?? false;
if (isFormVilled) {
final username = _usernameController.text;
final password = _passwordController.text;
try {
setState(() {
_isSigningIn = true;
});
await signInWithCognito(username, password);
} on AuthException catch (e) {
switch (e) {
case UserNotFoundException _:
_showSnackBar(
'User not found. Sign up?');
_usernameController.clear();
_passwordController.clear();
break;
case NotAuthorizedServiceException _:
_showSnackBar(
'Wrong password or username. Check your information',
);
_passwordController.clear();
break;
case LimitExceededException _:
_showSnackBar(
'Attempt limit exceeded, please try again later.',
);
break;
default:
safePrint('Sign in failed: $e');
break;
}
} finally {
setState(() {
_isSigningIn = false;
});
}
}

With this change, you are calling the sign-in function you created. If the sign-in fails, it displays an appropriate error message. Otherwise, it directs the user to the TOTP page.

Implementing TOTP

Now, it’s time to implement the TOTP feature you’ve been waiting for! TOTP, or Time-based One-Time Passwords, is a widely used form of two-factor authentication (2FA) that generates unique numeric passwords based on the current time.

For setting up TOTP for your application you need a companion authenticator app like Microsoft Authenticator, Google Authenticator or Authy. TOTP codes are communicated between the authenticator app and the service or platform that requires authentication through a shared secret key.

This secret key is initially exchanged and stored securely when the user sets up two-factor authentication. The authenticator app uses this secret key, along with the current time, to generate a time-sensitive one-time password (OTP). The user then enters this OTP into the service or platform, which also uses the shared secret key to independently calculate the expected OTP. If the entered OTP matches the expected value, authentication is successful. This way, both the authenticator app and the service can verify the user’s identity without needing to communicate directly, making it a secure and convenient method for two-factor authentication.

To set up TOTP, open the totp_setup_page.dart file and locate the // TODO: Call confirmSignIn function section. Replace all the content of the anonymous function with the following code:

final totpCode = [
_digit1Controller.text,
_digit2Controller.text,
_digit3Controller.text,
_digit4Controller.text,
_digit5Controller.text,
_digit6Controller.text,
].join();

try {
await Amplify.Auth.confirmSignIn(
confirmationValue: totpCode,
);
if (mounted) {
context.go(
Routes.home,
extra: widget.username,
);
}
} on AuthException catch (e) {
switch (e) {
case CodeMismatchException _:
_showSnackBar('Invalid verification code');
break;
default:
_showSnackBar('Verification failed. $e');
break;
}
} finally {
_digit1Controller.clear();
_digit2Controller.clear();
_digit3Controller.clear();
_digit4Controller.clear();
_digit5Controller.clear();
_digit6Controller.clear();
}

With this change, you are collecting the 6-digit code, and if it doesn’t result in an error, it directs you to the home page.

Regarding adding verification to the page, you don’t need to take any additional steps for that. Simply open the totp_verification_page.dart file and replace all the content of the anonymous function that contains the // TODO: Call confirmSignIn function line with the same code as mentioned above.

Cherry on top

To have a fully implemented authentication flow, it’s important to obtain the current authentication status and implement sign-out functionality.

To add sign-out functionality to the home_page.dart file, locate the // TODO: Call signOut function section and update the anonymous function as follows:

try {
await Amplify.Auth.signOut();
if (mounted) {
context.go(Routes.signIn);
}
} on AuthException catch (e) {
_showSnackBar('Error signing out - ${e.message}');
}

This code will sign out the current user and redirect them to the sign-in page.

To complete the last step of checking the authentication status of the user, navigate to the splash_page.dart file and update the _checkAuthStatus function as specified.

Future<(String, bool)> _checkAuthStatus() async {
final isSignedIn = await Amplify.Auth.fetchAuthSession();
if (isSignedIn.isSignedIn) {
final username = (await Amplify.Auth.getCurrentUser()).username;
return (username, true);
} else {
return ('', false);
}
}

Conclusion

In summary, our authentication setup emphasizes both user security and usability. It has username sign-in for simplicity while mandating email use for account activation and password recovery.

We’ve improved thesecurity through Multi-factor Authentication (MFA) with Time-based One-Time Passwords (TOTP), as an extra verification layer. Customization options have been streamlined to maintain clarity. You can check out our open-source project over GitHub.

For deeper insights into authentication and security, explore additional resources:

To connect with me and stay updated on similar topics, follow me over social media with the links below!

--

--