Phaser Leaderboard with User Authentication using Node + Express + MongoDB – Part 3

In Part 2 of this tutorial series, we continued working on our Express server. We did the following:

  • Added the rest of the endpoints that will be needed for our server
  • Created a user mongoose model for our database
  • Created the connection for connecting to MongoDB
  • Started updating the routes to send and retrieve data from MongoDB

In Part 3 of this tutorial series, we will continue working on our server by updating the rest of the endpoints for sending data to MongoDB, and we will add authentication to our endpoints.

You can download all of the files associated with the source code for Part 3 here.

If you didn’t complete Part 2 and would like to continue from there, you can find the code for it here.

Let’s get started!

BUILD GAMES

FINAL DAYS: Unlock 250+ coding courses, guided learning paths, help from expert mentors, and more.

Updating Score Routes

Currently, the /scores and /submit-score endpoints just have placeholder logic that will return a 200 response code. We will now update the /scores endpoint to get the top ten high scores from the database, and update the /submit-score endpoint to update a single user’s score. To do this, open routes/secure.js and replace all of the code in the file with the following:

const express = require('express');
const asyncMiddleware = require('../middleware/asyncMiddleware');
const UserModel = require('../models/userModel');

const router = express.Router();

router.post('/submit-score', asyncMiddleware(async (req, res, next) => {
  const { email, score } = req.body;
  await UserModel.updateOne({ email }, { highScore: score });
  res.status(200).json({ status: 'ok' });
}));

router.get('/scores', asyncMiddleware(async (req, res, next) => {
  const users = await UserModel.find({}, 'name highScore -_id').sort({ highScore: -1}).limit(10);
  res.status(200).json(users);
}));

module.exports = router;

Let’s review the code we just added:

  • First, we imported asyncMiddleware and UserModel, and then we wrapped both of the routes in the asyncMiddleware middleware we created in part two.
  • Next, in the submit-score route, we grabbed the email and score values from the request body. We then used the updateOne method on the UserModel to update a single document in the database where the provided email value matches the email property on the record.
  • Then, in the /scores route, we used the find method on the UserModel to search for documents in the database.
    • The find method takes two arguments, the first is an object that is used for limiting the documents that are returned from the database. By leaving this as an empty object, all documents will be returned from the database.
    • The second argument is a string that allows us to control which fields we want to be returned on the results that are returned to us. This argument is optional, and if it is not provided then all fields will be returned. By default, the _id field will always be returned, so to exclude it we need to use -_id in this argument.
  • We then called the sort method to sort the results that are returned. This method allows you to specify the field you would like to sort by, and by setting that value to -1 the results will be sorted in descending order.
  • Finally, we called the limit method to make sure we return 10 documents at max.

Now, if you save your code changes and start the server, you will be able to test the updated routes. To test the /submit-score endpoint, you will need to use curl, Postman, or some other method for sending a POST request. For the rest of this tutorial, we will be using curl.

In the terminal, open a new tab or window and enter the following code:

curl -X POST \
  http://localhost:3000/submit-score \
  -H 'Content-Type: application/json' \
  -d '{
	"email": "[email protected]",
	"score": "100"
}'

Once you submit the request, you should get the status ok message. To test the /scores endpoint, open a new tab in your browser and visit the following URL: http://localhost:3000/scores. You should see an array of objects that include the name and highscore fields.

Authentication

Now that the score endpoints are connected to our database, we will now start working on adding authentication to our server. For the authentication, we will be using passport.js along with passport-jwt. Passport is an authentication middleware for Nodejs that can easily be used with Express, and it supports many different types of authentication.

Along with passport, we will be using JSON web tokens for validating users. In addition to using a JWT for authenticating users, we will also be using a refresh token to allow a user to update their main JWT. The reason we are doing this is that we want the main JWT to be short-lived, and instead of requiring the player to have to keep re-logging in to get a new token, they can instead use their refresh token to update the main JWT, since it will be long-lived.

To get started, the first thing we need to do is install the required packages. In the terminal, run the following command:

npm install --save cookie-parser passport passport-local passport-jwt jsonwebtoken

This will install passport along with the two strategies we will need for our server. passport-local allows us to authenticate with a username and password on our server. passport-jwt allows us to authenticate with a JSON web token. Lastly, cookie-parser allows us to parse the cookie header and populate,req.cookies which is where we will be placing the JWT that is generated.

To get started, create a new folder in the root folder called auth and inside this file create a new file called auth.js. In auth.js, add the following code:

const passport = require('passport');
const localStrategy = require('passport-local').Strategy;
const JWTstrategy = require('passport-jwt').Strategy;

const UserModel = require('../models/userModel');

// handle user registration
passport.use('signup', new localStrategy({
  usernameField: 'email',
  passwordField: 'password',
  passReqToCallback: true
}, async (req, email, password, done) => {
  try {
    const { name } = req.body;
    const user = await UserModel.create({ email, password, name});
    return done(null, user);
  } catch (error) {
    done(error);
  }
}));

In the code above, we did the following:

  • First, we imported the passport, passport-local, and passport-jwt. Then, we imported the userModel.
  • Next, we configured passport to use a new local strategy when the signup route is called. The localStrategy takes two arguments: an options object and a callback function.
    • For the options object, we set the usernameField and passwordField fields. By default, if these fields are not provided passport-local will expect the username and password fields, and if we want to use a different combination we need to provide them.
    • Also, in the options object, we set the passReqToCallback field and we set it to true. By setting this field to true, the request object will be passed to the callback function.
  • Then, in the callback function, we took the logic for creating the user that was in the signup route and we placed it here.
  • Lastly, we called the done function that was passed as an argument to the callback function.

Before we update the server and routes to use the new authentication, we will need to finish configuring passport. In auth.js, add the following code at the bottom of the file:

// handle user login
passport.use('login', new localStrategy({
  usernameField: 'email',
  passwordField: 'password'
}, async (email, password, done) => {
  try {
    const user = await UserModel.findOne({ email });
    if (!user) {
      return done(null, false, { message: 'User not found' });
    }
    const validate = await user.isValidPassword(password);
    if (!validate) {
      return done(null, false, { message: 'Wrong Password' });
    }
    return done(null, user, { message: 'Logged in Successfully' });
  } catch (error) {
    return done(error);
  }
}));

// verify token is valid
passport.use(new JWTstrategy({
  secretOrKey: 'top_secret',
  jwtFromRequest: function (req) {
    let token = null;
    if (req && req.cookies) token = req.cookies['jwt'];
    return token;
  }
}, async (token, done) => {
  try {
    return done(null, token.user);
  } catch (error) {
    done(error);
  }
}));

Let’s review the code we just added:

  • We set up another local strategy for the login route. Then in the callback function, we took the logic for logging a user in from the login route and we placed it here.
  • Next, we configured passport to use a new JWT strategy. For the JWT strategy, we provided two arguments: an options object and a callback function.
    • In the options object, we provided two fields: secretOrKey and jwtFromRequest.
    • secretOrKey is used for signing the JWT that is created. For this tutorial, we used a placeholder secret and normally you would want to pull this from your environment variables or use some other secure method, and you would want to use a much more secure secret.
    • jwtFromRequest is a function that is used for getting the jwt from the request object. For this tutorial, we will be placing the jwt in a cookie, so in the function, we pull the jwt token from the request object cookie if it exists otherwise we return null.
    • Lastly, in the callback function, we call the done function that was provided to the callback.

Now that we have finished configuring passport, we need to update app.js to use passport. To do this, open app.js and add the following code at the top of the file with the other require statements:

const cookieParser = require('cookie-parser');
const passport = require('passport');

Then, add the following code below the `app.use(bodyParser.json());` line:

app.use(cookieParser());

// require passport auth
require('./auth/auth');

Finally, replace this line: `app.use(‘/’, secureRoutes);` with the following code:

app.use('/', passport.authenticate('jwt', { session : false }), secureRoutes);

In the code above, we did the following:

  • First, we imported cookie-parser and passport.
  • Then, told our express app to use cookie-parser. By using cookie-parser, the request object will have the cookies included.
  • Lastly, we imported our auth file and then updated our secure routes to use the passport JWT strategy we set up.

The last thing we need to do before we can test our code changes is, update the routes in routes/main.js. To do this, open routes/main.js and replace all of the code in the file with the following code:

const passport = require('passport');
const express = require('express');
const jwt = require('jsonwebtoken');

const tokenList = {};
const router = express.Router();

router.get('/status', (req, res, next) => {
  res.status(200).json({ status: 'ok' });
});

router.post('/signup', passport.authenticate('signup', { session: false }), async (req, res, next) => {
  res.status(200).json({ message: 'signup successful' });
});

