Перейти к содержимому
ГоМастер

Поиск по урокам

Найдите урок, курс, термин или раздел

Урок 17 из 25 ~16 мин +20 XP

Сервисные токены: сервис-сервис

Сервисные токены в Go решают задачу, к которой пользовательский jwt не приспособлен: service-a вызывает service-b, и личности пользователя в запросе нет. Разбираем api ключи vs jwt для связи сервис-сервис: статичный ключ с HMAC-подписью запроса, короткоживущий JWT с аудиторией, ротацию и хранение секретов в env.

Содержание урока · 6 разделов

Первый внутренний вызов появляется невинно: заказ сохранился — нужно уведомить рассыльщик, отчёт собрался — обновить биллинг. И здесь же совершается самая частая ошибка темы: для межсервисного вызова берут пользовательский jwt из тестового скрипта или заводят в базе служебного пользователя с паролем. Оба варианта ломают модель: токен привязан к человеку и умрёт вместе с его сессией, а у служебной учётки есть пароль, который можно перебрать, забыть сменить и утечь вместе с дампом. Машине нужен собственный идентификатор: сервисные токены в Go — это либо статичный API-ключ с подписью запроса, либо короткоживущий jwt с аудиторией, в которой записан вызываемый сервис. Разберём оба варианта, сравним api ключи vs jwt по честным критериям и соберём рабочую пару клиент-проверяющий. Всё, что здесь строится, не требует ни внешних сервисов, ни consul с vault: два процесса и две переменные окружения — уже рабочий стенд.

API-ключи против JWT между сервисами#

Статичный API-ключ — длинная случайная строка, одна на пару сервисов: клиент держит её в переменной окружения и прикладывает к запросу, проверяющий сравнивает со своей копией — в идеале в постоянное время, чтобы не подсказывать перебор по времени ответа. Просто, отзывается правкой конфига, но каждый запрос несёт один и тот же секрет: кто перехватил, владеет навсегда, пока вы не сменили. JWT-вариант переворачивает свойство: секрет по сети не ходит, вместо него летит подписанный токен на пять минут с полями sub — кто зовёт, aud — кого зовут — и сроком. Утёкший токен мёртв сам собой, зато появляется инфраструктура: выпуск, проверка, часы, распределение ключей. Моя граница выбора проста: пара сервисов с внутренним трафиком живёт с HMAC-подписью запроса; как только вызываемых становится больше двух-трёх или в вызов нужно положить контекст пользователя — берём короткоживущий jwt и дальше асимметрию из урока про RS256. Отдельный фактор — аудит: у статичного ключа все вызовы выглядят одинаково, а jwt с iss и jti оставляет в логах, кто, когда и каким вызовом пришёл — при разборе инцидентов это половина картины.

API-ключ в env и HMAC-подпись вызова

GO
func signedRequest(method, url string, body []byte) (*http.Request, error) {
        key := []byte(os.Getenv("BILLING_KEY"))
        ts := strconv.FormatInt(time.Now().Unix(), 10)
        mac := hmac.New(sha256.New, key)
        mac.Write([]byte(method + " " + url + " " + ts + " "))
        mac.Write(body)

        req, err := http.NewRequest(method, url, bytes.NewReader(body))
        if err != nil {
                return nil, err
        }
        req.Header.Set("Content-Type", "application/json")
        req.Header.Set("X-Service-ID", "notifier")
        req.Header.Set("X-Timestamp", ts)
        req.Header.Set("X-Signature", hex.EncodeToString(mac.Sum(nil)))
        return req, nil
}

Ключ не уезжает по сети: в заголовках идут имя вызывающего, метка времени и HMAC-SHA256 от метода, адреса, времени и тела. Секрет читается из переменной окружения — та же дисциплина, что и для HMAC-секрета из пятого урока. Подпись покрывает не только payload: метод и путь не дают переподставить запрос на другой эндпоинт, а время отсекает повтор через час. Подмена любого поля — тела, пути, метки — делает подпись недействительную. Тело подписывается как есть — байты, которые уйдут по сети: сначала подписал, потом изменил payload — и проверка развалится, что и требуется.

Проверяющая сторона симметрична и коротка. Сервис достаёт из заголовков X-Service-ID, X-Timestamp и X-Signature, берёт ключ этого сервиса из своего env — у каждой пары свой — и пересчитывает HMAC от тех же полей. Сравнивать подписи надо в постоянное время: crypto/subtle.ConstantTimeCompare не выходит раньше при первом несовпадающем байте, и время ответа не подсказывает нападающему, сколько байт угадано. Дальше свежесть: метка времени дальше пяти минут от текущей — отказ, и это защита от повтора перехваченного вызова. Кто хочет строже — держит короткий кэш виденных подписей в Redis и режет даже повтор внутри окна; честно скажу, для внутренних вызовов окно в пять минут почти всегда достаточно, а кэш добавляет зависимость, ложные срабатывания на ретраях и ещё один сервис, который может упасть раньше биллинга. Отдельная деталь — синхронизация часов: сервер и клиент со спешащими на минуты часами начнут терять честные вызовы по окну, поэтому NTP для внутренних машин — часть этой темы, а не отдельная забота.

