Phase 4: User Authentication Routes

← Back to Main Tutorial

Understanding the Problem

In this phase, we need to create the authentication API routes for our Express application. These routes will allow users to:

These routes form the foundation of user authentication in any web application. They allow users to establish and manage their identity within your system, which is crucial for personalized experiences and data security.

Planning the Solution

We will create the following API routes:

For each route, we will follow a feature-branch Git workflow to keep our development process organized and allow for more focused testing before merging changes into our main development branch.

Setting Up Route Files

First, let's create the necessary router files to organize our API endpoints. We'll create separate files for session-related routes and user-related routes.

Creating the Session Router

Create a file called session.js in the backend/routes/api folder to handle session-related routes:

// backend/routes/api/session.js
const express = require('express');
const router = express.Router();

module.exports = router;

Creating the Users Router

Create a file called users.js in the backend/routes/api folder to handle user-related routes:

// backend/routes/api/users.js
const express = require('express');
const router = express.Router();

module.exports = router;

Connecting the Routers

Now, let's update the backend/routes/api/index.js file to connect these routers to our API:

// backend/routes/api/index.js
const router = require('express').Router();
const sessionRouter = require('./session.js');
const usersRouter = require('./users.js');
const { restoreUser } = require("../../utils/auth.js");

// Connect restoreUser middleware to the API router
// If current user session is valid, set req.user to the user in the database
// If current user session is not valid, set req.user to null
router.use(restoreUser);

router.use('/session', sessionRouter);
router.use('/users', usersRouter);

router.post('/test', (req, res) => {
  res.json({ requestBody: req.body });
});

module.exports = router;

Note that we're connecting the restoreUser middleware before connecting our routers. This ensures that the req.user property is set for all routes, which is essential for our authentication logic.

Implementing Login Functionality

Creating a Login Feature Branch

Let's start by creating a feature branch for the login functionality:

git checkout dev
git checkout -b login

Implementing the Login Route

Update the backend/routes/api/session.js file to include the login route:

// backend/routes/api/session.js
const express = require('express');
const { Op } = require('sequelize');
const bcrypt = require('bcryptjs');

const { setTokenCookie, restoreUser } = require('../../utils/auth');
const { User } = require('../../db/models');

const router = express.Router();

// Log in
router.post('/', async (req, res, next) => {
  const { credential, password } = req.body;

  // Find the user by either username or email
  const user = await User.unscoped().findOne({
    where: {
      [Op.or]: {
        username: credential,
        email: credential
      }
    }
  });

  // Check if user exists and password is correct
  if (!user || !bcrypt.compareSync(password, user.hashedPassword.toString())) {
    const err = new Error('Login failed');
    err.status = 401;
    err.title = 'Login failed';
    err.errors = { credential: 'The provided credentials were invalid.' };
    return next(err);
  }

  // Create a safe user object (without hashedPassword)
  const safeUser = {
    id: user.id,
    email: user.email,
    username: user.username,
  };

  // Set the JWT cookie
  await setTokenCookie(res, safeUser);

  // Return the user information
  return res.json({
    user: safeUser
  });
});

module.exports = router;

Let's break down what this login route does:

  1. It extracts the credential (which can be either a username or email) and password from the request body.
  2. It finds a user with a matching username or email using Sequelize's Op.or operator.
  3. It calls User.unscoped() to temporarily remove the default scope, which allows us to access the hashedPassword field that's normally excluded.
  4. It checks if a user was found and if the provided password matches the stored hashed password.
  5. If either check fails, it creates an error and passes it to the next error-handling middleware.
  6. If the credentials are valid, it creates a safe user object (without sensitive information like the hashed password).
  7. It calls setTokenCookie to set a JWT cookie containing the user's information.
  8. Finally, it returns a JSON response with the user's information.

Testing the Login Route

Now, let's test the login route to ensure it's working correctly. Start your server if it's not already running:

cd backend
npm start

First, get a CSRF token by navigating to http://localhost:8000/api/csrf/restore. Then, open your browser's developer console and use fetch to test the login route.

Test logging in with a username:

fetch('/api/session', {
  method: 'POST',
  headers: {
    "Content-Type": "application/json",
    "XSRF-TOKEN": `<value of XSRF-TOKEN cookie>`
  },
  body: JSON.stringify({ credential: 'Demo-lition', password: 'password' })
}).then(res => res.json()).then(data => console.log(data));

Test logging in with an email:

fetch('/api/session', {
  method: 'POST',
  headers: {
    "Content-Type": "application/json",
    "XSRF-TOKEN": `<value of XSRF-TOKEN cookie>`
  },
  body: JSON.stringify({ credential: 'demo@user.io', password: 'password' })
}).then(res => res.json()).then(data => console.log(data));

Test with invalid credentials:

fetch('/api/session', {
  method: 'POST',
  headers: {
    "Content-Type": "application/json",
    "XSRF-TOKEN": `<value of XSRF-TOKEN cookie>`
  },
  body: JSON.stringify({ credential: 'Demo-lition', password: 'wrongpassword' })
}).then(res => res.json()).then(data => console.log(data));

If everything is working correctly, you should see a successful login response for the first two requests (with a token cookie set) and an error response for the third request.

Committing and Merging the Login Branch

Once you've confirmed that the login functionality is working, commit your changes and merge them into the dev branch:

git add .
git commit -m "Add User login backend endpoint"
git checkout dev
git pull origin dev  # If collaborating with others
git merge login
git push origin dev

Implementing Logout Functionality

Creating a Logout Feature Branch

Let's create a new feature branch for the logout functionality:

git checkout dev
git checkout -b logout

Implementing the Logout Route

Update the backend/routes/api/session.js file to include the logout route:

// backend/routes/api/session.js
// ... existing code

// Log out
router.delete('/', (_req, res) => {
  res.clearCookie('token');
  return res.json({ message: 'success' });
});

module.exports = router;

The logout route is much simpler than the login route. It simply clears the JWT token cookie and returns a success message. Notice that we use an underscore prefix for the _req parameter to indicate that it's not used in the function, which is a common convention.

Testing the Logout Route

Let's test the logout route to ensure it's working correctly:

fetch('/api/session', {
  method: 'DELETE',
  headers: {
    "Content-Type": "application/json",
    "XSRF-TOKEN": `<value of XSRF-TOKEN cookie>`
  }
}).then(res => res.json()).then(data => console.log(data));

If everything is working correctly, you should see { message: 'success' } in the console, and the token cookie should disappear from your browser's cookies.

Committing and Merging the Logout Branch

Once you've confirmed that the logout functionality is working, commit your changes and merge them into the dev branch:

git add .
git commit -m "Add User logout backend endpoint"
git checkout dev
git pull origin dev
git merge logout
git push origin dev

Implementing Signup Functionality

Creating a Signup Feature Branch

Let's create a new feature branch for the signup functionality:

git checkout dev
git checkout -b signup

Implementing the Signup Route

Update the backend/routes/api/users.js file to include the signup route:

// backend/routes/api/users.js
const express = require('express');
const bcrypt = require('bcryptjs');

const { setTokenCookie, requireAuth } = require('../../utils/auth');
const { User } = require('../../db/models');

const router = express.Router();

// Sign up
router.post('/', async (req, res) => {
  const { email, password, username } = req.body;
  
  // Hash the password
  const hashedPassword = bcrypt.hashSync(password);
  
  // Create a new user
  const user = await User.create({ email, username, hashedPassword });

  // Create a safe user object (without hashedPassword)
  const safeUser = {
    id: user.id,
    email: user.email,
    username: user.username,
  };

  // Set the JWT cookie
  await setTokenCookie(res, safeUser);

  // Return the user information
  return res.json({
    user: safeUser
  });
});

module.exports = router;

Let's break down what this signup route does:

  1. It extracts the email, password, and username from the request body.
  2. It hashes the password using bcrypt for secure storage.
  3. It creates a new user in the database with the provided information.
  4. It creates a safe user object (without sensitive information like the hashed password).
  5. It calls setTokenCookie to set a JWT cookie containing the user's information.
  6. Finally, it returns a JSON response with the user's information.

Testing the Signup Route

Let's test the signup route to ensure it's working correctly:

fetch('/api/users', {
  method: 'POST',
  headers: {
    "Content-Type": "application/json",
    "XSRF-TOKEN": `<value of XSRF-TOKEN cookie>`
  },
  body: JSON.stringify({
    email: 'spidey@spider.man',
    username: 'Spidey',
    password: 'password'
  })
}).then(res => res.json()).then(data => console.log(data));

If everything is working correctly, you should see a JSON response with the new user's information, and a token cookie should be set in your browser.

Now, try to test some validation errors by trying to create a user with an existing email or username:

fetch('/api/users', {
  method: 'POST',
  headers: {
    "Content-Type": "application/json",
    "XSRF-TOKEN": `<value of XSRF-TOKEN cookie>`
  },
  body: JSON.stringify({
    email: 'demo@user.io',  // This email already exists
    username: 'NewUser',
    password: 'password'
  })
}).then(res => res.json()).then(data => console.log(data));

You should receive a Sequelize validation error because the email is already in use.

Committing and Merging the Signup Branch

Once you've confirmed that the signup functionality is working, commit your changes and merge them into the dev branch:

git add .
git commit -m "Add User signup backend endpoint"
git checkout dev
git pull origin dev
git merge signup
git push origin dev

Implementing Get Session User Functionality

Creating a Get Session Feature Branch

Let's create a new feature branch for the get session user functionality:

git checkout dev
git checkout -b get-session

Implementing the Get Session User Route

Update the backend/routes/api/session.js file to include the get session user route:

// backend/routes/api/session.js
// ... existing code

// Restore session user
router.get('/', (req, res) => {
  const { user } = req;
  if (user) {
    const safeUser = {
      id: user.id,
      email: user.email,
      username: user.username,
    };
    return res.json({
      user: safeUser
    });
  } else return res.json({ user: null });
});

module.exports = router;

This route is quite simple. It checks if there's a user on the request object (which would have been set by the restoreUser middleware). If there is, it returns a safe version of the user object. If not, it returns { user: null }.

Testing the Get Session User Route

Let's test the get session user route to ensure it's working correctly. First, make sure you're logged in by using the login route. Then, test the get session user route:

fetch('/api/session')
  .then(res => res.json())
  .then(data => console.log(data));

If you're logged in, you should see your user information. If you're not logged in, you should see { user: null }.

Committing and Merging the Get Session Branch

Once you've confirmed that the get session user functionality is working, commit your changes and merge them into the dev branch:

git add .
git commit -m "Add a backend endpoint to get the current user session"
git checkout dev
git pull origin dev
git merge get-session
git push origin dev

Understanding the Authentication Flow

Now that we've implemented all the authentication routes, let's take a step back and understand how they work together to create a complete authentication system.

The Authentication Flow

The authentication flow in our application follows these steps:

  1. Signup: A new user provides their email, username, and password. The password is hashed, and a new user record is created in the database. A JWT containing the user's information is set as a cookie, and the user is considered logged in.
  2. Login: An existing user provides their credentials (username or email) and password. The application verifies these against the database. If valid, a JWT containing the user's information is set as a cookie, and the user is considered logged in.
  3. Session Restoration: On subsequent requests, the restoreUser middleware checks for a valid JWT cookie. If found, it retrieves the corresponding user from the database and attaches it to the request object, making it available to route handlers.
  4. Session Check: The GET /api/session route can be used to check if a user is currently logged in. It returns the user's information if they are, or null if they're not.
  5. Logout: When a user logs out, the JWT cookie is cleared, and the user is no longer considered logged in.
  6. Protected Routes: Routes that should only be accessible to logged-in users can use the requireAuth middleware, which checks if a user is attached to the request and returns an error if not.

JWT-based Authentication

Our authentication system uses JSON Web Tokens (JWT) to maintain user sessions. This approach has several advantages:

The JWT is stored in an HTTP-only cookie, which helps protect against cross-site scripting (XSS) attacks because JavaScript code can't access HTTP-only cookies.

Real-world Considerations

In a real-world application, you might want to add additional features to this basic authentication system:

These features would build upon the foundation we've established in this phase.

Common Issues and Troubleshooting

CSRF Token Issues

One common issue is getting a CSRF token error when making POST, PUT, DELETE, or PATCH requests. This can happen if:

Always make sure to include the XSRF-TOKEN value in your request headers for non-GET requests:

fetch('/api/session', {
  method: 'POST',
  headers: {
    "Content-Type": "application/json",
    "XSRF-TOKEN": `<value of XSRF-TOKEN cookie>`
  },
  body: JSON.stringify({ /* request body */ })
});

Authentication Issues

If you're having issues with authentication, check the following:

You can use console.log statements to debug these issues, or use the browser's developer tools to inspect cookies and network requests.

Database Issues

If you're having issues with the database, check the following:

You can use sqlite3 db/dev.db to directly inspect your database and verify that data is being stored correctly.

Next Steps

Now that you have implemented the core authentication routes for your application, you are ready to move on to Phase 5: Validating the Request Body. In that phase, you'll:

These validations will help improve the security and reliability of your application by ensuring that only valid data is processed.

Continue to Phase 5: Validating the Request Body