Конструкция аутентификации etcd v3
Почему не используется система аутентификации v2?
Вместо RESTful-интерфейса, как в v2, протокол v3 использует gRPC в качестве транспорта. Новый протокол даёт возможность развить и улучшить конструкцию v2. Например, аутентификация v3 выполняется для соединения, а не для каждого запроса, как более медленная аутентификация v2. Кроме того, на практике семантика аутентификации v2 неудобна для рассуждений о согласованности, что будет описано в следующих разделах. Для v3 существует чёткое описание и реализация механизма аутентификации, устраняющие недостатки системы v2.
Функциональные требования
- Аутентификация для соединения, а не для каждого запроса
- Для API gRPC реализована аутентификация по идентификатору пользователя и паролю
- После изменения политики аутентификацию необходимо обновлять
- Функциональность должна быть такой же простой и полезной, как в v2
- В отличие от структуры каталогов v2, v3 предоставляет плоское пространство ключей. Разрешения будут проверяться посредством сопоставления интервалов.
- Гарантии согласованности должны быть сильнее, чем у аутентификации v2
Основные необходимые изменения
- До отправки аутентифицированных запросов клиент должен создать отдельное соединение исключительно для аутентификации
- Добавить сведения о разрешениях (идентификатор пользователя и авторизованную ревизию) в команды Raft (
etcdserverpb.InternalRaftRequest) - Проверять разрешения каждого запроса на уровне конечного автомата, а не на уровне API
Согласованность метаданных разрешений
Метаданные аутентификации, как и другие данные etcd, также должны храниться и управляться в хранилище, контролируемом протоколом Raft etcd. Это необходимо, чтобы не жертвовать доступностью и согласованностью всего кластера etcd. Если для чтения или записи метаданных (например, сведений о разрешениях) требуется согласие каждого узла, а не только кворума, отказ одного узла может остановить весь кластер. Требование одновременного согласия всех узлов означает, что проверку обычных запросов чтения и записи невозможно завершить при недоступности любого участника, даже если кластер располагает кворумом. Такая единогласная схема в итоге снижает доступность кластера; консенсуса Raft на основе кворума должно быть достаточно, поскольку согласие следует из согласованного порядка.
В механизме аутентификации протокола etcd v2 есть сложность: согласованность метаданных должна работать описанным выше образом, но это не так. Каждая проверка разрешений обрабатывается участником etcd, получившим клиентский запрос (server/etcdserver/api/v2http/client.go), включая участников-последователей. Поэтому проверка может основываться на устаревших метаданных.
Эта устарелость означает, что конфигурация аутентификации не может отразиться сразу после выполнения операторами etcdctl. Поэтому невозможно определить, как долго остаются активны устаревшие метаданные. На практике изменение конфигурации отражается непосредственно после выполнения команды. Однако при высокой нагрузке несогласованное состояние иногда сохраняется дольше и может приводить к неочевидным для пользователей и разработчиков ситуациям. Требуется обходное решение наподобие этого .
Несогласованные разрешения небезопасны для линеаризованных запросов
Несогласованное состояние аутентификации особенно опасно для записи. Даже если оператор запретил пользователю запись, она может успешно завершиться, если упорядочена относительно хранилища ключей и значений, но не относительно системы аутентификации. Без упорядочивания как хранилища аутентификации, так и хранилища ключей и значений система будет уязвима для атак с устаревшими разрешениями.
Поэтому логику проверки разрешений следует добавить в конечный автомат etcd. Каждый конечный автомат должен проверять запросы по своим сведениям о разрешениях на фазе применения (следовательно, сведения об аутентификации не должны быть устаревшими).
Конструкция и реализация
Аутентификация
Сначала клиент должен создать соединение gRPC исключительно для аутентификации идентификатора пользователя и пароля. Сервер etcd отправляет ответ аутентификации: при успехе он содержит токен аутентификации, а при неудаче — ошибку. Клиент может предъявлять этот токен серверу etcd в качестве учётных данных при выполнении запросов API.
Клиентское соединение, использованное для запроса токена аутентификации, обычно закрывается: оно не может передавать учётные данные нового токена. Причина в том, что gRPC не позволяет добавить учётные данные отдельных RPC после создания соединения (вызова grpc.Dial()). Поэтому клиент не может назначить соединению токен, полученный через это же соединение. Для использования токена клиенту требуется новое соединение.
Примечания о реализации RPC Authenticate()
RPC Authenticate() создаёт токен аутентификации по заданному имени пользователя и паролю. etcd сохраняет настроенный пароль и проверяет переданный пароль с помощью пакета Go bcrypt. Механизм проверки пароля bcrypt намеренно требует значительных вычислительных ресурсов и занимает около 100ms на обычном сервере x64. Поэтому выполнение этой проверки в фазе применения конечного автомата создало бы проблемы с производительностью: весь кластер etcd мог бы обслуживать лишь около 10 запросов Authenticate() в секунду.
Для высокой производительности механизм аутентификации v3 проверяет пароли на уровне API etcd, где проверку можно распараллелить вне Raft. Однако это способно привести к потенциальным нарушениям разрешений типа «время проверки — время использования» (TOCTOU):
- клиент A отправляет запрос
Authenticate() - уровень API выполняет часть
Authenticate()с проверкой пароля - другой клиент B отправляет запрос
ChangePassword(), и сервер завершает его - уровень конечного автомата выполняет часть получения номера ревизии для
Authenticate()от A - сервер возвращает A успешный результат
- теперь A аутентифицирован по устаревшему паролю
Чтобы избежать такой ситуации, уровень API выполняет проверку номера версии на основе номера ревизии хранилища аутентификации. Во время проверки пароля уровень API сохраняет номер ревизии хранилища. После успешной проверки пароля он сравнивает сохранённый номер с последним номером ревизии. Если номера различаются, значит кто-то обновил метаданные аутентификации, и проверка выполняется повторно. Этот механизм предотвращает успешную проверку устаревшего пароля.
Разрешение токена на уровне API
После аутентификации с помощью Authenticate() клиент может создать соединение gRPC так же, как без аутентификации. Помимо обычной инициализации клиент должен связать токен с вновь созданным соединением. Для этого служит grpc.WithPerRPCCredentials().
Каждый аутентифицированный запрос клиента содержит токен. На стороне сервера его можно получить с помощью grpc.metadata.FromIncomingContext(). Сервер может определить, кто отправляет запрос и когда пользователь был авторизован. Уровень API заполняет эти сведения в заголовке (etcdserverpb.RequestHeader.Username и etcdserverpb.RequestHeader.AuthRevision) записи журнала Raft (etcdserverpb.InternalRaftRequest).
Проверка разрешения в конечном автомате
Сведения об аутентификации в etcdserverpb.RequestHeader проверяются на фазе применения конечного автомата. На этом шаге проверяется, предоставлено ли пользователю разрешение на запрошенные ключи в последней ревизии хранилища аутентификации.
Два типа токенов: simple и JWT
Существует два типа токенов: simple и JWT. Токен simple не предназначен для рабочих сценариев. Такие токены не подписаны криптографически, а серверы должны хранить состояние соответствия токенов пользователям; этот тип предназначен для тестирования при разработке. В рабочих развёртываниях следует использовать токены JWT, поскольку они подписываются и проверяются криптографически. С точки зрения реализации JWT не сохраняет состояние. Токен может содержать метаданные, включая имя пользователя и ревизию, поэтому серверам не нужно помнить соответствие между токенами и метаданными.
Для токенов simple известна проблема #18437 . В серверах etcd токены разрешаются на уровне API, а токены simple сохраняют состояние. Процесс не защищён линеаризуемой проверкой: участник etcd может не успеть завершить обработку предыдущего запроса аутентификации до получения следующего. В таких случаях участник может вернуть клиенту ошибку “invalid auth token”. На узле с хорошими сетевыми условиями проблема обычно возникает редко, но возможна при значительной задержке. В качестве обходного решения приложения могут реализовать повторные попытки обработки этой ошибки.
Непосредственная установка токенов JWT
Помимо стандартного потока RPC Authenticate(), etcd поддерживает непосредственную установку токенов JWT на уровне клиента. Это позволяет приложениям управлять полным жизненным циклом токенов JWT вне etcd, включая создание, проверку и ротацию токенов.
Сценарий использования и рабочий процесс
Этот подход полезен, когда:
- Отдельная система управления токенами (вне etcd) отвечает за создание и жизненный цикл токенов JWT
- Приложения получают заранее подписанные токены JWT через внешний механизм (например, переменные окружения или службу конфигурации)
- Жизненным циклом токена должно полностью управлять клиентское приложение, а не автоматическое создание токенов в etcd
Типичный рабочий процесс:
- Внешний доверенный центр (не etcd) создаёт подписанный токен JWT, содержащий имя пользователя и другие утверждения
- Приложение получает заранее подписанный токен и настраивает с ним клиент etcd
- Клиент отправляет токен JWT непосредственно с запросами (не вызывая
Authenticate()) - Сервер etcd проверяет подпись токена с помощью настроенного открытого ключа и предоставляет доступ на основе имени пользователя в токене
- До истечения срока токена приложение получает новый токен от внешнего доверенного центра
- Приложение создаёт новый клиент с обновлённым токеном (для обновления токена клиент необходимо пересоздать)
Отличия от стандартной аутентификации
При использовании стандартного потока Authenticate():
- Клиент вызывает
Authenticate()с именем пользователя и паролем - etcd создаёт и возвращает токен
- Клиент автоматически использует этот токен для последующих запросов
- Для обновления токена требуется снова вызвать
Authenticate()
При непосредственной установке токенов JWT:
- Клиент инициализируется с заранее подписанным токеном JWT
- Клиент не вызывает
Authenticate() - Токен используется непосредственно во всех запросах
- Клиентское приложение отвечает за получение новых токенов до истечения срока и управление жизненным циклом клиента
AuthStatus без действительного токена
Для поддержки приложений, самостоятельно управляющих токенами JWT, RPC AuthStatus позволяет клиентам определить, включена ли аутентификация, и получить текущую authRevision. Это важно для сценариев восстановления, когда срок токена истёк и клиенту требуется последняя ревизия, чтобы получить новый действительный токен от внешнего поставщика токенов.
Без этой возможности токен с истёкшим сроком мог бы помешать клиенту узнать текущую authRevision, создав взаимоблокировку, при которой невозможно создать новый токен.
Примечания о различиях между моделями KVS и файловой системы
etcd v3 — это KVS, а не файловая система. Поэтому разрешения пользователям можно предоставлять в форме точного имени ключа или диапазона ключей, например ["start key", "end key"). Это позволяет предоставить разрешение для несуществующего ключа. Пользователям следует учитывать возможность непреднамеренного предоставления разрешений. В системе, подобной файловой (например, Chubby или ZooKeeper), структура данных наподобие inode может содержать сведения о разрешениях. Поэтому предоставить разрешение для несуществующего ключа невозможно (за исключением случая sticky bits).
В отличие от систем, подобных файловой, модель etcd v3 требует нескольких поисков метаданных. В худшем случае стоимость поиска равна сумме всех ключей и интервалов, разрешённых пользователю. Избежать этих затрат нельзя, поскольку плоское пространство ключей v3 полностью отличается от модели файловой системы Unix (каждый inode содержит метаданные разрешений). На практике затраты не станут серьёзной проблемой, поскольку метаданные достаточно малы для эффективного кэширования.