Содержание урока · 13 разделов
- Анонимный endpoint — открытая дверь
- Канон: identity в контексте
- JWT: паспорт с подписью издателя
- 401 против 403: два разных отказа
- HS256 против RS256: один секрет или пара ключей
- Матрица: чем подписывать и чем защищать канал
- TLS: где кончается открытый HTTP
- Секреты: где им жить
- Токен между сервисами: путешествие продолжается
- Что дальше
- Песочница урока
- Коротко: что запомнить
- Вопросы и ответы
Анонимный endpoint — открытая дверь#
Посчитай ущерб на конкретике. stringsvc слушает :8080 в сети компании: любой, кто узнал адрес, вызывает Uppercase, ставит задачи в очередь из урока 21, читает факты из шины урока 22. Он не злодей — просто коллега с curl'ом; но лимит из урока 09 делится между честными и случайными, метрики врут, а инцидент не расследуется: в логах из урока 06 нет имени виновника. Два вопроса путать нельзя: аутентификация — «кто ты», проверка токена; авторизация — «что тебе можно», проверка прав. Первая — это endpoint middleware из урока 14: одна обёртка закрывает сразу HTTP из урока 04 и gRPC из урока 13, потому что оба транспорта зовут один и тот же endpoint, — поэтому аутентификация go kit живёт на endpoint, а не в поле фреймворка. Вторая — доменная логика: сервис смотрит на identity и решает. Разнесение мест критично: токен парсится один раз на границе, вниз по стеку едет готовая личность, а не сырой заголовок.
Канон: identity в контексте#
Канон stringsvc не ломается: Uppercase и Count остаются мгновенными, очередь и события работают. Рядом появляется привратник, и у него строгая грамматика. HTTP-декодер из урока 04 вытаскивает заголовок Authorization и кладёт токен в поле структуры запроса — транспорт знает про заголовки, endpoint знает про домен. Endpoint middleware проверяет подпись и срок, после чего кладёт в context.Identity: subject (кто), scopes (что можно). Дальше по стеку ходит личность, а не токен: сервис из урока 03 читает контекст, если ему нужен автор; payload события из урока 22 получает поле actor; лог из урока 06 пишет subject в каждое сообщение. Контекст — единственный канал: тащить токен аргументами через слои — значит заставлять каждый слой знать про аутентификацию, и при смене схемы токенов переписывать весь сервис. Ключ контекста — приватный тип: две библиотеки, случайно взявшие строковый ключ «identity», затрут друг друга; свой тип-ключ делает коллизию невозможной.
Auth: обёртка на endpoint
GO// internal/endpoint/auth: аутентификация как endpoint middleware
type identityKey struct{}
type Identity struct {
Subject string // sub токена: пользователь или соседний сервис
Scopes []string // что разрешено: "uppercase", "count", "jobs"
}
type AuthableRequest interface {
Token() string // каждую структуру запроса научим отдавать токен
}
var ErrUnauthorized = errors.New("auth: invalid or expired token")
func Auth(secret []byte, next endpoint.Endpoint) endpoint.Endpoint {
return func(ctx context.Context, request interface{}) (interface{}, error) {
req, ok := request.(AuthableRequest)
if !ok {
return nil, ErrUnauthorized
}
claims, err := parseToken(secret, req.Token())
if err != nil {
return nil, ErrUnauthorized // error encoder из урока 05 вернёт 401
}
ctx = context.WithValue(ctx, identityKey{}, Identity{
Subject: claims.Subject,
Scopes: claims.Scopes,
})
return next(ctx, request)
}
}Обёртка повторяет форму endpoint middleware из урока 14: вход — контекст и запрос, выход — ответ или ошибка. Токен добывается интерфейсом AuthableRequest: HTTP-декодер из урока 04 положил его из заголовка Authorization, gRPC-декодер из урока 13 — из metadata; middleware один и тот же, потому что он не знает, откуда взялся токен. ErrUnauthorized — сентинел канона урока 03: error encoder из урока 05 мапит его в 401, а не в 500. Identity кладётся под приватным типом identityKey: прочитать её сможет только этот пакет — и только осознанно.
JWT: паспорт с подписью издателя#
JWT — три части через точку: header с алгоритмом, payload с claims и подпись. Claims — стандартные iss (кто издал), sub (о ком), exp (до когда), aud (для кого), плюс свои: scopes. Токен подписан, но не шифрован: payload читает любой, кто перехватил, — в нём нельзя класть секреты, только факты о личности. Сервис токен не издаёт, а проверяет: подпись говорит «этот паспорт выдал наш auth-сервис», exp — «он ещё не просрочен». Проверка обязательна целиком: подпись без exp пропустит мёртвый паспорт, exp без подписи — подделку. Запрос «jwt микросервисы» в поиске приводит к библиотекам, и это правильно: бери проверенную (golang-jwt), самописный разбор подписи — как собственный криптопрак: можно, если знаешь, что делаешь, и нельзя, если не знаешь. TTL держи коротким: минуты для пользовательских токенов, часы для сервисных — просроченный токен безопаснее отозванного с опозданием, потому что не требует инфраструктуры отзыва.
Валидация: алгоритм, подпись, срок
GO// internal/platform/jwt: разбор и проверка токена
type Claims struct {
jwt.RegisteredClaims // iss, sub, aud, exp — стандарт
Scopes []string `json:"scopes,omitempty"`
}
func parseToken(secret []byte, raw string) (*Claims, error) {
tok, err := jwt.ParseWithClaims(raw, &Claims{}, func(t *jwt.Token) (any, error) {
if _, ok := t.Method.(*jwt.SigningMethodHMAC); !ok {
return nil, fmt.Errorf("unexpected alg: %v", t.Header["alg"])
}
return secret, nil // STRINGSVC_JWT_SECRET из урока 18
})
if err != nil || !tok.Valid {
return nil, ErrUnauthorized
}
claims, ok := tok.Claims.(*Claims)
if !ok {
return nil, ErrUnauthorized
}
return claims, nil
}ParseWithClaims проверяет exp автоматически: просроченный токен вернёт ошибку ещё до твоего кода. Главная строка — проверка семейства алгоритма: без неё атакующий подписывает токен alg=none или подсовывает публичный ключ в качестве HMAC-секрета — две исторические уязвимости реальных библиотек. Фиксируем HMAC и сравниваем с секретом из конфига урока 18: секрет приходит env'ом на старте, fail fast, если пуст. RegisteredClaims даёт стандартные поля бесплатно, scopes — свой claim: авторизация прочитает его из identity.
401 против 403: два разных отказа#
Отказы аутентификации — тоже контракт API, и клиент различает их по коду. 401 Unauthorized: «не знаю, кто ты» — заголовка нет, подпись битая, токен просрочен; ответ уходит с WWW-Authenticate: Bearer, и клиент понимает: иди за новым токеном. 403 Forbidden: «знаю, кто ты, но нельзя» — подпись валидна, scope не тот: токен сервисного аккаунта, который умеет Count, стучится в админ-endpoint. В go-kit оба случая раскладываются канонно: ErrUnauthorized из middleware мапится в 401, доменные ошибки авторизации — через codeFrom из урока 05 в 403. Логи из урока 06 пишут причину отказа и subject, если он известен, — но никогда не пишут сам токен: утёкший в лог токен — это действующий ключ. Метрика auth_failures_total из урока 07 с меткой причины (no_token, expired, bad_signature, forbidden) превращает перебор в всплеск на графике, который заметит алерт, а не пользователь.
HS256 против RS256: один секрет или пара ключей#
HS256 — HMAC: издатель и проверяющий держат один и тот же секрет. Быстро, просто, для одного сервиса — идеально. Цена: каждый, кто умеет проверять, умеет и издавать; секрет приходится раздавать всем сервисам, и один скомпрометированный узел подделывает паспорта всей системы. RS256 — RSA: приватный ключ только у издателя, публичный раздаётся всем проверяющим. Проверить может каждый, подделать — никто: сервисы проверяют, издаёт один auth-сервис. Плата — скорость (RSA дороже HMAC на порядок при проверке) и инфраструктура: ключи, ротация, JWKS — публичный endpoint издателя с текущими ключами, который проверяющие кэшируют. Правило выбора: один сервис или пара доверенных — HS256; федерация микросервисов, где токены ездят между командами, — RS256. И в обоих случаях секреты не в репозитории: тема «go-kit безопасность» заканчивается не кодом, а дисциплиной — env из урока 18, secrets оркестратора из урока 24, ротация по плану, а не по инциденту.
Матрица: чем подписывать и чем защищать канал#
| Критерий | HS256 | RS256 | mTLS |
|---|---|---|---|
| Подпись токена | симметричный HMAC | асимметричный RSA | сертификаты сторон |
| У проверяющего | общий секрет | публичный ключ | свой сертификат и CA |
| Кто может издавать | владелец секрета | владелец приватного ключа | никто: подписан канал |
| Скорость проверки | микросекунды | на порядок дороже | handshake раз на соединение |
| Компрометация | секрет издаёт чужие токены | приватный ключ у одного | сертификат отзывается |
| Ротация | сменить env и перезапустить | JWKS и kid в header | перевыпуск сертификата |
| Ключи в конфиге (урок 18) | STRINGSVC_JWT_SECRET | путь к публичному ключу | пути к cert и key |
| Канон в курсе | выбор stringsvc | рост до федерации | транспорт сервис-сервис |
| Риск по умолчанию | секрет раздали всем | нет кэша JWKS | бюрократия PKI |
TLS: где кончается открытый HTTP#
Токен в заголовке перехватывается так же, как пароль, если канал открыт: транспорт шифруем. Вопрос один: где терминировать TLS. Вариант первый — сам сервис: http.Server с TLSConfig, сертификаты из файлов, Let's Encrypt через autocert; сквозное шифрование, но и сложность в коде: продление, cipher suites, прокладка ключей. Вариант второй — канон микросервисов: на границе стоит reverse-proxy, nginx или ingress, он терминирует TLS и внутрь приватной сети передаёт открытый HTTP; pod слушает :8080, а прокси добавляет X-Forwarded-Proto: https — по нему сервис видит, что снаружи канал был шифрованным, и может отказывать запросам, пришедшим мимо прокси. gRPC из урока 13 живёт по тем же правилам: credentials на клиенте и сервере, а внутри кластера — mTLS, когда обе стороны предъявляют сертификаты и проверяют друг друга взаимно. Сертификаты хранятся секретами оркестратора, а не в образе — об этом урок 24.
Секреты: где им жить#
Секрет — то, что открывает дверь целиком, и у него три заповеди. Первая: не в репозитории — git помнит всё, даже удалённое; ключ из коммита трёхлетней давности жив, пока не ротирован. Вторая: не в образе и не в коде — ENV в Dockerfile виден docker inspect'ом (урок 24), константа в исходнике видна всем, у кого есть бинарник. Третья: не в логе — маскируй секрет и токен в логгере из урока 06. Живут секреты в env на старте (двенадцать факторов из урока 18) или в secrets-хранилище оркестратора; fail fast обязательна: пустой STRINGSVC_JWT_SECRET — не дефолт, а отказ стартовать. Ротация — часть жизни: смена секрета инвалидирует все HS256-токены разом — это фича, кнопка «разлогинить всех». Кто издаёт токены — отдельный вопрос: в проде auth-сервис, в демо хватит утилиты, подписывающей токен тем же секретом; главное — издатель и проверяющий не живут в одном бинарнике случайно.
Токен между сервисами: путешествие продолжается#
Микросервис вызывает микросервис: заказ едет в биллинг, биллинг — в нотификацию. Кто там, с другой стороны? Тот же запрос, и он обязан ехать с тем же паспортом: service.ProxyingMiddleware из урока 17 при пересылке копирует заголовок Authorization вперёд — identity сохраняется на всём пути, и каждый сервис в цепочке видит исходного subject, а не соседа. Лог из урока 06 в каждом сервисе пишет один и тот же subject — инцидент собирается в цепочку. Глубже — mTLS: сосед предъявляет сертификат, подписанный тем же CA, и ты знаешь, кто стучится, ещё до токена; это защита от имитации адреса в сети. Для событий из урока 22 правило то же: payload несёт actor — кто заказал работу, — и аудит через месяц отвечает на вопрос «кто запустил этот конвейер», не опрашивая свидетелей. Аутентификация — не одна дверь на входе, а паспорт, который признают на каждом рубеже.
Куда встроить аутентификацию, чтобы закрыть и HTTP из урока 04, и gRPC из урока 13 одним слоем?
Из parseToken убрали проверку t.Method. Какая атака становится возможной?
Токен валиден: подпись свежая, exp не вышел, но в scopes нет uppercase. Что вернёт stringsvc?
Просроченный паспорт
Прочитайте код и выберите, что он выведет:
secret := []byte("demo-secret")
// exp указан в прошлом
raw := "eyJhbGciOiJIUzI1NiJ9.eyJzdWIiOiJhbm4iLCJleHAiOjE1MDAwMDAwMDB9.x"
_, err := parseToken(secret, raw)
if err != nil {
fmt.Println("reject:", ErrUnauthorized)
} else {
fmt.Println("welcome")
}Скоупы решают
Прочитайте код и выберите, что он выведет:
func allowed(scopes []string, need string) bool {
for _, s := range scopes {
if s == need {
return true
}
}
return false
}
func main() {
id := Identity{Subject: "ann", Scopes: []string{"count", "jobs"}}
fmt.Println(allowed(id.Scopes, "uppercase"), allowed(id.Scopes, "count"))
}Собери Auth
Восстанови четыре пропуска endpoint-обёртки: достань токен, откажи честно, положи identity в контекст, передай ход.
Путь запроса с паспортом
Расставь этапы пути авторизованного запроса в stringsvc.
Строки
Ваша программа
Нажимайте на строки слева, чтобы собрать программу
Утёкший токен — действующий ключ от сервиса до конца TTL. Поэтому: никакой токен не попадает в лог (маскируй Authorization), не попадает в URL (GET с токеном в строке остаётся в access-логах прокси навсегда), не возвращается в ошибках. Короткий TTL — страховка: пользовательский токен живёт минуты, сервисный — часы; скомпрометированный протухнет сам, без инфраструктуры отзыва. И никогда не пиши собственный разбор JWT: парсить подпись руками — как изобретать свой breaker из урока 10 вместо gobreaker: выглядит похоже, гарантирует ничего.
HMAC-проверка — микросекунды: обёртка из этого урока не станет узким местом даже под лимитом из урока 09. Расходы живут в другом: JWKS-кэш при RS256 — обновляй в фоне, а не на каждом запросе; метки причин отказов в Prometheus — четыре метки из этого урока; алерт на всплеск auth_failures_total. И пропиши SLI: доля 401 среди запросов — сигнал и перебора, и кривого клиента, у которого токен истекает раньше, чем он успевает обновить. Второй полезный гейдж — возраст валидных токенов: резко помолодел — значит, произошла ротация секрета.
Auth-обёртка, парсер JWT и скоупы живут в реальном сервисе с библиотекой golang-jwt, в песочнице курса этот код не исполняется — интерактивы урока построены на чтении кода. Локально оживает за минуту: подпиши токен HS256 утилитой с твоим секретом, curl с заголовком Authorization: Bearer — получи 200; сотри последний символ подписи — 401; подожди exp — снова 401, уже по сроку, с другой меткой причины.
Термины урока
Что дальше#
Сервис получил привратника: токен проверяется один раз на endpoint, identity едет в контексте, отказы различаются честно, а секреты живут по правилам. Но stringsvc всё ещё запускается go run на ноутбуке: ни образа, ни окружения, ни ответа оркестратору «я жив и готов». Урок 24-deploy упакует его в Docker: multi-stage образ без сборочного мусора, exec-форма ENTRYPOINT ради сигналов, health-чеки для проб, env вместо настроек в коде — и чек-лист прод-деплоя, где каждая строка отсылает к уроку курса. Карта курса — на странице курса; обёртки endpoint — в уроке 14-endpoint-middleware; кодеки и честный 401 — в уроке 05-codeki; секреты в env — в уроке 18-konfiguraciya; метрики отказов — в уроке 07-metriki; proxy, пересылающий Authorization, — в уроке 17-struktura.
Песочница урока
Редактируйте код и запускайте его прямо здесь — интерпретатор работает в браузере, ничего устанавливать не нужно. За каждый запуск — +2 XP.
Запустите код, чтобы увидеть вывод.
Коротко: что запомнить
- Аутентификация — endpoint middleware из урока 14: одна обёртка защищает HTTP и gRPC, транспорт лишь кладёт токен в запрос.
- Identity — subject и scopes в context под приватным типом-ключом; сервис и события едят личность, а не сырой заголовок.
- JWT подписан, но не шифрован: проверяй целиком — алгоритм, подпись, exp; alg=none без белого списка — классическая дыра.
- 401 — не знаю, кто ты; 403 — знаю, но нельзя: перепутанный код уводит клиента в бесполезный перелогин.
- HS256 — общий секрет для пары доверенных; RS256 — приватный ключ издателя и публичные у проверяющих для федерации.
- TLS терминирует ingress перед приватной сетью или сам http.Server; X-Forwarded-Proto — признак шифрованного канала снаружи.
- Секреты: не в репо, не в образе, не в логе; env из урока 18, fail fast на пустой STRINGSVC_JWT_SECRET, ротация — план.
- Токен между сервисами пересылает service.ProxyingMiddleware из урока 17; в payload событий едет actor — аудит знает автора.
Домашнее задание
Поставь привратника в stringsvc. Первое: собери internal/endpoint/auth с Identity, AuthableRequest и Auth из урока; научи структуры запросов из урока 02 отдавать токен, а HTTP-декодер из урока 04 — вытаскивать Authorization: Bearer. Второе: подпиши тестовый токен HS256 утилитой, задай STRINGSVC_JWT_SECRET через env из урока 18 и проверь три сценария: без заголовка — 401, битая подпись — 401, валидный токен — 200 и subject в логе из урока 06. Третье: добавь проверку scopes и добейся честной пары отказов: токен без uppercase — 403, просроченный exp — 401; обе причины должны светиться в auth_failures_total из урока 07 с разными метками. Четвёртое: поставь stringsvc за nginx или Caddy с TLS-терминацией, убедись, что X-Forwarded-Proto доезжает, и отклоняй запросы, пришедшие мимо прокси по открытому каналу. Пятое: переправь Authorization через service.ProxyingMiddleware из урока 17 ко второй реплике и проверь, что identity живёт на всём пути, а в payload события из урока 22 появился actor.
Личные заметкиПусто
Вопросы и ответы по уроку
JWT не шифруется — это дыра?
Нет, это дизайн. Токен подписан, а не зашифрован: любой может прочитать claims, никто не может подделать. В claims кладут факты о личности — subject, scopes, срок, — а не секреты. Если payload действительно нужно спрятать, используют JWE или шифрованный канал; для API-аутентификации подписи достаточно, а TLS закрывает перехват.
Кто издаёт токены — тот же stringsvc?
Лучше нет. Издание и проверка — разные роли: издателю нужен приватный ключ и пользователи, проверяющему — только публичная часть. В проде это отдельный auth-сервис или внешний IdP; в демо — утилита, подписывающая токен тем же секретом. Совмещать издателя с бизнес-сервисом — значит держать приватный ключ там, где чужой баг откроет дверь.
Нужен ли refresh-токен и где его место?
Для пользовательских сессий — да: короткий access-токен в памяти клиента, refresh — у auth-сервиса с ротацией и отзывом. stringsvc этот вопрос не видит: он только проверяет access-токены и отвечает 401, когда истёк срок. Клиент получает 401, идёт к издателю за новым и повторяет запрос — сервису для этого не нужно ничего, кроме честного 401.
Термины урока
Ключевые понятия из этого урока — короткие определения в глоссарии. Наведите на термин, чтобы вспомнить, откройте карточку для подробностей и ссылки на практику.
Изучайте дальше
Следующая ступень учебного пути
gRPC-Go: высокопроизводительный RPC
Курс gRPC-Go: proto3, кодогенерация, unary и streaming вызовы, interceptors, TLS, дедлайны, load balancing. 25 уроков и итоговый проект.
- Писать .proto-схемы и генерировать код
- Реализовывать unary и streaming сервисы
- Управлять метаданными, дедлайнами и отменами
- Строить interceptors для логов и аутентификации