Когда между сервисами нужен jwt#

Сервисный jwt появляется там, где HMAC-подписи мало. Первый случай — контекст пользователя: нотификатор должен знать, чей заказ рассылать, и токен с полем act и sub пользователя сохраняет сквозную авторизацию без пересылки паролей. Второй — много вызываемых: подписывать запрос пятью разными ключами и хранить пять секретов в каждом клиенте хуже, чем выпускать короткие токены с аудиторией. Третий — внешний подрядчик: свой ключ давать страшно, а токен на пять минут с aud, ограничивающим один эндпоинт, — нормальный контракт. Сам выпуск — это RegisteredClaims с полями iss, sub, aud и коротким exp; проверяющий вызывает ParseWithClaims с опциями WithIssuer и WithAudience, и они сверяются наравне с подписью. Если проверяющих много и ключи надо раздавать публично — маршрут ведёт к JWKS и архитектуре из урока про микросервисы.

Сервисный JWT: пять минут и аудитория

GO
func ServiceToken(caller, audience string) (string, error) {
        claims := jwt.RegisteredClaims{
                Issuer:    caller,
                Subject:   caller,
                Audience:  jwt.ClaimStrings{audience},
                ID:        newJTI(),
                IssuedAt:  jwt.NewNumericDate(time.Now()),
                ExpiresAt: jwt.NewNumericDate(time.Now().Add(5 * time.Minute)),
        }
        secret := []byte(os.Getenv("SERVICE_JWT_SECRET"))
        return jwt.NewWithClaims(jwt.SigningMethodHS256, claims).SignedString(secret)
}

Пять минут срока, издатель и аудитория ограничивают применение токена одним вызываемым сервисом: даже утечка не открывает всю внутреннюю сеть. Проверяющая сторона передаёт в ParseWithClaims опции jwt.WithAudience и jwt.WithIssuer — несоответствие любого поля даёт ошибку до бизнес-логики. Для пары сервисов общий HS256-секрет честен; когда сервисов много, секрет меняют на пару RSA-ключей, и каждый проверяющий получает только публичный. Поле ID здесь — тот же jti, удобный для denylist при форс-отзыве.

Способы аутентификации сервисов#

СпособЧто доказываетОтзывКогда брать
Статичный API-ключвладение секретомправкой конфигапара сервисов, простой трафик
HMAC-подпись запросавладение секретом и целостность запросаправкой конфигато же, но с защитой от повтора и подмены тела
Сервисный JWTличность вызова с aud и срокомистечением exp и denylist по jtiконтекст пользователя, много вызываемых, подрядчики
mTLSвладение сертификатом на уровне соединенияотзывом сертификатастрогие регуляторные сети

Каким бы ни был токен, секрет живёт в env или менеджере секретов и попадает в контейнер при деплое, а не в репозиторий. Ротация статичного ключа делается двойным ключом: проверяющий временно принимает старый и новый одновременно, клиент переключается на новый, через сутки старый удаляется — downtime нулевой. У сервисного jwt ротация встроена: короткий exp сам выводит токены из оборота, а ключ подписи меняется по протоколу из JWKS-урока. Полезный слой сверх аутентификации — лимит по ключу: rate limiter на Redis считает вызовы каждого X-Service-ID отдельно, и всплеск от одного клиента не съедает квоту остальных; механика в уроке про rate limiter курса go-redis. Неудачные попытки аутентификации пишутся в лог с именем сервиса из заголовка — не секретом — и алерт на их всплеск находит утечку раньше владельца ключа. Все четыре способа из таблицы выше решают одну задачу с разной ценой; выбирать стоит от количества пар сервисов и требований регулятора, а не от моды на слово mTLS.

Проверка: защита от повтора

Что не позволит нападающему повторить перехваченный HMAC-запрос через два часа?

Проверка: утечка сервисного jwt

Сервисный JWT с aud=notifications попал к нападающему. Что он сможет вызвать с ним?

Повтор старого запроса

Прочитайте код и выберите, что он выведет:

// проверяющая сторона получила запрос:
// X-Timestamp на два часа раньше текущего времени
// X-Signature пересчитанный сервером HMAC совпал

// что вернёт проверка?

Восстановите вычисление подписи

