Головна / Статті / Аутентифікація проти авторизації: де кожна з них має бути у вашому коді

Аутентифікація проти авторизації: де кожна з них має бути у вашому коді

Дізнайтеся, як різняться процеси автентифікації та авторизації за способом реалізації, кодами стану HTTP та архітектурою системи, щоб уникнути поширених проблем з безпекою.

1335 слів

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

Якщо ви коли-небудь повертали код 401 у ситуації, коли насправді потрібен був код 403, або реалізовували процес входу та оголошували, що автентифікація завершена, не торкаючись при цьому логіки дозволів, ви вже стикалися з проблемами, які виникають від плутанини цих двох понять. Давайте остаточно розібратися в них.

Автентифікація: перевірка ідентичності

Автентифікація — це етап, під час якого система підтверджує, що користувач справді є тим, за кого себе видає. Вона відповідає на одне запитання: чи є ця ідентичність достовірною?

Це одноразова подія, яка зазвичай відбувається під час входу, і її результатом є певний тип облікових даних — сеанс, токен чи куки — які дозволяють решті програми довіряти користувачеві без необхідності повторно перевіряти пароль при кожному запиті.

Типові способи реалізації автентифікації включають:

  • Автентифікація на основі пароля — надані облікові дані порівнюються з хешованою версією, яка зберігається
  • Автентифікація на основі токена (JWT) — після успішного входу генерується підписаний токен, який потім перевіряється при кожному наступному запиті
  • OAuth 2.0 / OpenID Connect — перевірка ідентичності передається зовнішньому постачальнику, такому як Google чи GitHub
  • Ключі API — фіксований секретний рядок, який ідентифікує певного клієнта чи сервіс
  • Багатофакторна аутентифікація (MFA) — додатковий крок перевірки (одноразовий код, додаток-автентифікатор), який додається до пароля
  • Ось як виглядає стандартний обмін даними під час аутентифікації:

    POST /api/login
    Content-Type: application/json
    
    {
      "email": "user@example.com",
      "password": "hashed_and_verified_serverside"
    }
    
    Response: 200 OK
    {
      "token": "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9..."
    }
    

    Сервер перевіряє надані облікові дані, і якщо вони підтверджуються, повертає токен. Відтоді цей токен виступає доказом ідентичності користувача для кожного запиту:

    GET /api/profile
    Authorization: Bearer eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9...
    

    Проміжний модуль, відповідальний за перевірку цього токена, зазвичай виглядає приблизно так:

    function authenticate(req, res, next) {
      const token = req.headers.authorization?.split(' ')[1];
      if (!token) return res.status(401).json({ error: 'No token provided' });
    
      jwt.verify(token, process.env.JWT_SECRET, (err, decoded) => {
        if (err) return res.status(401).json({ error: 'Invalid or expired token' });
        req.user = decoded;
        next();
      });
    }
    

    Зверніть увагу на те, що цей мідлвер з наміром пропускає. Він жодним чином не вирішує, що саме дозволено користувачеві робити. Він просто підтверджує, що токен є легітимним, та додає розшифрований вміст до об’єкта запиту. Більше нічого не входить до обов’язків механізму автентифікації.

    Авторизація: перевірка дозволів

    Авторизація починає діяти лише після того, як автентифікація вже вдалась. Вона відповідає на зовсім інше питання: чи дозволено цій перевіреній особі виконати цю конкретну дію?

    Деякі поширені підходи до структурування авторизації:

    • Контроль доступу на основі ролей (RBAC) — дозволи прив’язуються до ролей, таких як admin, editor чи viewer
  • Контроль доступу на основі атрибутів (ABAC) — рішення щодо доступу обчислюються на основі таких атрибутів, як відділ, регіон, власник ресурсу чи навіть час доби
  • Списки контролю доступу (ACL) — дозволи визначаються явно для кожного користувача та кожного ресурсу
  • Діапазони (OAuth) — сам токен кодує конкретні операції, які дозволені, наприклад read:messages або write:files
  • Типова перевірка авторизації виконується після того, як запит вже пройшов процедуру аутентифікації:

    function authorize(requiredRole) {
      return (req, res, next) => {
        if (req.user.role !== requiredRole) {
          return res.status(403).json({ error: 'Insufficient permissions' });
        }
        next();
      };
    }
    
    app.get('/api/admin/dashboard', authenticate, authorize('admin'), (req, res) => {
      res.json({ data: 'sensitive admin data' });
    });
    

    Це дві окремі функції проміжного програмування, які обробляють різні завдання та виконуються у фіксованому порядку: authenticate визначає, хто є звернувшимся користувачем, а authorize вирішує, чи дозволено цьому користувачеві продовжити. Запит може пройти перевірку та все одно бути відхиленим під час другої перевірки — причина відхилення тут не пов’язана з ідентичністю користувача.

    Коди статусу, які фактично відображають цю різницю

    Саме тут теоретична різниця перетворюється на щось, що можна буквально перевірити в коді.

    401 Unauthorized — назва є оманливою, але цей код насправді вказує на проблему з автентифікацією. Це означає, що сервер не отримав жодних облікових даних, або ті, що він отримав (включаючи токен), є недійсними або закінчили термін дії. На цьому етапі сервер не має жодної підтвердженої інформації про те, хто надсилає запит.

    403 Forbidden — цей статус вказує на проблему з авторизацією. Сервер вже визначив, хто є запитувачем; він просто не дозволяє цій особі виконати запитану дію чи отримати потрібний ресурс.

    // Authentication failure — identity unknown or invalid
    res.status(401).json({ error: 'Invalid credentials' });
    
    // Authorization failure — identity known, permission denied
    res.status(403).json({ error: 'You do not have access to this resource' });
    

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

    Де це трапляється у реальних архітектурах

    Полезні навантаження JWT часто поєднують ці аспекти без наміру когось. Токен призначений для зберігання даних про ідентичність — таких як sub, email, iat та exp. Чи повинен він також містити дані про дозволи, наприклад role чи scope, — це рішення з боку проектувальника, яке залежить від системи. Включення ролей безпосередньо до токена зручне та дозволяє уникнути додаткових пошуків, але це має серйозну ціну: будь-які зміни у дозволах користувача не набудуть чинності, доки той токен не буде перевиданий. Це справжній компроміс, а не швидкий спосіб без наслідків.

    {
      "sub": "user_12345",
      "email": "user@example.com",
      "role": "editor",
      "iat": 1710000000,
      "exp": 1710003600
    }
    

    Системи, побудовані навколо сеансів з боку сервера, зазвичай тримають ці дві функції окремо. Ідентифікатор сеансу відповідає за автентифікацію, тоді як дозволи завантажуються безпосередньо з бази даних при кожному запиті. Це усуває проблему застарілих даних, описану вище, але при цьому потрібен додатковий запит до бази даних за кожен запит.

    У архітектурах з мікросервісами автентифікація зазвичай централізована — її обробляє шлюз або постачальник ідентичності, такий як Auth0, Keycloak чи AWS Cognito, який видає токен. Однак авторизація зазвичай залишається на розсуд кожного окремого сервісу, який читає дані з цього токена та застосовує власні правила. В результаті одна й та сама подія автентифікації може бути інтерпретована по-різному десятьма окремими сервісами, кожен з яких самостійно вирішує, що ця ідентичність може робити.

    Правило, яке запобігає більшості проблем з безпекою

    Аутентифікація має виконуватися перед авторизацією, і ці два етапи ніколи не повинні об’єднуватися в одну спільну перевірку.

    // Correct: sequential, separate concerns
    app.post('/api/posts/:id/delete', authenticate, authorize('editor'), deleteHandler);
    
    // Wrong: conflating identity with permission in one step
    app.post('/api/posts/:id/delete', (req, res) => {
      if (req.headers.authorization === 'valid-token-and-also-admin') {
        // fragile, unscalable, and impossible to audit correctly
      }
    });
    

    Система, яка ретельно перевіряє аутентичність, але ігнорує авторизацію, бездоганно підтверджує ідентичність, а потім дозволяє кожній підтвердженій особі робити все, що вона хоче — тож компрометація одного облікового запису з низькими правами фактично означає повний компрометацій системи. Якщо все навпаки, то система з ретельно налаштованою авторизацією, яка працює над слабкою аутентифікацією, має протилежну проблему: ретельно визначені дозволи охороняють „двері“, крізь які будь-хто може пройти без жодних перешкод.

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

    Пов’язана література

  • Переосмислення стану React: Де насправді мають знаходитися ваші дані — У цій статті пояснюється, як зменшити кількість помилок у React шляхом переміщення стану в URL, DOM чи похідні значення замість надмірного використання useState.