Modern JWT authorization for the modern Node.js Koa framework

image
The authorization task arises in almost every Node.js project, however, in order to configure it correctly, you need to connect a large number of modules and collect a lot of information from different sources.

In this article, I will describe a complete JSON Web Token (JWT) authorization solution for Node.js and Koa with password hashes stored in MongoDB. A basic knowledge of Node.js and principles for working with MongoDB through Mongoose is expected from the reader.

A few words about what specifically will be discussed and why.

Why koa. Despite the much greater popularity of the Express, Koa frameworkprovides the ability to write applications using modern async / await syntax. Using async / await instead of callbacks is a big enough incentive to take a closer look at this framework.

Why JWT. The approach to authorization using sessions can already be called obsolete, since it does not allow using it in mobile applications and where there is no support for cookies. Also, problems with sessions can occur in cluster systems. JWT authorization does not have these disadvantages, and has a number of additional advantages. You can read more about JWT here.

The article will consider a complete authorization solution using:

  1. passport.js. De facto standard for working with authorization in Node.js projects
  2. hashing passwords and storing hashes in the MongoDB database
  3. authentication for REST API
  4. authentication for socket.io, which is usually a more complex topic than point 3

In order to preserve the educational value of the article in the code, there will be no extended checks for errors and exceptions, which often make the code less clear. Therefore, before using the code examples in production, we need to work on error handling and control of input from the client.

So, let's begin


1. We connect Koa. Unlike Express, Koa is a lighter framework and therefore is usually used with a number of additional modules.

const Koa = require('koa'); // ядро
const Router = require('koa-router'); // маршрутизация
const bodyParser = require('koa-bodyparser'); // парсер для POST запросов
const serve = require('koa-static'); // модуль, который отдает статические файлы типа index.html из заданной директории
const logger = require('koa-logger'); // опциональный модуль для логов сетевых запросов. Полезен при разработке.
const app = new Koa();
const router = new Router();
app.use(serve('public'));
app.use(logger());
app.use(bodyParser());

2. We connect Passport.js . Passport.js allows you to flexibly configure authorization using various mechanisms called Strategies (local, social networks, etc.). The library currently has over 300 strategy options.

const passport = require('koa-passport'); //реализация passport для Koa
const LocalStrategy = require('passport-local'); //локальная стратегия авторизации
const JwtStrategy = require('passport-jwt').Strategy; // авторизация через JWT
const ExtractJwt = require('passport-jwt').ExtractJwt; // авторизация через JWT
app.use(passport.initialize()); // сначала passport
app.use(router.routes()); // потом маршруты
const server = app.listen(3000);// запускаем сервер на порту 3000

3. We connect work with JWT. In a nutshell, JWT is just JSON in which, for example, the user's email can be stored. This JSON is signed with a secret key, which does not allow this email to be changed, although it allows you to read it.

Thus, when receiving a JWT from a client, you are sure that the user whom he claims to have come to you (provided that his JWT was not stolen by someone, but this is a completely different story).

const jwtsecret = "mysecretkey"; // ключ для подписи JWT
const jwt = require('jsonwebtoken'); // аутентификация по JWT для hhtp
const socketioJwt = require('socketio-jwt'); // аутентификация по JWT для socket.io

4. We connect socket.io. In a nutshell, socket.io is a module for working with applications that respond to changes that occur on the server, for example, it can be used for chat. If the server and browser support the WebSockets protocol, then socket.io will be using it, otherwise it will look for other mechanisms for implementing two-way communication between the browser and the server.

const socketIO = require('socket.io');

5. We connect MongoDB for storage of objects of users.

const mongoose = require('mongoose'); // стандартная прослойка для работы с MongoDB
const crypto = require('crypto'); // модуль node.js для выполнения различных шифровальных операций, в т.ч. для создания хэшей.

Now run it all together


The user object ( user ) will consist of its name, e-mail and password hash.

To turn the password received from the POST request into a hash, which will be stored in the database, the concept of virtual fields is used. A virtual field is a field that is in the Mongoose model, but which is not in the MongoDB database.

mongoose.Promise = Promise; // Просим Mongoose использовать стандартные Промисы
mongoose.set('debug', true);  // Просим Mongoose писать все запросы к базе в консоль. Удобно для отладки кода
mongoose.connect('mongodb://localhost/test'); // Подключаемся к базе test на локальной машине. Если базы нет, она будет создана автоматически.

We create the scheme and model for the User:

const userSchema = new mongoose.Schema({
  displayName: String,
  email: {
    type: String,
    required: 'Укажите e-mail',
    unique: 'Такой e-mail уже существует'
  },
  passwordHash: String,
  salt: String,
}, {
  timestamps: true
});
userSchema.virtual('password')
.set(function (password) {
  this._plainPassword = password;
  if (password) {
    this.salt = crypto.randomBytes(128).toString('base64');
    this.passwordHash = crypto.pbkdf2Sync(password, this.salt, 1, 128, 'sha1');
  } else {
    this.salt = undefined;
    this.passwordHash = undefined;
  }
})
.get(function () {
  return this._plainPassword;
});
userSchema.methods.checkPassword = function (password) {
  if (!password) return false;
  if (!this.passwordHash) return false;
  return crypto.pbkdf2Sync(password, this.salt, 1, 128, 'sha1') == this.passwordHash;
};
const User = mongoose.model('User', userSchema);

For a deeper understanding of the mechanism for working with password hashes, you can read about the pbkdf2Sync command in the dock on Node.js

We configure work with Passport.js


The user authorization process is as follows:

Step 1. A new user is registered, and a record is created about him in the MongoDB database.
Step 2. The user logs in with the password on the site and upon successful login and password, receives the JWT.
Step 3. The user enters an arbitrary resource, sends his JWT, by which he logs in without entering a password.

The configuration mechanism for Passport.js consists of two stages:

Stage 1. Configuring Strategies. Upon successful authorization, the strategy returns the user object described earlier in the userSchema scheme.
Stage 2. Using the user object obtained in stage 1 for subsequent actions, for example, creating a JWT for it.

Stage 1


Configure Passport Local Strategy. More details on how the strategy works can be found here .

passport.use(new LocalStrategy({
    usernameField: 'email',
    passwordField: 'password',
    session: false
  },
  function (email, password, done) {
    User.findOne({email}, (err, user) => {
      if (err) {
        return done(err);
      }
      if (!user || !user.checkPassword(password)) {
        return done(null, false, {message: 'Нет такого пользователя или пароль неверен.'});
      }
      return done(null, user);
    });
  }
  )
);

Configure Passport JWT Strategy. More details on how the strategy works can be found here .

// Ждем JWT в Header
const jwtOptions = {
  jwtFromRequest: ExtractJwt.fromAuthHeader(),
  secretOrKey: jwtsecret
};
passport.use(new JwtStrategy(jwtOptions, function (payload, done) {
    User.findById(payload.id, (err, user) => {
      if (err) {
        return done(err)
      }
      if (user) {
        done(null, user)
      } else {
        done(null, false)
      }
    })
  })
);

Stage 2


We will create a REST API that will work with the user object.

The API will consist of three endpoints corresponding to the three Steps of the authorization process described above.

Post request to / user - creates a new user. Typically, this API is called when a new user is registered. In the body of the request, we expect JSON with the user name, mail and password.

router.post('/user', async(ctx, next) => {
  try {
    ctx.body = await User.create(ctx.request.body);
  }
  catch (err) {
    ctx.status = 400;
    ctx.body = err;
  }
});

A post request to / login creates a JWT to use. In the body of the request, we expect to receive JSON in which there will be a mail and user password. In production, it is logical to issue JWT also during user registration.

router.post('/login', async(ctx, next) => {
  await passport.authenticate('local', function (err, user) {
    if (user == false) {
      ctx.body = "Login failed";
    } else {
      //--payload - информация которую мы храним в токене и можем из него получать
      const payload = {
        id: user.id,
        displayName: user.displayName,
        email: user.email
      };
      const token = jwt.sign(payload, jwtsecret); //здесь создается JWT
      ctx.body = {user: user.displayName, token: 'JWT ' + token};
    }
  })(ctx, next);  
});

A GET request to / custom checks for a valid JWT.

router.get('/custom', async(ctx, next) => {
  await passport.authenticate('jwt', function (err, user) {
    if (user) {
      ctx.body = "hello " + user.displayName;
    } else {
      ctx.body = "No such user";
      console.log("err", err)
    }
  } )(ctx, next)  
});

Now let's make the final chord on setting up authorization for socket.io. The problem here is that the WebSockets protocol runs on top of tcp, not http, and the REST API mechanisms are not applicable to it. Fortunately, there is a socketio-jwt module for it, which allows you to succinctly describe authorization through JWT.

let io = socketIO(server);
io.on('connection', socketioJwt.authorize({
  secret: jwtsecret,
  timeout: 15000
})).on('authenticated', function (socket) {
  console.log('Это мое имя из токена: ' + socket.decoded_token.displayName);
  socket.on("clientEvent", (data) => {
    console.log(data);
  })
});

You can read more about authorization through JWT for socket.io here .

Conclusion


Using the code above, you can build a working Node.js application using a modern authorization approach. Of course, in production it will be necessary to add a number of checks that are usually standard for this kind of application.

The full version of the code with a description of how to test it can be found on GitHub.

Also popular now: