Server-Side Swift Beta

Learn web development with Swift, using Vapor and Kitura.

SMS user authentication with Vapor and AWS

In this SMS user authentication tutorial you will learn how to use AWS and Vapor to authenticate your users with their phone number.

5/5 3 Ratings

Version

  • Swift 5, macOS 10.15, Xcode 11

There are many reasons why you’d want to verify your app’s users and identify them by phone number. SMS-based authentication is one of the options for a quick login experience that doesn’t require remembering passwords.

Nowadays, there are many services which provide SMS, aka short message service, authentication on your behalf. Using them might save you some time writing backend code, but it adds another dependency to your server and all of your clients.

Writing your own solution is simpler than you think. If you already have a Vapor server for your app, or if you want to build a microservice for it, then you’ve come to the right place!

In this tutorial, you’ll learn how to build your own SMS authentication with Vapor and Amazon Web Services’ SNS. SNS, or Simple Notification Service, is AWS’s service for sending messages of various types: push, e-mail, and of course — SMS. It requires an AWS account and a basic knowledge of Vapor and Swift.

By the end of this tutorial, you’ll have two HTTP APIs which will allow you to create a user for your app.

Getting Started

Note: A local installation of Vapor and a set of AWS keys for SNS are a prerequisite for this tutorial. Simply create a free AWS account, follow the basic SNS service set up, and create an AWS Access Key and Secret inside IAM (Identity Access Management)

Download the materials for this tutorial using the Download Materials button at the top or bottom of this page. Open the starter directory in your favorite Terminal application, and run the following command:

open Package.swift

Once your project is open in Xcode it will fetch all the dependencies defined in the manifest. This may take a few minutes to complete. Once that’s finished, build and run the Run scheme to make sure the starter project compiles. Before you start coding, quickly browse through the starter project’s source code to get a sense of the various pieces.

How a Generic SMS Auth Works Behind the Curtains

You’ve most likely used an app with SMS authentication before. Insert your phone number, move to another screen, enter the code received in the SMS and you’re in. Have you ever thought about how it works behind the scenes?

If you haven’t, fear not: I’ve got you covered!

  1. The client asks the server to send a code to a phone number.
  2. The server creates a four or six digit code and asks an SMS provider to deliver it to the phone number in question.
  3. The server adds an entry in the database associating the sent code to the phone number.
  4. The user receives the SMS and inputs it in the client.
  5. The client sends back the code to the server.
  6. The server queries the database for the phone number and tries to match the code it saved before to the code it received.
  7. If they match, the server looks in the database to see if a user is associated with the phone number. If it doesn’t find an existing user, it creates a new one.
  8. The server returns the user object, and some sort of authentication token, to the client.

You can see the above steps in this diagram:

Note: Although the diagram represents a database, the sample project uses an in-memory SQLite database for learning purposes. This is not intended for production usage, as this database is not shared across many server instances and doesn’t persist between launches. Check the Vapor documentation or the Server Side Swift with Vapor book to learn how to use a more persistent database.

Interacting With AWS SNS

To execute step two in the diagram above, you’ll need to create a class that asks SNS to send the text message. In the SourcesApp folder, create a new Swift file named SMSSender.swift. Make sure you’re creating this file in the App target. Next, add the following contents:

import Vapor

protocol SMSSender: Service {
  func sendSMS(to phoneNumber: String, message: String) throws -> Future<Bool>
}

There are a few things to notice here:

  1. You define a protocol called SMSSender which abstracts sending an SMS. This means it can be used to create many classes, each with its own mechanism for SMS delivery.
  2. Service is an empty protocol. You can use it to register services in the server configuration and later access them via the Request object and use them in controllers. More on that later.
  3. sendSMS(to:message:) receives a destination phone number and a text message, and returns a Future<Bool>, a future value which indicates if sending the message succeeded or failed. You can learn more about the Future type and asynchronous programming in this article or the Vapor’s Async docs.

Next, you’ll create the class that implements this protocol. Create a file named AWSSNSSender.swift, under the SourcesApp folder and add the following code to it:

import Vapor
import SNS

class AWSSNSSender {
  //1
  private let sns: SNS
  //2
  private let messageAttributes: [String: SNS.MessageAttributeValue]?

  init(accessKeyID: String, secretAccessKey: String, senderId: String?) {
    //3
    sns = SNS(accessKeyId: accessKeyID, secretAccessKey: secretAccessKey)

    //4
    messageAttributes = senderId.map { sender in
      let senderAttribute = SNS.MessageAttributeValue(binaryValue: nil,
                                                      dataType: "String",
                                                      stringValue: sender)
      return ["AWS.SNS.SMS.SenderID": senderAttribute]
    }
  }
}

This is the class definition and initialization. In this code, you:

  1. Keep a private property of the the SNS class. This class comes from the aws-sdk-swift dependency declared in Package.swift. Notice that in the second line, you need to import the SNS module.
  2. SNS allows setting specific message attributes. You’re interested in SenderID, so the SMS messages arrive with the sender name of your app. The class will use the property messageAttributes whenever a message is sent as part of the payload.
  3. The initializer receives your AWS access key and the matching secret. You pass these on to the `SNS` class initializer.
  4. The initializer may also receive an optional senderId. Use the map method on Optional to map it to the messageAttributes dictionary. If senderId is nil, messageAttributes will also be nil. If it has a value, map will transform the string into the needed dictionary.

For security and to allow for easier replacement, it’s advised you don’t hardcore your AWS keys. Instead, you should use environment variables. These are variables passed to the app as it executes, and can be accessed in runtime.

To add them in Xcode, edit the Run scheme:

You can also edit the current scheme by typing Command + Shift + ,.

Then, select the Arguments tab. Under Environment Variables click the + button to add a new variable.

You’ll need two variables: AWS_KEY_ID and  AWS_SECRET_KEY. Add the corresponding value for each one:

Add the values of the variables in Xcode.

Note: You’ll need these environment variables when deploying your server to staging/production as well. Check Section V: Production & External Deployment of the Server Side Swift with Vapor book to learn more about environment variables when deploying.

Next, add the following conformance to the SMSSender protocol, directly below the code you just wrote:

extension AWSSNSSender: SMSSender {
  func sendSMS(to phoneNumber: String, message: String) throws -> Future<Bool> {
    //1
    let input = SNS.PublishInput(message: message,
                                 messageAttributes: messageAttributes,
                                 phoneNumber: phoneNumber)

    //2
    return sns.publish(input).map { $0.messageId != nil }
  }
}

This protocol conformance is quite straightforward. It delegates the request to publish a message to the AWS SNS service like so:

  1. First, you create a PublishInput with the message, the attributes created in the initialization and the receiver phone number.
  2. Next, you ask the SNS instance to publish the input. Because it returns a Future<PublishResponse>, it maps to a boolean by making sure the response’s messageId exists.

Finally, you still need to initialize an instance of AWSSNSSender and register it in the configuration. Open configure.swift and, right after the migrations registration, add this block of code:

//1
guard let accessKeyId = Environment.get("AWS_KEY_ID"),
      let secretKey = Environment.get("AWS_SECRET_KEY") else {
  throw ConfigError.missingAWSKeys
}

//2
let snsSender = AWSSNSSender(accessKeyID: accessKeyId,
                             secretAccessKey: secretKey,
                             senderId: "SoccerRadar")
//3
services.register(snsSender, as: SMSSender.self)

Here’s what you’ve done next:

  1. You retrieve the AWS keys from your environment variables, throwing an error if your server can’t find them.
  2. You initialize AWSSNSSender with those keys and the app’s name. In this case, the name is SoccerRadar.
  3. Lastly, you register the snsSender as your server’s SMSSender. You’ll see an example case use of this Service in the next section.

Once you have the sender configured, initialized and registered, it’s time to move on to actually using it.

Your First API: Sending the SMS

When looking at existing code in the starter project, you’ll find the following types:

Models:

  • User: This entity represents your users. Notice how this class adds a unique index in the phoneNumber property. This means the database will not accept two users with the same phone number.
  • SMSVerificationAttempt: The server saves every verification attempt containing a code and a phone number.
  • UserSession: Whenever a user successfully authenticates, the server generates a session. This class implements the Token protocol, which Vapor will use to match and authenticate future requests by the associated user.

Others:

  • UserController: This controller handles the requests, asks SNS to send the messages, deals with the database layer and provides adequate responses.
  • A String extension with two methods: One generates a n-digit length numeric code and the other one removes any character which is not a digit or a +.

Before creating your API method, it’s important to define what data the request needs to receive. First, it should receive a phone number. After sending the SMS it should return the number, formatted without dashes, and the verification attempt identifier.

Create a new file named UserControllerTypes.swift with the following code:

import Vapor

extension UserController {
  struct SendUserVerificationPayload: Codable, Content {
    let phoneNumber: String
  }
  
  struct SendUserVerificationResponse: Codable, Content {
    let phoneNumber: String
    let attemptId: UUID
  }
}

Vapor defines the Content protocol, which allows receiving and sending request and response bodies. Now, create the first request. Open UserController.swift and define the method that will handle the request in the UserController class:

// 1
private func beginSMSVerification(
  _ req: Request,
  payload: SendUserVerificationPayload
) throws -> Future<SendUserVerificationResponse> {
  // 2
  let phoneNumber = payload.phoneNumber.removingInvalidCharacters
  // 3
  let code = String.randomDigits(ofLength: 6)
  let message = "Hello soccer lover! Your SoccerRadar code is \(code)"

  // 4
  return try req.make(SMSSender.self)
    .sendSMS(to: phoneNumber, message: message)
    // 5
    .flatMap(to: SMSVerificationAttempt.self) { success in
      guard success else {
        throw Abort(.internalServerError, 
                    reason: "SMS could not be sent to \(phoneNumber)")
      }

      let smsAttempt = 
        SMSVerificationAttempt(code: code,
                               expiresAt: Date().addingTimeInterval(600),
                               phoneNumber: phoneNumber)
      return smsAttempt.save(on: req)
    }
    .map { attempt in
      // 6
      let attemptId = try attempt.requireID()
      return SendUserVerificationResponse(phoneNumber: phoneNumber, 
                                          attemptId: attemptId)
    }
}

Here’s a breakdown of what’s going on in this method:

  1. The method expects a Request object and a SendUserVerificationPayload, which contains the phone number. If nothing inside its implementation throws an error it then returns a Future containing a SendUserVerificationResponse.
  2. Extract the phone number and remove any invalid characters.
  3. Create a six digit random code and generate the text message to send with it.
  4. Retrieve the registered SMSSender from the request object. This is how controllers get Service objects from the request and any other Containers. Then, call the only function in this protocol to send the SMS.
  5. The sendSMS function returns a future boolean. You need to save the attempt information, so you convert the type of the future from boolean to SMSVerificationAttempt.

    First, make sure the delivery succeeded. Then, create the attempt object with the sent code, phone number and an expiration of ten minutes from the request’s date. Finally, save it.

  6. After sending the SMS and saving the attempt, create and return the response, using the phone number and the id of the attempt object. It’s safe to call requireID() on the attempt after it was already saved and has an id assigned.

Alright – time to implement your second method!

Your Second API: Authenticating the Received Code

You need to define what the second stage should receive and return before implementing it. Open UserControllerTypes.swift again and add the following structs inside the UserController extension:

struct UserVerificationPayload: Codable, Content {
  let attemptId: UUID //1
  let phoneNumber: String //2
  let code: String //3
}
  
struct UserVerificationResponse: Codable, Content {
  let status: String //4
  let user: User? //5
  let sessionToken: String? //6
}

In the request body, the server needs to receive the following to make sure they all match and verify the user:

  1. The attempt id.
  2. The phone number.
  3. The code sent.

