25
Password Reset & Emails
Written by Tim Condon
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.