Привет, друзья!


На досуге разработал шаблон Node.js-сервера для аутентификации/авторизации, которым хочу с вами поделиться. Надеюсь, кому-нибудь пригодится.


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


Также обратите внимание, что в коде имеется несколько console.log для облегчения процесса разработки приложения. В продакшне они не нужны. В производственном режиме также не следует возвращать столь информативные message.


Если возможностей, реализованных в шаблоне, окажется недостаточно, вот парочка более продвинутых инструментов:


  • oidc-client — разработчик отказался от дальнейшей поддержки, новый мейнтейнер пока не нашелся
  • oidc-provider — рекомендация моих более опытных коллег

Если вас интересует полноценная платформа для аутентификации/авторизации "из коробки", рассмотрите возможность использования Auth0.


Репозиторий


Сервер реализован с помощью Express.js


В качестве базы данных используется MongoDB Atlas


Другие используемые технологии:


  • nodemon — утилита для запуска сервера для разработки
  • mongooseORM для MongoDB. Здесь вы найдете руководство по работе с этой ORM
  • jsonwebtoken — утилита для работы с токенами. Здесь вы найдете шпаргалку по работе с этой утилитой
  • argon2 — утилита для хеширования паролей
  • helmet — утилита для установки HTTP-заголовков, связанных с безопасностью. Здесь вы найдете шпаргалку по работе с этой утилитой, а здесь — шпаргалку и туториал по заголовкам безопасности
  • cors — утилита для установки HTTP-заголовков, связанных с CORS. Здесь вы найдете шпаргалку по работе с этой утилитой
  • cookie-parser — утилита для разбора куки, содержащихся в запросе
  • cross-env — утилита для установки переменных среды окружения
  • @sentry/node и @sentry/tracingSDK для интеграции с Sentry (только для продакшна)

Алгоритм локального запуска проекта:


  • Клонируем репозиторий:

git clone https://github.com/harryheman/express-mongo-auth-server-template.git

  • Переходим в директорию и устанавливаем зависимости:

cd express-mongo-auth-server-template
yarn
# or
npm i

  • Переименовываем директорию config.example в config. Устанавливаем значения содержащихся в config/index.js переменных. Для режима разработки достаточно установить значение переменной MONGODB_URI. Для производственного режима также необходимо установить значение переменной SENTRY_DSN (если вы планируете использовать этот сервис). Значения переменных VERIFICATION_CODE и ACCESS_TOKEN_SECRET в продакшне должны быть случайными строками. Эти переменные являются общими для сервера и клиента. При использовании на клиенте они должны оставаться скрытыми (REACT_APP_VERIFICATION_CODE, например). Значения этих переменных должны периодически обновляться.


  • Генерируем ключи (см. ниже):



yarn gen
# or
npm run gen

Ключи также должны периодически обновляться (ротация ключей, key rotation).


  • Запускаем сервер для разработки:

yarn dev
# or
npm run dev

  • Запускаем сервер для продакшна:

yarn start
# or
npm start

Поступим следующим образом: сначала немного теории, затем структура проекта и особенности процесса аутентификации/авторизации, реализованного в приложении, и в конце тестирование сервера с помощью Insomnia.


Теория


Аутентификация и авторизация


Понятия "аутентификация" и "авторизация" часто употребляются как синонимы, но на самом деле они обозначают два разных процесса.


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


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


Признаки аутентификации:


  • определение того, кем является пользователь;
  • валидация полномочий (пароль, ответ на секретный вопрос, распознавание лица и т.п.);
  • как правило, выполняется перед авторизацией;
  • обычно преобразуется в токен идентификации (ID Token).

Признаки авторизации:


  • определение того, к чему пользователь имеет доступ;
  • дополнительная проверка доступа на основе политик (policies) и/или правил (rules);
  • обычно выполняется после аутентификации;
  • как правило, преобразуется в токен доступа (Access Token).

Таким образом, для доступа к приложению требуется как аутентификация, так и авторизация. Если пользователь не может подтвердить свою личность (идентичность, identity), он не будет иметь доступа. И даже если пользователь подтвердил свою личность, но не авторизовался, в доступе ему будет отказано.


Токен





Под токеном в рамках настоящей статьи подразумевается JSON Web Token.


