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.
Sign up/Sign in
With a free Kodeco account you can download source code, track your progress, bookmark, personalise your learner profile and more!
Create accountAlready a member of Kodeco? Sign in
Sign up/Sign in
With a free Kodeco account you can download source code, track your progress, bookmark, personalise your learner profile and more!
Create accountAlready a member of Kodeco? Sign in
Sign up/Sign in
With a free Kodeco account you can download source code, track your progress, bookmark, personalise your learner profile and more!
Create accountAlready a member of Kodeco? Sign in
Contents
SMS User Authentication With Vapor and AWS
25 mins
- Getting Started
- How SMS Auth Works Behind the Curtain
- Interacting With AWS SNS
- Your First API: Sending the SMS
- Your Second API: Authenticating the Received Code
- Validating the Code
- Returning the User and the Session Token
- Testing the APIs With cURL
- Registering the Routes
- Calling the First API
- Calling the Second API
- Where to Go From Here?
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 thephoneNumber
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 ann
-digit numeric code, andremovingInvalidCharacters
returns a copy of the originalString
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:
- The method expects a
Request
object, and it tries to decode aSendUserVerificationPayload
from its body, which contains the phone number. - Extract the phone number and remove any invalid characters.
- Create a six-digit random code and generate the text message to send with it.
- Retrieve the registered
SMSSender
from theapplication
object. The force unwrap is acceptable in this case, as you previously registered the service in the server configuration. Then callsendSMS
to send the SMS, passing the request’s event loop as the last parameter. - The
sendSMS
function returns a future Boolean. You need to save the attempt information, so you convert the type of the future from Boolean toSMSVerificationAttempt
. 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. - 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:
- The attempt ID
- The phone number
- The code the user received
Upon successful validation, the server should return:
- The status
- The user
- 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:
- 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. - 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. - 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 theinvalid-code
orexpired-code
status, leaving out the user and session token. - 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.