← назад к разделу

Когда сервис А вызывает сервис Б внутри кластера, кажется, что всё безопасно — internal network, VPC, «свои». Но скомпрометированный контейнер, сетевой перехват или атака изнутри — реальные сценарии. Именно поэтому межсервисный трафик аутентифицируется так же строго, как пользовательский.

В Go есть два устоявшихся подхода: mTLS и Client Credentials Flow. Разберём оба.

Зачем аутентифицировать трафик между своими сервисами

Представьте: сервис заказов вызывает сервис товаров по HTTP без заголовка Authorization. Если кто-то получит доступ к подам кластера, он сможет дёргать любой сервис, притворяясь любым другим. Анонимный межсервисный вызов — это не «экономия на аутентификации», а дыра в периметре.

Правило простое: каждый запрос между сервисами несёт удостоверение — либо в транспорте (mTLS), либо в заголовке (Authorization: Bearer).

Способ 1: mTLS

mTLS (mutual TLS) — двусторонний TLS, где обе стороны предъявляют сертификат. Клиент доказывает серверу «я сервис заказов», сервер доказывает клиенту «я сервис товаров». Никаких паролей, никаких токенов — identity встроена в транспортный слой.

В Kubernetes-кластере с Istio или Linkerd всё происходит автоматически: каждому поду выдаётся SPIFFE-сертификат, sidecar-прокси шифрует трафик и проверяет сертификаты. Приложение на Go вообще не пишет auth-код — всё делает sidecar.

Если Service Mesh недоступен (локальная разработка, тест-стенд без Istio), mTLS можно настроить напрямую через стандартную библиотеку:

// adapters/out/product/client.go

func NewMTLSClient(certFile, keyFile, caFile string) (*http.Client, error) {
    cert, err := tls.LoadX509KeyPair(certFile, keyFile)
    if err != nil {
        return nil, fmt.Errorf("load client cert: %w", err)
    }
    caCert, err := os.ReadFile(caFile)
    if err != nil {
        return nil, fmt.Errorf("read ca cert: %w", err)
    }
    pool := x509.NewCertPool()
    pool.AppendCertsFromPEM(caCert)

    tlsCfg := &tls.Config{
        Certificates: []tls.Certificate{cert},
        RootCAs:      pool,
        MinVersion:   tls.VersionTLS12,
    }
    return &http.Client{
        Transport: &http.Transport{TLSClientConfig: tlsCfg},
        Timeout:   10 * time.Second,
    }, nil
}

Пути до файлов сертификатов берутся из переменных окружения — их монтирует Kubernetes из Secret. Никаких путей прямо в коде:

// cmd/app/config.go
type Config struct {
    MTLS struct {
        CertFile string `envconfig:"MTLS_CERT_FILE,required"`
        KeyFile  string `envconfig:"MTLS_KEY_FILE,required"`
        CAFile   string `envconfig:"MTLS_CA_FILE,required"`
    }
    ProductSvcURL string `envconfig:"PRODUCT_SVC_URL,required"`
}

Главное преимущество mTLS: identity нельзя «забыть» добавить в запрос — она в транспорте. Ротация сертификатов при Istio автоматическая, раз в 24 часа.

Способ 2: Client Credentials Flow

Когда Service Mesh недоступен или не планируется, используют Client Credentials Flow — стандартный OAuth2-механизм для машинного взаимодействия.

Схема простая:

  1. Сервис заказов запрашивает токен у IdP (Keycloak, Auth0): «я order-service, вот мой client_id и client_secret, хочу токен со scope service:product:read».
  2. IdP возвращает access_token.
  3. Сервис заказов ставит токен в заголовок Authorization: Bearer ... и вызывает сервис товаров.
  4. Сервис товаров проверяет JWT: подпись, срок действия, scope.

В Go это реализует пакет golang.org/x/oauth2/clientcredentials. Ключевая особенность: TokenSource кеширует токен и автоматически обновляет его до истечения. Хранить токен вручную или обновлять самостоятельно не нужно — пакет это делает.

// cmd/app/wire.go
import "golang.org/x/oauth2/clientcredentials"

ccCfg := clientcredentials.Config{
    ClientID:     cfg.S2S.ClientID,
    ClientSecret: cfg.S2S.ClientSecret,
    TokenURL:     cfg.S2S.TokenURL,
    Scopes:       []string{"service:product:read"},
}
tokenSrc := oauth2.ReuseTokenSource(nil, ccCfg.TokenSource(ctx))
productClient := product.NewClient(tokenSrc, cfg.ProductSvcURL)