JWT — это открытый стандарт (RFC 7519), определяющий компактный и автономный способ безопасной передачи данных между сторонами в виде объекта формата JSON. JWT — это стандарт, т.е. все JWT являются токенами, но не все токены являются JWT.


Благодаря относительно небольшому размеру JWT может передаваться через URL, тело POST-запроса, HTTP-заголовок и т.д. Как правило, он содержит информацию, достаточную для однократного обращения к БД с целью получения необходимых данных о пользователе. Валидация токена выполняется только на сервере.


По сравнению с другими токенами, такими как простые веб-токены (SWT) или токены языка разметки утверждения безопасности (SAML), JWT имеет следующие преимущества:


  • более компактный размер;
  • более высокая степень безопасности;
  • более широкая распространенность;
  • более легкое обслуживание.

JWT может использоваться для:


  • аутентификации: при успешном входе пользователя в систему возвращается токен идентификации;
  • авторизации: одновременно с токеном идентификации или вслед за ним пользователю предоставляется токен доступа, который в дальнейшем прикрепляется к каждому запросу пользователя на доступ к защищенным ресурсам;
  • обмена информацией: токены отлично подходят для обмена "секретными" сообщениями.

Информация содержащаяся в токене, может быть проверена и является доверенной благодаря цифровой подписи (digital sign). Шифрование токена применяется редко, хотя такая возможность имеется (речь идет о шифровании содержимого токена). В шаблоне используются подписанные токены (signed tokens).


Токен может быть подписан с помощью секрета (secret) (алгоритм HMAC) или с помощью публичного и приватного ключей (public/private key pair) (алгоритм RSA). В шаблоне используются оба варианта (просто для примера). Когда токен подписан с помощью приватного ключа, он может быть подтвержден (проверен, verify) только стороной, владеющей публичным ключом.


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


Структура токена


Токен состоит из трех частей, представляющих собой закодированные base64-строки, разделенные точками (.):


  • JOSE Header: содержит данные о типе токена и криптографическом алгоритме, использованным для его подписания

{
 "alg": "HS256",
 "typ": "JWT"
}

  • JWS Payload (настройки или заявки, claims): проверяемые инструкции безопасности, такие как идентичность пользователя и его полномочия

{
 "sub": "1234567890",
 "name": "John Doe",
 "admin": true
}

  • JWS signature: используется для проверки того, что токен не был модифицирован и поэтому является доверенным

HMACSHA256(
 base64UrlEncode(header) + "." +
 base64UrlEncode(payload),
 secret
)

Виды токенов


Существует 3 основных вида токенов: токен идентификации, токен доступа и токен обновления (Refresh Token). В шаблоне токен идентификации как таковой не используется.


Токен идентификации используется только приложением. Такие токены не должны использоваться для доступа к API. Каждый токен идентификации содержит информацию, предназначенную для определенной аудитории (audience), которой обычно является адресат (получатель, recipient) токена.


Согласно спецификации OpenID Connect аудиторией токена идентификации (указанной в настройке aud) должен быть идентификатор клиента (client ID), выполняющего запрос на аутентификацию. Если это не так, токен считается не заслуживающим доверия. Наличие идентификатора клиента означает, что только данный клиент должен потреблять (consume) этот токен.


Токен доступа используется для уведомления API о том, что его предъявитель (bearer) имеет доступ к API, т. е. выполнил все необходимые действия (в соответствии со сферами доступа — scopes).


Токены доступа не должны использоваться для аутентификации. Обычно в такой токен включается только идентификатор клиента (настройка sub).


Токен обновления, как следует из названия, используется для обновления токена доступа без принуждения пользователя к повторной аутентификации, т.е. автоматически.


Правила использования токенов и алгоритмы подписи


Правила


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


  • секретность означает безопасность: ключ, используемый для подписания токена, должен быть скрытым;
  • токен не должен содержать чувствительных данных пользователя;
  • токен должен иметь ограниченное время жизни (expiration): технически после подписания токен является валидным до тех пор, пока не изменится ключ, использованный для его подписания, или пока не истечет время его жизни;
  • для передачи токена должно использоваться только HTTPS-соединение: в противном случае, токен может быть перехвачен и скомпрометирован;
  • при необходимости для проверки токенов должна использоваться вторичная система верификации;
  • с целью уменьшения количества запросов к серверу следует предусмотреть возможность временного хранения токенов на стороне клиента (в шаблоне это реализовано с помощью куки — для токена обновления, для токена доступа на клиенте следует использовать sessionStorage (но не localStorage) или просто хранить токен в памяти).

