Содержание

Межсервисная аутентификация по общему ключу

Слово «внутренний» в названии маршрута часто создаёт ложное чувство безопасности. В интернет-магазине orders-service принимает запрос на создание заказа, а identity-service проверяет пользовательский токен. Маршрут проверки не опубликован в интернете, поэтому отдельная защита поначалу кажется лишней.

Но «доступен только во внутренней сети» не означает «вызывается доверенным сервисом». Ошибка в сетевых правилах, скомпрометированный контейнер или новый внутренний сервис могут открыть путь к тому же маршруту. identity-service нужен ответ ещё на один вопрос: какое приложение сделало запрос и разрешено ли ему обращаться сюда?

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

Предполагается, что вы знакомы с промежуточными обработчиками HTTP в Go. Авторизация отдельных сервисов, выпуск токенов и настройка взаимной TLS-аутентификации (mTLS) останутся за рамками основного примера.

Один запрос — две разные проверки

В нашем примере участвуют два независимых подтверждения:

  • пользовательский JWT приходит в orders-service как Authorization: Bearer ..., а затем передаётся в теле запроса на проверку;
  • X-Service-Key: ... относится к приложению и отвечает на вопрос «можно ли этому сервису вызывать внутренний маршрут?».
браузер orders-service identity-service │ │ │ │ Authorization: Bearer… │ │ ├───────────────────────────▶│ │ │ │ POST /internal/v1/tokens/verify │ │ │ {"token":"eyJ..."} │ │ │ X-Service-Key: [секрет] │ │ ├─────────────────────────────────▶│

Сервисный ключ не заменяет пользовательский токен. Даже правильный X-Service-Key только разрешает дойти до обработчика проверки; валидность JWT отдельно проверяет verifyToken. Формат JSON здесь — часть контракта между двумя сервисами, а не рекомендация переносить любой пользовательский токен из заголовка в тело запроса.

Зафиксируем контракт до реализации:

ЗапросРезультат проверки сервиса
X-Service-Key отсутствует401 Unauthorized
ключ передан, но неверен403 Forbidden
ключ верныйзапрос передаётся бизнес-обработчику

Это выбранный контракт API: 401 сообщает, что межсервисные данные аутентификации отсутствуют, а 403 — что предъявленный ключ не даёт доступ. Он немного раскрывает причину отказа, зато делает ошибку конфигурации заметнее. В более строгой модели оба случая можно скрыть за одинаковым 401, но тогда нужно согласованно изменить сервер, клиентов, тесты и документацию.

Проверяем ключ до бизнес-логики

Промежуточный обработчик — подходящее место для этой проверки: она выполняется до бизнес-логики и не размазывается по каждому внутреннему маршруту.

package serviceauth import ( "crypto/subtle" "encoding/json" "net/http" ) func Middleware(expectedKey string) func(http.Handler) http.Handler { return func(next http.Handler) http.Handler { return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { providedKey := r.Header.Get("X-Service-Key") if providedKey == "" { writeServiceAuthError( w, http.StatusUnauthorized, "missing service key", ) return } if !equalKey(providedKey, expectedKey) { writeServiceAuthError( w, http.StatusForbidden, "invalid service key", ) return } next.ServeHTTP(w, r) }) } } func equalKey(provided, expected string) bool { if len(provided) != len(expected) { return false } return subtle.ConstantTimeCompare([]byte(provided), []byte(expected)) == 1 } func writeServiceAuthError(w http.ResponseWriter, status int, message string) { w.Header().Set("Content-Type", "application/json") if status == http.StatusUnauthorized { w.Header().Set("WWW-Authenticate", `ServiceKey realm="internal"`) } w.WriteHeader(status) _ = json.NewEncoder(w).Encode(map[string]string{ "message": message, }) }

crypto/subtle сравнивает содержимое строк одинаковой длины за постоянное время. Ранняя проверка длины остаётся заметной, но длина сгенерированного ключа не является секретом; нам важно не допустить посимвольного сравнения самого значения.

Ответ 401 содержит WWW-Authenticate, как требует HTTP. ServiceKey — имя внутренней схемы, а realm="internal" обозначает защищённую область. В ответе 403 этот заголовок уже не нужен: клиент предъявил данные аутентификации, но сервер их отверг.

Защищаем группу маршрутов

Подключим промежуточный обработчик ко всей внутренней группе chi, а не к одному маршруту:

r.Route("/internal/v1", func(r chi.Router) { r.Use(serviceauth.Middleware(cfg.ServiceKey)) r.Post("/tokens/verify", verifyToken) r.Post("/users/batch", getUsersByIDs) })

Теперь новый маршрут внутри /internal/v1 автоматически получает ту же проверку. Переменные cfg, verifyToken и getUsersByIDs принадлежат приложению и здесь показаны только для связи промежуточного обработчика с маршрутизатором.

Проверим границу снаружи. Без заголовка запрос должен завершиться внутри промежуточного обработчика:

curl -i -X POST http://localhost:8081/internal/v1/tokens/verify
HTTP/1.1 401 Unauthorized Content-Type: application/json WWW-Authenticate: ServiceKey realm="internal" {"message":"missing service key"}

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

curl -i -X POST http://localhost:8081/internal/v1/tokens/verify \ -H "X-Service-Key: $SERVICE_KEY" \ -H "Content-Type: application/json" \ -d "{\"token\":\"$USER_TOKEN\"}"

Клиент добавляет доказательство сам

Бизнес-код orders-service не должен помнить о заголовке при каждом вызове. Ключ становится частью настроенного клиента:

type AuthClient struct { baseURL string serviceKey string client *http.Client } func (c *AuthClient) addAuthentication(req *http.Request) { req.Header.Set("X-Service-Key", c.serviceKey) }

Создавайте запрос через http.NewRequestWithContext, проверяйте базовый URL при сборке клиента и добавляйте ключ непосредственно перед отправкой. Сам секрет не должен появляться в бизнес-параметрах, логах или тексте ошибки.

Есть ещё одна неочевидная граница: стандартный http.Client может перенести нестандартный заголовок при перенаправлении на другой узел. Для межсервисного клиента перенаправления лучше запретить либо разрешать только после проверки протокола, имени узла и порта:

client := &http.Client{ Timeout: 3 * time.Second, CheckRedirect: func(_ *http.Request, _ []*http.Request) error { return http.ErrUseLastResponse }, }

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

От локального ключа к секрету рабочего окружения

Строка вроде dev-service-key удобна для локального запуска, если она явно обозначена как заглушка. В реальном окружении сгенерируйте случайное значение:

openssl rand -hex 32

Передайте его через хранилище секретов или защищённую переменную окружения. Для сред разработки (dev), предварительной проверки (staging) и рабочего окружения (production) нужны разные значения. При старте рабочего сервиса проверяйте, что SERVICE_KEY задан: небезопасное значение по умолчанию превращает ошибку конфигурации в работающий, но открытый сервис.

Настоящий ключ не должен попадать в Git, образ контейнера, дамп конфигурации или диагностический архив. Общие правила разобраны в статье «Безопасность и криптография в Go».

Ротируем ключ без общей остановки

Если сервер принимает только одно значение, все клиенты приходится обновлять одновременно. Безопаснее устроить короткое переходное окно:

  1. сервер начинает принимать текущий и следующий ключ;
  2. вызывающие сервисы переходят на следующий;
  3. метрика или безопасный лог показывает, что старый ключ больше не используется;
  4. старый ключ удаляется.

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

Где общий ключ перестаёт работать

Один секрет подтверждает только принадлежность к группе его держателей. Если пять сервисов знают одинаковое значение, identity-service не может определить, кто именно сделал запрос. Компрометация одного участника даёт права всей группы, а отзыв секрета затрагивает всех.

По мере роста системы выбирают более точную идентичность. OWASP рассматривает несколько вариантов межсервисной аутентификации:

ПодходЧто подтверждаетОсновная цена
Отдельный ключ на сервисКакое приложение вызывает маршрутУправление несколькими секретами и политиками
Короткоживущий подписанный токенПриложение и разрешённые действияНужны издатель, проверка полей токена и обновление токенов
mTLSОбе стороны соединенияВыпуск, доставка и ротация сертификатов

При взаимной TLS-аутентификации (mutual TLS, mTLS) клиент и сервер предъявляют сертификаты друг другу. Какой бы прикладной механизм вы ни выбрали, соединение всё равно должно идти по TLS: сервисный ключ подтверждает запрос, но не шифрует трафик.

Что проверить перед использованием

  • Middleware подключён ко всей внутренней группе, поэтому новый маршрут не останется открытым случайно.
  • Запрос без ключа и запрос с неверным ключом завершаются до бизнес-обработчика.
  • Клиент не отправляет X-Service-Key при перенаправлении на неизвестный адрес.
  • Настоящие ключи различаются по окружениям и отсутствуют в репозитории, логах и образах.
  • У ротации есть наблюдаемый критерий завершения и срок удаления старого значения.
  • Команда понимает, какие сервисы делят один ключ и когда понадобится отдельная идентичность.

Следующий шаг после статьи

Закрепите тему в реальном проекте и продолжайте обучение на курсах Praxis.

Продолжить изучение

Выбери следующую статью по маршруту или углубись в смежную тему.

Похожие статьи