router.post('/login', async (req, res, next) => {
  passport.authenticate('login', async (err, user, info) => {
    try {
      if (err || !user) {
        const error = new Error('An Error occured');
        return next(error);
      }
      req.login(user, { session: false }, async (error) => {
        if (error) return next(error);
        const body = {
          _id: user._id,
          email: user.email
        };

        const token = jwt.sign({ user: body }, 'top_secret', { expiresIn: 300 });
        const refreshToken = jwt.sign({ user: body }, 'top_secret_refresh', { expiresIn: 86400 });

        // store tokens in cookie
        res.cookie('jwt', token);
        res.cookie('refreshJwt', refreshToken);

        // store tokens in memory
        tokenList[refreshToken] = {
          token,
          refreshToken,
          email: user.email,
          _id: user._id
        };

        //Send back the token to the user
        return res.status(200).json({ token, refreshToken });
      });
    } catch (error) {
      return next(error);
    }
  })(req, res, next);
});

router.post('/token', (req, res) => {
  const { email, refreshToken } = req.body;

  if ((refreshToken in tokenList) && (tokenList[refreshToken].email === email)) {
    const body = { email, _id: tokenList[refreshToken]._id };
    const token = jwt.sign({ user: body }, 'top_secret', { expiresIn: 300 });

    // update jwt
    res.cookie('jwt', token);
    tokenList[refreshToken].token = token;

    res.status(200).json({ token });
  } else {
    res.status(401).json({ message: 'Unauthorized' });
  }
});

router.post('/logout', (req, res) => {
  if (req.cookies) {
    const refreshToken = req.cookies['refreshJwt'];
    if (refreshToken in tokenList) delete tokenList[refreshToken]
    res.clearCookie('refreshJwt');
    res.clearCookie('jwt');
  }

  res.status(200).json({ message: 'logged out' });
});

module.exports = router;

Let’s review the code we just added:

  • First, we imported jsonwebtoken and passport, and then we removed `userModel` and `asyncMiddleware`.
  • Next, in the signup route, we added the passport.authenticate middleware and set it to use the passport signup configuration we created. Since we moved all of the logic for creating the use to the auth.js file, the only thing we need to do in the callback function is to return a 200 response.
  • Then, in the login route we added the passport.authenticate middleware and set it to use the passport login configuration we created.
    • In the callback function, we first check to see if there was an error or if a user object was not returned from the passport middleware. If this check is true, then we create a new error and pass it to the next middleware.
    • If that check is false, we then call the login method that is exposed on the request object. This method is added by passport automatically. When we call this method, we pass the user object, an options object, and a callback function as arguments.
    • In the callback function, we create two JSON web tokens by using the `jsonwebtoken` library.  For the JWTs, we include the id and email of the user in the JWT payload, and we set the main token to expire in five minutes and the refreshToken to expire in one day.
    • Then, we stored both of these tokens in the response object by calling the cookie method, and we stored these tokens in memory so we can reference them later in the token refresh endpoint. Note: for this tutorial, we are storing these tokens in memory, but in practice, you would want to store this data in some type of persistent storage.
    • Lastly, we responded with a 200 response code and in the response, we send the token and refreshToken.
  • Next, in the token route, we pulled the email and refreshToken from the request body. We then checked to see if the refreshToken is in the tokenList object we are using for tracking the user’s tokens, and we made sure the provided email matches the one stored in memory.
    • If these do not match, or if the token is not in memory, then we respond with a 401 status code.
    • If they do match, then we create a new token and store it in memory and update the response cookie with the new token.
    • We then respond with a 200 response code and in the response, we send the new token.
  • Finally, in the logout route, we check to request object has any cookies.
    • If the request object does have any cookies, then we pull the `refreshJwt` from the cookie and delete it from our in-memory token list if it exists.
    • We then clear the jwt and refreshJwt cookies by calling the clearCookie method on the response object.
    • Lastly, we respond with a 200 response code.

Now that the routes have been updated, you can test the new authentication. To do this, save your code changes and restart your server. If you try sending a request to the `submit-score` and scores endpoints, you should a 401 unauthorized message.

If you send a login request, you should get a response that includes the token and refreshToken.

Conclusion

Now that we have finished setting up the user authentication, that brings Part 3 of this tutorial to an end. In Part 4, we will start working on our game by adding a login page, which will then take the player to the Phaser game after they log in.

I hope you enjoyed this tutorial and found it helpful. If you have any questions, or suggestions on what we should cover next, please let us know in the comments below.