Алгоритмы


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


  • RS256 (сигнатура RSA с SHA-256): алгоритм ассиметричного шифрования — у нас имеется 2 ключа, публичный и приватный, приватный ключ должен храниться в секрете. Для подписания токена используется приватный ключ, а для его проверки — публичный;
  • HS256 (HMAC с SHA-256): алгоритм симметричного шифрования — у нас имеется только один ключ, который используется и для подписания токена, и для его проверки. Этот ключ должен храниться в секрете.

RS256 считается более безопасным.


В шаблоне для подписания токена обновления используется RS256, а для подписания токена доступа HS256.


Для тех, кого интересует дополнительная информация о JWT — вот хорошая статья.


Структура проекта


Проект имеет следующую структуру:


- `config.example` - данная директория должны быть переименована в `config` после установки значений переменных. Обычно, переменные среды окружения помещаются в файл `.env`, но можно использовать и такой вариант, главное, не забыть указать `config` в `.gitignore`
 - `index.js`
- `middlewares` - посредники, промежуточный слой
 - `index.js` - агрегация и повторный экспорт посредников
 - `setCookie.js` - для генерации токенов обновления и доступа
 - `setSecurityHeaders.js` - для установки заголовков безопасности, используется вместо `helmet` (вы должны понимать, что делаете)
 - `verifyAuth.js` - для проверки аутентификации
 - `verifyAccess.js` - для проверки авторизации
 - `verifyPermission.js` - для дополнительной проверки полномочий пользователя
- `models`
 - `User.js` - модель пользователя для `mongoose`
- `routes`
 - `app.routes.js` - роуты приложения
 - `auth.routes.js` - роуты аутентификации/авторизации
- `services`
 - `auth.services.js` - сервисы аутентификации/авторизации
- `utils` - утилиты
 - `generateKeyPair.js` - для генерации публичного и приватного ключей (запускается при выполнении команды `yarn gen`)
 - `token.js` - для подписания и проверки токенов
- `index.js` - основной файл сервера
- ...

Роуты, реализованные в приложении:


- `api/`
 - `/auth`
   - `/` - получение данных аутентифицированного пользователя
   - `/register` - регистрация нового пользователя
   - `/login` - авторизация пользователя (вход в систему)
   - `/logout` - выход из системы
   - `/remove` - удаление пользователя

Логика авторизации:


  • При инициализации клиентское приложение отправляет на сервер GET-запрос по адресу /api/auth/ (в дальнейшем я буду опускать /api/auth). Данный запрос проходит через посредника verifyAuth. Этот посредник проверяет 2 вещи:


    • наличие и значение специального заголовка X-Verification-Code. Вместо этого может использоваться любой другой способ уникальной идентификации запроса, позволяющий достоверно определить, что запрос отправлен доверенным клиентом
    • наличие и время жизни токена обновления, содержащегося в куки


Если заголовок и токен в порядке, токен декодируется, его содержимое записывается в req.user и управление передается сервису getUser. Сервис getUser получает данные пользователя по его ID из БД, обновляет req.user и передает управление посреднику setCookie. Посредник setCookie подписывает токен доступа (поскольку токен обновления уже имеется, он игнорируется) и возвращает его клиенту вместе с данными пользователя. Это называется тихой аутентификацией (silent authentication).


Если время жизни токена обновления истекло, возвращается статус 401 и соответствующее сообщение. Здесь возможно 2 варианта: либо мы заставляем пользователя пройти повторную аутентификацию, либо молча продляем токен обновления, но для этого придется соорудить вторую систему верификации.


Токен обновления подписывается с помощью RS256 и содержит только ID пользователя. Время его жизни составляет 7 дней.


Токен доступа подписывается с помощью HS256 и содержит ID пользователя и его роль (role). Время его жизни составляет 1 час (это очень много, такое время жизни должно использоваться только при разработке приложения, в продакшне оно должно составлять 5-10 мин).


