Chapters

Hide chapters

Server-Side Swift with Vapor

Third Edition - Early Acess 1 · iOS 13 · Swift 5.2 - Vapor 4 Framework · Xcode 11.4

Before You Begin

Section 0: 3 chapters
Show chapters Hide chapters

Section I: Creating a Simple Web API

Section 1: 13 chapters
Show chapters Hide chapters

24. Password Reset & Emails
Written by Tim Condon

Heads up... You're reading this book for free, with parts of this chapter shown beyond this point as scrambled text.

Note: This update is an early-access release. This chapter has not yet been updated to Vapor 4.

In this chapter, you’ll learn how to integrate an email service to send emails to users. Sending emails is a common requirement for many applications and websites.

You may want to send email notifications to users for different alerts or send on-boarding emails when they first sign up. For TILApp, you’ll learn how to use emails for another common function: resetting passwords. First, you’ll change the TIL User to include an email address. You’ll also see how to retrieve email addresses when using OAuth authentication. Next, you’ll integrate a community package to send emails via SendGrid. Finally, you’ll learn how to set up a password reset flow in the website.

User email addresses

To send emails to users, you need a way to store their addresses! In Xcode, open User.swift and after var password: String add the following:

var email: String

This adds a new property to the User model to store an email address. Replace the initializer to account for the new property:

init(
  name: String, 
  username: String, 
  password: String,
  email: String
) {
  self.name = name
  self.username = username
  self.password = password
  self.email = email
}

Next, in the extension conforming User to Migration, add the following after builder.unique(on: \.username):

builder.unique(on: \.email)

This creates a unique key constraint on the email field. In AdminUser, replace let user = User(...) with the following:

let user = User(
  name: "Admin",
  username: "admin",
  password: hashedPassword,
  email: "admin@localhost.local")

This adds a password to the default admin user as it’s now required when creating a user. Provide a known email address if you wish.

Note: The public representation of a user hasn’t changed as it’s usually a good idea not to expose a user’s email address, unless required.

Web registration

One method of creating users in the TIL app is registering through the website. Open WebsiteController.swift and add the following property to the bottom of RegisterData:

let emailAddress: String
try validations.add(\.emailAddress, .email)
let user = User(
  name: data.name, 
  username: data.username,
  password: password, 
  email: data.emailAddress)
<div class="form-group">
  <label for="emailAddress">Email Address</label>
  <input type="email" name="emailAddress" class="form-control"
   id="emailAddress"/>
</div>

Social media login

Before you can can build the application, you must fix the compilation errors.

Fixing Google

Getting the user’s email address for a Google login is simple; Google provides it when you request the user’s information! Open ImperialController.swift and, in processGoogleLogin(request:token:), replace let user = ... with the following:

let user = User(
  name: userInfo.name,
  username: userInfo.email,
  password: UUID().uuidString,
  email: userInfo.email)

Fixing GitHub

Getting the email address for a GitHub user is more complicated. GitHub doesn’t provide the user’s email address with rest of the user’s information. You must get the email address in a second request.

try router.oAuth(
  from: GitHub.self, 
  authenticate: "login-github", 
  callback: githubCallbackURL,
  scope: ["user:email"], 
  completion: processGitHubLogin)
struct GitHubEmailInfo: Content {
  let email: String
}
// 1
static func getEmails(on request: Request) throws
  -> Future<[GitHubEmailInfo]> {
    // 2
    var headers = HTTPHeaders()
    headers.bearerAuthorization =
      try BearerAuthorization(token: request.accessToken())

    // 3
    let githubUserAPIURL = "https://api.github.com/user/emails"
    return try request.client()
      .get(githubUserAPIURL, headers: headers)
      .map(to: [GitHubEmailInfo].self) { response in
        // 4
        guard response.http.status == .ok else {
          // 5
          if response.http.status == .unauthorized {
            throw Abort.redirect(to: "/login-github")
          } else {
            throw Abort(.internalServerError)
          }
        }
        // 6
        return try response.content
          .syncDecode([GitHubEmailInfo].self)
    }
}
func processGitHubLogin(request: Request, token: String) throws
  -> Future<ResponseEncodable> {
    // 1
    return try flatMap(
      to: ResponseEncodable.self,
      GitHub.getUser(on: request),
      GitHub.getEmails(on: request)) { userInfo, emailInfo in
        return User.query(on: request)
          .filter(\.username == userInfo.login)
          .first().flatMap(to: ResponseEncodable.self) {
          foundUser in
            guard let existingUser = foundUser else {
              // 2
              let user = User(
                name: userInfo.name,
                username: userInfo.login,
                password: UUID().uuidString,
                email: emailInfo[0].email)
              return user.save(on: request)
                .map(to: ResponseEncodable.self) { user in
                  try request.authenticateSession(user)
                  return request.redirect(to: "/")
              }
            }
            try request.authenticateSession(existingUser)
            return request.future(request.redirect(to: "/"))
        }
    }
}
docker stop postgres
docker rm postgres
docker run --name postgres -e POSTGRES_DB=vapor \
  -e POSTGRES_USER=vapor -e POSTGRES_PASSWORD=password \
  -p 5432:5432 -d postgres

Fixing the tests

In Xcode, change the scheme to TILApp-Package. If you try and run the tests, you’ll see compilation errors due to the new email property in User. Open Models+Testable.swift and in create(name:username:on:), replace let user = ... with the following:

let user = User(
  name: name,
  username: createUsername,
  password: password,
  email: "\(createUsername)@test.com")
let user = User(
  name: usersName,
  username: usersUsername,
  password: "password",
  email: "\(usersUsername)@test.com")

iOS app registration

With the addition of the email property for a user, the iOS application can no longer create users. Open the iOS project in Xcode and open User.swift. Add a new property to CreateUser below var password: String?:

var email: String?
init(
  name: String, 
  username: String, 
  password: String,
  email: String
) {
  self.name = name
  self.username = username
  self.password = password
  self.email = email
}
guard let email = emailTextField.text, !email.isEmpty else {
  ErrorPresenter
  	.showError(message: "You must specify an email", on: self)
  return
}
let user = CreateUser(
  name: name,
  username: username,
  password: password,
  email: email)

Integrating SendGrid

Finally, you’ve added an email address to the user model! Now it’s time to learn how to send emails. This chapter uses SendGrid for that purpose. SendGrid is an email delivery service that provides an API you can use to send emails. It has a free tier allowing you to send 100 emails a day at no cost. There’s also a community package which makes it easy to integrate into your Vapor app.

Adding the dependency

In the TIL app, open Package.swift and replace .package(url: "https://github.com/vapor-community/Imperial.git", from: "0.7.1") with the following:

.package(
  url: "https://github.com/vapor-community/Imperial.git",
  from: "0.7.1"),
.package(
  url: "https://github.com/vapor-community/sendgrid-provider.git",
  from: "3.0.0")
dependencies: ["FluentPostgreSQL",
               "Vapor",
               "Leaf",
               "Authentication",
               "Imperial",
               "SendGrid"]
touch Sources/App/Models/ResetPasswordToken.swift
vapor xcode -y

Signing up for SendGrid and getting a token

To use SendGrid, you must create an account. Visit https://signup.sendgrid.com and fill out the form to sign up:

Integrating with Vapor

With your API key created, go back to the TIL app in Xcode. Open configure.swift and add the following below import Authentication:

import SendGrid
try services.register(SendGridProvider())
// 1
guard let sendGridAPIKey = Environment.get("SENDGRID_API_KEY") else {
  fatalError("No Send Grid API Key specified")
}
// 2
let sendGridConfig = SendGridConfig(apiKey: sendGridAPIKey)
// 3
services.register(sendGridConfig)

Setting up a password reset flow

Forgotten password page

The app should make it possible for a user to reset a forgotten password. The first part of the password reset flow consists of two actions:

// 1
func forgottenPasswordHandler(_ req: Request)
  throws -> Future<View> {
  // 2
  return try req.view().render(
    "forgottenPassword", 
    ["title": "Reset Your Password"])
}
authSessionRoutes.get(
  "forgottenPassword",
  use: forgottenPasswordHandler)
#// 1
#set("content") {
  #// 2
  <h1>#(title)</h1>

  #// 3
  <form method="post">
    #// 4
    <div class="form-group">
      <label for="email">Email</label>
      <input type="email" name="email" class="form-control"
       id="email"/>
    </div>

    #// 5
    <button type="submit" class="btn btn-primary">
      Reset Password
    </button>
  </form>
}

#// 6
#embed("base")
<br />
<a href="/forgottenPassword">Forgotten your password?</a>

// 1
func forgottenPasswordPostHandler(_ req: Request)
  throws -> Future<View> {
    // 2
    let email =
      try req.content.syncGet(String.self, at: "email")
    // 3
    return User.query(on: req)
      .filter(\.email == email)
      .first()
      .flatMap(to: View.self) { user in
        // 4
        return try req.view()
          .render("forgottenPasswordConfirmed")
    }
}
authSessionRoutes.post(
  "forgottenPassword", 
  use: forgottenPasswordPostHandler)
#set("content") {
  <h1>#(title)</h1>

  <p>Instructions to reset your password have 
     been emailed to you.</p>
}

#embed("base")
import FluentPostgreSQL

// 1
final class ResetPasswordToken: Codable {
  var id: UUID?
  var token: String
  var userID: User.ID

  init(token: String, userID: User.ID) {
    self.token = token
    self.userID = userID
  }
}

// 2
extension ResetPasswordToken: PostgreSQLUUIDModel {}
// 3
extension ResetPasswordToken: Migration {
  static func prepare(on connection: PostgreSQLConnection)
    -> Future<Void> {
      return Database.create(self, on: connection) { builder in
        try addProperties(to: builder)
        builder.reference(from: \.userID, to: \User.id)
      }
  }
}

// 4
extension ResetPasswordToken {
  var user: Parent<ResetPasswordToken, User> {
    return parent(\.userID)
  }
}
migrations.add(model: ResetPasswordToken.self, database: .psql)

