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

Когда 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, никогда в коде.

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