Когда order-service вызывает payment-service, кто он такой? Если ответ «ну, он внутри кластера» — это не аутентификация, это надежда. Разберём, как сервисы по-настоящему удостоверяют свою личность друг перед другом.
Почему «внутренняя сеть» не защищает
Распространённое заблуждение: раз сервисы в одном Kubernetes-кластере, значит они уже в безопасной зоне и можно не проверять, кто к кому обращается.
Проблема в том, что один скомпрометированный pod открывает путь ко всем соседним. Если payment-service принимает запросы от любого, кто знает его URL, атакующий, попавший в кластер через любую брешь, получает доступ к платёжной логике без каких-либо проверок.
Принцип zero trust звучит так: каждый межсервисный вызов аутентифицирован, даже внутри кластера. Кто ты — докажи, вне зависимости от того, откуда ты звонишь.
Есть два проверенных способа это организовать.
Способ 1: mTLS через Istio
mTLS (mutual TLS, взаимный TLS) — это когда при соединении не только клиент проверяет сертификат сервера, но и сервер проверяет сертификат клиента. Каждый pod получает свой уникальный сертификат, и по нему можно точно сказать, кто звонит.
В Kubernetes-кластере с Istio (или Linkerd) это работает автоматически. Istio раздаёт каждому pod-у SPIFFE-сертификат, шифрует весь внутренний трафик и проверяет сертификаты на стороне получателя. NestJS-приложение не пишет ни строчки кода аутентификации — всё происходит на уровне sidecar-прокси до того, как запрос доходит до приложения.
// main.ts — приложение запускается как обычно, mTLS прозрачен для него
async function bootstrap() {
const app = await NestFactory.create(AppModule);
await app.listen(3000);
}
bootstrap();
Дополнительно можно включить строгий режим mTLS для конкретного сервиса через манифест Istio:
# kubernetes/payment-service-peer-auth.yaml
apiVersion: security.istio.io/v1beta1
kind: PeerAuthentication
metadata:
name: payment-service
namespace: production
spec:
selector:
matchLabels:
app: payment-service
mtls:
mode: STRICT
С STRICT сервис будет принимать только mTLS-соединения — анонимный трафик блокируется на уровне сети.
Плюсы mTLS: личность сервиса встроена в транспортный уровень, сертификаты обновляются автоматически каждые 24 часа, подход одинаково работает для NestJS, Java, Python и Go.
Минус один: нужна инфраструктура Service Mesh. Если Istio нет, используют второй способ.
Способ 2: Client Credentials Flow
Это стандартный OAuth2-поток для машин. Сервис-клиент запрашивает у провайдера идентичности (IdP) токен, предъявляя свой идентификатор и секрет, а затем прикладывает этот токен к каждому исходящему запросу.
Схема взаимодействия:
order-service → IdP: POST /oauth/token
grant_type=client_credentials
client_id=order-service-prod
client_secret=$ORDER_SERVICE_CLIENT_SECRET
scope=payment:charge
← IdP: { "access_token": "...", "expires_in": 3600 }
order-service → payment-service:
POST /charge
Authorization: Bearer <token>
payment-service: проверяет JWT-подпись и scope=payment:charge
Кеширование токена
Токен выдаётся на час (обычно). Запрашивать новый при каждом вызове — лишняя нагрузка на IdP и лишние миллисекунды к каждому запросу. Правильно: получить токен один раз и держать его в памяти, обновляя заранее до истечения.
// adapters/out/payment/token-cache.service.ts
import { Injectable, Logger } from '@nestjs/common';
import axios from 'axios';
import { AppConfig } from '../../../config/app.config';
interface TokenEntry {
value: string;
expiresAt: number;
}
@Injectable()
export class TokenCacheService {
private readonly logger = new Logger(TokenCacheService.name);
private cache = new Map<string, TokenEntry>();
constructor(private readonly config: AppConfig) {}
async token(scope: string): Promise<string> {
const cached = this.cache.get(scope);
// обновляем за 30 секунд до истечения, чтобы не словить гонку
if (cached && cached.expiresAt > Date.now() + 30_000) {
return cached.value;
}
return this.fetch(scope);
}
private async fetch(scope: string): Promise<string> {
const { data } = await axios.post<{ access_token: string; expires_in: number }>(
this.config.auth.tokenUri,
new URLSearchParams({
grant_type: 'client_credentials',
client_id: this.config.auth.clientId,
client_secret: this.config.auth.clientSecret,
scope,
}),
{ headers: { 'Content-Type': 'application/x-www-form-urlencoded' } },
);
this.cache.set(scope, {
value: data.access_token,
expiresAt: Date.now() + data.expires_in * 1000,
});
this.logger.debug(`token refreshed for scope=${scope}`);
return data.access_token;
}
}
Заметьте: expiresAt > Date.now() + 30_000 — обновляем не когда токен уже протух, а за 30 секунд до этого. Clock skew между серверами и задержки сети могут привести к тому, что токен формально ещё действующий, но IdP уже его не примет.
Клиент с автоматическим добавлением токена
Вместо того чтобы добавлять Authorization в каждом методе вручную, ставят axios-перехватчик один раз при создании клиента:
// adapters/out/payment/payment.client.ts
import { Injectable } from '@nestjs/common';
import axios, { AxiosInstance } from 'axios';
import { AppConfig } from '../../../config/app.config';
import { TokenCacheService } from './token-cache.service';
@Injectable()
export class PaymentClient {
private readonly http: AxiosInstance;
constructor(
private readonly tokens: TokenCacheService,
config: AppConfig,
) {
this.http = axios.create({ baseURL: config.payment.baseUrl });
this.http.interceptors.request.use(async (req) => {
const bearer = await this.tokens.token('payment:charge');
req.headers['Authorization'] = `Bearer ${bearer}`;
return req;
});
}
async charge(orderId: string, amount: number): Promise<Receipt> {
const { data } = await this.http.post<Receipt>('/charge', { orderId, amount });
return data;
}
}
Перехватчик срабатывает перед каждым запросом и добавляет актуальный токен. Метод charge не знает про OAuth2 — он просто делает HTTP-вызов.
Отдельный scope для каждой операции
Соблазн выдать сервису один широкий токен на всё взаимодействие — распространённая ошибка. Если order-service скомпрометирован и у него токен со scope payment:*, атакующий может делать всё что угодно с платёжным сервисом.
Правильный подход: scope привязан к конкретной операции.
// adapters/out/inventory/inventory.client.ts
async reserve(productId: string, qty: number): Promise<void> {
const bearer = await this.tokens.token('inventory:reserve');
await this.http.post('/reserve', { productId, qty }, {
headers: { Authorization: `Bearer ${bearer}` },
});
}
async release(reservationId: string): Promise<void> {
const bearer = await this.tokens.token('inventory:release');
await this.http.post(`/reservations/${reservationId}/release`, null, {
headers: { Authorization: `Bearer ${bearer}` },
});
}
order-service может зарезервировать товар и освободить резервацию, но не удалить товар из каталога (catalog:delete). Даже если сервис взломают, ущерб ограничен теми операциями, которые ему реально нужны.
Анонимный трафик — частая ошибка
Вот как выглядит уязвимый клиент:
// УЯЗВИМО: нет Authorization
@Injectable()
export class CustomerClient {
async profile(customerId: string): Promise<CustomerProfile> {
const { data } = await axios.get(`${this.baseUrl}/customers/${customerId}`);
return data;
}
}
Любой pod в кластере может обратиться к customer-service и получить профиль клиента без какой-либо проверки.
Корректный вариант — перехватчик централизованно добавляет токен:
// ВЕРНО: перехватчик добавляет Bearer к каждому запросу
@Injectable()
export class CustomerClient {
private readonly http: AxiosInstance;
constructor(tokens: TokenCacheService, config: AppConfig) {
this.http = axios.create({ baseURL: config.customer.baseUrl });
this.http.interceptors.request.use(async (req) => {
req.headers['Authorization'] = `Bearer ${await tokens.token('customer:read')}`;
return req;
});
}
async profile(customerId: string): Promise<CustomerProfile> {
const { data } = await this.http.get<CustomerProfile>(`/customers/${customerId}`);
return data;
}
}
Секреты — не в коде
clientSecret — это пароль сервиса. Хранить его в коде или в git недопустимо. Правило простое: только через переменные окружения или Vault.
// config/app.config.ts
import { IsString, IsUrl } from 'class-validator';
export class AuthConfig {
@IsUrl()
tokenUri: string;
@IsString()
clientId: string;
@IsString()
clientSecret: string; // из process.env.CLIENT_SECRET, не строка в коде
}
# .env.example — только примеры, реальные значения через Vault / SealedSecrets
AUTH_TOKEN_URI=https://idp.internal/oauth/token
AUTH_CLIENT_ID=order-service-prod
AUTH_CLIENT_SECRET=<from-vault>
Коротко
- Внутренняя сеть не гарантирует безопасность: один скомпрометированный pod открывает доступ ко всем соседям.
- Два подхода: mTLS (через Istio/Linkerd, прозрачен для NestJS-кода) и Client Credentials Flow (OAuth2 для окружений без Service Mesh).
- При Client Credentials Flow токен кешируют в памяти и обновляют за 30 секунд до истечения — не вручную по таймеру, а при следующем запросе.
- Токен добавляют через axios-перехватчик один раз, а не в каждом методе клиента.
- Scope привязывают к конкретной операции (
payment:charge,inventory:reserve), не к сервису целиком. clientSecret— только через переменные окружения или Vault, никогда в коде.
Что почитать дальше
- JWT-валидация в NestJS — как
payment-serviceпроверяет входящий токен отorder-service. - Хранение секретов и PII —
clientSecretчерез Vault, маскировка данных в логах. - Где ставить проверку прав — роль
systemдля межсервисных вызовов в RBAC. - Идемпотентность —
Idempotency-Keyдля денежных операций через межсервисные вызовы.