Amusement Park Tracker with Authentication

This project picks up where the first Amusement Park Tracker project left off! In the provided starter project, you can view, create, update, and delete both parks and attractions. In this project you'll extend the provided application with the following features:

  • User self-registration and login; and
  • Ability for users to record visits to park attractions.

Phase 0: Download the starter project

Clone the starter project:

git clone https://github.com/appacademy-starters/express-amusement-park-tracker-with-auth.git

Then complete the following set up steps:

  • Create the database and limited access database user;
  • Add an .env file containing the variables from the .env.example file;
  • Install the project's dependencies (npm install); and
  • Use the Sequelize CLI to apply the provided database migrations and seeder.

Now you can start (npm start) and test the application!

Phase 1: Create the User model

The User model should include the following properties:

  • emailAddress - A non-nullable string (length: 255) representing the user's email address;
  • firstName - A non-nullable string (length: 50) representing the user's first name;
  • lastName - A non-nullable string (length: 50) representing the user's last name; and
  • hashedPassword - A non-nullable binary string representing the user's hashed password.

Use the Sequelize CLI to generate the User model and migration. Then edit both files to use the expected attribute and column configuration. Remember to make the hashedPassword attribute have a datatype of STRING.BINARY.

Apply the migration when you're ready.

Phase 2: Configure Express to use sessions

Take a moment to install:

npm install express-session

Now let's configure your session store. Add the express-session middleware to the app module:

const session = require('express-session');

Make sure you have required session from the express-session package and have your application use the session. Make sure you configure the session with both resave and saveUninitialized set to false.

Take a moment set a SESSION_SECRET environment variable in your .env file. Add a key of sessionSecret connected to the process.env.SESSION_SECRET in your ./config/index.js module as well. As a reminder, you can generate a UUID to have a more secure sessionSecret variable value.

In the app module, make sure to also import the sessionSecret in your ./config require statement. As a reminder, be sure to use the same secret value for the express-session and cookie-parser middleware.

Your session should be configured like so:

app.use(session({
  secret: sessionSecret, 
  resave: false, 
  saveUninitialized: false,
}));

Phase 3: Support user self-registration

Now it's time to add the user registration form.

Begin by creating a ./routes/user module. Import express and instantiate a router with express.Router(). Import your db from your ../db/models and create GET and POST routes for the "Register" page (/user/register). Make sure to use CSRF protection as well the bcryptjs npm package to hash user passwords. Lastly, don't forget to export the router module you have just created.

Take note that you already have a routes/utils.js module that holds utility functions like the csrfProtection and asyncHandler methods you are familiar with. Import both of these methods into your ./routes/user module with a require statement to the ./utils module like so:

const { csrfProtection, asyncHandler } = require('./utils');

Now you can use your csrfProtection and asyncHandler in your ./routes/user module!

In your GET /user/register route, use db.User.build() to initialize a new user to pass into the user-register view. Make sure to also pass in a title for your "Register" page as well as a csrfToken (with a value of req.csrfToken()). Add csrfProtection to your route, and let's create your user-register template.

Render a template that extends the main layout and has a form within block content. Take note of the mixins in the utils.pug file that are available for you to use. The form should contain the following input fields:

  • First Name
  • Last Name
  • Email Address
  • Password
  • Confirm Password

Don't forget to have a hidden input field for your _csrf field as well as a submit button. Once you have your view set up, let's create your POST route for user registration!

In your POST /user/register route, make sure to use userValidators in addition to your csrfProtection middleware. This means you'll need to import check and validationResult from express-validator.

At this moment, implement the following validation rules:

  • firstName
    • Not null or empty
    • Not longer than 50 characters
  • lastName
    • Not null or empty
    • Not longer than 50 characters
  • emailAddress
    • Not null or empty
    • Not longer than 255 characters
    • Is a valid email address
    • Should not be in use by an existing account
  • password
    • Not null or empty
    • Not longer than 50 characters
    • Should contain at least 1 lowercase letter, uppercase letter, number, and special character (i.e. "!@#$%^&*") Hint: review the Implementing Session-Based Authentication reading and see below for reminders on how to use regex for validation!
  • confirmPassword
    • Not null or empty
    • Not longer than 50 characters
    • Should match the provided password value

