Skip to main content

OAuth Authentication with Express & Passport

Learning Objectives​

Students will be able to:
Explain the difference between Authentication & Authorization
Identify the advantages OAuth provides for users and businesses
Explain what happens when a user clicks "Login with [OAuth Provider]"
Add OAuth authentication to an Express app using PassportJS
Use Middleware & PassportJS to implement authorization

Road Map​

  1. Setup
  2. Intro to Authentication
  3. Why OAuth?
  4. What is OAuth?
  5. Preview the Completed mongoose-movies App
  6. Today's OAuth Game Plan
    • Step 1: Register our App with Google's OAuth Server
    • Step 2: Define the User model
    • Step 3: Discuss PassportJS
    • Step 4: Install & Configure Session middleware
    • Step 5: Install PassportJS
    • Step 6: Create a Passport config module
    • Step 7: Install a Passport Strategy for OAuth
    • Step 8: Configure Passport
    • Step 9: Define routes for authentication
    • Step 10: Add Login/Logout UI
  7. Code the User Stories
  8. Implement Authorization
  9. Challenge Exercise (optional)

Videos​

Video πŸ“Ή Link

1. Setup​

This lesson will finalize mongoose-movies!

The starter code for this lesson begins where the Mongoose Queries Lab left off (which also seeded the database with some Movies and performers).

You only have to sync if your code is complete or is not working.

  1. Move into the mongoose-movies project we started in the Intro to Mongoose lesson:

    cd ~/code/mongoose-movies
  2. Again, you only have to sync if your code is complete or is not working:

    git reset --hard origin/sync-15-finish-seeding-query-lab
  3. Open the project's folder in VS Code:

    code .
  4. Open an integrated terminal session:

    control + backtick
  5. Start the Express server:

    nodemon
  6. Browse to localhost:3000

Remove Any Existing Reviews​

The upcoming user-centric change that's going to be made to the reviewSchema will prevent being able to add new reviews to any movie that currently has existing reviews (because they won't conform to the new schema).

Therefore, we need to clear out the reviews array exactly how/why we needed to clear out the cast array during the referencing lesson.

  1. Open a new Node REPL:
node
  1. Load crud-helper.js:
.load crud-helper.js
  1. Run the following command to clear out all reviews:
Movie.updateMany({}, { reviews: [] }).then(console.log);
  1. Exit the REPL:
.exit

Now back to our regularly scheduled programming...

2. Intro to Authentication​

Why We Need Authentication​

An application's functionality typically revolves around a particular user.

For example, when we use online banking, or more importantly, save songs to our Spotify playlists, the application has to know who we are - and this is where authentication comes in.

What is Authentication?​

Authentication is what enables an application to know the identity of the person using it.

In SEI, we're going to learn 3 types of authentication:

  • Unit 2: Logging in via a third-party provider (OAuth)
  • Unit 3: Session-based username/password login
  • Unit 4: Token-based username/password login

Authentication vs. Authorization​

Authentication and authorization are not the same thing...

Authentication verifies a user's identity.

Authorization determines what functionality a given user can access. For example:

  • Features a logged in (authenticated) user has vs. an anonymous visitor
  • Features an admin user has vs. some other user role
  • Only the user that added a given comment can delete that comment

3. Why OAuth?​

Consider applications where we have to sign up and log in using a username and a password...

❓ What pitfalls of username/password authentication can you think of from a user's perspective?


  • Creating multiple logins requires you to remember and manage all of those login credentials.

  • You will often use the same credentials across multiple sites, so if there's a security breach at one of the sites where you are a member, the hackers know that users often use the same credentials across all of their sites - oh snap!

  • You are tempted to use simple/weak passwords so that you can remember all of them.


❓ What might be the pitfalls of username/password authentication from a business that owns the web app point of view?


  • Managing users' credentials requires carefully crafted security code written by highly-paid developers.

  • Users (customers) are annoyed by having to create dedicated accounts, especially for entertainment or personal interest type websites.

  • Managing credentials makes your business a target for hackers (internal and external) and that brings with it liability.


The bottom-line is that users prefer to use OAuth instead of creating another set of credentials to use your site.

When users are your customers, you want to make them as happy as possible!

OAuth is hot, so let's use it!

4. What is OAuth?​

