Дизайн автентифікації в etcd v3

Автентифікація в etcd v3

Чому не використовувати систему автентифікації v2?

Протокол v3 використовує gRPC як транспорт замість RESTful інтерфейсу, як у v2. Цей новий протокол надає можливість покращити дизайн v2. Наприклад, автентифікація v3 базується на зʼєднанні, а не на кожному запиті, як у v2. Крім того, семантика автентифікації v2 на практиці є незручною з погляду узгодженості, що буде описано в наступних розділах. Для v3 є чіткий опис та реалізація механізму автентифікації, який виправляє недоліки системи автентифікації v2.

Вимоги до функціональності

  • Автентифікація на основі зʼєднання, а не на кожному запиті
    • Автентифікація на основі ідентифікатора користувача та пароля, реалізована для gRPC API
    • Автентифікація повинна оновлюватися після змін політики автентифікації
  • Її функціональність повинна бути такою ж простою та корисною, як у v2
    • v3 надає плоский простір ключів, на відміну від структури тек v2. Перевірка дозволів буде здійснюватися як інтервальне зіставлення.
  • Вона повинна мати сильніші гарантії узгодженості, ніж автентифікація v2

Основні необхідні зміни

  • Клієнт повинен створити спеціальне зʼєднання тільки для автентифікації перед надсиланням автентифікованих запитів
  • Додати інформацію про дозволи (ідентифікатор користувача та авторизовану ревізію) до команд Raft (etcdserverpb.InternalRaftRequest)
  • Кожен запит перевіряється на наявність дозволів на рівні машини станів, а не на рівні API

Узгодженість метаданих дозволів

Метадані для автентифікації також повинні зберігатися та керуватися в сховищі, контрольованому протоколом Raft, як і інші дані, що зберігаються в etcd. Це необхідно для того, щоб не жертвувати доступністю та узгодженістю всього кластера etcd. Якщо для читання або запису метаданих (наприклад, інформації про дозволи) потрібна згода кожного вузла (більше ніж кворум), відмова одного вузла може зупинити весь кластер. Вимога до всіх вузлів погодитися одночасно означає, що перевірка звичайних запитів на читання/запис не може бути завершена, якщо будь-який член кластера не працює, навіть якщо кластер має доступний кворум. Ця одностайна схема в кінцевому підсумку знижує доступність кластера; кворумний консенсус від Raft повинен бути достатнім, оскільки згода випливає з послідовного порядку.

Механізм автентифікації в протоколі etcd v2 має складну частину, оскільки узгодженість метаданих повинна працювати, як описано вище, але не працює: кожна перевірка дозволів обробляється членом etcd, який отримує запит клієнта (server/etcdserver/api/v2http/client.go), включаючи членів-послідовників. Тому можливо, що перевірка може базуватися на застарілих метаданих.

Ця застарілість означає, що конфігурація автентифікації не може бути відображена відразу після виконання операторами etcdctl. Тому немає способу дізнатися, як довго активні застарілі метадані. Практично, зміна конфігурації відображається відразу після виконання команди. Однак у деяких випадках при великому навантаженні неконсистентний стан може тривати довше, що може призвести до контрінтуїтивних ситуацій для користувачів та розробників. Це вимагає обхідного шляху, як цей: https://github.com/etcd-io/etcd/pull/4317#issuecomment-179037582.

Неконсистентні дозволи небезпечні для лінеаризованих запитів

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

Тому логіка перевірки дозволів має бути додана до автомата станів etcd. Кожен автомат повинен перевіряти запити на основі інформації про дозволи на етапі застосування (тому інформація про авторизацію не повинна бути застарілою).

Дизайн та реалізація

Автентифікація

Спочатку клієнт повинен створити gRPC-зʼєднання лише для автентифікації свого ідентифікатора користувача та пароля. Сервер etcd відповість автентифікаційною відповіддю. Відповідь буде автентифікаційним токеном у разі успіху або помилкою у разі невдачі. Клієнт може використовувати свій автентифікаційний токен для предʼявлення своїх облікових даних до etcd під час виконання API-запитів.

Зʼєднання клієнта, яке використовується для запиту автентифікаційного токена, зазвичай відкидається; воно не може нести облікові дані нового токена. Це тому, що gRPC не надає можливості додавання облікових даних для кожного RPC після створення зʼєднання (виклику grpc.Dial()). Тому клієнт не може призначити токен своєму зʼєднанню, отриманому через це зʼєднання. Клієнту потрібне нове зʼєднання для використання токена.

Примітки щодо реалізації RPC Authenticate()

RPC Authenticate() генерує автентифікаційний токен на основі заданого імені користувача та пароля. etcd зберігає та перевіряє налаштований пароль і заданий пароль за допомогою пакунку Go bcrypt. За дизайном, механізм перевірки пароля bcrypt є обчислювально дорогим, займаючи майже 100 мс на звичайному сервері x64. Тому виконання цієї перевірки на етапі застосування машини станів спричинило б проблеми з продуктивністю: весь кластер etcd може обслуговувати майже 10 запитів Authenticate() на секунду.

Для забезпечення гарної продуктивності механізм автентифікації v3 перевіряє паролі на рівні API etcd, де це можна паралелізувати поза raft. Однак це може призвести до потенційних проблем з перевіркою часу (TOCTOU, time-of-check/time-of-use):

  1. клієнт A надсилає запит Authenticate()
  2. рівень API обробляє частину перевірки пароля Authenticate()
  3. інший клієнт B надсилає запит на ChangePassword(), і сервер його завершує
  4. рівень машини станів обробляє частину отримання номера ревізії для Authenticate() від A
  5. сервер повертає успіх A
  6. тепер A автентифікований за застарілим паролем

Щоб уникнути такої ситуації, рівень API виконує перевірку номера версії на основі номера ревізії сховища автентифікації. Під час перевірки пароля рівень API зберігає номер ревізії сховища автентифікації. Після успішної перевірки пароля рівень API порівнює збережений номер ревізії з останнім номером ревізії. Якщо номери відрізняються, це означає, що хтось інший оновив метадані автентифікації. Тому він повторює перевірку. Завдяки цьому механізму можна уникнути успішної перевірки пароля на основі застарілого пароля.

Розвʼязання токена на рівні API

Після автентифікації за допомогою Authenticate() клієнт може створити gRPC-зʼєднання, як це було б без автентифікації. На додаток до наявного процесу ініціалізації, клієнт повинен асоціювати токен з новоствореним зʼєднанням. grpc.WithPerRPCCredentials() надає функціональність для цієї мети.

Кожен автентифікований запит від клієнта має токен. Токен можна отримати за допомогою grpc.metadata.FromIncomingContext() на стороні сервера. Сервер може отримати, хто видає запит і коли користувач був авторизований. Інформація буде заповнена рівнем API у заголовку (etcdserverpb.RequestHeader.Username та etcdserverpb.RequestHeader.AuthRevision) запису журналу raft (etcdserverpb.InternalRaftRequest).

Перевірка дозволів у машині станів

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

Два типи токенів: простий і JWT

Існує два типи токенів: простий і JWT. Простий токен не призначений для використання у промислових випадках. Його токени не підписані криптографічно, і сервери повинні відстежувати відповідність токенів і користувачів; він призначений для тестування під час розробки. JWT токени слід використовувати для промислових розгортань, оскільки вони підписані та перевірені криптографічно. З погляду реалізації, JWT не має збереженого стану (stateless). Його токен може включати метадані, включаючи імʼя користувача та ревізію, тому серверам не потрібно запамʼятовувати відповідність між токенами та метаданими.

Примітка: Існує відома проблема #18437 з простими токенами. У серверах etcd токени розвʼязуються на рівні API, а прості токени є stateful. Процес не захищений лінеаризованою перевіркою, що означає, що член etcd може не завершити обробку попереднього запиту на автентифікацію перед отриманням наступного. У таких випадках член може повернути клієнту помилку “недійсний токен автентифікації”. Ця проблема зазвичай рідкісна на вузлі з хорошими мережевими умовами, але може виникнути, якщо є значна затримка. Як обхідний шлях, застосунки можуть реалізувати механізм повторної спроби для обробки цієї помилки.

Примітки щодо різниці між моделями KVS та файловими системами

etcd v3 є KVS, а не файловою системою. Тому дозволи можуть бути надані користувачам у формі точного імені ключа або діапазону ключів, як-от ["початковий ключ", "кінцевий ключ"). Це означає, що надання дозволу на відсутній ключ можливе. Користувачі повинні бути обережними щодо ненавмисного надання дозволів. У випадку файлової системи (наприклад, Chubby або ZooKeeper) структура даних, подібна до inode, може містити інформацію про дозволи. Тому надання дозволу на відсутній ключ буде неможливим (за винятком випадків з липкими бітами (sticky bits)).

Модель etcd v3 вимагає кількох пошуків метаданих, на відміну від файлових систем. Найгірший випадок вартості пошуку буде сумою загальної кількості наданих користувачем ключів та діапазонів. Цієї вартості не можна уникнути, оскільки плоский простір ключів v3 повністю відрізняється від моделі файлової системи Unix (кожен inode містить метадані дозволів). Практично ця вартість не буде серйозною проблемою, оскільки метадані достатньо малі, щоб отримати вигоду від кешування.