Автор статьи, перевод которой мы сегодня публикуем, говорит, что сейчас можно наблюдать рост популярности таких сервисов аутентификации, как Google Firebase Authentication, AWS Cognito и Auth0. Индустриальным стандартом стали универсальные решения наподобие passport.js. Но, учитывая сложившуюся ситуацию, обычным явлением стало то, что разработчики никогда в полной мере не понимают того, какие именно механизмы принимают участие в работе систем аутентификации.

Этот материал посвящён проблеме организации аутентификации пользователей в среде Node.js. В нём на практическом примере рассмотрена организация регистрации пользователей в системе и организация их входа в систему. Здесь будут подняты такие вопросы, как работа с технологией JWT и имперсонация пользователей.



Кроме того, обратите внимание на этот GitHub-репозиторий, в котором содержится код Node.js-проекта, некоторые примеры из которого приведены в этой статье. Этот репозиторий вы можете использовать в качестве основы для собственных экспериментов.

Требования к проекту


Вот требования к проекту, которым мы будем здесь заниматься:

  • Наличие базы данных, в которой будет храниться адрес электронной почты пользователя и его пароль, либо — clientId и clientSecret, либо — нечто вроде комбинации из приватного и публичного ключей.
  • Использование сильного и эффективного криптографического алгоритма для шифрования пароля.

В тот момент, когда я пишу этот материал, я считаю, что лучшим из существующих криптографических алгоритмов является Argon2. Я прошу вас не использовать простые криптографические алгоритмы вроде SHA256, SHA512 или MD5.

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

Регистрация пользователей в системе


Когда в системе создаётся новый пользователь, его пароль необходимо хэшировать и сохранить в базе данных. Пароль в базе сохраняют вместе с адресом электронной почты и другими сведениями о пользователе (например, среди них может быть профиль пользователя, время регистрации и так далее).

import * as argon2 from 'argon2';