A Bit of OAuth Vocabulary​

  • OAuth 2.0: The current OAuth standard. Version 1.0 is obsolete and should not be used.

  • OAuth provider: A service company such as Google that makes its OAuth authentication service available to third-party applications.

  • client application: Our web application! Remember, this is from an OAuth provider's perspective.

  • owner: A user of a service such as Facebook, Google, Dropbox, etc.

  • resources: An owner's information on a service that may be exposed to client applications. For example, a user of Dropbox may allow access to their files.

  • access token: An temporary key that provides access to an owner's resources.

  • scope: Determines what resources and rights (read-only, update, etc) a particular token has.

What is it?​

OAuth is an open standard that provides client applications access to resources of a service such as Google with the permission of the resources' owner.

There are numerous OAuth Providers including:

OAuth 2's Flow & Scope​

The above image, taken from this excellent article, diagrams the flow of logging in using OAuth 2.0.

The ultimate goal is for the client application (our web app) to obtain an access token from an OAuth provider that allows the app to access the user's resources from that provider's API's.

OAuth is token based. A token is a generated string of characters.

Each token has a scope that determines what resources an app can access for that user.

For mongoose-movies, as is the case with many applications, we are only interested in using OAuth for identifying who the user is by accessing their basic profile info (name, email & avatar).

If an application needs to access more than a user's basic profile, the scope would need to be expanded as dictated by the specific provider's documentation on how to access additional resources.

Yes, OAuth is complex. But not to worry, we don't have to know all of the nitty gritty details in order to take advantage of it because we will be using PassportJS middleware that will handle most of the "OAuth dance" for us.

❓ OAuth Review Questions​

(1) True or False: If your site allows users to authenticate via OAuth, you should ensure they create a "strong" password.


False - Trick question because users won't be providing a password to our site if we use OAuth πŸ˜€


(2) To an OAuth provider, such as Google, what is the client application?


Our web application/server


5. Preview the Completed mongoose-movies App​

The deployed mongoose-movies includes OAuth authentication & authorization.

Here are some of the differences in the final code vs. where the app is now:

  • The ability to log in and out with Google OAuth has been implemented.
  • The navbar dynamically renders its links based upon whether a user is logged in or not.
  • A User model has been added and when a user logs in for the first time the user is added to the database.
  • The Movie --< Review relationship has been updated to be "user-centric", i.e., Movie --< Review >-- User

6. Today's OAuth Game Plan​

Ready for some OAuth? Here's today's game plan:

  • Step 1: Register our App with Google's OAuth Server
  • Step 2: Define the User model
  • Step 3: Discuss PassportJS
  • Step 4: Install & Configure Session middleware
  • Step 5: Install PassportJS
  • Step 6: Create a Passport config module
  • Step 7: Install a Passport Strategy for OAuth
  • Step 8: Configure Passport
  • Step 9: Define routes for authentication
  • Step 10: Add Login/Logout UI

Yep, lots of fun ahead...

Step 1 - Register our App​

Every OAuth provider requires that our web app be registered with it.

When we do so, we obtain a Client ID and a Client Secret that identifies our application to the OAuth provider.

For this lesson, we are going to use Google's OAuth provider.

Time to register our app...

Step 1.1 - Google Developers Console​

Step 1.2 - Create a Project​

  • Click Select a project:
  • Click NEW PROJECT:
  • Update the Project name to something like mongoose-movies, then click CREATE:

Note: The project name must be globally unique, so Google will append and additional id to the name you provide.

  • It might take a bit to create the project. When done, click SELECT PROJECT:

Step 1.3 - Enable the People API​

  • So that we can access the user's basic profile, we'll need to enable the People API.

  • Click Go to APIs overview:

  • Click + ENABLE APIS AND SERVICES:
  • Search for people and click on Google People API when it is visible:
  • Click ENABLE:

Step 1.4 - Obtain Credentials for App​

  • Now we need to create credentials for the app. Click CREATE CREDENTIALS:
  • Select User data then click NEXT:
  • Under OAuth Consent Screen provide a friendly App name, select the User support email, leave App Logo blank, type in a developer Email address then click SAVE AND CONTINUE:
  • Ignore the Scopes section by clicking SAVE AND CONTINUE (you may have to scroll down a bit):
  • In the dropdown, select Web application, then click the + ADD URI in the Authorized redirect URIs:
  • Copy/paste http://localhost:3000/oauth2callback into the URIs 1 input then click CREATE:

πŸ‘€ You will have to add an additional entry in the Authorized redirect URIs once you have deployed your application to Heroku - something like https://<your app name>.herokuapp.com/oauth2callback.

  • We now need to change the publishing status so that anyone can log in. Click the OAuth consent screen page link:
  • Click the PUBLISH APP button:
  • Click the CONFIRM:
  • Awesome. Now let's go view our credentials by clicking Credentials in the side menu:
  • Click the Web client 1 link:

Step 1.5 - Add the Client ID and Client Secret to .env​

Strangely, we either have to make our browser window very narrow or very large in order to see the Client ID and the Client secret:

Now we can add the credentials, along with that callback URI we provided, to the .env file so that it looks something like this:

.env
DATABASE_URL=mongodb+srv://<username>:<password>@cluster0-oxnsb.azure.mongodb.net/mongoose-movies?retryWrites=true&w=majority
GOOGLE_CLIENT_ID=245025414218-2r7f4bvh3t88s3shh6hhagrki0f6op8t.apps.googleusercontent.com
GOOGLE_SECRET=Yn9T_2BKzxr5zgprzKDGI5j3
GOOGLE_CALLBACK=http://localhost:3000/oauth2callback
caution

πŸ‘€ Registering a project with other OAuth providers will be similar, but unique to that provider.

Congrats on Registering your App with Google's OAuth Service!

Step 2 - Define the User Model​

Let's start by defining a minimal User model:

  1. Create the Node module:

    touch models/user.js
  2. Define the minimal schema, compile the schema into a User model and export it:

    models/User.js
    const mongoose = require("mongoose");
    const Schema = mongoose.Schema;

    const userSchema = new Schema(
    {
    name: String,
    },
    {
    timestamps: true,
    }
    );

    module.exports = mongoose.model("User", userSchema);

We will add additional properties once we "discover" what user info Google provides us with.

Step 3 - Passport Discussion​

Implementing OAuth is complex. There are redirects going on everywhere, access tokens that only last for a short time, refresh tokens used to obtain a fresh access token, etc.

As usual, we will stand on the shoulders of giants that have done much of the heavy lifting for us - enter PassportJS.

Passport is by far the most popular authentication framework out there for Express apps.

Passport's website states that it provides Simple, unobtrusive authentication for Node.js.

Basically this means that it handles much of the mundane tasks related to authentication for us, but leaves the details up to us, for example, not forcing us to configure our user model a certain way.

There are numerous types of authentication, if Passport itself was designed to do them all, it would be ginormous!

Instead, Passport uses Strategies designed to handle a given type of authentication. Think of them as plug-ins for Passport.

Each Express app with Passport can use one or more of these strategies.

Passport's site currently shows over 500 strategies available.

OAuth2, although a standard, can be implemented slightly differently by OAuth providers such as Facebook and Google.

As such, there are strategies available for each flavor of OAuth provider.

For this lesson, we will be using the passport-google-oauth strategy.

Passport is just middleware designed to authenticate requests.

IMPORTANT INFO BELOW

When a request is sent from an authenticated user, Passport's middleware will automatically add a user property to the req object.

πŸ‘€ req.user will be the logged in user's Mongoose document❗️

If a user is not logged in, req.user will be undefined.

You will then be able to access the req.user document in all of the controller actions - so, DO NOT write code to retrieve the user document from the DB because req.user is already the document!

Step 4 - Session Middleware​

Before we install Passport and a strategy, we need to install the express-session middleware.

Sessions, are a server-side way of remembering a user's browser session.

Sessions remembers the browser session by setting a cookie that contains a session id. No other data is stored in the cookie, just the id of the session.

On the server-side, your application can optionally store data pertaining to the user's session (req.session).

Passport will use the session, which is an in-memory data-store by default, to store a nugget of information that will allow us to lookup the user in the database.

FYI, since sessions are maintained in memory by default, if the server restarts, session data will be lost. You will see this happen when nodemon restarts the server and you are no longer logged in.

Step 4.1 - Installing Session Middleware​

Let's install the module:

npm install express-session

Next, require it below the logger:

server.js
var logger = require("morgan");
// new code below
var session = require("express-session");

Step 4.2 - Add a SECRET to .env​

When session middleware is configured, it will require a "secret" string that it uses to digitally sign the session cookie. Let's add the secret to the .env:

.env
...
GOOGLE_CALLBACK=http://localhost:3000/oauth2callback
SECRET=SEIRocks

Step 4.3 - Configure and Mount Session Middleware​

Now, we can configure and mount the session middleware below the express.static middleware:

server.js
app.use(cookieParser());
app.use(express.static(path.join(__dirname, "public")));

app.use(
session({
secret: process.env.SECRET,
resave: false,
saveUninitialized: true,
})
);

The resave and saveUninitialized settings, they are being set to suppress deprecation warnings.

Step 4.4 - Verifying Session Middleware​

Make sure your server is running with nodemon.

Browse to the app at localhost:3000.

Open the Application tab in DevTools, then expand Cookies in the menu on the left.

A cookie named connect.sid confirms that the session middleware is doing its job.

Congrats, the session middleware is now in place!

Time for a few questions...

❓ Review Questions (1 min)​

(1) Before a web app can use an OAuth provider, it must first r__________with it to obtain a client____ and a client secret.


...it must first register with it to obtain a client ID and a client secret.


(2) Passport uses s__________ designed to handle specific types of authentication.


strategies


(3) If there is an authenticated user, the request (req) object will have what property attached to it by Passport?


req.user


πŸ‘€ Do you need to sync your code?


git reset --hard origin/sync-16-sessions


Step 5 - Install Passport​

The Passport middleware is easy to install, but challenging to configure correctly.

First the easy part:

npm i passport

Require it as usual below express-session:

server.js
var session = require("express-session");
// new code below
const passport = require("passport");

Step 5.1 - Mount Passport​

With Passport required, we need to mount it. Be sure to mount it after the session middleware and always before any of the routes that would need access to the current user:

server.js
// app.use(session({... code above
app.use(passport.initialize());
app.use(passport.session());

The way passport middleware is being mounted is straight from the docs.

Step 6 - Create a Passport Config Module​

Because it takes a significant amount of code to configure Passport, we will create a separate module so that we don't pollute server.js.

Let's create the file:

touch config/passport.js

In case you're wondering, although the module is named the same as the passport module we've already required, it won't cause a problem because a module's full path uniquely identifies it to Node.

Step 6.1 - Passport Module's Exports Code​

Our config/passport module is not middleware.

Its code will basically configure Passport and be done with it. We're not going to export anything either.

Requiring below our database is as good of a place as any in server.js:

server.js
require("./config/database");
// new code below
require("./config/passport");

Step 6.2 - Require Passport​

In the config/passport.js module we will certainly need access to the passport module:

config/passport.js
const passport = require("passport");

Now on to the strategy...

Step 7 - Install the OAuth Strategy​

Time to install the strategy that will implement Google's flavor of OAuth:

npm i passport-google-oauth

This module implements Google's OAuth 2.0 and 1.0 API.

Note that OAuth 1.0 does still exist here and there, but it's obsolete.

Step 7.1 - Require the OAuth Strategy​

Now let's require the passport-google-oauth module below that of passport in config/passport.js:

config/passport.js
const passport = require("passport");
// new code below
const GoogleStrategy = require("passport-google-oauth").OAuth2Strategy;

Note that the variable is named using upper-camel-case.

❓ What does this naming convention typically hint at?


The it's a class


Let's make sure there's no errors before moving on to the fun stuff!

Step 8 - Configuring Passport​

To configure Passport we will:

  1. Call the passport.use() method to plug-in an instance of the OAuth strategy and provide a verify callback function that will be called whenever a user has logged in using OAuth.

  2. Call the passport.serializeUser() method to provide a callback that Passport will call after the verify callback.

  3. Call the passport.deserializeUser() method to provide a callback that Passport will call for every request when a user is logged in.

Step 8.1 passport.use()​

Time to call the passport.use() method to plug-in an instance of the OAuth strategy and provide a verify callback function that will be called whenever a user logs in with OAuth.

In config/passport.js:

config/passport.js
const GoogleStrategy = require("passport-google-oauth").OAuth2Strategy;
// new code below
passport.use(
new GoogleStrategy(
// Configuration object
{
clientID: process.env.GOOGLE_CLIENT_ID,
clientSecret: process.env.GOOGLE_SECRET,
callbackURL: process.env.GOOGLE_CALLBACK,
},
// The verify callback function
// Let's use async/await!
async function (accessToken, refreshToken, profile, cb) {
// A user has logged in with OAuth...
}
)
);

Note the settings from the .env file being passed to the GoogleStrategy constructor function.

Next we have to code the verify callback function...

Step 8.2 - The Verify Callback​

The verify callback will be called by Passport when a user has logged in with OAuth.

