Главная / Статьи

Создание готовой к использованию системы аутентификации JWT для API на Node.js

Как хешировать пароли, выдавать краткосрочные JWT, добавлять токены обновления и механизмы отзыва, разделять авторизацию и аутентификацию, противостоять атакам типа brute force и проверять корректную ошибку авторизации.

Издатель: REACTAPP.TOP13 мин чтения2888 слов

Источники: JWT Authentication in Node.js: What I Learned Building Secure APIs

В документе с требованиями процесс аутентификации описывается в одной строке: пользователь входит с помощью электронной почты и пароля, после чего API предоставляет доступ. Но как только его реализуют, из этой одной строки возникает дюжина вопросов. Где хранятся пароли и как они проверяются? Как API распознаёт звонящего при каждом запросе? Что происходит, когда токен истекает, какова роль обновляющих токенов, и что должна делать система после серии неудачных попыток входа? За всеми этими вопросами стоит самый важный: как не допустить, чтобы сама система входа стала слабейшей частью приложения?

В этом руководстве рассматриваются эти вопросы применительно к API на Node.js и TypeScript с использованием PostgreSQL, где аутентификация рассматривается не как отдельный эндпоинт, а как слой безопасности, охватывающий всё приложение. К концу вы получите четкое представление о каждом слое, о возможных проблемах в нем и список проверок для определения того, готово ли система к использованию в производственных условиях.

Храните хеши паролей, никогда не пароли сами по себе

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

email: sachin@example.com
password: MyPassword123

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

import bcrypt from "bcrypt";

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

const passwordHash = await bcrypt.hash(password, 12);

При входе пароль не декодируется. Сервер берет только что введенный пользователем пароль и запрашивает у bcrypt, совпадает ли он с сохраненным хешем. Метод bcrypt.compare считывает соль и коэффициент затрат, встроенные в хеш, повторяет вычисления и возвращает логическое значение.

const isValid = await bcrypt.compare(
  password,
  user.passwordHash
);

Ключевое отличие заключается в том, что пароли хешируются, а не шифруются для последующего восстановления. После завершения регистрации вашему приложению никогда не должно быть нужно или возможно восстановить исходный пароль. Два практических замечания: bcrypt учитывает только первые 72 байта входных данных, поэтому очень длинные пароли молча обрезаются, а такие алгоритмы, как Argon2, являются распространенной альтернативой, если требуется вариант с высокими требованиями к ресурсам памяти.

Рассматривайте процесс входа как рабочий процесс, а не запрос

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

Client
   ↓
POST /api/auth/login
   ↓
Validate input
   ↓
Find user
   ↓
Compare password
   ↓
Create authentication tokens
   ↓
Return authentication response

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

Что такое JWT и чем оно не является

JSON Web Tokens — это распространённый способ представления авторизованной идентичности в API. После успешного входа сервер выдаёт подписанный токен, описывающий пользователя. Минимальный набор данных может включать только идентификатор пользователя и его роль; sub — это стандартное поле для идентификатора пользователя.

{
  "sub": "user-id",
  "role": "user"
}

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

Authorization: Bearer <access-token>

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

Login
  ↓
Credentials verified
  ↓
Access token issued
  ↓
Client sends token
  ↓
Server verifies token
  ↓
Protected API is allowed

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

Сокращайте срок действия токенов доступа

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

В приведенном ниже примере используется библиотека jsonwebtoken для подписи токена, содержащего идентификатор и роль пользователя, с использованием секретного ключа, загружаемого из окружения; параметр expiresIn устанавливается на пятнадцать минут. Знак ! после process.env.JWT_SECRET обозначает операцию ненулевой ассертации в TypeScript; в производственном коде безопаснее проверять наличие переменной при запуске, чем делать ассертацию на каждом месте её использования.

const accessToken = jwt.sign(
  {
    sub: user.id,
    role: user.role
  },
  process.env.JWT_SECRET!,
  {
    expiresIn: "15m"
  }
);

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

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

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

┌─────────────────┐
             │      Login      │
             └────────┬────────┘
                      ↓
             ┌─────────────────┐
             │ Access Token    │
             │ Short Lifetime  │
             └────────┬────────┘
                      │
                 API Requests
                      │
                      ↓
              Token expires
                      │
                      ↓
             ┌─────────────────┐
             │ Refresh Token   │
             │ Longer Lifetime │
             └────────┬────────┘
                      ↓
             New Access Token

Такой подход позволяет сохранять короткие токены доступа, при этом сессии остаются непрерывными. Однако он вводит дополнительную ответственность, поскольку токен обновления становится более ценным удостоверением. Его необходимо хранить осторожно (обычно в куки с атрибутами httpOnly и Secure на веб-сайте, а не в хранилище, доступном из JavaScript), отправлять только на специальный эндпоинт обновления и строго проверять на сервере. Для более подробного обсуждения дизайна см. нашу инструкцию по стратегии использования токенов обновления в системах аутентификации Node.js.

План отзыва токенов

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

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

User
 ├── Refresh Token A
 ├── Refresh Token B
 └── Refresh Token C

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

Обеспечение аутентификации в мидлвэре

Повторное выполнение проверок токенов в каждом контроллере подвержено ошибкам; одна забытая проверка может оставить конечную точку уязвимой. Мидлвэр централизует эту логику. В Express защищенный маршрут просто размещает функцию authenticate перед своим контроллером:

app.get(
  "/api/profile",
  authenticate,
  profileController
);

У этого мидлвэра есть небольшая, четко определенная задача:

  1. Читать заголовок Authorization.
  • Извлеките токен из значения Bearer.
  • Проверьте подпись и срок действия токена.
  • Определите, какого пользователя представляет токен.
  • Присвойте эту идентичность объекту запроса для дальнейшей обработки.
  • Отклоните запрос с ошибкой, если какой-либо шаг завершится неудачно.
  • Результативный поток управления легко понимаем: недействительный или отсутствующий токен приводит к коду 401, а только действительный токен доходит до контроллера.

    Request
       ↓
    Authentication Middleware
       ↓
    Token valid?
      ↙       ↘
     No        Yes
     ↓          ↓
    401       Controller
                 ↓
              Response
    

    Аутентификация и авторизация — это разные проверки

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

    Представьте приложение с тремя ролями:

    Admin
    Staff
    User
    

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

    GET /api/profile
    GET /api/issues
    POST /api/issues
    

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

    PATCH /api/users/:id
    DELETE /api/issues/:id
    

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

    Защита от многократных неудачных входов

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

    Attempt 1 → Failed
    Attempt 2 → Failed
    Attempt 3 → Failed
    Attempt 4 → Failed
    Attempt 5 → Failed
                  ↓
              Temporary lock
    

    На практике в зависимости от требований можно комбинировать несколько мер:

    • Ограничение количества запросов к точке входа.
    • Временная блокировка учётных записей.
  • Постепенное увеличение задержек после каждой неудачи.
  • Мониторинг подозрительных паттернов.
  • Защита, основанная на IP-адресе.
  • Уведомления о событиях, касающихся безопасности.
  • Выбирайте осторожно, поскольку мера защиты может стать инструментом для отказа в обслуживании. Если кто-либо может заблокировать аккаунт, просто пять раз неправильно введя его электронную почту, злоумышленники могут намеренно лишить доступа настоящих пользователей. Цель заключается не просто в блокировке после неудачных попыток, но и в том, чтобы сделать автоматизированные атаки дорогостоящими, при этом законные пользователи всё равно могли войти, например, путем сочетания задержек для каждого аккаунта с ограничениями по количеству запросов на каждый IP-адрес, а не только полной блокировки.

    Никогда не доверяйте информации об идентичности, предоставленной клиентом

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

    {
      "userId": "123",
      "role": "admin"
    }
    

    Ничто не мешает злонамеренному клиенту изменять эти поля. Бэкенд никогда не должен предполагать, что они верны. Идентификация пользователя должна основываться на учетных данных, проверенных самим сервером; это означает, что идентификатор пользователя и его роль читаются из подтвержденного токена (или из базы данных с использованием информации о пользователе из токена), а проверки прав осуществляются на их основе:

    Request
       ↓
    Verify JWT
       ↓
    Read authenticated user ID
       ↓
    Check permissions
       ↓
    Access resource
    

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

    Избегайте утечки информации в сообщениях об ошибках

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

    Email does not exist.
    

    с ответом, сознательно сформулированным косвенно:

    Invalid email or password.
    

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

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

    Не включайте секреты в исходный код

    Аутентификация зависит от секретов, таких как ключи подписи, строки подключения к базе данных и ключи API сторонних сервисов:

    JWT_SECRET
    DATABASE_URL
    API_KEY
    

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

    const secret = "my-super-secret-key";
    

    Чтение его из окружения — это минимальное улучшение:

    const secret = process.env.JWT_SECRET;
    

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

    Проверка корректного отклонения аутентификации

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

    Регистрация

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

    Valid registration → Success
    Invalid email → Rejected
    Weak/invalid input → Rejected
    Duplicate account → Rejected
    

    Вход

    Успешно происходит вход с правильными учетными данными; неверные пароли, неизвестные учетные записи и заблокированные учетные записи отклоняются:

    Correct password → Success
    Wrong password → Rejected
    Unknown account → Rejected
    Locked account → Rejected
    

    Защищенные маршруты

    Отсутствующие, некорректные и истекшие токены приводят к коду 401, корректный токен позволяет пройти дальше, а корректный токен без необходимых прав приводит к коду 403. Обратите внимание на разницу: 401 означает «мы не знаем, кто вы», в то время как 403 означает «мы знаем, кто вы, но ответ — нет».

    No token → 401
    Invalid token → 401
    Expired token → 401
    Valid token → Allowed
    Valid token + insufficient role → 403
    

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

    Многоуровневая модель аутентификации

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

    ┌─────────────────────────────┐
    │       User Credentials      │
    └──────────────┬──────────────┘
                   ↓
    ┌─────────────────────────────┐
    │     Password Verification   │
    └──────────────┬──────────────┘
                   ↓
    ┌─────────────────────────────┐
    │       Token Issuance        │
    └──────────────┬──────────────┘
                   ↓
    ┌─────────────────────────────┐
    │      Token Validation       │
    └──────────────┬──────────────┘
                   ↓
    ┌─────────────────────────────┐
    │       Authorization         │
    └──────────────┬──────────────┘
                   ↓
    ┌─────────────────────────────┐
    │       Protected Resource    │
    └─────────────────────────────┘
    

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

    Чек-лист готовности к эксплуатации

    Прежде чем считать систему аутентификации готовой, убедитесь, что:

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

    Основные выводы

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

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