Современная JWT авторизация для современного Node.js фреймворка Koa

image
Задача авторизации возникает практически в каждом Node.js проекте, однако, чтобы ее правильно настроить, необходимо подключить большое количество модулей и собрать кучу информации из разных источников.

В этой статье я опишу полноценное решение по авторизации на основе JSON Web Token (JWT) для Node.js и Koa с хранением хэшей паролей в MongoDB. От читателя ожидаются базовые знания Node.js и принципов работы с MongoDB через Mongoose.

Несколько слов, о чем конкретно пойдет речь и почему.

Почему Коа. Не смотря на значительно большую популярность фреймворка Express, Koa предоставляет возможность писать приложения используя современный синтаксис async/await. Использование async/await вместо callback’ов является достаточно большим стимулом, чтобы присмотреться к этому фреймворку.

Почему JWT. Подход к авторизации с помощью сессий можно уже назвать устаревшим, так как он не позволяет использовать его в мобильных приложениях и там, где нет поддержки cookies. Также проблемы с сессиями могут возникнуть в кластерных системах. JWT авторизация не имеет этих недостатков, и обладает еще рядом дополнительных преимуществ. Более подробно про JWT можно прочитать тут.

В статье будет рассмотрено полноценное решение по авторизации с использованием:

  1. passport.js. Де-факто стандарт для работы с авторизацией в Node.js проектах
  2. хешированием паролей и хранением хэшей в базе MongoDB
  3. аутентификацией для REST API
  4. аутентификацией для socket.io, что является обычно более сложной темой, чем п.3

Чтобы сохранить образовательную ценность статьи в коде не будет расширенных проверок на ошибки и исключения, которые часто делают код менее понятным. Поэтому перед использованием примеров кода в продакшене, надо поработать над обработкой ошибок и контролем входных данных от клиента.

Итак, начнем


1. Подключаем Koa. В отличие от Express, Koa является более легким фреймворком и поэтому, обычно, используется с рядом дополнительных модулей.

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. Подключаем Passport.js. Passport.js позволяет гибко настраивать авторизацию, используя разные механизмы, которые называются Стратегиями (локальная, социальные сети д.р.). В настоящий момент библиотека насчитывает более 300 вариантов стратегий.

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. Подключаем работу с JWT. В двух словах JWT — это просто JSON в котором может храниться, например, email пользователя. Этот JSON подписывается секретным ключом, что не позволяет этот email изменить, хотя позволяет его прочитать.

Таким образом, получая с клиента JWT вы уверены, что к вам пришел именно тот пользователь, за которого он себя выдает (при условии, что его JWT не был кем-то украден, но это уже совсем другая история).

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

4. Подключаем socket.io. В двух словах socket.io — это модуль для работы приложений, которые реагируют на изменения происходящие на сервере, например его можно использовать для чата. Если сервер и браузер поддерживают протокол WebSockets, то socket.io будет использовав его, иначе он поищет другие механизмы реализации двустороннего общения браузера с сервером.

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

5. Подключаем MongoDB для хранения объектов пользователей.

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

Теперь запустим все это вместе


Объект пользователя (user) будет состоять из его имени, e-mail и хэша пароля.

Для превращения пароля, получаемого из POST запроса в хэш, который будет храниться в базе применяется концепция виртуальных полей. Виртуальное поле — это поле, которое есть в модели Mongoose, но которого нет в базе MongoDB.

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

Создаем схему и модель для Пользователя:

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);

Для более глубокого понимания механизма работы с хэшами паролей можно почитать про команду pbkdf2Sync в доке по Node.js

Настраиваем работу с Passport.js


Процесс авторизации пользователя выглядит следующим образом:

Шаг 1. Новый пользователь регистрируется, и создается запись о нем в базе MongoDB.
Шаг 2. Пользователь логинится с паролем на сайте и при успешном вводе логина и пароля получает JWT.
Шаг3. Пользователь заходит на произвольный ресурс, отсылает свой JWT, по которому и авторизуется уже без ввода пароля.

Механизм настройки Passport.js состоит из двух этапов:

Этап 1. Настройка Стратегий. Стратегия при успешной авторизации возвращает объект user, описанный ранее в схеме userSchema.
Этап 2. Использование полученного на этапе 1 объекта user для последующих действий, например, создания для него JWT.

Этап 1


Настраиваем Passport Local Strategy. Более подробно, как работает стратегия можно прочитать на тут.

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);
    });
  }
  )
);

Настраиваем Passport JWT Strategy. Более подробно, как работает стратегия можно прочитать на тут.

// Ждем 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)
      }
    })
  })
);

Этап 2


Мы создадим REST API, который будет работать с объектом user.