Sending emails

Return to WebsiteController.swift. At the top of the file, insert the following below import Authentication:

import SendGrid
// 1
guard let user = user else {
  return try req.view().render(
    "forgottenPasswordConfirmed",
    ["title": "Password Reset Email Sent"])
}
// 2
let resetTokenString = try CryptoRandom()
  .generateData(count: 32)
  .base32EncodedString()
// 3
let resetToken = try ResetPasswordToken(
  token: resetTokenString,
  userID: user.requireID())
// 4
return resetToken.save(on: req).flatMap(to: View.self) { _ in
  // 5
  let emailContent = """
  <p>You've requested to reset your password. <a
  href="http://localhost:8080/resetPassword?\
  token=\(resetTokenString)">
  Click here</a> to reset your password.</p>
  """
  // 6
  let emailAddress = EmailAddress(
    email: user.email,
    name: user.name)
  let fromEmail = EmailAddress(
    email: "0xtimc@gmail.com",
    name: "Vapor TIL")
  // 7
  let emailConfig = Personalization(
    to: [emailAddress],
    subject: "Reset Your Password")
  // 8
  let email = SendGridEmail(
    personalizations: [emailConfig],
    from: fromEmail,
    content: [
      ["type": "text/html",
      "value": emailContent]
    ])
  // 9
  let sendGridClient = try req.make(SendGridClient.self)
  return try sendGridClient.send([email], on: req.eventLoop)
    .flatMap(to: View.self) { _ in
      // 10
      return try req.view().render(
        "forgottenPasswordConfirmed",
        ["title": "Password Reset Email Sent"]
      )
  }
}
struct ResetPasswordContext: Encodable {
  let title = "Reset Password"
  let error: Bool?

  init(error: Bool? = false) {
    self.error = error
  }
}
func resetPasswordHandler(_ req: Request)
  throws -> Future<View> {
    // 1
    guard let token = req.query[String.self, at: "token"] else {
      return try req.view().render(
        "resetPassword",
        ResetPasswordContext(error: true)
      )
    }
    // 2
    return ResetPasswordToken.query(on: req)
      .filter(\.token == token)
      .first()
      .map(to: ResetPasswordToken.self) { token in
        // 3
        guard let token = token else {
          throw Abort.redirect(to: "/")
        }
        return token
      }.flatMap { token in
        // 4
        return token.user.get(on: req).flatMap { user in
          try req.session().set("ResetPasswordUser", to: user)
          // 5
          return token.delete(on: req)
        }
      }.flatMap {
        // 6
        try req.view().render(
          "resetPassword",
          ResetPasswordContext()
        )
      }
}
authSessionRoutes.get(
  "resetPassword", 
  use: resetPasswordHandler)
#set("content") {
  <h1>#(title)</h1>

  #// 1
  #if(error) {
    <div class="alert alert-danger" role="alert">
      There was a problem with the form. Ensure you clicked on
      the full link with the token and your passwords match.
    </div>
  }

  #// 2
  <form method="post">
    #// 3
    <div class="form-group">
      <label for="password">Password</label>
      <input type="password" name="password"
       class="form-control" id="password"/>
    </div>

    #// 4
    <div class="form-group">
      <label for="confirmPassword">Confirm Password</label>
      <input type="password" name="confirmPassword"
       class="form-control" id="confirmPassword"/>
    </div>

    #// 5
    <button type="submit" class="btn btn-primary">
      Reset
    </button>
  </form>
}

#embed("base")
struct ResetPasswordData: Content {
  let password: String
  let confirmPassword: String
}
// 1
func resetPasswordPostHandler(
  _ req: Request,
  data: ResetPasswordData) throws -> Future<Response> {
    // 2
    guard data.password == data.confirmPassword else {
      return try req.view().render(
        "resetPassword",
        ResetPasswordContext(error: true))
        .encode(for: req)
    }
    // 3
    let resetPasswordUser = try req.session()
      .get("ResetPasswordUser", as: User.self)
    try req.session()["ResetPasswordUser"] = nil
    // 4
    let newPassword = try BCrypt.hash(data.password)
    resetPasswordUser.password = newPassword
    // 5
    return resetPasswordUser
      .save(on: req)
      .transform(to: req.redirect(to: "/login"))
}
authSessionRoutes.post(
  ResetPasswordData.self,
  at: "resetPassword",
  use: resetPasswordPostHandler)

Where to go from here?

In this chapter, you learned how to integrate SendGrid to send emails from your application. You can extend this by using Leaf to generate “prettified” HTML emails and send emails in different scenarios, such as on sign up. This chapter also introduced a method to reset a user’s password. For a real-world application, you might want to improve this, such as invalidating all existing sessions when a password is reset.

Have a technical question? Want to report a bug? You can ask questions and report bugs to the book authors in our official book forum here.
© 2024 Kodeco Inc.

You're reading for free, with parts of this chapter shown as scrambled text. Unlock this book, and our entire catalogue of books and videos, with a Kodeco Personal Plan.

Unlock now