Chapters

Hide chapters

Server-Side Swift with Vapor

Third Edition · 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

25. 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.

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 siwaIdentifier: String? add the following:

@Field(key: "email")
var email: String

This adds a new property to the User model to store an email address. Next, replace the initializer with the following, to account for the new property:

init(
  id: UUID? = nil, 
  name: String, 
  username: String, 
  password: String, 
  siwaIdentifier: String? = nil, 
  email: String
) {
  self.name = name
  self.username = username
  self.password = password
  self.siwaIdentifier = siwaIdentifier
  self.email = email
}

Next, open CreateUser.swift. In prepare(on:), add the following below .field("siwaIdentifier", .string):

.field("email", .string, .required)
.unique(on: "email")

This adds the field to the database and creates a unique key constraint on the email field. In CreateAdminUser.swift, replace let user = User(...) with the following:

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

This adds an email 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
validations.add("emailAddress", as: String.self, is: .email)
validations.add(
  "zipCode",
  as: String.self,
  is: .zipCode,
  required: false)
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 Sign in with Apple

Getting the user’s email address for a Sign in with Apple login is simple; Apple provides it in the JWT used for logging in! Open WebsiteController.swift, find appleAuthRedirectHandler(_:) and replace let user = ... with the following:

let user = User(
  name: "\(firstName) \(lastName)", 
  username: email, 
  password: UUID().uuidString, 
  siwaIdentifier: siwaToken.subject.value, 
  email: email)
let user = User(
  name: name, 
  username: email, 
  password: UUID().uuidString, 
  siwaIdentifier: siwaToken.subject.value, 
  email: email)

Fixing Google

Getting the user’s email address for a Google login is also 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 routes.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 
  -> EventLoopFuture<[GitHubEmailInfo]> {
    // 2
    var headers = HTTPHeaders()
    try headers.add(
      name: .authorization, 
      value: "token \(request.accessToken())")
    headers.add(name: .userAgent, value: "vapor")

    // 3
    let githubUserAPIURL: URI = 
      "https://api.github.com/user/emails"
    return request.client
      .get(githubUserAPIURL, headers: headers)
      .flatMapThrowing { response in
        // 4
        guard response.status == .ok else {
          // 5
          if response.status == .unauthorized {
            throw Abort.redirect(to: "/login-github")
          } else {
            throw Abort(.internalServerError)
          }
        }
        // 6
        return try response.content
          .decode([GitHubEmailInfo].self)
    }
}
func processGitHubLogin(request: Request, token: String) throws 
  -> EventLoopFuture<ResponseEncodable> {
    // 1
    return try GitHub.getUser(on: request)
      .and(GitHub.getEmails(on: request))
      .flatMap { userInfo, emailInfo in
        return User.query(on: request.db)
          .filter(\.$username == userInfo.login)
          .first()
          .flatMap { 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.db).flatMap {
                request.session.authenticate(user)
                return generateRedirect(on: request, for: user)
              }
            }
            request.session.authenticate(existingUser)
            return generateRedirect(
              on: request, 
              for: existingUser)
        }
    }
}

Fixing the tests

The main target now compiles. However, 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")
userToLogin = User(
  name: "Admin", 
  username: "admin", 
  password: "password", 
  email: "admin@localhost.local")
let user = User(
  name: usersName,
  username: usersUsername,
  password: "password",
  email: "\(usersUsername)@test.com")

Running the app

The application should now compile. Before you can run the app, however, you must reset the database due to the new email property. In Terminal, type:

docker rm -f postgres
docker run --name postgres \
  -e POSTGRES_DB=vapor_database \
  -e POSTGRES_USER=vapor_username \
  -e POSTGRES_PASSWORD=vapor_password \
  -p 5432:5432 -d postgres

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 CreateUserData.swift. Add a new property to CreateUserData 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 = CreateUserData(
  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 — https://github.com/vapor-community/sendgrid-provider — 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/jwt.git", from: "4.0.0"), with the following:

.package(
  url: "https://github.com/vapor/jwt.git", 
  from: "4.0.0"),
.package(
  url: "https://github.com/vapor-community/sendgrid.git", 
  from: "4.0.0")
.product(name: "JWT", package: "jwt"),
.product(name: "SendGrid", package: "sendgrid")

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 Leaf:

import SendGrid
app.sendgrid.initialize()
SENDGRID_API_KEY=<YOUR_API_KEY>

Setting up a password reset flow

To build a good experience for your app’s users, you must provide a way for them to reset a forgotten password. You’ll implement that now.

Forgotten password page

The first part of the password reset flow consists of two actions:

// 1
func forgottenPasswordHandler(_ req: Request)
  -> EventLoopFuture<View> {
  // 2
  req.view.render(
    "forgottenPassword", 
    ["title": "Reset Your Password"])
}
authSessionsRoutes.get(
  "forgottenPassword",
  use: forgottenPasswordHandler)
<!-- 1 -->
#extend("base"):
  <!-- 2 -->
  #export("content"):
    <!-- 3 -->
    <h1>#(title)</h1>

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

      <!-- 6 -->
      <button type="submit" class="btn btn-primary">
        Reset Password
      </button>
    </form>
  #endexport
#endextend
<br />
<a href="/forgottenPassword">Forgotten your password?</a>

// 1
func forgottenPasswordPostHandler(_ req: Request)
  throws -> EventLoopFuture<View> {
    // 2
    let email = 
      try req.content.get(String.self, at: "email")
    // 3
    return User.query(on: req.db)
      .filter(\.$email == email)
      .first()
      .flatMap { user in
        // 4
        req.view
          .render("forgottenPasswordConfirmed")
    }
}
authSessionsRoutes.post(
  "forgottenPassword", 
  use: forgottenPasswordPostHandler)
#extend("base"):
  #export("content"):
    <h1>#(title)</h1>

    <p>Instructions to reset your password have 
     been emailed to you.</p>
  #endexport
#endextend
import Fluent
import Vapor

final class ResetPasswordToken: Model, Content {
  static let schema = "resetPasswordTokens"

  @ID
  var id: UUID?

  @Field(key: "token")
  var token: String

  @Parent(key: "userID")
  var user: User

  init() {}

  init(id: UUID? = nil, token: String, userID: User.IDValue) {
    self.id = id
    self.token = token
    self.$user.id = userID
  }
}
import Fluent

struct CreateResetPasswordToken: Migration {
  func prepare(on database: Database) -> EventLoopFuture<Void> {
    database.schema("resetPasswordTokens")
      .id()
      .field("token", .string, .required)
      .field(
        "userID",
        .uuid,
        .required,
        .references("users", "id"))
      .unique(on: "token")
      .create()
  }

  func revert(on database: Database) -> EventLoopFuture<Void> {
    database.schema("resetPasswordTokens").delete()
  }
}
app.migrations.add(CreateResetPasswordToken())

Sending emails

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

import SendGrid
// 1
guard let user = user else {
  return req.view.render(
    "forgottenPasswordConfirmed",
    ["title": "Password Reset Email Sent"])
}
// 2
let resetTokenString = 
  Data([UInt8].random(count: 32)).base32EncodedString()
// 3
let resetToken: ResetPasswordToken
do {
  resetToken = try ResetPasswordToken(
    token: resetTokenString, 
    userID: user.requireID())
} catch {
  return req.eventLoop.future(error: error)
}
// 4
return resetToken.save(on: req.db).flatMap {
  // 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: "<SENDGRID SENDER EMAIL>",
    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 emailSend: EventLoopFuture<Void>
  do {
    emailSend = 
      try req.application
        .sendgrid
        .client
        .send(email: email, on: req.eventLoop)
  } catch {
    return req.eventLoop.future(error: error)
  }
  return emailSend.flatMap {
    // 10
    return 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)
  -> EventLoopFuture<View> {
    // 1
    guard let token = 
      try? req.query.get(String.self, at: "token") else {
        return req.view.render(
          "resetPassword",
          ResetPasswordContext(error: true)
        )
    }
    // 2
    return ResetPasswordToken.query(on: req.db)
      .filter(\.$token == token)
      .first()
      // 3
      .unwrap(or: Abort.redirect(to: "/"))
      .flatMap { token in
        // 4
        token.$user.get(on: req.db).flatMap { user in
          do {
            try req.session.set("ResetPasswordUser", to: user)
          } catch {
            return req.eventLoop.future(error: error)
          }
          // 5
          return token.delete(on: req.db)
        }
      }.flatMap {
        // 6
        req.view.render(
          "resetPassword",
          ResetPasswordContext()
        )
      }
}
authSessionsRoutes.get(
  "resetPassword", 
  use: resetPasswordHandler)
#extend("base"):
  #export("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>
    #endif

    <!-- 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>
  #endexport
#endextend
struct ResetPasswordData: Content {
  let password: String
  let confirmPassword: String
}
func resetPasswordPostHandler(_ req: Request) 
  throws -> EventLoopFuture<Response> {
    // 1
    let data = try req.content.decode(ResetPasswordData.self)
    // 2
    guard data.password == data.confirmPassword else {
      return req.view.render(
        "resetPassword",
        ResetPasswordContext(error: true))
        .encodeResponse(for: req)
    }
    // 3
    let resetPasswordUser = try req.session
      .get("ResetPasswordUser", as: User.self)
    req.session.data["ResetPasswordUser"] = nil
    // 4
    let newPassword = try Bcrypt.hash(data.password)
    // 5
    return try User.query(on: req.db)
      .filter(\.$id == resetPasswordUser.requireID())
      .set(\.$password, to: newPassword)
      .update()
      .transform(to: req.redirect(to: "/login"))
}
authSessionsRoutes.post(
  "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