User Authentication For Web And iOS Apps With AWS Cognito (Part 2)
If you regularly create new web or mobile applications, then Amazon Cognito is a powerful tool that can cut 90% of the time it usually takes to set up a custom user-management solution.
In Our Last Episode
In the previous article, I introduced the concept of user management and how complicated it is in our current digital landscape. In addition, I introduced Amazon Cognito (henceforth referred to as Cognito), a service provided through Amazon Web Services, as a way to deal with this complexity. To illustrate this, I created an iOS application that uses Cognito to provide a login for users using a custom user pool.
Now that we have the basics of an application in place, I want to implement several aspects of user management within the sample application. These aspects include user signup, email verification, password resetting and multi-factor authentication. After that, I want to talk through some of the additional value that Cognito provides through its security model and how you could use it to secure the back end of your application.
To get up and running for this article, you’ll need to leverage the user pool that was created in the previous article. If you haven’t done that work, you can be up and running in about 30 minutes and ready to move forward with what I am covering today.
Rethinking Common Password Habits
The more services we use, the more passwords we’re forced to remember. Is there a way to improve this? Well, just like everything else, we need to do what’s right for our users. Read a related article →
Sample Code
If you are following along with the sample code, you will need to have set up a user pool, configured it properly, and added the settings to a plist
file before the application will work. This is a fairly simple (but lengthy) process that was covered thoroughly in the previous article. Most every section of this article will include a link to the Git tag that is used at that point of the code. I hope this will make it easier to follow along.
Letting Users Sign Up
The first step in a user’s journey in your application is the signup process. Cognito provides the capability to allow for users to sign up or to allow only administrators to sign up users. In the previous article, we configured the user pool to allow users to sign up.
One key element to remember in regards to signup is that this is where your attribute settings and password policy are enforced. If you set attributes as required in the user pool, you will have to submit all of those values in order for your signup call to be successful. In our case, we need to gather the user’s first name, last name, email address and desired password.
In this new view controller, SignupViewController
, we have an action that gets called when the user clicks the “Complete Registration” button. We need to respond to a few things if they occur:
- If there is an error, we need to present the error to the end user and allow them to resubmit the form.
- If we are successful with the signup, we need to check whether the user requires verification of their email address. According to how we set up the user pool, this is a requirement. In most all cases, the user will be required to go to the next step and verify their email address.
The following code represents this process in action:
@IBAction func signupPressed(_ sender: AnyObject) {
// Get a reference to the user pool
let userPool = AppDelegate.defaultUserPool()
// Collect all of the attributes that should be included in the signup call
let emailAttribute = AWSCognitoIdentityUserAttributeType(name: "email", value: email.text!)
let firstNameAttribute = AWSCognitoIdentityUserAttributeType(name: "given_name", value: firstName.text!)
let lastNameAttribute = AWSCognitoIdentityUserAttributeType(name: "family_name", value: lastName.text!)
// Actually make the signup call passing in those attributes
userPool.signUp(UUID().uuidString, password: password.text!, userAttributes: [emailAttribute, firstNameAttribute, lastNameAttribute], validationData: nil)
.continueWith { (response) -> Any? in
if response.error != nil {
// Error in the signup process
let alert = UIAlertController(title: "Error", message: response.error?.localizedDescription, preferredStyle: .alert)
alert.addAction(UIAlertAction(title: "OK", style: .default, handler:nil))
self.present(alert, animated: true, completion: nil)
} else {
self.user = response.result!.user
// Does user need verification?
if (response.result?.userConfirmed != AWSCognitoIdentityUserStatus.Confirmed.rawValue) {
// User needs confirmation, so we need to proceed to the verify view controller
DispatchQueue.main.async {
self.performSegue(withIdentifier: "VerifySegue", sender: self)
}
} else {
// User signed up but does not need verification
DispatchQueue.main.async {
self.presentingViewController?.dismiss(animated: true, completion: nil)
}
}
}
return nil
}
}
Verifying Email Addresses
Email verification is configured in the creation of the user pool, which we covered in the previous article. As you can see in the following image, we chose to require email validation for users. By verifying email addresses, we can leverage the addresses for other use cases, such as resetting passwords.
Workflow
The entire signup process will also include verification if the user pool was configured that way. The “happy path” representation of this flow would be as follows:
- The user is presented with the login screen when launching the application.
- The user presses the “Sign up for an account” option on the login screen.
- The user is presented with the signup form.
- The user fills out the signup form correctly with their information.
- The user is presented with the email verification form.
- The user enters the code from their email.
- The user is sent back to the login form, where they can now log in with their new account.
This flow can be seen in the following view controllers, which are a part of the sample application:
There is one important note about verification to consider. If you decide to require both email address and phone number for verification, Cognito will choose to insert the phone number verification into the signup process. You will then have to detect whether the email address is verified and include the logic required to verify it. However, in most cases, if you verify one of the attributes and use it for password resetting, then there isn’t as much of a need to verify the other. If you plan to use multi-factor authentication, I would recommend requiring phone-number verification only.
Coding The Email Verification
Two key actions need to be supported in the view controller that is handling the verification process: submitting the verification code, and requesting that the code be sent again. Luckily, these two actions are fairly easy to implement, and the code can be seen below:
func resetConfirmation(message:String? = "") {
self.verificationField.text = ""
let alert = UIAlertController(title: "Error", message: message, preferredStyle: .alert)
alert.addAction(UIAlertAction(title: "Retry", style: .default, handler: nil))
self.present(alert, animated: true, completion:nil)
}
@IBAction func verifyPressed(_ sender: AnyObject) {
// Confirm / verify the user based on the text in the verify field
self.user?.confirmSignUp(verificationField.text!)
.continueWith(block: { (response) -> Any? in
if response.error != nil {
// In this case, there was some error or the user didn't enter the right code.
// We will just rely on the description that Cognito passes back to us.
self.resetConfirmation(message: response.error!.localizedDescription)
} else {
DispatchQueue.main.async {
// Return to Login View Controller - this should be handled a bit differently, but added in this manner for simplicity
self.presentingViewController?.presentingViewController?.dismiss(animated: true, completion: nil)
}
}
return nil
})
}
@IBAction func resendConfirmationCodePressed(_ sender: AnyObject) {
self.user?.resendConfirmationCode()
.continueWith(block: { (response) -> Any? in
let alert = UIAlertController(title: "Resent", message: "The confirmation code has been resent.", preferredStyle: .alert)
alert.addAction(UIAlertAction(title: "OK", style: .default, handler: nil))
self.present(alert, animated: true, completion:nil)
return nil
})
}
As discussed in the signup and verification flow section, if the user is successful in verifying their address, they will be sent back to the login screen. After this login, the user will be able to get into the protected view within the application, which will display their attributes.
Signup and verification were relatively painless. If you want to see the code at this point of the article, check out the signup-verify
tag of the code on GitHub.
Forgot Password
Another key use case in user management is helping users to reset their password securely. Cognito provides this functionality through either email or text message, depending on how the user pool has been configured.
It is important to note that verification is required for resetting a user’s password. If you don’t specify either the phone number or email address to be verified, then the user will have to reach out to you to reset their password. In short, every user pool should have, at minimum, one verified attribute.
Workflow
Just as with signup and verification, the forgot-password workflow is a multi-step process. To achieve the goal of resetting the user’s password, we need to follow these steps (which only include the happy path):
- We need to get the user’s email address to get a reference to the user.
- We need to request that a verification code be sent to the user. If the user pool has a verified phone number, it will be sent as an SMS. If there isn’t a verified phone number, it will be sent as an email.
- We need to inform the user where to look for the code in a secure manner. Cognito returns a masked version of the phone number or email address, to give the user a heads up of where to look.
- We allow the user to fill out a form, including both the verification code and a new password.
- We submit the password and verification code to Cognito.
- We let the user know that their password reset was successful, and they can now log into the application.
- We send them back to the login screen.
This flow can be seen in the following view controllers, which are a part of the sample application:
Coding The Forgot-Password Workflow
The first step in coding this workflow is to request that the user be sent a verification code. To do this, LoginViewController
will pass the email address to ForgotPasswordViewController
before the segue is performed. Once the new view appears, the email address will be used to get a reference to the user and request a verification code.
override func viewDidAppear(_ animated: Bool) {
super.viewDidAppear(animated)
if !emailAddress.isEmpty {
// Get a reference to the user pool
let pool = AppDelegate.defaultUserPool()
// Get a reference to the user using the email address
user = pool.getUser(emailAddress)
// Initiate the forgot password process, which will send a verification code to the user
user?.forgotPassword()
.continueWith(block: { (response) -> Any? in
if response.error != nil {
// Cannot request password reset due to error (for example, the attempt limit is exceeded)
let alert = UIAlertController(title: "Cannot Reset Password", message: (response.error! as NSError).userInfo["message"] as? String, preferredStyle: .alert)
alert.addAction(UIAlertAction(title: "OK", style: .default, handler: { (action) in
self.clearFields()
self.presentingViewController?.dismiss(animated: true, completion: nil)
}))
self.present(alert, animated: true, completion: nil)
return nil
}
// Password reset was requested and message sent. Let the user know where to look for code.
let result = response.result
let isEmail = (result?.codeDeliveryDetails?.deliveryMedium == AWSCognitoIdentityProviderDeliveryMediumType.email)
let destination:String = result!.codeDeliveryDetails!.destination!
let medium = isEmail ? "an email" : "a text message"
let alert = UIAlertController(title: "Verification Sent", message: "You should receive \(medium) with a verification code at \(destination). Enter that code here along with a new password.", preferredStyle: .alert)
alert.addAction(UIAlertAction(title: "OK", style: .default, handler: nil))
self.present(alert, animated: true, completion: nil)
return nil
})
}
}
One point to note on this is that Cognito returns information that enables you to tell the user where to find the code. In the code above, we are detecting whether the code will be sent to an email address or phone number, as well as the masked version of the destination. This can be seen in the sample application in the image below.
After requesting the code, we need to allow the user to enter the new code and updated password. This code from ForgotPasswordViewController
is triggered when the user clicks on the “Reset Password” button.
@IBAction func resetPasswordPressed(_ sender: AnyObject) {
// Kick off the 'reset password' process by passing the verification code and password
user?.confirmForgotPassword(self.verificationCode.text!, password: self.newPassword.text!)
.continueWith { (response) -> Any? in
if response.error != nil {
// The password could not be reset - let the user know
let alert = UIAlertController(title: "Cannot Reset Password", message: (response.error! as NSError).userInfo["message"] as? String, preferredStyle: .alert)
alert.addAction(UIAlertAction(title: "Resend Code", style: .default, handler: { (action) in
self.user?.forgotPassword()
.continueWith(block: { (result) -> Any? in
print("Code Sent")
return nil
})
}))
alert.addAction(UIAlertAction(title: "Cancel", style: .cancel, handler: { (action) in
DispatchQueue.main.async {
self.presentingViewController?.dismiss(animated: true, completion: nil)
}
}))
self.present(alert, animated: true, completion: nil)
} else {
// Password reset. Send the user back to the login and let them know they can log in with new password.
DispatchQueue.main.async {
let presentingController = self.presentingViewController
self.presentingViewController?.dismiss(animated: true, completion: {
let alert = UIAlertController(title: "Password Reset", message: "Password reset. Please log into the account with your email and new password.", preferredStyle: .alert)
alert.addAction(UIAlertAction(title: "OK", style: .default, handler: nil))
presentingController?.present(alert, animated: true, completion: nil)
self.clearFields()
}
)
}
}
return nil
}
}
Once this process is complete, the user is returned to the login screen and informed that the password reset was successful. At this point, the user can log into the application using their new password.
If you want to see the code at this point of the article, check out the forgot-password
tag of the code on GitHub.
Multi-Factor Authentication
Multi-factor authentication (MFA) is becoming a standard for anything requiring heightened security. Right now, if I want to log into my email, my bank, my mortgage account or even GitHub, I have to enter a code that I get via text message. Because of this extra layer of security, we prevent anyone from logging into an account if they have both my username and password. Instead of just using a single factor (the password) with the username, they have to use multiple factors (password and the code via SMS).
The great thing is that Cognito supports this out of the box through SMS. The Cognito team has stated that it is also working to add support for email MFA; however, at the time of writing, this isn’t an option. To make this work, we’ll need to change some configuration settings in AWS and create a new user pool.
New User Pool
To properly demonstrate the capabilities of multi-factor authentication using SMS, I will be creating a new custom user pool with only a few modifications compared to our previous user pool. Note that some features (such as required attributes) cannot be modified once a user pool has been created. Because of this, you’ll want to have a solid strategy around your user pool before starting to use it.
The modifications that I made compared to the previous user pool are detailed below:
- Phone number has been added as a required attribute.
- Users can sign in using their email address or phone number (we will still choose the email address here).
- Multi-factor authentication has been set as optional.
- Email verification has been disabled, and phone number verification has been enabled.
Using SMS Instead Of Email
If you are going to send anything more than a few SMS messages per month, you’ll need to request a higher Simple Notification Service (SNS) limit for SMS messages. This is mostly painless, but it is a requirement if you are going to ship an app. The following excerpt from Cognito’s documentation will point you in the right direction. While the team responds to requests fairly quickly, it isn’t instant. I would recommend submitting the request at least a few weeks before a push to production. According to “Specifying User Pool MFA Setting and Email and Phone Verification Settings” in the Amazon Cognito Documentation:
"The default spend limit per account (if not specified) is 1.00 USD per month. If you want to raise the limit, submit an SNS Limit Increase case in the AWS Support Center. For New limit value, enter your desired monthly spend limit. In the Use Case Description field, explain that you are requesting an SMS monthly spend limit increase.
"
Workflow
Because we aren’t forcing multi-factor authentication for the user pool (although you certainly could), we will have a multi-step process to enable and then execute multi-factor authentication. These steps are detailed below (these are the happy path steps):
- When the user signs up for an account, they will be asked to verify their phone number (instead of their email address).
- After logging into the application, the user will be able to enable multi-factor authentication in their profile.
- After enabling multi-factor authentication, the user will be presented with the authentication challenge upon their next login.
- Upon entering the multi-factor authentication code correctly, the user will be able to use the application as expected.
Coding Multi-Factor Authentication
The Cognito SDK triggers the display of the multi-factor authentication view controller. You have to define how you want to handle that trigger. In the sample application, this happens in AppDelegate
. Because our AppDelegate
implements AWSCognitoIdentityInteractiveAuthenticationDelegate
, we have the option to define how we want this to be handled. The code below shows the implementation in the sample application:
func startMultiFactorAuthentication() -> AWSCognitoIdentityMultiFactorAuthentication {
if (self.multiFactorAuthenticationController == nil) {
self.multiFactorAuthenticationController = self.storyboard?.instantiateViewController(withIdentifier: "MultiFactorAuthenticationController") as? MultiFactorAuthenticationController
}
DispatchQueue.main.async {
if(self.multiFactorAuthenticationController!.isViewLoaded || self.multiFactorAuthenticationController!.view.window == nil) {
self.navigationController?.present(self.multiFactorAuthenticationController!, animated: true, completion: nil)
}
}
return self.multiFactorAuthenticationController!
}
In the case above, we instantiate the view controller from the storyboard, and then we present it. Finally, this view controller is presented.
Within MultiFactorAuthenticationController
, we handle the process much like we did in LoginViewController
. The view controller itself implements a protocol that is specific to this use case: AWSCognitoIdentityMultiFactorAuthentication
. By implementing this protocol, you get an instance in the getCode
method, which you must use to pass in the code for verification. The result of this verification will trigger the didCompleteMultifactorAuthenticationStepWithError
method.
class MultiFactorAuthenticationController: UIViewController {
@IBOutlet weak var authenticationCode: UITextField!
@IBOutlet weak var submitCodeButton: UIButton!
var mfaCompletionSource:AWSTaskCompletionSource?
@IBAction func submitCodePressed(_ sender: AnyObject) {
self.mfaCompletionSource?.set(result: NSString(string: authenticationCode.text!))
}
}
extension MultiFactorAuthenticationController: AWSCognitoIdentityMultiFactorAuthentication {
func getCode(_ authenticationInput: AWSCognitoIdentityMultifactorAuthenticationInput, mfaCodeCompletionSource: AWSTaskCompletionSource) {
self.mfaCompletionSource = mfaCodeCompletionSource
}
func didCompleteMultifactorAuthenticationStepWithError(_ error: Error?) {
DispatchQueue.main.async {
self.authenticationCode.text = ""
}
if error != nil {
let alertController = UIAlertController(title: "Cannot Verify Code",
message: (error! as NSError).userInfo["message"] as? String,
preferredStyle: .alert)
let resendAction = UIAlertAction(title: "Try Again", style: .default, handler:nil)
alertController.addAction(resendAction)
let logoutAction = UIAlertAction(title: "Logout", style: .cancel, handler: { (action) in
AppDelegate.defaultUserPool().currentUser()?.signOut()
self.dismiss(animated: true, completion: {
self.authenticationCode.text = nil
})
})
alertController.addAction(logoutAction)
self.present(alertController, animated: true, completion: nil)
} else {
self.dismiss(animated: true, completion: nil)
}
}
}
One additional piece needs to be noted. Because we didn’t force every user to use multi-factor authentication, we have to give the user the option to enable it. To do this, we’ll need to update the user’s settings. The code below (from AppViewController
) demonstrates how to both enable and disable multi-factor authentication by using UISwitch
as input:
let settings = AWSCognitoIdentityUserSettings()
if mfaSwitch.isOn {
// Enable MFA
let mfaOptions = AWSCognitoIdentityUserMFAOption()
mfaOptions.attributeName = "phone_number"
mfaOptions.deliveryMedium = .sms
settings.mfaOptions = [mfaOptions]
} else {
// Disable MFA
settings.mfaOptions = []
}
user?.setUserSettings(settings)
.continueOnSuccessWith(block: { (response) -> Any? in
if response.error != nil {
let alert = UIAlertController(title: "Error", message: (response.error! as NSError).userInfo["message"] as? String, preferredStyle: .alert)
alert.addAction(UIAlertAction(title: "OK", style: .default, handler: nil))
self.present(alert, animated: true, completion:nil)
self.resetAttributeValues()
} else {
self.fetchUserAttributes()
}
return nil
})
To see the code at this point of the article, check out the mfa
tag of the code on GitHub.
Taking It Further: API Security
One of the benefits of using Cognito for user management is how it integrates with other AWS services. One great example of this is how it integrates with API Gateway. If you want to have a set of APIs that only logged-in users can access, you can use the user group authorizer for API Gateway. This allows you to pass the token you receive when logging into your API calls, and API Gateway will handle the authorization for you.
This can be particularly powerful if you want to set up a serverless back end (via Lambda) that is exposed through API Gateway. This means you could pretty quickly set up a secured set of API endpoints to power your web or mobile application. If you are interested in exploring this more, feel free to check the following article from the API Gateway documentation: “Use Amazon Cognito User Pools with API Gateway.”
Unfortunately, setting up a back end with Lambda is beyond the scope of this article. However, Amazon has provided a resource on getting started with Lambda: “Build an API to Expose a Lambda Function.”
Conclusion
Within a couple of articles, we have managed to progress from an insecure application to an application that supports all of the common user management use cases. What’s even better is that this user management capability is a service supported by AWS, and until you get over 10,000 active users, it is mostly free to use. I believe that whether you are a web, iOS or Android developer, this toolset will prove to be a valuable one. I’m sure you’ve got an idea of what you can build to test this out. Feel free to use the sample code to help you in that process.
Happy coding!
Links And Resources
- Amazon Cognito
- “Developer Resources,” Amazon Cognito
- “AWS Mobile SDK,” AWS
Further Reading
- Picture Perfect: Meet Pixo, A Photo Editor For Your End Users
- A Simple Guide To Retrieval Augmented Generation Language Models
- Top Front-End Tools Of 2023
- Authenticating React Apps With Auth0