oauth2.ReuseTokenSource добавляет поверх базового TokenSource кеш: повторный вызов .Token() отдаёт уже полученный токен вплоть до его истечения. Без ReuseTokenSource каждый запрос пойдёт за новым токеном в IdP.

Клиент сервиса, использующий токен:

// adapters/out/product/client.go
type Client struct {
    http     *http.Client
    tokenSrc oauth2.TokenSource
    baseURL  string
}

func NewClient(tokenSrc oauth2.TokenSource, baseURL string) *Client {
    return &Client{
        http:     &http.Client{Timeout: 10 * time.Second},
        tokenSrc: tokenSrc,
        baseURL:  baseURL,
    }
}

func (c *Client) GetProduct(ctx context.Context, productID string) (Product, error) {
    req, err := http.NewRequestWithContext(ctx, http.MethodGet,
        c.baseURL+"/products/"+productID, nil)
    if err != nil {
        return Product{}, fmt.Errorf("build request: %w", err)
    }

    tok, err := c.tokenSrc.Token()
    if err != nil {
        return Product{}, fmt.Errorf("get token: %w", err)
    }
    req.Header.Set("Authorization", "Bearer "+tok.AccessToken)

    resp, err := c.http.Do(req)
    // ...
}

Обратите внимание: токен ставится в клиенте (adapters/out/), а не в обработчике входящих запросов. Это важно — auth-логика входящего и исходящего трафика не смешивается.

Scope задаётся по операции

Ошибка — выдать одному сервису единый широкий scope service на всё. Правильно — отдельный scope на каждую операцию:

// adapters/out/inventory/client.go
ccInventory := clientcredentials.Config{
    ClientID:     cfg.S2S.ClientID,
    ClientSecret: cfg.S2S.ClientSecret,
    TokenURL:     cfg.S2S.TokenURL,
    Scopes:       []string{"service:inventory:reserve"},   // не "service"
}

// adapters/out/customer/client.go
ccCustomer := clientcredentials.Config{
    Scopes: []string{"service:customer:read"},
}

Разный scope — разные права. Если токен сервиса заказов утечёт, злоумышленник сможет только резервировать инвентарь, но не читать данные клиентов и не делать что-то ещё. Радиус поражения ограничен.

Конфигурация: секреты только из окружения

client_secret нельзя хардкодить в исходниках или конфигурационных файлах в репозитории. Только из переменных окружения или Vault:

// cmd/app/config.go
type S2SConfig struct {
    ClientID     string `envconfig:"S2S_CLIENT_ID,required"`
    ClientSecret string `envconfig:"S2S_CLIENT_SECRET,required"`
    TokenURL     string `envconfig:"S2S_TOKEN_URL,required"`
}

envconfig с тегом required упадёт при старте, если переменная не задана — лучше упасть на старте, чем незаметно работать без аутентификации. И никогда не логируйте структуру Config целиком — client_secret не должен попасть в логи.

Типичные ошибки

Анонимный вызов через http.DefaultClient. Самая частая ошибка — использовать http.Get(...) или создать http.Client{} без TokenSource или tls.Config. Выглядит безобидно, на деле — запрос без удостоверения:

// Неправильно: анонимный вызов к другому сервису
func (c *CustomerClient) GetCustomer(ctx context.Context, id string) (Customer, error) {
    resp, err := http.Get(c.baseURL + "/customers/" + id)
    // ...
}

Правильно — TokenSource в клиенте, как в примере выше.

Ручное управление токеном. Некоторые разработчики хранят токен в переменной и сами проверяют expires_in. Это изобретение велосипеда — oauth2.ReuseTokenSource уже делает всё правильно.

Один scope на все вызовы. scope=service или scope=internal — слишком широко. Если скомпрометирован один клиент, под угрозой все операции.

Секрет в коде. client_secret: "abc123" прямо в config.go — секрет попадает в историю git навсегда.

Коротко

  • Анонимный межсервисный трафик — дыра в безопасности; каждый запрос аутентифицируется.
  • mTLS — identity в транспорте; при Istio/Linkerd приложение не пишет auth-код вообще.
  • Client Credentials Flow — OAuth2 для машин; сервис получает токен у IdP и ставит его в Authorization: Bearer.
  • В Go — clientcredentials.Config + oauth2.ReuseTokenSource; токен кешируется и обновляется автоматически.
  • Scope — по операции (service:product:read), не один широкий на все вызовы.
  • client_secret — только из переменных окружения, никогда в коде или git.
  • Токен ставится в исходящем клиенте (adapters/out/), не в обработчике входящих запросов.

Что почитать дальше