Головна / Статті

Створення системи автентифікації 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 є оператором переконання в тому, що значення не дорівнює null у 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, мідлвейр, поведінку фронтенду, обробку помилок, логування, тестування та розгортання, а насамперед — на безпеку. Кінцева точка входу потребує кількох рядків коду; проте створення системи, яка буде безпечно функціонувати під час виникнення ситуацій із справжніми користувачами, зловмисниками, простроченими токенами, некоректними запитами та несправностями в продакшені, є значно складнішою інженерною задачею.

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