Допишите HMAC-подпись: создание mac с алгоритмом хеширования и кодирование суммы.

mac := hmac.New(sha256., key) mac.Write([]byte(method + " " + path + " " + ts + " ")) mac.Write(body) sig := hex.(mac.Sum(nil))

Соберите подписанный вызов

Расставьте строки клиента в правильном порядке: от чтения ключа до отправки запроса.

Строки

Ваша программа

Нажимайте на строки слева, чтобы собрать программу

Карточки: сервис-сервис

Термин

API-ключ

нажмите, чтобы перевернуть
Определение

Статичный секрет пары сервисов: живёт в env, прикладывается к запросу, отзывается правкой конфига. Каждый запрос несёт одно и то же — перехват действителен до ротации.

1 / 5

Межсервисная история смыкается с пользовательской: внутри сервисного вызова может ехать jwt пользователя с его правами, и проверять его будет gateway — об этом урок про JWT в микросервисах. Криптографическая база темы — HMAC из пятого урока и асимметричные подписи из RS256. Клиентская сторона обычного браузерного запроса с тем же заголовком Authorization разобрана в уроке про fetch и Bearer, чек-лист по хранению секретов собран в уроке про лучшие практики. Всё из этого урока повторяется руками на локальном Go: два сервиса на разных портах и env с ключом — весь стенд. Вернуться к оглавлению можно на странице курса.

Песочница урока

Редактируйте код и запускайте его прямо здесь — интерпретатор работает в браузере, ничего устанавливать не нужно. За каждый запуск — +2 XP.

Вывод

Запустите код, чтобы увидеть вывод.

Коротко: что запомнить

  • Сервисные токены go — идентификация машин, а не людей: пользовательский jwt для этого не годится.
  • API-ключи против jwt: ключ проще и отзывается правкой, jwt короткоживущий и несёт aud и sub.
  • HMAC-подпись запроса покрывает метод, путь, время и тело; сравнение — в постоянное время.
  • Сервисный jwt живёт пять минут и проверяется с WithIssuer и WithAudience.
  • Ротация двойным ключом меняет секреты без простоя; ключи живут в env, не в git.
  • Лимит и лог отказов по X-Service-ID находят утечку раньше владельца ключа.

Домашнее задание

Соберите стенд на локальном Go: два хендлера на разных портах, ключ в env, подпись запроса по образцу урока. Прогоните честный вызов, затем подмените тело на стороне клиента и убедитесь, что подпись отвалилась; сдвиньте X-Timestamp на десять минут назад и получите отказ по окну. В конце поменяйте ключ в env только у клиента — проверяющая сторона обязана молча вернуть 401 и записать в лог имя сервиса. Запишите время, которое ушло на стенд: оно равно времени внедрения в реальном проекте, и это лучший аргумент против хождения без аутентификации.

Личные заметкиПусто
Хранятся только в вашем браузере (JWT · Сервисные токены: сервис-сервис)

Вопросы и ответы по уроку

Не проще ли ходить между сервисами вообще без аутентификации внутри VPN?

Внутри доверенной сети аутентификация всё равно нужна: VPN защищает периметр, а не направление запроса, и один скомпрометированный под становится точкой входа. Минимальная схема с ключом из env стоит минуты и оставляет в логах, кто именно вызвал сервис.

Почему сравнивать ключи нужно в постоянное время?

Обычное сравнение строк выходит при первом несовпадающем байте, и микроскопические различия времени ответа позволяют угадывать ключ по байтам. crypto/subtle.ConstantTimeCompare тратит одинаковое время независимо от позиции несовпадения — атака теряет канал.

Где взять большое случайное значение для ключа?

Генератором паролей или openssl rand -hex 32 в терминале — получите 256 бит энтропии. Не используйте UUID: у него энтропии достаточно для идентификатора, но привычка к uuid-ключам приводит к ключам из инкрементов в старых системах. Ключ раз в пару лет стоит ротировать даже без компрометации — процедура должна быть наезженной до инцидента, а не во время него.

Термины урока

Ключевые понятия из этого урока — короткие определения в глоссарии. Наведите на термин, чтобы вспомнить, откройте карточку для подробностей и ссылки на практику.

Следующая ступень учебного пути

Telegram25 уроков~12 чновичок25 уроков уже доступны

Telegram-боты на Go: от эха до полноценного сервиса

Учимся писать Telegram-ботов на Go: токен BotFather, команды, клавиатуры, состояния диалога, медиа, рассылки, webhook и деплой. 25 уроков с кодом.

  • Создавать бота через BotFather и хранить токен безопасно
  • Получать обновления через long polling и webhook
  • Отвечать сообщениями, командами и клавиатурами
  • Строить многошаговые диалоги с сохранением состояния