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

Когда один микросервис вызывает другой внутри кластера, это выглядит как «безопасный» внутренний запрос. Изолированная сеть, VPC, закрытые порты — кажется, что бояться нечего. Это ошибочное представление: взломанный под, уязвимость в образе контейнера, горизонтальное перемещение по кластеру — всё это реальные сценарии. Принцип «не доверяй, проверяй» применяется не только к внешним пользователям, но и к трафику внутри кластера.

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

mTLS — когда сертификат заменяет пароль

Обычный TLS (тот, что вы видите в браузере) — односторонний: сервер доказывает клиенту, кто он. mTLS (mutual TLS) — двусторонний: и сервер, и клиент предъявляют сертификаты. Поэтому payment-service точно знает, что запрос пришёл именно от order-service, а не от чего-то постороннего.

В Kubernetes это делает Service Mesh (Istio, Linkerd). Каждому поду sidecar-прокси (Envoy) автоматически выдаётся уникальный сертификат SPIFFE-формата. Все вызовы между подами прозрачно шифруются и аутентифицируются — приложение об этом даже не знает.

order-service → [istio-proxy: cert=order-service-prod] ──mTLS──▶ [istio-proxy] → payment-service

Если на стороне принимающего сервиса нужно знать, кто именно обратился (для авторизации), Spring Security умеет читать CN клиентского сертификата:

@Configuration
@ConditionalOnProperty(name = "security.mtls.enabled", havingValue = "true")
public class MtlsSecurityConfig {

    @Bean
    SecurityFilterChain internalApi(HttpSecurity http) throws Exception {
        return http
            .securityMatcher("/internal/**")
            .x509(x509 -> x509
                .subjectPrincipalRegex("CN=(.*?)(?:,|$)")
                .userDetailsService(serviceUserDetails()))
            .authorizeHttpRequests(authz -> authz.anyRequest().authenticated())
            .build();
    }

    @Bean
    UserDetailsService serviceUserDetails() {
        return username -> User.builder()
            .username(username)
            .password("")
            .authorities("ROLE_system")
            .build();
    }
}

Здесь CN сертификата (order-service-prod) становится именем «пользователя» для Spring Security. Никаких токенов, никаких паролей — личность зашита в транспорт.

Почему mTLS удобен:

  • Нечего «забыть» — identity передаётся автоматически на уровне сети, разработчик не может пропустить заголовок.
  • Istio ротирует сертификаты каждые 24 часа без участия команды.
  • Работает одинаково для Java, Go, Python — язык не важен.

Где mTLS сложнее:

  • Нужна Service Mesh инфраструктура — это серьёзная операционная нагрузка.
  • В локальной разработке и тестах без Service Mesh нужен другой подход.

Client Credentials Flow — токен вместо сертификата

Если Service Mesh недоступен, используют OAuth2 Client Credentials Flow. Идея: каждый сервис имеет свои client_id и client_secret, с которыми получает короткоживущий токен у провайдера идентичности (Keycloak, Auth0 и т.д.), а потом передаёт этот токен в заголовке запроса.

order-service ──▶ IdP: POST /oauth/token
                       grant_type=client_credentials
                       client_id=order-service-prod
                       client_secret=...
                       scope=payment:charge

            ◀── IdP: { "access_token": "...", "expires_in": 3600 }

order-service ──▶ payment-service: POST /charge
                                   Authorization: Bearer <token>

payment-service: проверяет подпись токена, видит scope=payment:charge, разрешает.

Spring Security берёт всё управление токенами на себя. Достаточно описать регистрацию клиента в application.yml:

spring:
  security:
    oauth2:
      client:
        registration:
          payment-service:
            client-id: order-service-prod
            client-secret: ${ORDER_SERVICE_CLIENT_SECRET}
            authorization-grant-type: client_credentials
            scope: payment:charge
        provider:
          payment-service:
            token-uri: ${IDP_TOKEN_URI}