Regex Reminders

  • The hat operator ^ is used to start matching at the beginning of the password.
  • The expression (?=.*[a-z]) is used to check that the password contains at least one lowercase character.
  • The expression (?=.*[A-Z]) is used to check that the password contains at least one uppercase character.
  • The expression (?=.*[0-9]) is used to check that the password contains at least one numeric character.
  • The expression (?=.*[!@#$%^&*]) is used to check that the password contains at least one special character.

Now return to your POST route and wrap your asynchronous route function with your asyncHandler so that you can await certain processes in your route. Begin by destructuring the emailAddress, firstName, lastName, and password from your req.body object. Then use the emailAddress, firstName, and lastName variables (but not the password variable) to build a user with the db.User.build() method.

At this point, generate your validatorErrors within your route by using the validationResult method from express-validator. If the validatorErrors are empty, await the generation of your hashedPassword created with bcrypt.hash(). Make sure import bcrypt by installing and requiring the bcryptjs package. Remember that the first argument of bcrypt.hash() is a password string. You can use use an integer for the second argument to auto-generate a salt that will be incorporated in the password hash process. After hashing the user password, set the user.hashedPassword property and await the save of your user instance. Lastly, redirect the user to the home page (/) upon successful registration.

If the validatorErrors are NOT empty, use array() to transform the validatorErrors object into a mappable array. Map over each error object in the array and pluck out each error's msg property to generate an array of error messages. Lastly, re-render your user-register form and pass in your title of "Register", the user object, the errors array, and the csrfToken.

Now run your application and test the /user/register route! Remember that you can test your route by registering a user through the form and using Postbird to confirm whether or not your user has been persisted to the database.

Phase 4: Support user login

Now it's time to add the user login form! Begin by updating the ./routes/user module with GET and POST routes for the "Login" page (/user/login). Make sure to use CSRF protection for both routes.

Render your user-login template in your GET /user/login route. Pass along a title for your "Login" page as well as a csrfToken. Now let's create the view template for your login page!

Create a user-login.pug template in your views directory. Think of how you can include and re-use mixins from your utils.pug file just like in your "Register" page. The "Login" form should contain an "Email Address" field, a "Password" field, a hidden _csrf field, and a submit button.

Now let's revisit the POST /user/login route. You'll want to validate your login form data, so take a moment to implement the following validation rules:

  • emailAddress
    • Not null or empty
  • password
    • Not null or empty

After your loginValidators have been created, wrap your asynchronous route function within your asyncHandler function and destructure the emailAddress and password from your req.body object. Generate your validatorErrors by passing in the req body into the validationResult function. Also take a moment to initialize an errors array. You'll manually push error messages to render into this array.

If your validation errors are empty, try to find the user by their email address. You can await the database fetch of a user by using the db.User.findOne() function where the user has a matching emailAddress. If the user exists, use the bcrypt.compare() function to check whether the user.hashedPassword (parsed into string format) property matches the provided password from req.body. If there is a password match, log in the user (for now just leave yourself a TODO comment to log in the user) and redirect the user to the home page (/).

If your validator errors are empty and the user was not found, or the password did not match the hashedPassword, display an error message to the user by pushing in a "Login failed for the provided email address and password" message into the errors array you initialized.

If your validation errors are not empty, convert your validatorErrors object into an mappable errors array to pluck error messages and generate an array of error messages. Lastly, you need to render a user-login view for this route. Make sure to pass in the "Login" title, the emailAddress from req.body, the errors array, and a csrfToken.

Testing user login

Run the application and browse to the /user/login route. You can test the user login form with the following actions:

  • Submit the form with no values.
    • You should see two validation messages asking you to provide values.
  • Submit the form with an email address that isn't associated with a user record in the database and a password (doesn't matter if the password is correct or not).
    • You should see a validation message letting you know that the login attempt failed.
  • Submit the form with an email address that's associated with a user record in the database but with an incorrect password.
    • You should see a validation message letting you know that the login attempt failed.
  • Submit the form with an email address that's associated with a user record in the database and with a correct password.
    • This time you should be redirected to the "Home" page.

Phase 5: Persist user login state

Now it's time to handle persisting the user's login state after they've successfully logged into the website!

Using sessions to persist a user's login state

Add a new module named auth to the root of your project and add function named loginUser() to handle persisting a user's login state to session.

Update the ./routes/user module to import the loginUser() function from the auth module. Then within the POST /user/login route handler add a call to the loginUser() function just before redirecting the user to the default route if the password matched.

Also, after a new user has registered in the POST /user/register route handler, add a call to the loginUser() function after saving the user to the database but before redirecting then to the default route.

Testing user login state persistence

Run the application (if it's not already running) and use the "Register" and "Login" pages to register a new user and login an existing user. Everything should work as it did before, but the user's login state is being persisted in session.

At this point in the project, there isn't any visual indication if the user is logged in or not (that's something that you'll fix in a bit). If you open your developer tools and view the "Application" tab, you can view the cookies for http://localhost:8080. After registering a new user or logging in an existing user, you should see a cookie named reading-list.sid. That's the session cookie!

Phase 6: Restore the authenticated user from session

Now that you're persisting a user's login state to session, you need to make that user's information easily accessible to your application when it's processing requests.

In the auth module, define a middleware function named restoreUser() to retrieve the user's information from the database if they're authenticated.

The function should check if the req.session.auth property is defined to determine if there's an authenticated user. If there is, extract the userId from the req.session.auth property and retrieve the user from the database.

If the user is successfully retrieved from the database, then use the res.locals object to define and set two properties:

  • authenticated - Set to true to indicate that the current request has an authenticated user; and
  • user - Set to the user that was just retrieved from the database.

If the req.session.auth property isn't defined or if retrieving the user from the database throws an error then set the res.locals.authenticated property to false to indicate that the current request doesn't have an authenticated user (i.e. it's an anonymous request).

After defining the restoreUser() function, export it from the auth module and import it into the app module. Then add the restoreUser() middleware function to the application just before the routes are added.

Phase 7: Display the user's login state

It's helpful to display to the end user whether or not they're currently logged in. A common approach is to display login and registration links or a welcome message in the header of the website.

If the user isn't logged in, they would see links to log in or register:

Login | Register

If the user is logged in, they would be welcomed and have access to logging out:

Welcome «current user name»! | Logout

To do that, update your ./views/layout.pug template to use the locals.authenticated property to determine if the current user is logged in or not.

If the current user is logged in, then render a short, friendly "welcome" message is along with a simple form that contains a single "Logout" submit button:

span(class='navbar-text px-4') Welcome #{user.firstName}!
form(class='form-inline pr-4' action='/user/logout' method='post')
  button(class='btn btn-sm btn-warning' type='submit') Logout

If the current user isn't logged in, then render links (styled as buttons using Bootstrap CSS classes) to the "Login" and "Register" pages.

span(class='navbar-text px-4')
  a(class='btn btn-sm btn-dark mr-2' href='/user/login') Login
  a(class='btn btn-sm btn-dark' href='/user/register') Register

Now if you run and test your application, you'll see the current user's login state displayed in the header! If you log in and click the "Logout" button in the header, you'll receive a "Page Not Found" error. This is occurring because the POST /user/logout route doesn't exist. Time to fix that!

Phase 8: Implement user logout

Define and export a logoutUser() function in the auth module that removes the auth property from the req.session object.

Then add a POST /user/logout to the ./routes/user module to process POST requests from the logout form. Import the logoutUser() function from the auth module and call it within the POST /user/logout route handler then redirect the user to the default route.

The POST /user/logout route isn't modifying any of the user's data in the database so there's no need to protect it from CSRF attacks.

Testing the latest changes

Run the application and use the "Login" page to login an existing user. You should now see the user's first name displayed in the header.

After logging in, you should see something like this logged to the console:

Session {
  cookie: { path: '/', _expires: null, originalMaxAge: null, httpOnly: true },
  auth: { userId: 1 }
}

Now click the "Logout" button, and you should be redirected to the "Login" page. In the console you should see that the session.auth property is no longer defined on the session object:

Session {
  cookie: { path: '/', _expires: null, originalMaxAge: null, httpOnly: true }
}

Phase 9: Support user attraction visits

Now you're ready to add support for user attraction visits.

Create the AttractionVisit model

The AttractionVisit model should include the following properties:

  • visitedOn - A non-nullable date only attribute representing the date that the user visited the attraction;
  • rating - A non-nullable integer attribute representing the user's rating of the attraction; and
  • comments - A nullable text attribute representing the user's comments about the attraction.

Use the Sequelize CLI to generate the AttractionVisit model and migration. Then edit both files to use the expected attribute and column configuration.

Before applying the migration, associate the model with both the Attraction and User models:

// ./db/models/attractionvisit.js

'use strict';
module.exports = (sequelize, DataTypes) => {
  const AttractionVisit = sequelize.define('AttractionVisit', {

    // Code removed for brevity.

  }, {});
  AttractionVisit.associate = function(models) {
    AttractionVisit.belongsTo(models.Attraction, {
      as: 'attraction',
      foreignKey: 'attractionId'
    });
    AttractionVisit.belongsTo(models.User, {
      as: 'user',
      foreignKey: 'userId'
    });
  };
  return AttractionVisit;
};
// ./db/models/attraction.js

'use strict';
module.exports = (sequelize, DataTypes) => {
  const Attraction = sequelize.define('Attraction', {

    // Code removed for brevity.

  }, {});
  Attraction.associate = function(models) {
    Attraction.belongsTo(models.Park, {
      as: 'park',
      foreignKey: 'parkId'
    });
    Attraction.hasMany(models.AttractionVisit, {
      as: 'visits',
      foreignKey: 'attractionId'
    });
  };
  return Attraction;
};
// ./db/models/user.js

'use strict';
module.exports = (sequelize, DataTypes) => {
  const User = sequelize.define('User', {

    // Code removed for brevity.

  }, {});
  User.associate = function(models) {
    User.hasMany(models.AttractionVisit, {
      as: 'visits',
      foreignKey: 'userId'
    });
  };
  return User;
};

Then update the ./db/migrations/[timestamp]-create-attraction-visit.js migration file with the userId and attractionId foreign key columns:

'use strict';
module.exports = {
  up: (queryInterface, Sequelize) => {
    return queryInterface.createTable('AttractionVisits', {
      id: {
        allowNull: false,
        autoIncrement: true,
        primaryKey: true,
        type: Sequelize.INTEGER
      },
      userId: {
        allowNull: false,
        references: {
          model: 'Users',
          key: 'id',
        },
        type: Sequelize.INTEGER,
      },
      attractionId: {
        allowNull: false,
        references: {
          model: 'Attractions',
          key: 'id',
        },
        type: Sequelize.INTEGER,
      },

      // Code removed for brevity.

    });
  },
  down: (queryInterface, Sequelize) => {
    return queryInterface.dropTable('AttractionVisits');
  }
};

These model associations create a one-to-many relationship between the Attraction and AttractionVisit models and a one-to-many relationship between the User and AttractionVisit model. Alternatively, you can think of the relationship as a many-to-many between the Attraction and User models (i.e. an attraction can be visited by many users and a user can visit many attractions).

Apply the migration when you're ready.

Update the Attraction Detail page

Update the Attraction Detail page to display a list of attraction visits. Display an "Add Visit" button (a hyperlink styled as a button using Bootstrap's CSS classes) above the list of attraction visits that when clicked, navigates the user to the "Add Visit" page.

Add the Add Visit page

Add a ./routes/visit module, then add the GET and POST routes for the "Add Visit" page:

const visitValidators = [
  // TODO Define validators.
];

router.get('/attraction/:attractionId(\\d+)/visit/add', csrfProtection,
  asyncHandler(async (req, res) => {
    // TODO Implement route handler.
  }));

router.post('/attraction/:attractionId(\\d+)/visit/add', csrfProtection, visitValidators,
  asyncHandler(async (req, res) => {
    // TODO Implement route handler.
  }));

In your POST /user/register route, make sure to use visitValidators in addition to your csrfProtection middleware. This means you'll need to import check and validationResult from express-validator.

Implement the following validation rules:

  • visitedOn
    • Not null or empty
    • Is a valid date
  • rating
    • Not null or empty
    • Is an integer between 1 and 5

Render a template that extends the main layout and has a form within block content. Take note of the mixins and validationErrorSummary template that are available for you to use. The form should contain the following input fields:

  • Visited On
  • Rating
  • Comments

Require a logged in user

To add a new visit, the user needs to be logged into the website. Without a logged in user, you wouldn't know who to add the visit for!

Add a new function named requireAuth() to the auth module. Update the requireAuth() function to redirect the user to the "Login" page if the res.locals.authenticated property is set to false, otherwise pass control to the next middleware function by calling the next() method.

Then import the requireAuth() function into the ./routes/visit module and add it to the GET and POST routes for the "Add Visit" page:

router.get('/attraction/:attractionId(\\d+)/visit/add', requireAuth, csrfProtection,
  asyncHandler(async (req, res) => {
    // Code removed for brevity.
  }));

router.post('/attraction/:attractionId(\\d+)/visit/add', requireAuth, csrfProtection, visitValidators,
  asyncHandler(async (req, res) => {
    // Code removed for brevity.
  }));

Now, if the current user isn't logged in, they'll be redirected to the "Login" page if they attempt view the "Add Visit" page!

Bonus Phase 1: Adding the Edit Visit and Delete Visit pages

  • Add the edit and delete attraction visit routes and views.
  • Only display the "Edit" and "Delete" buttons on a visit if the visit's user is the current user.
  • Check that the current user is the owner of the visit before allowing them to edit or delete it.

Bonus Phase 2: Locking down parks and attractions

  • Add an attribute to the User model that allows you to indicate which users are "Admin" users.
  • Update the park and attraction CRUD routes to only allow authenticated "Admin" users.