Upon successful validation, it should return:

  1. The status.
  2. The user.
  3. The session token.

If validation fails, only the status should be present, therefore user and sessionToken are both optional.

As a quick recap, here’s what the controller needs to do:

  • Query the database to check if the codes match.
  • Validate the attempt based on the expiration date.
  • Find or create a user with the associated phone number.
  • Create a new session for the user.
  • Wrap the user and the session’s token in the response.

This is a little much to handle in a single method, so you’ll split it into two parts. The first part will validate the code and the second will find or create the user and its session.

Validating the Code

Add this first snippet inside UserController.swift, inside the UserController class:

// 1
private func validateVerificationCode(_ req: Request,
                                      payload: UserVerificationPayload) throws 
  -> Future<UserVerificationResponse> {
  // 2
  let code = payload.code
  let attemptId = payload.attemptId
  let phoneNumber = payload.phoneNumber.removingInvalidCharacters

  // 3
  return SMSVerificationAttempt.query(on: req)
    .filter(\.code == code)
    .filter(\.phoneNumber == phoneNumber)
    .filter(\.id == attemptId)
    .first()
    .flatMap(to: UserVerificationResponse.self) { attempt in
      // 4
      guard let expirationDate = attempt?.expiresAt else {
        return Future.map(on: req) { 
          UserVerificationResponse(status: "invalid-code", 
                                   user: nil, 
                                   sessionToken: nil) 
        }
      }

      guard expirationDate > Date() else {
        return Future.map(on: req) { 
          UserVerificationResponse(status: "expired-code", 
                                   user: nil, 
                                   sessionToken: nil) 
        }
      }

      // 5
      return self.verificationResponseForValidUser(with: phoneNumber, on: req)
  }
}

Here’s what this method does:

  1. Besides the request object, it receives the UserVerificationPayload struct, and returns a Future containing UserVerificationResponse.
  2. It extracts the three pieces needed to query the attempt. Remember that it needs to remove possible invalid characters from the phone number before it can use it.
  3. Then it creates a query on the SMSVerificationAttempt, constraining it to the code, phone number and attempt id from the previous step. Notice how useful is Vapor’s Fluent support for filtering by KeyPaths and custom operators are.
  4. Attempt to unwrap the queried attempt’s expiresAt date and make sure the expiration didn’t occur yet. If any of these tests fail, it creates a response with the invalid-code or expired-code status and no user or session token.
  5. It calls the second method which, from a validated phone number, will take care of getting the user and session token and wrap them in the response.

If you try to compile the project now, it will fail. Don’t worry – that’s because the function verificationResponseForValidUser is still missing.

Returning the User and the Session Token

Right below the code you just added in UserController.swift, add this:

private func verificationResponseForValidUser(with phoneNumber: String,
                                              on req: Request) -> 
  Future<UserVerificationResponse> {
  //1
  return User.query(on: req)
    .filter(\.phoneNumber == phoneNumber)
    .first()
    //2
    .flatMap(to: User.self) { queriedUser in
      if let existingUser = queriedUser {
        return Future.map(on: req) { existingUser }
      }

      return User(phoneNumber: phoneNumber).save(on: req)
    }
    .flatMap(to: UserVerificationResponse.self) { user in
      //3
      return try UserSession.generate(for: user)
        .save(on: req)
        .map { 
          UserVerificationResponse(status: "ok", 
                                   user: user, 
                                   sessionToken: $0.token) 
        }
  }
}

There’s a lot going on here, but let’s look at everything happening piece by piece:

  1. First, you look for an existing user with the given phone number.
  2. queriedUser is optional because the user might be new, so it creates and saves a new one in this case. If it already exists, it continues by returning a Future with the existing user.
  3. Finally, you create a new UserSession for this user and save it. Upon completion, you map it to the response with the user and the session’s token.

Build and run your server – it should compile without any issues at this point. Now it’s time to call your APIs!

Testing the APIs With cURL

In the following example, you’ll use curl in the command line, but feel free to use another GUI app you might feel comfortable with, such as Postman or Paw.

