Шаблон сервера на Express.js с авторизацией и простейшим интерфейсом. В интерфейсе разделены компоненты логина и основного приложения, что исключает получение кода основного приложения без авторизации.
Обратите внимание, данный шаблон - это всего лишь отправная точка, с которой можно начать разработку собственного сервиса. Он не предназначен для использования в продакшне как есть, поскольку настройки, определяющие гибкость его архитектуры и уровень его безопасности, зависят от потребностей конкретного приложения и должны быть реализованы вручную.
Также обратите внимание, что в коде имеется несколько console.log
для помощи в процессе разработки приложения. В продакшне они не нужны. В производственном режиме также не следует возвращать столь информативные message
.
Если возможностей, реализованных в шаблоне, окажется недостаточно, можно использовать Auth0
, это полноценная платформа для аутентификации/авторизации.
В качестве базы данных использована MongoDB
и ORM mongoose
.
Настройка MongoDB в данном описании не рассматривается.
Чтобы использовать Sentry
(в продакшне) нужно добавить SDK
для интеграции с Sentry:
"@sentry/node": "^6.14.3",
"@sentry/tracing": "^6.14.3",
В проекте реализовано разделение фронтенд-частей приложения на аутентификацию и остальную часть, чтобы пользователи не прошедшие авторизацию не могли просматривать код приложения. Для этого Express настроен на проверку refreshToken, который передаётся только в куках по HTTPS. Так как поднимать свой HTTPS-сервер для разработки не очень удобно, данная проверка отключена для development-режима, это следует иметь ввиду, также поведение сервера требует тестирования в продакшене. Использовать Express для отдачи статики не рекомендуется, желательно вообще настроить на адрес авторизации отдельный сервер, который будет отдавать статические страницы, а для всех запросов API выделить отдельный путь и убрать роуты авторизации из Express.
nodemon
- для автоматического перезапуска скрипта при обновлении файлов проекта (удобно)mongoose
-ORM
дляMongoDB
. Здесь вы найдете руководство по работе с этойORM
jsonwebtoken
- для работы с токенами. Здесь вы найдете шпаргалку по работе с этой утилитойhelmet
- утилита для установкиHTTP-заголовков
, связанных с безопасностью. Здесь вы найдете шпаргалку по работе с этой утилитой, а здесь - шпаргалку и туториал по заголовкам безопасностиcookie-parser
- утилита для разбора куки, содержащихся в запросеcross-env
- для установки переменных среды окружения. Есть в зависимостях, но не используется.
- Переходим в директорию и устанавливаем зависимости:
cd NodeAuthServer/backend
npm i
-
Устанавливаем значения содержащихся в
backend\config\index.example.js
переменных, сам файл переименовываем вindex.js
. Для режима разработки достаточно установить значение переменнойMONGODB_URI
. Для производственного режима также необходимо установить значение переменнойSENTRY_DSN
(если вы планируете использовать этот сервис). -
Значения переменных
VERIFICATION_CODE
(в файлеcommons\config.json
) иACCESS_TOKEN_SECRET
в продакшне должны быть случайными строками. Эти переменные являются общими для сервера и клиента. Значения этих переменных должны периодически обновляться. -
Генерируем ключи (см. ниже):
npm run gen
Ключи также должны периодически обновляться (ротация ключей, key rotation).
- Запускаем сервер для разработки (для продакшена используется команда
npm start
):
npm run dev
В таком режиме сервер будет отдавать файлы интерфейса, уже лежащие в папке NodeAuthServer/backend/public
(в браузере адрес http://localhost:3000), а также будет обрабатывать запросы к API. Далее в новом окне переходим в папку NodeAuthServer/frontend
, устанавливаем зависимости и запускаем vite для удобной разработки интерфейса:
cd NodeAuthServer/frontend
npm i
npm run dev
Vite запустит dev-сервер, доступный по адресу (в браузере адрес http://localhost:5000). Меняя код фронтенда можно сразу наблюдать изменения.
- Когда закончили фронт, его нужно собрать
npm run build
Данная команда создаст сборку интерфейса и положит файлы в папку NodeAuthServer/backend/public
.
Не забывайте о том, что логика роутинга в продакшене и разработке отличается - так как entry-points для авторизации и основного интерфейса разделены, в продакшн-режиме сервер проверяет есть ли у пользователя доступ к основном интерфейсу.
Проект имеет следующую структуру:
- `config` - Обычно, переменные среды окружения помещаются в файл `.env`, но можно использовать и такой вариант
- `index.example.js` - после клонирования проекта нужно переименовать в `index.js` и указать настройки
- `middlewares` - посредники, промежуточный слой
- `checkHasRefresh.js` - проверяет наличие refreshToken в куках для ограничения доступа не зарегистрированных пользователей к интерфейсу
- `index.js` - агрегация и повторный экспорт посредников
- `setCookie.js` - для генерации токенов обновления и доступа
- `setSecurityHeaders.js` - для установки заголовков безопасности, используется вместо `helmet` (вы должны понимать, что делаете)
- `verifyAuth.js` - для проверки аутентификации
- `verifyAccess.js` - для проверки авторизации
- `verifyPermission.js` - для дополнительной проверки полномочий пользователя
- `models`
- `User.js` - модель пользователя для `mongoose`
- `public` - папка с готовой сборкой интерфейса
- `routes`
- `interface.router.js` - роуты отдающие фронтенд приложения
- `auth.router.js` - роуты аутентификации/авторизации
- `services`
- `auth.services.js` - сервисы аутентификации/авторизации
- `utils` - утилиты
- `generateKeyPair.js` - для генерации публичного и приватного ключей (запускается при выполнении команды `yarn gen`)
- `hasp.js` - хелперы для хэширования и проверки пароля
- `token.js` - для подписания и проверки токенов
- `server.js` - основной файл сервера
- ...
Роуты API, реализованные в приложении:
- `api/`
- `/auth`
- `/register` - регистрация нового пользователя
- `/login` - авторизация пользователя (вход в систему), получение токенов
- `/getUser` - получение данных аутентифицированного пользователя
- `/logout` - выход из системы
- `/remove` - удаление пользователя
- `/updateToken` - обновление обоих токенов (для этого используется токен обновления);
Далее немного базовой теории о принципах авторизации.
Под токеном подразумевается JSON Web Token
.
JWT
- это открытый стандарт (RFC 7519
), определяющий компактный и автономный способ безопасной передачи данных между сторонами в виде объекта формата JSON
.
Благодаря относительно небольшому размеру JWT
может передаваться через URL
, тело POST-запроса
, HTTP-заголовок
и т.д. Валидация токена выполняется только на сервере.
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-соединение
: в противном случае, токен может быть перехвачен и скомпрометирован; - при необходимости для проверки токенов должна использоваться вторичная система верификации;
- с целью уменьшения количества запросов к серверу следует предусмотреть возможность временного хранения токенов на стороне клиента. В шаблоне токен обновления хранится в куке, недоступной для JS, а токен доступа хранится в памяти клиента и при каждом запуске клиента обновляется. Можно также не обновлять токен доступа каждый раз при запуске, а использовать для его хранения
sessionStorage
(но неlocalStorage
).
Алгоритмы
Для подписания токенов обычно используются следующие алгоритмы:
RS256
(сигнатураRSA
сSHA-256
): алгоритм ассиметричного шифрования - у нас имеется 2 ключа, публичный и приватный, приватный ключ должен храниться в секрете. Для подписания токена используется приватный ключ, а для его проверки - публичный;HS256
(HMAC
сSHA-256
): алгоритм симметричного шифрования - у нас имеется только один ключ, который используется и для подписания токена, и для его проверки. Этот ключ должен храниться в секрете.
RS256
считается более безопасным.
В шаблоне для подписания токена обновления используется RS256
, а для подписания токена доступа HS256
.
Дополнительная информация о JWT.
Для регистрации на сервер отправляется POST-запрос
по адресу /api/auth/register
. Тело запроса должно содержать имя, адрес электронной почты, пароль пользователя. Сервер проверяет наличие пользователя с указанным именем или email в БД. Если пользователь новый, его пароль хешируется, после чего данные пользователя записываются в БД и в req.user
.
Для авторизации приложение отправляет post-запрос на адрес /api/auth/login
с username и паролем пользователя. В процессе авторизации
сервер проверяет существование пользователя и сверяет пароль. Если данные верны, сервер подписывает токен доступа и токен обновления.
Токен обновления зашивается в куку, передаваемую только по HTTPS и доступную только на сервере, а токен доступа и данные пользователя
возвращаются клиенту. Также сервер сохраняет время последнего логина чтобы иметь возможность принудительного выхода из системы.
Токен обновления подписывается с помощью RS256
и содержит только ID
пользователя.
Токен доступа подписывается с помощью HS256
и содержит ID
пользователя и его роль (role). Время его жизни составляет 1 час
. Теоретически это очень много, такое время жизни должно использоваться только при разработке приложения, в продакшне оно должно составлять 5-10 мин
, однако чтобы реализовать постоянное обновление, нужно учесть это в коде клиентской части приложения.
Время жизни токенов зависит от потребностей приложения. Общее правило таково: чем меньше время жизни токена, тем лучше.
Для выхода из системы на сервер отправляется GET-запрос
по адресу /api/auth/logout
. Данный запрос проходит через посредника verifyAccess
(обращение к данной конечной точке представляет собой обращение к защищенному ресурсу ). Посредник verifyAccess
проверяет наличие и время жизни токена доступа.
Если токен в порядке, он декодируется, его содержимое записывается в req.user
и управление передается сервису logoutUser
. Все, что делает данный сервис - это очищает куку, содержащую токен обновления, а также сбрасывает время начала сессии у пользователя, чтобы никто не мог зайти по старому токену обновления в случае если он не просрочен.
Если время жизни токена доступа истекло, возвращается статус 401
и соответствующее сообщение. В этом случае клиент выполняет запрос на получение данных пользователя, в результате которого подписывается новый токен доступа, после чего запрос на выход из системы повторяется.
При удалении пользователя на сервер отправляется DELETE-запрос
по адресу /remove
. Данный запрос последовательно проходит через посредников verifyAccess
и verifyPermission
(обращение к данной конечной точке также представляет собой обращение к защищенному ресурсу - просто для примера).
Посредник verifyPermission
проверяет, что пользователь является администратором (декодированный токен доступа содержит роль пользователя). Если запрос отправлен администратором, управление передается сервису removeUser
. Тело запроса должно содержать имя или email удаляемого пользователя. Если пользователь существует, он удаляется.
Это не совсем очевидный момент, чтобы понять зачем нужно два токена, нужно рассмотреть логику авторизации более общими мазками. Схема с двумя токенами работает следующим образом:
- Пользователь логинится в приложении, передавая логин и пароль на сервер. Они не сохраняются на устройстве, а сервер возвращает два токена и время их жизни.
- Приложение сохраняет токены и использует access token для последующих запросов.
- Когда время жизни access token подходит к концу (приложение может само проверять время жизни, или дождаться пока во время очередного использования сервер ответит «ой, всё»), приложение использует refresh token, чтобы обновить оба токена и продолжить использовать новый access token
Но всё-таки зачем два?
Рассмотрим случаи атаки на приложение.
-
Случай 1: Злоумышленник узнал оба токена Алисы и не воспользовался refresh token. В этом случае хакер получит доступ к сервису на время жизни access token. Как только оно истечет и приложение, которым пользуется Алиса, воспользуется refresh token, сервер вернет новую пару токенов, а те, что узнал злоумышленник, превратятся в тыкву.
-
Случай 2: Злоумышленник узнал оба токена Алисы и воспользовался refresh. В этом случае оба токена Алисы превращаются в тыкву, приложение предлагает ей авторизоваться логином и паролем, сервер возвращает новую пару токенов, а те, что узнал хакер аннулируются (тут есть нюанс с device id, может вернуть ту же пару что и у хакера. В таком случае следующее использование refresh токена разлогинит злоумышленника).
Таким образом, схема с refresh + access токенами на самом деле нужна чтобы ограничить время, на которое атакующий может получить доступ к сервису. По сравнению с одним токеном, которым злоумышленник может пользоваться неделями и никто об этом не узнает.