И настроить HTTP-клиент с перехватчиком:

@Configuration
@RequiredArgsConstructor
public class PaymentClientConfig {

    @Bean
    OAuth2AuthorizedClientManager authorizedClientManager(
        ClientRegistrationRepository registrations,
        OAuth2AuthorizedClientService clients
    ) {
        var provider = OAuth2AuthorizedClientProviderBuilder.builder()
            .clientCredentials()
            .build();
        var manager = new AuthorizedClientServiceOAuth2AuthorizedClientManager(registrations, clients);
        manager.setAuthorizedClientProvider(provider);
        return manager;
    }

    @Bean
    RestClient paymentRestClient(OAuth2AuthorizedClientManager authorizedClientManager) {
        var interceptor = new OAuth2ClientHttpRequestInterceptor(authorizedClientManager);
        interceptor.setClientRegistrationIdResolver(req -> "payment-service");
        return RestClient.builder()
            .baseUrl("https://payment-service.internal")
            .requestInterceptor(interceptor)
            .build();
    }
}

OAuth2ClientHttpRequestInterceptor сам запросит токен при первом обращении, закеширует его до истечения и обновит, когда тот устареет. Никакого ручного управления жизнью токена не нужно.

Важная деталь про scope. Правильно делать отдельный scope на каждую операцию: payment:charge, payment:refund, inventory:reserve. Один общий scope вроде service для всего — антипаттерн: если токен утечёт, атакующий получает неограниченный доступ ко всем операциям.

Анонимный трафик — почему это опасно

Частая ошибка — написать HTTP-клиент без аутентификации:

// Опасно: любой под в кластере может позвонить в payment-service
@Component
public class PaymentClient {
    private final RestTemplate restTemplate;

    public Receipt charge(Long orderId, Money amount) {
        return restTemplate.postForObject(
            "http://payment-service/charge",
            new ChargeRequest(orderId, amount),
            Receipt.class
        );
    }
}

Если один под в кластере взломан через уязвимость в образе, атакующий может вызывать payment-service от его имени. Без аутентификации payment-service не отличит легитимный запрос от мошеннического.

Правило простое: любой HTTP-клиент, вызывающий другой сервис, должен либо идти через mTLS-sidecar (тогда сеть сама добавит identity), либо использовать OAuth2ClientHttpRequestInterceptor (тогда токен добавится автоматически). Аутентификация никогда не добавляется вручную внутри бизнес-логики — только на уровне клиентской конфигурации.

Частые ошибки

client_secret в коде или в application.yml в открытом виде. Секрет сервиса должен приходить через переменную окружения или хранилище секретов (Vault, AWS Secrets Manager). В конфигурации — только плейсхолдер вроде ${ORDER_SERVICE_CLIENT_SECRET}.

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

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

Ручное добавление Authorization заголовка в обработчике запроса. Это смешивает аутентификационную инфраструктуру с бизнес-логикой. Перехватчик на уровне HTTP-клиента — единственное правильное место.

Коротко

  • Трафик между сервисами внутри кластера — не «безопасный по умолчанию». Каждый вызов нужно аутентифицировать.
  • mTLS через Service Mesh: сертификат = identity, Spring-приложение не трогает. Хорошо там, где есть Istio/Linkerd.
  • Client Credentials Flow: сервис получает токен от провайдера идентичности и передаёт его в Authorization: Bearer. Spring Security кеширует и обновляет токен автоматически.
  • Scope должен быть на операцию (payment:charge), не на сервис целиком.
  • client_secret — только через переменную окружения или хранилище секретов, никогда в коде.
  • Аутентификация на HTTP-клиент через перехватчик, не внутри бизнес-логики.

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

  • JWT validation — как принимающий сервис проверяет токен от вызывающего.
  • PII и секреты — хранение client_secret через Vault.
  • Где какая проверка — роль system для S2S запросов.