Now open Terminal and execute the following command, replacing +1234567890 with your phone number. Don’t forget your country code:

curl -X "POST" "http://localhost:8080/users/send-verification-sms" \
     -H 'Content-Type: application/json; charset=utf-8' \
     -d $'{ "phoneNumber": "+1234567890" }'

Oops. This request returns a HTTP 404 error: {"error":true,"reason":"Not Found"}.

Registering the Routes

When you see a 404, it’s most likely because the functions weren’t registered with the Router in use or the HTTP method used doesn’t match the registered method. You need to make UserController conform to RouteCollection so you can register it in the routes configuration. Open UserController.swift and add the following at its end:

extension UserController: RouteCollection {
  func boot(router: Router) throws {
    //1
    let usersRoute = router.grouped("users")

    //2
    usersRoute.post(SendUserVerificationPayload.self, 
                    at: "send-verification-sms", 
                    use: beginSMSVerification)
    usersRoute.post(UserVerificationPayload.self, 
                    at: "verify-sms-code", 
                    use: validateVerificationCode)
  }
}

The code above has two short steps:

  1. First, it groups the routes under the users path. All routes added to usersRoute declared in this line will be prefixed by users. For example, https://your-server.com/users/send-verificcation-sms.
  2. Then it registers two HTTP POST endpoints, each expecting a different type of payload, at a different path and using a different method of those you created in the previous sections.

Now, open routes.swift and add this line inside the only existing method. This function registers your app’s routes:

try router.register(collection: UserController())

Calling the First API

Build and run your project again and try the previously-failing curl command by pressing the up arrow key followed by Enter. You should get the following response with a new UUID:

{
  "attemptId": "477687D3-CA79-4071-922C-4E610C55F179",
  "phoneNumber": "+1234567890"
}

This response is your server saying that sending the SMS succeeded. Check for the message on your phone.

Excellent! Notice how the sender ID used in the initialization of AWSSNSSender is working correctly.

Calling the Second API

Now you’re ready to test the second part of the authentication – verifying the code. Take the attemptId from the previous request, which is the phone number you used in the previous step, and the received code and replace them in the following command, which you should run in Terminal as well:

curl -X "POST" "http://localhost:8080/users/verify-sms-code" \
     -H 'Content-Type: application/json; charset=utf-8' \
     -d $'{"phoneNumber": "+1234567890", "attemptId": "", "code": "123456" }'

If you replaced each parameter correctly, the request should return an object with three properties: The status, the user object, and a token:

{
  "status": "ok",
  "user": {
    "id": "31D39FAD-A0A9-46E7-91CF-AEA774EA0BBE",
    "phoneNumber": "+1234567890"
  },
  "sessionToken": "lqa99MN31o8k43dB5JATVQ=="
}

Mission accomplished! How cool is it to build it yourself, without giving up on your users’ privacy or adding big SDKs to your client apps?

Where to Go From Here

You can download the completed version of the project using the Download Materials button at the top or bottom of this tutorial.

You should save the session token in your client apps as long as the user is logged in. Check out Section III of the Server Side Swift with Vapor book: Validation, Users & Authentication, to learn how to use the session token to authenticate other requests. The chapters on API authentication are particularly helpful.

You can also read the documentation of Vapor’s Auth framework to better understand where you should add the session token in the following requests.

Do you want to continue to improve your SMS authentication flow? Try one of these challenges:

  • Start using a PostgreSQL or MySQL DB instead of the in-memory SQLite and convert the models accordingly.
  • To avoid privacy issues and security breaches, hash phone numbers before saving and querying them, both in the User and the SMSVerificationAttempt models.
  • Think of ways to improve the flow. For example, you could add a isValid boolean to make sure the code is only used once or delete the attempt upon successful verification.
  • Implement a job that deletes expired and successful attempts.

Please add any comments, suggestions or questions to comments section below.

Average Rating

5/5

Add a rating for this content

3 ratings

Contributors

Comments