Когда один микросервис вызывает другой внутри кластера, это выглядит как «безопасный» внутренний запрос. Изолированная сеть, 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) сама выдаёт и обновляет сертификаты, команде за этим следить не нужно.
- Работает одинаково для 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 для всего — антипаттерн: если токен утечёт, атакующий получает неограниченный доступ ко всем операциям.
Как это ложится на маркетплейс
Примеры выше не выдуманы: order-service и payment-service — это два из шести сервисов кейса маркетплейса. Разложим по ним все внутренние вызовы и нужные им права.
| Кто зовёт | Кого | Зачем | Scope |
|---|---|---|---|
| Order | Catalog | зарезервировать остаток при оформлении | catalog:reserve |
| Order | Payment | списать деньги за заказ | payment:charge |
| Backoffice | Payment | вернуть деньги по решению спора | payment:refund |
| Payment | Order | сообщить, что оплата прошла | order:confirm |
| Order | Notification | отправить письмо продавцу о новой продаже | notification:send |
Видно, зачем scope дробят по операциям. У Order есть право списать деньги, но нет права вернуть — возврат оформляет Backoffice по решению оператора. Если бы обоим выдали общий scope payment, утёкший секрет сервиса заказов открывал бы и возвраты, а это прямой путь к выводу денег.
Второй вопрос, который встаёт на первом же вызове: что делать с пользователем. Покупатель нажал «Оформить», Order пошёл в Payment — чей токен туда ехать? Вариантов два, и они не равнозначны:
- Проброс токена пользователя. Payment видит, кто именно платит, и может проверить владение. Но токен покупателя расползается по всем сервисам цепочки, и любой из них может им воспользоваться от его имени.
- Свой токен сервиса, а идентификатор пользователя — в теле запроса. Payment знает, что зовёт именно Order (по client credentials), а покупателя видит как поле
buyerIdв команде. Проверку «заказ принадлежит этому покупателю» при этом делает Order — тот, у кого есть данные заказа.
Для маркетплейса берут второй: проверка владения живёт там, где лежат данные, а токен пользователя дальше витрины не уезжает. Payment при этом обязан не верить полю buyerId на слово в вопросах денег — сумма и получатель выплаты берутся из его собственных записей, а не из входящего запроса.
И отдельно про Notification. Он ничего не решает и никем не владеет — просто отправляет то, что попросили. Но право notification:send всё равно выдают адресно: сервис, который может слать письма от имени площадки, — удобный инструмент для рассылки чего угодно, если секрет утечёт.
Анонимный трафик — почему это опасно
Частая ошибка — написать 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 запросов. - Карта сервисов маркетплейса — шесть сервисов сквозного кейса и их вызовы друг к другу.