20.
Web Authentication, Cookies & Sessions
Written by Tim Condon
Note: This update is an early-access release. This chapter has not yet been updated to Vapor 4.
In the previous chapters, you learned how to implement authentication in the TIL app’s API. In this chapter, you’ll see how to implement authentication for the TIL website. You’ll see how authentication works on the web and how Vapor’s Authentication module provides all the necessary support. You’ll then see how to protect different routes on the website. Finally, you’ll learn how to use cookies and sessions to your advantage.
Web authentication
How it works
Earlier, you learned how to use HTTP basic authentication and bearer authentication to protect the API. As you’ll recall, this works by sending tokens and credentials in the request headers. However, this isn’t possible in web browsers. There’s no way to add headers to requests your browser makes with normal HTML.
To work around this, browsers and web sites use cookies. A cookie is a small bit of data your application sends to the browser to store on the user’s computer. Then, when the user makes a request to your application, the browser attaches the cookies for your site.
You combine this with sessions to authenticate users. Sessions allow you to persist state across requests. In Vapor, when you have sessions enabled, the application provides a cookie to the user with a unique ID. This ID identifies the user’s session. When the user logs in, Vapor saves the user in the session. When you need to ensure a user has logged in or get the current authenticated user, you query the session.
Implementing sessions
Vapor manages sessions using a middleware, SessionsMiddleware. Open the project in Xcode and open configure.swift. In the middleware configuration section, add the following below middlewares.use(ErrorMiddleware.self):
middlewares.use(SessionsMiddleware.self)
This registers the sessions middleware as a global middleware for your application. It also enables sessions for all requests. Next, add the following at the bottom of configure(_:_:_:):
config.prefer(MemoryKeyedCache.self, for: KeyedCache.self)
This tells your application to use MemoryKeyedCache when asked for the KeyedCache service. The KeyedCache service is a key-value cache that backs sessions. There are multiple implementations of KeyedCache and you can learn more in Chapter 24, “Caching”.
Next, open User.swift and add the following at the bottom of the file:
// 1
extension User: PasswordAuthenticatable {}
// 2
extension User: SessionAuthenticatable {}
Here’s what this does:
- Conform
UsertoPasswordAuthenticatable. This allows Vapor to authenticate users with a username and password when they log in. Since you’ve already implemented the necessary properties forPasswordAuthenticatableinBasicAuthenticatable, there’s nothing to do here. - Conform
UsertoSessionAuthenticatable. This allows the application to save and retrieve your user as part of a session.
Log in
To log a user in, you need two routes — one for showing the login page and one for accepting the POST request from that page. Open WebsiteController.swift and, add the following at the bottom of the file, to create a context for the login page:
struct LoginContext: Encodable {
let title = "Log In"
let loginError: Bool
init(loginError: Bool = false) {
self.loginError = loginError
}
}
This provides the title of the page and a flag to indicate a login error. At the bottom of WebsiteController, add a route handler for the page:
// 1
func loginHandler(_ req: Request) throws -> Future<View> {
let context: LoginContext
// 2
if req.query[Bool.self, at: "error"] != nil {
context = LoginContext(loginError: true)
} else {
context = LoginContext()
}
// 3
return try req.view().render("login", context)
}
Here’s what this does:
- Define a route handler for the login page that returns a future
View. - If the request contains the error parameter, create a context with
loginErrorset totrue. - Render the login.leaf template, passing in the context.
Create the new template, login.leaf, in Resources/Views and open the file. Insert the following:
#// 1
#set("content") {
#// 2
<h1>#(title)</h1>
#// 3
#if(loginError) {
<div class="alert alert-danger" role="alert">
User authentication error. Either your username or
password was invalid.
</div>
}
#// 4
<form method="post">
#// 5
<div class="form-group">
<label for="username">Username</label>
<input type="text" name="username" class="form-control"
id="username"/>
</div>
#// 6
<div class="form-group">
<label for="password">Password</label>
<input type="password" name="password"
class="form-control" id="password"/>
</div>
#// 7
<button type="submit" class="btn btn-primary">
Log In
</button>
</form>
}
#embed("base")
Here’s what’s going on in the template:
- Set
contentas required by base.leaf. - Set the title for the page using the provided
titlefrom the context. - If the context value for
loginErroristrue, display a suitable message. - Define a
<form>that sends a POST request to same URL when submitted. - Add an input for the user’s username.
- Add an input for the user’s password. Note the
type="password"— this tells the browser to render the input as a password field. - Add a submit button for the form.
Next, open WebsiteController.swift, and add the following at the bottom of the file:
struct LoginPostData: Content {
let username: String
let password: String
}
This new Content type defines the data you expect when you receive the login POST request. Next, at the top of WebsiteController.swift, all the following directly below import Leaf:
import Authentication
This allows you to see the Crypto module required for BCrypt. Next, below loginHandler(_:), add the following route handler for this request:
// 1
func loginPostHandler(
_ req: Request,
userData: LoginPostData
) throws -> Future<Response> {
// 2
return User.authenticate(
username: userData.username,
password: userData.password,
using: BCryptDigest(),
on: req).map(to: Response.self) { user in
// 3
guard let user = user else {
return req.redirect(to: "/login?error")
}
// 4
try req.authenticateSession(user)
// 5
return req.redirect(to: "/")
}
}
Here’s what this does:
-
Define the route handler that decodes
LoginPostDatafrom the request and returnsFuture<Response>. -
Call
authenticate(username:password:using:on:). This checks the username and password against the database and verifies the BCrypt hash. This function returns aniluser in a future if there’s an issue authenticating the user. -
Verify
authenticate(username:password:using:on:)returned an authenticated user; otherwise, redirect back to the login page to show an error. -
Authenticate the request’s session. This saves the authenticated
Userinto the request’s session so Vapor can retrieve it in later requests. This is how Vapor persists authentication when a user logs in. -
Redirect to the home page after the login succeeds.
Finally, at the bottom of boot(router:), register the two routes:
// 1
router.get("login", use: loginHandler)
// 2
router.post(LoginPostData.self, at: "login",
use: loginPostHandler)
Here’s what this does:
- Route GET requests for /login to
loginHandler(_:). - Route POST requests for /login to
loginPostHandler(_:userData:), decoding the request body intoLoginPostData.
Build and run and save login.leaf. In your browser, visit http://localhost:8080/login. Click Log In without entering data to see the error handling.
Note: If you’re continuing from the last chapter, be sure to set your active scheme back to Run in Xcode.
Next, enter your credentials and click Log In again. After the app validates your credentials, it redirects you to the main acronyms list.
Protecting routes
In the API, you used GuardAuthenticationMiddleware to assert that the request contained an authenticated user. This middleware throws an authentication error if there’s no user, resulting in a 401 Unauthorized response to the client.
On the web, this isn’t the best user experience. Instead, you use RedirectMiddleware to redirect users to the login page when they try to access a protected route without logging in first. Before you can use this redirect, you must first translate the session cookie, sent by the browser, into an authenticated user.
In WebsiteController, replace the entire contents of boot(router:), including the new routes you just added with the following:
let authSessionRoutes =
router.grouped(User.authSessionsMiddleware())
This creates a route group that runs AuthenticationSessionsMiddleware before the route handlers. This middleware reads the cookie from the request and looks up the session ID in the application’s session list. If the session contains a user, AuthenticationSessionsMiddleware adds it to the AuthenticationCache, making the user available later in the process.
Next, register all the public routes, including the new login routes, in this route group:
authSessionRoutes.get(use: indexHandler)
authSessionRoutes.get("acronyms", Acronym.parameter,
use: acronymHandler)
authSessionRoutes.get("users", User.parameter, use: userHandler)
authSessionRoutes.get("users", use: allUsersHandler)
authSessionRoutes.get("categories", use: allCategoriesHandler)
authSessionRoutes.get("categories", Category.parameter,
use: categoryHandler)
authSessionRoutes.get("login", use: loginHandler)
authSessionRoutes.post(LoginPostData.self, at: "login",
use: loginPostHandler)
This makes the User available to these pages, even though it’s not required. This is useful for displaying user-specific content, such as a profile link, on any page you desire. Underneath these routes, add the following:
let protectedRoutes = authSessionRoutes
.grouped(RedirectMiddleware<User>(path: "/login"))
This creates a new route group, extending from authSessionRoutes, that includes RedirectMiddleware. The application runs a request through RedirectMiddleware before it reaches the route handler, but after AuthenticationSessionsMiddleware. This allows RedirectMiddleware to check for an authenticated user. RedirectMiddleware requires you to specify the path for redirecting unauthenticated users and the Authenticatable type to check for. In this case, that’s your User model.
Finally, register the routes that require protection — creating, editing and deleting acronyms — to this route group:
protectedRoutes.get("acronyms", "create",
use: createAcronymHandler)
protectedRoutes.post(CreateAcronymData.self, at: "acronyms",
"create", use: createAcronymPostHandler)
protectedRoutes.get("acronyms", Acronym.parameter, "edit",
use: editAcronymHandler)
protectedRoutes.post("acronyms", Acronym.parameter, "edit",
use: editAcronymPostHandler)
protectedRoutes.post("acronyms", Acronym.parameter, "delete",
use: deleteAcronymHandler)
Remember this includes both the GET requests and the POST requests. Build and run, then visit http://localhost:8080 in your browser.
Click Create An Acronym in the navigation bar and, this time, the app redirects you to the login page:
Enter the credentials for the seeded admin user and click Log In. The application redirects you to the main acronym list. If you click Create An Acronym again, the application lets you access the page.
Updating the site
Just like the API, now that users must login, the application knows which user is creating or editing an acronym. Still in WebsiteController.swift, find CreateAcronymData and remove the user ID:
let userID: User.ID
This is no longer required since you can get it from the authenticated user. Next, find createAcronymPostHandler(_:data:) and replace:
let acronym = Acronym(short: data.short, long: data.long,
userID: data.userID)
With the following:
let user = try req.requireAuthenticated(User.self)
let acronym = try Acronym(
short: data.short,
long: data.long,
userID: user.requireID())
This gets the user from the request using requireAuthenticated(_:), as in the API. Next, in editAcronymPostHandler(_:) add the following before acronym.short = data.short:
let user = try req.requireAuthenticated(User.self)
Again, this gets the authenticated user from the request. Finally, replace acronym.userID = data.userID with the following:
acronym.userID = try user.requireID()
This uses the authenticated user’s ID for the updated acronym. Now, both creating and editing acronyms use the authenticated user. As a result, you no longer need to show the users in the form. Open createAcronym.leaf and remove the following code:
<div class="form-group">
<label for="userID">User</label>
<select name="userID" class="form-control" id="userID">
#for(user in users) {
<option value="#(user.id)"
#if(editing){#if(acronym.userID == user.id){selected}}>
#(user.name)
</option>
}
</select>
</div>
As you use the same template for creating and editing acronyms, you only need to remove this from one place! Next, open WebsiteController.swift and remove the following from CreateAcronymContext:
let users: Future<[User]>
This is no longer required as the template doesn’t use users anymore. In createAcronymHandler(_:), address the change by replacing:
let context = CreateAcronymContext(
users: User.query(on: req).all())
With the following:
let context = CreateAcronymContext()
Next, remove the following from EditAcronymContext:
let users: Future<[User]>
Next, in editAcronymHandler(_:) replace:
let context = EditAcronymContext(
acronym: acronym,
users: users,
categories: categories)
With the following:
let context = EditAcronymContext(
acronym: acronym,
categories: categories)
Finally, delete the following from editAcronymHandler(_:) as you no longer use it:
let users = User.query(on: req).all()
Build and run, then visit http://localhost:8080/ in your browser. Click Create An Acronym and log in again.
Note: You need to log in again after restarting because the application keeps sessions in memory. For production applications, you can use Redis or a database to persist this information and share it across server instances.
Head back to Create An Acronym and the form no longer includes the list of users:
Create an acronym. When the application redirects you to the acronym’s page, you’ll see Vapor has used the authenticated user as the acronym’s user:
Log out
When you allow users to log in to your site, you should also allow them to logout. Still in WebsiteController.swift, add the following after loginPostHandler(_:userData:):
// 1
func logoutHandler(_ req: Request) throws -> Response {
// 2
try req.unauthenticateSession(User.self)
// 3
return req.redirect(to: "/")
}
Here’s what this does:
- Define a route handler that simply returns
Response. There’s no asynchronous work in this function so it doesn’t need to return a future. - Call
unauthenticateSession(_:)on the request. This deletes the user from the session so it can’t be used to authenticate future requests. - Return a redirect to the index page.
Register the route inside boot(router:) after authSessionRoutes.post(LoginPostData.self, at: "login", use: loginPostHandler):
authSessionRoutes.post("logout", use: logoutHandler)
This connects POST requests for /logout to logoutHandler(). You should always use POST requests for anything that changes application state. Modern browsers prefetch GET requests which could result in your users being unexpectedly logged out if you don’t use POST!
Open base.leaf and below </ul> in the navigation bar add the following:
#// 1
#if(userLoggedIn) {
#// 2
<form class="form-inline" action="/logout" method="POST">
#// 3
<input class="nav-link btn" type="submit"
value="Log out">
</form>
}
Here’s what this does:
- Check to see if
userLoggedInis set so you only display the logout option when a user’s logged in. - Create a new form that sends a POST request to /logout.
- Add a submit button to the form with the value Log out and style it like a navigation link.
Save the file. Next, open WebsiteController.swift and at the bottom of IndexContext add the following:
let userLoggedIn: Bool
This is the flag you set to tell the template that the request contains a logged in user. Finally, in indexHandler(_:) replace let context = IndexContext(title: "Home page", acronyms: acronyms) with the following:
// 1
let userLoggedIn = try req.isAuthenticated(User.self)
// 2
let context = IndexContext(
title: "Home page",
acronyms: acronyms,
userLoggedIn: userLoggedIn)
Here’s what this does:
- Check if the request contains an authenticated user.
- Pass the result to the new flag in
IndexContext.
Build and run, then head to your browser. Click Create An Acronym and then log in. When the application redirects you to the home page.
You’ll see a new Log out option in the top right:
If you click this, then click Create An Acronym again, you’ll need to sign in as the application has logged you out.
Cookies
Cookies are widely used on the web. Everyone’s seen the cookie consent messages that pop up on a site when you first visit. You’ve already used cookies to implement authentication, but sometimes you want to set and read cookies manually.
A common way to handle the cookie consent message is to add a cookie when a user has accepted the notice (the irony!).
Open base.leaf and, above the script tag for jQuery, add the following:
#// 1
#if(showCookieMessage) {
#// 2
<footer id="cookie-footer">
<div id="cookieMessage" class="container">
<span class="muted">
#// 3
This site uses cookies! To accept this, click
<a href="#" onclick="cookiesConfirmed()">OK</a>
</span>
</div>
</footer>
#// 4
<script src="/scripts/cookies.js"></script>
}
Here’s what the code does:
- Check whether a
showCookieMessageflag has been set for the template. - If so, add a
<footer>for the cookie message, styled using Bootstrap. - Add an OK link for users to click. This calls
cookiesConfirmed(), a JavaScript function that dismisses the cookie message. - Add the JavaScript file for cookies.
Next, in base.leaf above <title>#(title) | Acronyms</title>, add the following:
<link rel="stylesheet" href="/styles/style.css">
This includes a new stylesheet for the website. You’ll use this to add custom styling to your site. Save the file.
To create this stylesheet, enter the following in Terminal:
mkdir Public/styles
touch Public/styles/style.css
Next, open style.css and add the following:
#cookie-footer {
position: absolute;
bottom: 0;
width: 100%;
height: 60px;
line-height: 60px;
background-color: #f5f5f5;
}
This styling pins the cookie message to the bottom of the page. Save the stylesheet. Next, enter the following into Terminal to create a new file in Public/scripts called cookies.js :
touch Public/scripts/cookies.js
Next, open cookies.js and add the following:
// 1
function cookiesConfirmed() {
// 2
$('#cookie-footer').hide();
// 3
var d = new Date();
d.setTime(d.getTime() + (365*24*60*60*1000));
var expires = "expires="+ d.toUTCString();
// 4
document.cookie = "cookies-accepted=true;" + expires;
}
Here’s what the JavaScript does:
- Define a function,
cookiesConfirmed(), that the browser calls when the user clicks the OK link in the cookie message. - Hide the cookie message.
- Create a date that’s one year in the future. Then, create the expires string required for the cookie. By default, cookies are valid for the browser session — when the user closes the browser window or tab, the browser deletes the cookie. Adding the date ensures the browser persists the cookie for a year.
- Add a cookie called
cookies-acceptedto the page using JavaScript. You’ll check to see if this cookie exists when working out whether to show the cookie consent message.
Save the file. Open WebsiteController.swift in Xcode and add the following to the bottom of IndexContext:
let showCookieMessage: Bool
This flag indicates to the template whether it should display the cookie consent message. In indexHandler(_:), replace let context = IndexContext... with the following:
// 1
let showCookieMessage =
req.http.cookies["cookies-accepted"] == nil
// 2
let context = IndexContext(
title: "Home page",
acronyms: acronyms,
userLoggedIn: userLoggedIn,
showCookieMessage: showCookieMessage)
Here’s what this does:
- See if a cookie called
cookies-acceptedexists. If it doesn’t, set theshowCookieMessageflag totrue. You can read cookies from the request and set them on a response. - Pass the flag to
IndexContextso the template knows whether to show the message.
Build and run, then go to http://localhost:8080 in your browser. The site shows the cookie consent message on the page:
Click OK in the cookie consent message and your JavaScript code hides it. Refresh the page and the site won’t show the message again.
Sessions
In addition to using cookies for web authentication, you’ve also made use of sessions. Sessions are useful in a number of scenarios, including authentication.
Another such scenario is Cross-Site Request Forgery (CSRF) prevention. CSRF is where an attacker tricks a user into sending an unexpected or unintended POST request, such as a request to a bank to transfer money. If the user is logged in, the site processes the request without any issue.
The same is possible with creating acronyms in the TIL website. If someone tricked an already-authenticated user into sending a POST request to /acronyms/create, the application would create the acronym!
A common approach to solving this problem involves including a CSRF token in the form. When the application receives the POST request, it verifies that the CSRF token matches the one issued to the form. If the tokens match, the application processes the request; otherwise, it rejects the request.
To add CSRF token support, begin by opening WebsiteController.swift and adding the following to the bottom of CreateAcronymContext:
let csrfToken: String
This is the CRSF token you’ll pass into the template. In createAcronymHandler(_:) replace let context = CreateAcronymContext() with the following:
// 1
let token = try CryptoRandom()
.generateData(count: 16)
.base64EncodedString()
// 2
let context = CreateAcronymContext(csrfToken: token)
// 3
try req.session()["CSRF_TOKEN"] = token
Here’s what the new code does:
- Create a token using 16 bytes of randomly generated data, base64 encoded.
- Initialize a
CreateAcronymContextwith the created token. - Save the token into the request’s session under the CSRF_TOKEN key.
Vapor persists the token in the session across different requests. When the user makes a new request and provides the cookie that identifies the session, all the session data is available. Open createAcronym.leaf and, underneath <form method="post">, add the following:
#if(csrfToken) {
<input type="hidden" name="csrfToken" value="#(csrfToken)">
}
This checks to see if the context contains a token. If so, the template adds a new input element to the form with the token as the value. Since this element is hidden, the browser doesn’t display the token to the user.
Save the file. Back in WebsiteController.swift, add the following to the bottom of CreateAcronymData:
let csrfToken: String?
This is the CSRF token that the form sends using the hidden input. The token is optional as it’s not required by the edit acronym page for now. Finally, at the beginning of createAcronymPostHandler(_:data:), add the following:
// 1
let expectedToken = try req.session()["CSRF_TOKEN"]
// 2
try req.session()["CSRF_TOKEN"] = nil
// 3
guard let csrfToken = data.csrfToken,
expectedToken == csrfToken else {
throw Abort(.badRequest)
}
Here’s what this does:
- Get the expected token from the request’s session. This is the token you saved in
createAcronymHandler(_:). - Clear the CSRF token now that you’ve used it. You generate a new token with each form.
- Ensure the provided token is not nil and matches the expected token; otherwise, throw a 400 Bad Request error.
Build and run, then visit http://localhost:8080 in your browser. Go to the Create An Acronym page once you’ve logged in and create a new acronym. The application creates the acronym as the form provided the correct CSRF token. If you send a request without the token, either by removing it from your page or using RESTed, you’ll get a 400 Bad Request response.
Where to go from here?
In this chapter, you learned how to add authentication to the application’s web site. You also learned how to make use of both sessions and cookies. You might want to look at adding CSRF tokens to the other POST routes, such as deleting and editing acronyms. In the next chapter, you’ll learn how to use Vapor’s validation library to automatically validate objects, request data and inputs.