SMS User Authentication With Vapor and AWS

In this SMS user authentication tutorial, you’ll learn how to use Vapor and AWS SNS to authenticate your users with their phone numbers. By Natan Rolnik.

Leave a rating/review
Download materials
Save for later
Share
You are currently viewing page 2 of 3 of this article. Click here to view the first page.

Your First API: Sending the SMS

When looking at the initial code in the starter project, you’ll find the kinds of definitions outlined below.

Models and Migrations

In Vapor 4, for each model you define, you need to perform a migration and create — or modify — the entity in the database. In the starter project, you’ll find the models and migrations in the folders with the same names.

  • User / CreateUser: This entity represents your users. Notice how the migration adds a unique index in the phoneNumber property. This means the database won’t accept two users with the same phone number.
  • SMSVerificationAttempt / CreateSMSVerificationAttempt: The server saves every verification attempt containing a code and a phone number.
  • Token / CreateToken: Whenever a user successfully authenticates, the server generates a session, represented by a token. Vapor will use it 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 a method and a computed property. randomDigits generates an n-digit numeric code, and removingInvalidCharacters returns a copy of the original String that has had any character which is not a digit or a + removed.

Before creating your API method, it’s important to define which data will flow to and from the server. First, the server receives a phone number. After sending the SMS, it returns the phone 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: Content {
    let phoneNumber: String
  }
  
  struct SendUserVerificationResponse: 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 handler. Open UserController.swift and define the method that will handle the request in the UserController class:

private func beginSMSVerification(_ req: Request) throws -> EventLoopFuture<SendUserVerificationResponse> {
  // 1
  let payload = try req.content.decode(SendUserVerificationPayload.self)
  let phoneNumber = payload.phoneNumber.removingInvalidCharacters

  // 2
  let code = String.randomDigits(ofLength: 6)
  let message = "Hello soccer lover! Your SoccerRadar code is \(code)"

  // 3
  return try req.application.smsSender!
    .sendSMS(to: phoneNumber, message: message, on: req.eventLoop)
    // 4
    .flatMap { success -> EventLoopFuture<SMSVerificationAttempt> in
      guard success else {
        let abort = Abort(
          .internalServerError,
          reason: "SMS could not be sent to \(phoneNumber)")
        return req.eventLoop.future(error: abort)
    }

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

Here’s a breakdown of what’s going on:

  1. The method expects a Request object, and it tries to decode a SendUserVerificationPayload from its body, which contains the phone number.
  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 application object. The force unwrap is acceptable in this case, as you previously registered the service in the server configuration. Then call sendSMS to send the SMS, passing the request’s event loop as the last parameter.
  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 SMS send succeeded. Then, create the attempt object with the sent code, phone number and an expiration of 10 minutes from the request’s date. Finally, store it in the database.
  6. After sending the SMS and saving the attempt record, you 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’s saved and has an ID assigned.

Alright — time to implement your second method!

Your Second API: Authenticating the Received Code

Similar to the pattern you used for the first API, you need to define what the second API should receive and return before implementing it.

Open UserControllerTypes.swift again and add the following structs inside the UserController extension:

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

In the request payload, the server needs to receive the following to match the values and verify the user:

  1. The attempt ID
  2. The phone number
  3. The code the user received

Upon successful validation, the server should return:

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

If validation fails, only the status should be present, so 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 token for the user.
  • Wrap the user and the token’s value in the response.

This is a lot 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 their session token.

Validating the Code

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

private func validateVerificationCode(_ req: Request) throws ->
  EventLoopFuture<UserVerificationResponse> {
  // 1
  let payload = try req.content.decode(UserVerificationPayload.self)
  let code = payload.code
  let attemptId = payload.attemptId
  let phoneNumber = payload.phoneNumber.removingInvalidCharacters

  // 2
  return SMSVerificationAttempt.query(on: req.db)
    .filter(\.$code == code)
    .filter(\.$phoneNumber == phoneNumber)
    .filter(\.$id == attemptId)
    .first()
    .flatMap { attempt in
      // 3
      guard let expirationDate = attempt?.expiresAt else {
        return req.eventLoop.future(
          UserVerificationResponse(
            status: "invalid-code",
            user: nil, 
            sessionToken: nil))
      }

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

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

Here’s what this method does:

  1. It first decodes the request body into a UserVerificationPayload to extract 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.
  2. Then it creates a query on the SMSVerificationAttempt, and it finds the first attempt record that matches the code, phone number and attempt ID from the previous step. Notice the usefulness of Vapor Fluent’s support for filtering by key path and operator expression.
  3. It attempts to unwrap the queried attempt’s expiresAt date and ensures that the expiration date hasn’t yet occurred. If any of these guards fail, it returns a response with only the invalid-code or expired-code status, leaving out the user and session token.
  4. It calls the second method, which will take care of getting the user and session token from a validated phone number, and it wraps them in the response.

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