API будет состоять из трех endpoints, соответствующих трем Шагам процесса авторизации, описанному выше.

Post запрос на /user – создает нового пользователя. Обычно этот API вызывается при регистрации нового пользователя. В теле запроса мы ожидаем JSON с именем, почтой и паролем пользователя.

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

Post запрос на /login создает JWT для пользоваться. В теле запроса мы ожидаем получить JSON в котором будет почта и пароль пользователя. В продакшене логично JWT выдавать также и при регистрации пользователя.

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);  
});

GET запрос на /custom проверяет наличие валидного 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)  
});

Теперь сделаем финальный аккорд по настройке авторизации для socket.io. Проблема тут в том, что протокол WebSockets работает поверх tcp, а не http и механизмы REST API к нему не применимы. К счастью, для него есть модуль socketio-jwt, который позволяет достаточно лаконично описать авторизацию через 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);
  })
});

Более подробно про авторизацию через JWT для socket.io можно почитать тут.

Заключение


Используя код выше вы можете построить рабочее Node.js приложение, используя современный подход к авторизации. Разумеетсяя в продакшене надо будет добавить ряд проверок, которые обычно стандартны для такого рода приложений.

Полную версию кода с описание того, как его протестировать можно посмотреть в GitHub.
Share post

Comments 17

    +2
    Для лучшего усвоения вот добавка:
    критика JWT

    JOSE (Javascript Object Signing and Encryption) is a Bad Standard That Everyone Should Avoid
      0
      Тут есть небольшие подвижки. https://tools.ietf.org/html/draft-ietf-cose-msg-24
        0
        Мне кажется проблемы более надуманы. Да, для супер надежных финансовых систем может быть, но что мешает забанить злого юзера вместо того. чтобы что-то делать с его токеном?

        Учитывая использования выражений типа «Bad Standard That Everyone Should Avoid» я бы не принимал эту статью и те статьи, куда она ведет очень близко к сердцу.
        0
        >> нет поддержки cookies

        нет поддержки http заголовков? Это скорее проблемы имплементации http клиента…
          0
          Я правильно понимаю (по приведённому коду), что однажды выданный токен будет работать вечно и отозвать его невозможно?
            0
            можно сменить secret ^)
              0
              тогда все предыдущие токены станут недействительными?
                0
                или для каждого юзера свой secret?
                0
                Используйте OAuth2 + OpenID Connect. В первом комменте очень полезная ссылка. Как раз про некорректное использование.
                  0
                  Можно в токене указывать период валидности. Так обычно все и делают, просто как было сказано в начале статьи, я старался не загромождать код.

                  Кроме того, ничто не мешает нужного юзера добавить в группу, для которой не будет работать авторизация по токену, таким образом отправив его снова на логин/пароль. После этого убрать его из этой группы.
                    0
                    в зависимости от Вашей реализации, можно установить свойство exp для токена и проверять его :)
                      0
                      Для токена можно установить время жизни, после которого он будет не валиден:
                      jwt.sign({
                        data: 'foobar'
                      }, 'secret', { expiresIn: '1h' });
                      
                      +1
                      буквально неделю назад поднимал аутентификацию на таком же стеке, лепил туда так же паспорт… потом одумался, выбросил его и все отлично работает. До сих пор не пойму, какую функцию он здесь выполняет. Декодирование токена отлично делает koa-jwt, а проверка существования пользователя при запросе — 2 строки кода запроса в БД (с асинк\эвейтом).
                      Его использование оправдано разве что если нужна аутентификация по соц сетям… но разве она совместима с jwt?
                        +1
                        Я согласен, что в случае с токеном пользы от паспорта может быть меньше, чем в сессиях. Но однажды освоив паспорт вы будет любую авторизацию писать легко и не принужденно.

                        А если вы перейдете с Коа на hapi, придется искать модуль для декодирования jwt для hapi? если паспорт не вызывает отторжения ( а при первом знакомстве такое может произойти) я бы советовал использовать его.
                        0
                        А у меня в личном кабинете сделан функционал, который показывает все текущие сессии, с информацией об IP и устройстве, с которого она открыта. Плюс позволяет оборвать любую открытую сессию с любого устройства.
                        В случае с JWT мне нужно самому писать замену сессиям? Я правильно понимаю?
                          0
                          если Вам действительно надо смотреть сессии и иметь возможность их обрывать, то наверно Вам лучше продолжать использовать сессии. Задача токена, не улучшить управляемость доступом к сайту, а упростить ее и сделать более универсальной.
                          0

                          Я не совсем понял как обращаться к "/custom", я использую postman, зарегаться и получить токен — понятно, а как его отдавать в GET запросе?

                          Only users with full accounts can post comments. Log in, please.