Время жизни токенов зависит от потребностей приложения. Общее правило таково: чем меньше время жизни токена, тем лучше.


  • При регистрации на сервер отправляется POST-запрос по адресу /register. Тело запроса должно содержать имя, адрес электронной почты, пароль пользователя и, опционально, его роль (по умолчанию USER, второй вариант — ADMIN). Сервер проверяет наличие пользователя с указанным именем или email в БД. Если пользователь новый, его пароль хешируется, после чего данные пользователя записываются в БД и в req.user. Затем управление передается посреднику setCookie. Данный посредник подписывает токен обновления и токен доступа (возможно, стоило все-таки разделить ответственность). Токен обновления зашивается в куки, передаваемую только по HTTPS и доступную только на сервере. Токен доступа и данные пользователя возвращаются клиенту.


  • При выполнении входа в систему на сервер отправляется POST-запрос по адресу /login. Тело запроса должно содержать имя или email и пароль пользователя. Данные пользователя извлекаются из БД, пароли сравниваются. Если все ок, остальная часть "флоу" аналогична регистрации.


  • При выполнении выхода из системы на сервер отправляется GET-запрос по адресу /logout. Данный запрос проходит через посредника verifyAccess (обращение к данной конечной точке представляет собой обращение к защищенному ресурсу — просто для примера). Посредник verifyAccess проверяет наличие и время жизни токена доступа.



Если токен в порядке, он декодируется, его содержимое записывается в req.user и управление передается сервису logoutUser. Все, что делает данный сервис — это очищает куки, содержащую токен обновления. Обратите внимание: после ответа сервера об успешном выходе из системы клиент должен обнулить токен доступа на своей стороне.


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


  • При удалении пользователя на сервер отправляется DELETE-запрос по адресу /remove. Данный запрос последовательно проходит через посредников verifyAccess и verifyPermission (обращение к данной конечной точке также представляет собой обращение к защищенному ресурсу — просто для примера).

Посредник verifyPermission проверяет, что пользователь является администратором (декодированный токен доступа содержит роль пользователя). Если запрос отправлен администратором, управление передается сервису removeUser. Тело запроса должно содержать имя или email удаляемого пользователя. Если пользователь существует, он удаляется.


Тестирование сервера


Запускаем сервер для разработки с помощью команды yarn dev.




Получаем сообщения о подключении к БД и запуске сервера.


Я буду использовать Insomnia. В качестве альтернативы можно использовать Postman.


Попробуем получить данные пользователя.




Получаем сообщение об отсутствии кода верификации.


Добавляем соответствующий заголовок и пробуем снова.




Получаем сообщение об отсутствии токена обновления.


Зарегистрируем 2 пользователей. Пусть Bob будет админом, а Alice — обычным пользователем.








Возвращаемся к получению данных пользователя. Копируем куки (вкладка Cookie, кнопка Manage Cookies), добавляем соответствующий заголовок и выполняем запрос.











Отлично, мы получили данные Alice и токен доступа.


Теперь попробуем выйти из системы.




Получаем сообщение об отсутствии токена доступа.


Добавляем соответствующий заголовок (Authorization: Bearer [token]) и пробуем снова.




Получаем сообщение об успешном выходе из системы.


Наконец, попробуем удалить пользователя Bob от лица Alice .




Получаем сообщение о том, что только админы могут выполнять такие операции.


Теперь попробуем удалить Alice от лица Bob.


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




Выполняем запрос.





Получаем сообщение об успешном удалении Alice.


Кажется, что наша система аутентификации/авторизации работает, как ожидается.


Пожалуй, это все, чем я хотел с вами поделиться в данной статье.


Благодарю за внимание и хорошего дня!


Как всегда, приветствуется любая форма обратной связи.




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


  1. korsetlr473
    03.12.2021 12:39
    -2

    как правильно сделать чтобы не убить сервис такой юз кейс

    1) пользователей может купить конкретную статью

    2) пользователь может купить подписку на автора с доступом ко всем статьям

    Неужто только на каждый реквест лезть в билинг и проверять куплена или нет?


  1. monochromer
    03.12.2021 14:36

    argon2 — утилита для хеширования паролей

    Почему не использовали встроенный scrypt?