class AuthService {
  public async SignUp(email, password, name): Promise<any> {
    const passwordHashed = await argon2.hash(password);

    const userRecord = await UserModel.create({
      password: passwordHashed,
      email,
      name,
    });
    return {
      // Никогда не передавайте куда-либо пароль!!!
      user: {
        email: userRecord.email,
        name: userRecord.name,
      },
    
  
}

Данные учётной записи пользователя должны выглядеть примерно так, как показано ниже.


Данные пользователя, полученные из MongoDB с помощью Robo3T

Вход пользователей в систему


Вот как выглядит схема действий, выполняемых в том случае, когда пользователь пытается войти в систему.


Вход пользователя в систему

Вот что происходит при входе пользователя в систему:

  • Клиент отправляет серверу комбинацию, состоящую из публичного идентификатора и приватного ключа пользователя. Обычно это — адрес электронной почты и пароль.
  • Сервер ищет пользователя в базе данных по адресу электронной почты.
  • Если пользователь существует в базе данных — сервер хэширует отправленный ему пароль и сравнивает то, что получилось, с хэшем пароля, сохранённым в базе данных.
  • Если проверка оказывается успешной — сервер генерирует так называемый токен или маркер аутентификации — JSON Web Token (JWT). 

JWT — это временный ключ. Клиент должен отправлять этот ключ серверу с каждым запросом к аутентифицированной конечной точке.

import * as argon2 from 'argon2';

class AuthService {
  public async Login(email, password): Promise<any> {
    const userRecord = await UserModel.findOne({ email });
    if (!userRecord) {
      throw new Error('User not found')
    } else {
      const correctPassword = await argon2.verify(userRecord.password, password);
      if (!correctPassword) {
        throw new Error('Incorrect password')
      
    

    return {
      user: {
        email: userRecord.email,
        name: userRecord.name,
      },
      token: this.generateJWT(userRecord),
    
  
}

Верификация пароля производится с использованием библиотеки argon2. Это делается для предотвращения так называемых «атак по времени». При выполнении такой атаки злоумышленник пытается взломать пароль методом грубой силы, основываясь на анализе того, сколько времени нужно серверу на формирование ответа.

Теперь давайте поговорим о том, как генерировать JWT.

Что такое JWT?


JSON Web Token (JWT) — это закодированный в строковой форме JSON-объект. Токены можно воспринимать как замену куки-файлов, имеющую несколько преимуществ перед ними.

Токен состоит из трёх частей. Это — заголовок (header), полезная нагрузка (payload) и подпись (signature). На следующем рисунке показан его внешний вид.


JWT

Данные токена могут быть декодированы на стороне клиента без использования секретного ключа или подписи.

Это может быть полезным для передачи, например метаданных, закодированных внутри токена. Подобные метаданные, могут описывать роль пользователя, его профиль, время действия токена, и так далее. Они могут быть предназначены для использования во фронтенд-приложениях.

Вот как может выглядеть декодированный токен.


Декодированный токен

Генерирование JWT в Node.js


Давайте создадим функцию generateToken, которая нужна нам для завершения работы над сервисом аутентификации пользователей.

Создавать JWT можно с помощью библиотеки jsonwebtoken. Найти эту библиотеку можно в npm.

import * as jwt from 'jsonwebtoken'
class AuthService {
  private generateToken(user) {

    const data =  {
      _id: user._id,
      name: user.name,
      email: user.email
    };
    const signature = 'MySuP3R_z3kr3t';
    const expiration = '6h';

    return jwt.sign({ data, }, signature, { expiresIn: expiration });
  
}

Самое важное здесь — это закодированные данные. Не отправляйте в токенах секретную информацию о пользователях.

Подпись (здесь — константа signature) — это секретные данные, которые используются для генерирования JWT. Очень важно следить за тем, чтобы подпись не попала в чужие руки. Если подпись окажется скомпрометированной — атакующий сможет генерировать токены от имени пользователей и красть их сессии.

Защита конечных точек и проверка JWT


Теперь клиентскому коду нужно отправлять JWT в каждом запросе к защищённой конечной точке.

Рекомендуется включать JWT в заголовки запросов. Обычно их включают в заголовок Authorization.


Заголовок Authorization

Теперь, на сервере, нужно создать код, представляющий собой промежуточное ПО для маршрутов express. Поместим этот код в файл isAuth.ts:

import * as jwt from 'express-jwt';

// Мы исходим из предположения о том, что JWT приходит на сервер в заголовке Authorization, но токен может быть передан и в req.body, и в параметре запроса, поэтому вам нужно выбрать тот вариант, который подходит вам лучше всего. 
const getTokenFromHeader = (req) => {
  if (req.headers.authorization && req.headers.authorization.split(' ')[0] === 'Bearer') {
    return req.headers.authorization.split(' ')[1];
  
}

export default jwt({
  secret: 'MySuP3R_z3kr3t', // Тут должно быть то же самое, что использовалось при подписывании JWT

  userProperty: 'token', // Здесь следующее промежуточное ПО сможет найти то, что было закодировано в services/auth:generateToken -> 'req.token'

  getToken: getTokenFromHeader, // Функция для получения токена аутентификации из запроса
})

Полезно иметь возможность получать полные сведения об учётной записи пользователя из базы данных и присоединять их к запросу. В нашем случае эта возможность реализуется средствами промежуточного ПО из файла attachCurrentUser.ts. Вот его упрощённый код:

export default (req, res, next) => {
 const decodedTokenData = req.tokenData;
 const userRecord = await UserModel.findOne({ _id: decodedTokenData._id })

  req.currentUser = userRecord;

 if(!userRecord) {
   return res.status(401).end('User not found')
 } else {
   return next();
 
}

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

import isAuth from '../middlewares/isAuth';
  import attachCurrentUser from '../middlewares/attachCurrentUser';
  import ItemsModel from '../models/items';

  export default (app) => {
    app.get('/inventory/personal-items', isAuth, attachCurrentUser, (req, res) => {
      const user = req.currentUser;

      const userItems = await ItemsModel.find({ owner: user._id });

      return res.json(userItems).status(200);
    })

Теперь маршрут inventory/personal-items защищён. Для доступа к нему пользователь должен иметь валидный JWT. Маршрут, кроме того, может использовать сведения о пользователе для поиска в базе данных необходимых ему сведений.

Почему токены защищены от злоумышленников?


Почитав об использовании JWT, вы можете задаться следующим вопросом: «Если данные JWT могут быть декодированы на стороне клиента — можно ли так обработать токен, чтобы изменить идентификатор пользователя или другие данные?».

Декодирование токена — операция очень простая. Однако нельзя «переделать» этот токен, не имея той подписи, тех секретных данных, которые были использованы при подписывании JWT на сервере.

Именно поэтому так важна защита этих секретных данных.

Наш сервер проверяет подпись в промежуточном ПО isAuth. За проверку отвечает библиотека express-jwt.

Теперь, после того, как мы разобрались с тем, как работает технология JWT, поговорим о некоторых интересных дополнительных возможностях, которые она нам даёт.

Как имперсонировать пользователя?


Имперсонация пользователей — это техника, используемая для входа в систему под видом некоего конкретного пользователя без знания его пароля.

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

Работать с приложением от имени пользователя можно и не зная его пароля. Для этого достаточно сгенерировать JWT с правильной подписью и с необходимыми метаданными, описывающими пользователя.

Создадим конечную точку, которая может генерировать токены для входа в систему под видом конкретных пользователей. Этой конечной точкой сможет пользоваться только супер-администратор системы.

Для чала нам нужно назначить этому пользователю роль, с которой связан более высокий, чем у других пользователей, уровень привилегий. Это можно сделать множеством различных способов. Например, достаточно просто добавить поле role в сведения о пользователе, хранящиеся в базе данных.

Выглядеть это может так, как показано ниже.


Новое поле в сведениях о пользователе

Значением поля role супер-администратора будет super-admin.

Далее, надо создать новое промежуточное ПО, которое проверяет роль пользователя:

export default (requiredRole) => {
  return (req, res, next) => {
    if(req.currentUser.role === requiredRole) {
      return next();
    } else {
      return res.status(401).send('Action not allowed');
    
  
}

Оно должно быть помещено после isAuth и attachCurrentUser. Теперь создадим конечную точку, которая генерирует JWT для пользователя, от имени которого супер-администратор хочет войти в систему:

  import isAuth from '../middlewares/isAuth';
  import attachCurrentUser from '../middlewares/attachCurrentUser';
  import roleRequired from '../middlwares/roleRequired';
  import UserModel from '../models/user';

  export default (app) => {
    app.post('/auth/signin-as-user', isAuth, attachCurrentUser, roleRequired('super-admin'), (req, res) => {
      const userEmail = req.body.email;

      const userRecord = await UserModel.findOne({ email: userEmail });

      if(!userRecord) {
        return res.status(404).send('User not found');
      

      return res.json({
        user: {
          email: userRecord.email,
          name: userRecord.name
        },
        jwt: this.generateToken(userRecord)
      })
      .status(200);
    })

Как видите, тут нет ничего таинственного. Супер-администратор знает адрес электронной почты пользователя, от имени которого нужно войти в систему. Логика работы вышеприведённого кода очень напоминает то, как работает код, обеспечивающий вход в систему обычных пользователей. Главное отличие заключается в том, что здесь не производится проверка правильности пароля.
Пароль тут не проверяется из-за того, что он здесь просто не нужен. Безопасность конечной точки обеспечивается промежуточным ПО.

Итоги


Нет ничего плохого в том, чтобы полагаться на сторонние сервисы и библиотеки аутентификации. Это помогает разработчикам экономить время. Но им необходимо ещё и знать о том, на каких принципах основана работа систем аутентификации, о том, что обеспечивают функционирование таких систем.

В этом материале мы исследовали возможности JWT-аутентификации, поговорили о важности выбора хорошего криптографического алгоритма для хэширования паролей. Мы рассмотрели создание механизма имперсонации пользователей.

Сделать то же самое с помощью чего-то вроде passport.js далеко не так просто. Аутентификация — это огромная тема. Возможно, мы к ней ещё вернёмся.

Уважаемые читатели! Как вы создаёте системы аутентификации для своих Node.js-проектов?

Комментарии (8)


  1. ferocactus
    26.06.2019 17:17
    +3

    Токены можно воспринимать как замену куки-файлов, имеющую несколько преимуществ перед ними.

    Для меня звучит как «время можно воспринимать как замену часам». Ведь куки — это просто средство хранения информации. А JWT это та самая информация, которую надо где-то хранить.

    А ещё в статье не увидел, в чём же преимущество использования JWT перед собственно паролем. Дело в ведь в ограничении успешного взлома по времени? Сломали JWT, а он заэкспайрился через пару часов.



  1. EgorVolokitin
    26.06.2019 20:33
    +1

    Это может быть полезным для передачи, например метаданных, закодированных внутри токена. Подобные метаданные, могут описывать роль пользователя, его профиль, время действия токена, и так далее. Они могут быть предназначены для использования во фронтенд-приложениях

    Не нужен jwt для того чтобы передавать открытые данные на фронтенд.
    Дело в том, что jwt хранит первые 2 части в base64. Это позволяет легко декодировать их как раз в json (потому он так и называется). Но третья часть, подпись, не нужна на клиенте ни за чем. Если рассуждать как автор — я на клиенте должен описать примерно такую логику:
    function encodeFromJWT(token) {
      const payload = token.split('.')[1];
      return btoa(payload);
    }
    

    Я бы понял, если мог бы изначально проверить токен. Но для проверки мне надо знать секретный ключ (соль) для того, чтобы библиотека jsonwebtoken мне с ним высчитала хэш и сверила с подписью в токене, а хранение соли на клиенте равносильно подписи токена от любого источника. Как результат — использование JWT для передачи данных на клиент это велосипед, который не дает преимуществ перед обычным json.

    Так же по поводу cookies. Дело в том, что куки не просто хранилище. Они позволяют вам защитить хранимые данные или поставить им время жизни (или и то и то). Поэтому JWT храниться в куках браузера, а заменить их и подавно не может. Но для хранения аутентификации необходимо поставить флажок httpOnly, что позволит «спрятать» токен от клиентского JS. Если вы используете https-соединение стоит поставить еще флажок secure, который отключает передачу токена вне https.

    В статье еще не была названа основная фишка JWT и преимущества перед связкой login:password_hash, прилетающей на сервер например из куки, — JWT гарантирует неизменность данных, что позволяет избежать лишнего запроса к базе.

    В то же время у JWT есть минус: его не возможно отозвать. Он будет валиден даже будет украден. Это решается одним из 3х способов:
    1) хранение в JWT даты обновления пользователя в базе. При любом обновлении профиля все прошлые JWT становятся не валидными. Но тогда в чем смысл JWT если я храню дату обновления? он не дает преимуществ по сравнению с login:password_hash
    2) Хранение в базе черного списка JWT. Но для этого нужен и белый по сути — иначе не знаешь какой токен поместить в черный. Проблемы та же что и в 1м случае.
    3) Способ, который использую я: в JWT храню хэш fingerprint-а, что позволяет мне идентифицировать устройство пользователя. При запросах (важных) посылаю на сервер fingerprint, высчитываю хэш и сравниваю. Если хэши разные — устройство было изменено и аутентификация не прошла. В результате не требуется запрос к базе и мы можем быть уверены что токен не был украден.

    В дополнение JWT имеет свое поле со временем жизни. При валидации токена сравнивается не только подпись, но и это поле, что позволяет реализовать механизм access-refresh, за счет чего злоумышленника выкинет даже если он украдет аутентифицированное устройство, а пользователь сменит пароль.


    1. Gugic
      27.06.2019 01:01

      Добавлю (не вам, а тем, кому эта информация может быть полезна в дополнение к вашей):
      Хранение токена в куках само по себе даже с secure и httpOnly не защитит вас от самых банальных csrf-атак вроде вызова fetch с "{credentials: 'include'}" (но тут хоть как-то можно отбиться с помощью CORS) или даже просто стандартной формой с самым обычным POST где-то на просторах.

      Поэтому не забывйте и про CSRF-токены, которые не должны храниться в куках.


    1. Wh1teHunter
      27.06.2019 13:22

      У JWT токена есть подпись, подписывать можно разными алгоритмами, можно выбрать из RSA («RS256», «RS512» ...). Создаёшь пару открытого и закрытого ключей, подписываешь закрытым, открытый отправляешь пользователю после успешной аутентификации вместе с подписанным токеном.

      Единственный момент: если использовать связку Access и Refresh токенов, то при ответе сервера с новой парой токенов пользователю злоумышленник может перехватить эти данные и пользоваться ими сколько захочет, пока пользователь не узнает, что его взломали


  1. kovert99
    27.06.2019 13:22

    Раз уж вы все равно получаете пользователя на сервере, то почему для JWT выбрана именно почта и пароль? Гораздо логичнее было бы генерировать для каждого пользователя рандомную строку, которая бы шифровалась в JWT и использовалась для аутенификации.

    Таким образом, если бы пользователю необходимо было бы разлогинить себя везде, то мы могли бы просто обновить рандомную строку.


    1. Wh1teHunter
      27.06.2019 15:05

      JWT это не шифр, а формат данных, его может каждый прочитать, просто декодировав base64. Однако есть JWE (JSON Web Encryption), который как раз шифрует, но встаёт проблема распределения ключей


    1. EgorVolokitin
      27.06.2019 15:49

      Для JWT не обязателен логин и пароль. Более того: пароль вообще не нужен, в прочем как и логин. Можно просто хранить id пользователя в базе. Я, к примеру, помимо id храню в JWT данные, которые не вижу смысла получать постоянно заново из базы. Например это может быть ник пользователя или дата создания аккаунта (если она вам нужна)