It's called a verify callback because with most other strategies we would have to verify the credentials, but with OAuth, well, there are no credentials!

In this callback we must:

  • Fetch the user from the database and provide them back to Passport by calling the cb() callback function, or...

  • If the user does not exist, we have a new user! We will add them to the database and pass along this new user in the cb() callback function.

But wait, how can we tell what user to lookup?

Looking at the callback's signature:

function(accessToken, refreshToken, profile, cb) {

We can see that we are being provided the user's profile by Google.

If we were to inspect this profile object, we'd find the following useful properties:

  • id: The user's Google Id that uniquely identifies each Google account.
  • displayName: The user's full name.
  • emails: An array of email objects associated with the account.
  • photos: An array of avatar image objects associated with the account.

Let's add these field to our User model's schema to hold it...

Step 8.3 - Modify the User Model​

Let's add a property for googleId to our userSchema inside models/user.js file:

models/user.js
const userSchema = new Schema(
{
name: String,
googleId: {
type: String,
required: true,
},
email: String,
avatar: String,
},
{
timestamps: true,
}
);

Cool, now when we get a new user via OAuth, we can use the Google profile object's info to create our new user!

Step 8.4 - Verify Callback Code​

Now we need to code the callback!

We're going to need access to our User model:

const GoogleStrategy = require("passport-google-oauth").OAuth2Strategy;
// new code below
const User = require("../models/user");

Cool, here comes the code for the entire passport.use method. We'll review it as we type it in...

server.js
passport.use(
new GoogleStrategy(
// Configuration object
{
clientID: process.env.GOOGLE_CLIENT_ID,
clientSecret: process.env.GOOGLE_SECRET,
callbackURL: process.env.GOOGLE_CALLBACK,
},
// The verify callback function...
// Marking a function as an async function allows us
// to consume promises using the await keyword
async function (accessToken, refreshToken, profile, cb) {
// When using async/await we use a
// try/catch block to handle an error
try {
// A user has logged in with OAuth...
let user = await User.findOne({ googleId: profile.id });
// Existing user found, so provide it to passport
if (user) return cb(null, user);
// We have a new user via OAuth!
user = await User.create({
name: profile.displayName,
googleId: profile.id,
email: profile.emails[0].value,
avatar: profile.photos[0].value,
});
return cb(null, user);
} catch (err) {
return cb(err);
}
}
)
);

Step 8.5 serializeUser() & deserializeUser() Methods​

With the passport.use() method done, we now need to code two more methods inside of config/passport module.

Step 8.6 serializeUser() Method​

After the verify callback function returns the user document, passport calls the passport.serializeUser() method's callback passing that same user document as an argument.

It is the job of that callback function to return the nugget of data that passport is going to add to the session used to track the user:

config/passport.js
passport.serializeUser(function (user, cb) {
cb(null, user._id);
});

By using the MongoDB _id of the user document we can easily retrieve the user doc within the deserializeUser() method's callback...

Step 8.7 deserializeUser() Method​

The passport.deserializeUser() method's callback function is called every time a request comes in from an existing logged in user.

The callback needs to return what we want passport to assign to the req.user object.

Code it below the passport.serializeUser() method:

config/passport.js
// Add to bottom of config/passport.js
passport.deserializeUser(async function (userId, cb) {
// It's nice to be able to use await in-line!
cb(null, await User.findById(userId));
});

Let's do another error check.

πŸ‘€ Do you need to sync your code?


git reset --hard origin/sync-17-passport


Step 9 - Define Routes for Authentication​

We're going to need three auth related routes:

  1. A route to handle the request sent when the user clicks Login with Google

  2. The /oauth2callback route that we told Google to call after the user confirms or denies the OAuth login.

  3. Lastly, we will need a route for the user to logout.

Step 9.1 Coding the Routes​

We're going to code these three new auth related routes in the existing routes/index module.

These new routes will need to access the passport module, so let's require it in routes/index.js:

routes/index.js
var router = require("express").Router();
// new code below
const passport = require("passport");

Step 9.2 Login Route​

In routes/index.js, let's add the login route below the root route:

routes/index.js
// Google OAuth login route
router.get(
"/auth/google",
passport.authenticate(
// Which passport strategy is being used?
"google",
{
// Requesting the user's profile and email
scope: ["profile", "email"],
// Optionally force pick account every time
// prompt: "select_account"
}
)
);

The passport.authenticate() method will return a middleware function that does the coordinating with Google's OAuth server.

The user will be presented the consent screen if they haven't previously consented.

Step 9.3 Google Callback Route​

Below the login route we just added, let's add the "callback" route that Google will call after the user confirms:

// Google OAuth callback route
router.get(
"/oauth2callback",
passport.authenticate("google", {
successRedirect: "/movies",
failureRedirect: "/movies",
})
);

Note that we can specify the redirects for a successful and unsuccessful login. For this app, we will redirect to our main /movies route in both cases.

Step 9.4 Logout Route​

The last route to add is the route that will logout the user:

// OAuth logout route
router.get("/logout", function (req, res) {
req.logout(function () {
res.redirect("/movies");
});
});

The logout() method was automatically added to the req object by Passport!

Good time to do another error check.

Step 10 - Add Login/Logout UI​

We want the nav bar in views/partials/header.ejs to update dynamically depending upon whether there's an authenticated user or not:


versus

Step 10.1 Pass req.user to All Views via res.locals & Middleware​

Instead of having to pass req.user every time we render a template, let's take advantage of Express' res.locals object and a tidy custom middleware function:

In server.js:

server.js
app.use(passport.session());
// Add this middleware BELOW passport middleware
app.use(function (req, res, next) {
res.locals.user = req.user;
next();
});

Now the logged in user is in a user variable that's available inside all EJS templates!

If nobody is logged in, user will be undefined.

Step 10.2 Add the Login / Logout UI Logic​

We're going to use an if/else block in the EJS to dynamically render the nav links depending upon whether there is a logged in user or not.

Here's the updated <nav> element in partials/header.ejs:

views/partials/header.ejs
<nav>
<img src="/images/camera.svg">
<a href="/movies" <%- title === 'All Movies' ? 'class="active"' : '' %>>ALL MOVIES</a>
<% if (user) { %>
<a href="/performers/new" <%- title === 'Add Performer' ? 'class="active"' : '' %>>
ADD PERFORMER</a>
<a href="/movies/new" <%- title === 'Add Movie' ? 'class="active"' : '' %>>ADD MOVIE</a>
<a href="/logout">LOG OUT</a>
<% } else { %>
<a href="/auth/google" class="login">LOG IN&nbsp;<img src="https://i.imgur.com/FHjYyi0.png"></a>
<% } %>
</nav>

There's a class="login" in use - add the following to the bottom of public/stylesheets/style.css:

public/stylesheets/style.css
.login {
display: flex;
justify-content: center;
align-items: center;
height: 34px;
}

.login img {
height: 30px;
}

Step 10.3 Try Logging In/Out​

We've finally got to the point where you can test out our app's authentication!

May the force be with us!

πŸ‘€ Do you need to sync your code?


git reset --hard origin/sync-18-login-out


7. Code the User Stories​

Our first user story reads:

AAU, I want to add reviews for a movie when I'm logged in.

We already have a <form> for creating reviews on the views/movies/show.ejs template.

However, we only want this UI to show when there's a logged in user.

πŸ‘‰ You Do - Hide/Show Review <form> (2 minutes)​

Using an <% if %> EJS block in views/movies/show.ejs, render the <form> for adding a review ONLY if there's a logged in user.

Solution


views/movies/show.ejs
<!-- Add the line below -->
<% if (user) { %>
<form id="add-review-form" method="POST" action="/movies/<%= movie._id %>/reviews">
<label>Review:</label>
<textarea name="content"></textarea>
<label>Rating:</label>
<select name="rating">
<option value="1">1</option>
<option value="2">2</option>
<option value="3">3</option>
<option value="4">4</option>
<option value="5">5</option>
</select>
<input type="submit" value="Add Review">
</form>
<!-- Add the line below -->
<% } %>

Update the reviewSchema​

We're going to make three changes to the reviewSchema to make it user-centric:

  1. Add a user property that references the user's ObjectId that created the review.
  2. To avoid having to populate review sub-docs just to display the user's name with them, we're also going to add a userName property.
  3. Similar to userName, let's also add a userAvatar property.

Update reviewSchema in models/movie.js:

models/movie.js
const reviewSchema = new Schema(
{
content: {
type: String,
required: true,
},
rating: {
type: Number,
min: 1,
max: 5,
default: 5,
},
// Don't forget to add the comma above then
// add the 3 new properties below
user: {
type: Schema.Types.ObjectId,
ref: "User",
required: true,
},
userName: String,
userAvatar: String,
},
{
timestamps: true,
}
);
tip

Note that required: true has been added to prevent "orphan" reviews. You'll want to do the same on other user-centric schemas as well.

Update the create action in the Reviews controller​

Much of an application's CRUD data operations pertain to the logged in user.

The Guide to User-Centric CRUD using Express & Mongoose will prove very helpful when coding your projects.

We've already added the necessary properties to the reviewSchmea, now let's update the create action in the controllers/reviews.js controller:

controllers/reviews.js
async function create(req, res) {
const movie = await Movie.findById(req.params.id);

// Add the user-centric info to req.body (the new review)
req.body.user = req.user._id;
req.body.userName = req.user.name;
req.body.userAvatar = req.user.avatar;

// We can push (or unshift) subdocs into Mongoose arrays
movie.reviews.push(req.body);
try {
// Save any changes made to the movie doc
await movie.save();
} catch (err) {
console.log(err);
}
res.redirect(`/movies/${movie._id}`);
}
info

Remember: req.user is the logged in user's Mongoose document!

Update How Reviews are Being Displayed​

Here's the updated views/movies/show.ejs that displays the user's name and avatar with each review:

views/movies/show.ejs
<table>
<thead>
<tr>
<th>User</th>
<th>Date</th>
<th>Review</th>
<th>Rating</th>
</tr>
</thead>
<tbody>
<% let total = 0 %>
<% movie.reviews.forEach(function(r) { %>
<% total += r.rating %>
<tr>
<td class="review-user"><img alt="avatar" src="<%= r.userAvatar %>" referrerpolicy="no-referrer" ><%= r.userName %></td>
<td><%= r.createdAt.toLocaleDateString() %></td>
<td><%= r.content %></td>
<td><%= r.rating %></td>
</tr>
<% }); %>
<tr>
<td colspan="3"></td>
<td><strong><%= (total / movie.reviews.length).toFixed(1) %></strong></td>
</tr>
</tbody>
</table>
note

Note: The referrerpolicy="no-referrer" attribute added to the <img> cures Google's finickiness handling the browser's request for the avatar image.

Sprinkle in the the following CSS at the end of public/stylesheets/style.css:

public/stylesheets/style.css
.review-user {
display: flex;
justify-content: center;
align-items: center;
}

.review-user img {
border-radius: 50%;
height: 40px;
}

Test the First User Story

That should take care of our first user story - try it out!

Yes, the UX is not that great because of the full-page refresh, but we'll address that when we develop single-page apps with React.

πŸ‘€ Do you need to sync your code?


git reset --hard origin/sync-19-first-user-story


Code the Next User Story​

Our next user story reads:

AAU, I want to be able to delete a review that I previously created.

We want to ensure that only the user that created a review can delete it.

As usual, we'll follow the 5 steps to add delete functionality...

Step 1 - Determine the Proper Route​

❓ What is the proper route to delete a review?


DELETE /reviews/:id

Although Reviews are a nested resource, the server doesn't need to know the id of the movie that the review is embedded within; thus the "shallow route".


Step 2 - Display the UI to Send the HTTP Request​

Nothing fancy...

Again, in views/movies/show.ejs:

views/movies/show.ejs
...
<th>Rating</th>
<!-- Add this placeholder header cell -->
<th></th>
...
<td><%= r.rating %></td>
<!-- Add this td -->
<td>
<% if (user?._id.equals(r.user)) { %>
<form action="/reviews/<%= r._id %>?_method=DELETE" method="POST">
<button type="submit">X</button>
</form>
<% } %>
</td>

The user?._id... is using JS's fresh optional chaining operator (?) to avoid causing an error if user is not a an object (user not logged in).

The _id.equals() method is used to compare _ids - this is necessary because they are objects.

Lastly, looks like we need to install and mount method-override middleware:

npm i method-override

Require it:

server.js
var passport = require("passport");
// Add the line below
var methodOverride = require("method-override");

Now let's mount it:

server.js
app.use(cookieParser());
// Add the line below
app.use(methodOverride("_method"));

Step 3 - Define the Route​

πŸ‘‰ You Do - Define the delete Route (1 min)

Add the new route for deleting a review to routes/reviews.js router.

Solution


// DELETE /reviews
router.delete("/reviews/:id", reviewsCtrl.delete);

Step 4 - Code the delete Action​

It's not enough to just hide the delete button when the review does not belong to the logged in user.

We should validate ownership in the controller action as well. Again, using the Guide to User-Centric CRUD using Express & Mongoose...

controllers/reviews.js
module.exports = {
create,
// Add this export
delete: deleteReview,
};

async function deleteReview(req, res) {
// Note the cool "dot" syntax to query on the property of a subdoc
const movie = await Movie.findOne({
"reviews._id": req.params.id,
"reviews.user": req.user._id,
});
// Rogue user!
if (!movie) return res.redirect("/movies");
// Remove the review using the remove method available on Mongoose arrays
movie.reviews.remove(req.params.id);
// Save the updated movie doc
await movie.save();
// Redirect back to the movie's show view
res.redirect(`/movies/${movie._id}`);
}
info

πŸ‘€ If you provide a non-null argument when calling next(), an error propagates through the Express framework which results in the built-in views/error.ejs error page displaying.

Step 5 - Render or Redirect​

Already done in the above code!

Test it out!

Cool, just one step left!

8. Implement Authorization​

❓ What is authorization?


Authorization determines what functionality a given user can access. For example, we don't want unauthenticated users to be able to delete reviews.


We've already coded some client-side authorization by:

  • Conditionally displaying the <form> used to add reviews.
  • Showing the delete button for only the reviews created by the logged in user.

We also performed a bit of server-side authorization by ensuring that the logged in user was the owner of the review being deleted.

In this step, we will see how we can easily protect the routes that require a user to be logged in, e.g., adding a movie or review.

Passport adds a nice method to the req object, req.isAuthenticated() that returns true or false depending upon whether there's a logged in user or not.

We're going to write our own little middleware function to take advantage of req.isAuthenticated() to perform some authorization.

Code the Authorization Middleware​

Since we want to protect routes defined in multiple modules, we'll want to stay DRY and code the middleware function in its own module.

touch config/ensureLoggedIn.js

We only want to export a single thing, a middleware function. Thus, the best approach is to assign the function to module.exports as follows:

config/ensureLoggedIn.js
// Middleware for routes that require a logged in user
module.exports = function (req, res, next) {
// Pass the req/res to the next middleware/route handler
if (req.isAuthenticated()) return next();
// Redirect to login if the user is not already logged in
res.redirect("/auth/google");
};

Our custom ensureLoggedIn middleware function, like all middleware, will either call next(), or respond to the request.

Using Authorization Middleware​

Express's middleware and routing is extremely flexible and powerful.

We can add multiple middleware functions before a route's final middleware function!

Let's modify routes/movies.js to protect routes that require a logged in user using our new ensureLoggedIn middleware:

routes/movies.js
// routes/movies.js

const moviesCtrl = require("../controllers/movies");
// Require the auth middleware
const ensureLoggedIn = require("../config/ensureLoggedIn");

router.get("/", moviesCtrl.index);
// Use ensureLoggedIn middleware to protect routes
router.get("/new", ensureLoggedIn, moviesCtrl.new);
router.get("/:id", moviesCtrl.show);
router.post("/", ensureLoggedIn, moviesCtrl.create);

Note the inserted ensureLoggedIn middleware function!

If the ensureLoggedIn middleware calls next(), then the next middleware, e.g., moviesCtrl.new will be called.

Test it out by logging out, and manually attempting to browse to http://localhost:3000/movies/new. Very cool!

πŸ‘‰ You Do - Protect More Routes (3 mins)​

  • Protect the appropriate routes in:
    • routes/reviews.js
    • routes/performers.js

Solution


routes/reviews.js

routes/reviews.js
router.post("/movies/:id/reviews", ensureLoggedIn, reviewsCtrl.create);
router.delete("/reviews/:id", ensureLoggedIn, reviewsCtrl.delete);

routes/performers.js

routes/performers.js
router.get("/performers/new", ensureLoggedIn, performersCtrl.new);
router.post("/performers", ensureLoggedIn, performersCtrl.create);
router.post(
"/movies/:movieId/performers",
ensureLoggedIn,
performersCtrl.addToCast
);

πŸ‘ Congrats on implementing OAuth authentication and authorization​

Now you're ready to start your project and implement OAuth authentication before any other CRUD functionality.

πŸ‘€ Do you need to sync your code?


git reset --hard origin/sync-20-finish


9. πŸ’ͺ Challenge Exercise (optional)​

As an optional challenge exercise, use the Guide to User-Centric CRUD using Express & Mongoose to help you implement the following user story:

AAU, I want to be able to update a review that I previously created.

References​