Backend-разработка

Построение безопасного внутреннего REST API для B2B: ключи, scopes и аудит на PHP

Ruslan Ismailov Опубликовано 18 мин чтения
П

Введение: специфика B2B API и его отличие от пользовательского

B2B API — это не публичный интерфейс для конечных пользователей, а инфраструктурный контракт между компаниями. Здесь другие требования к безопасности: ключи живут месяцами, партнёры имеют разные уровни доверия, а каждый запрос должен быть воспроизводимым и аудируемым. Ошибка в проектировании обходится дорого — в прямых потерях данных или нарушении SLA.

Принципиальные отличия от пользовательского API:

  • Нет браузерных сессий — только machine-to-machine аутентификация.
  • Долгоживущие учётные данные — API-ключи вместо краткосрочных токенов.
  • Гранулярные права — один партнёр читает заказы, другой может их создавать.
  • Обязательный аудит — юридические и compliance-требования фиксировать каждое действие.
  • Версионирование — обратная совместимость критична, партнёр не обновляется по первому требованию.

Архитектура аутентификации: API-ключи vs OAuth2 vs mTLS

Для B2B-интеграций в 2026 году актуальны три подхода. Выбор зависит от требований к безопасности и сложности инфраструктуры партнёра.

API-ключи

Простейший вариант: статический секрет передаётся в заголовке X-Api-Key. Легко реализовать, достаточно для большинства внутренних B2B-интеграций. Минус — ключ нельзя отозвать мгновенно без участия базы данных, поэтому важно хранить только хеш.

OAuth2 Client Credentials

Подходит, если партнёр уже работает с OAuth2-экосистемой или нужны короткоживущие access-токены. Увеличивает сложность: нужен authorization server (например, Laravel Passport или отдельный сервис). Рекомендуется, когда партнёров десятки и нужна федерация прав.

mTLS (взаимная TLS-аутентификация)

Максимальная безопасность: клиент и сервер предъявляют сертификаты. Используется в финтехе и здравоохранении. Сложность развёртывания высокая — PKI, ротация сертификатов, поддержка со стороны партнёра.

Для большинства B2B SaaS-продуктов оптимальный выбор в 2026 году — API-ключи с хешированием + scopes + rate limiting. OAuth2 Client Credentials — если партнёров более 50 или требуется делегирование прав.

Реализация системы API-ключей на Laravel

Схема базы данных

Создадим таблицы для клиентов и их ключей в PostgreSQL:

-- Таблица B2B-клиентов (партнёров)
CREATE TABLE api_clients (
    id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
    name VARCHAR(255) NOT NULL,
    company VARCHAR(255) NOT NULL,
    is_active BOOLEAN NOT NULL DEFAULT TRUE,
    created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
    updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
);

-- Таблица API-ключей
CREATE TABLE api_keys (
    id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
    client_id UUID NOT NULL REFERENCES api_clients(id) ON DELETE CASCADE,
    key_hash VARCHAR(64) NOT NULL UNIQUE,  -- SHA-256 хеш ключа
    key_prefix VARCHAR(8) NOT NULL,         -- первые 8 символов для идентификации
    name VARCHAR(255),                      -- метка ключа ("production", "staging")
    scopes JSONB NOT NULL DEFAULT '[]',
    last_used_at TIMESTAMPTZ,
    expires_at TIMESTAMPTZ,
    is_active BOOLEAN NOT NULL DEFAULT TRUE,
    created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
    revoked_at TIMESTAMPTZ
);

CREATE INDEX idx_api_keys_hash ON api_keys(key_hash);
CREATE INDEX idx_api_keys_client ON api_keys(client_id);

Генерация и хранение ключей в Laravel

Никогда не храните ключ в открытом виде. Генерируем криптографически стойкий ключ, отдаём его клиенту один раз, сохраняем только хеш:

<?php

namespace App\Services;

use App\Models\ApiKey;
use Illuminate\Support\Str;

class ApiKeyService
{
    /**
     * Генерирует новый API-ключ для клиента.
     * Возвращает plain-text ключ ТОЛЬКО один раз.
     */
    public function generate(string $clientId, array $scopes, string $name = '', ?\DateTimeInterface $expiresAt = null): array
    {
        // 32 байта = 256 бит энтропии, base64url-encoded
        $plainKey = 'b2b_' . Str::random(48);
        $keyHash  = hash('sha256', $plainKey);
        $prefix   = substr($plainKey, 0, 8);

        $apiKey = ApiKey::create([
            'client_id'  => $clientId,
            'key_hash'   => $keyHash,
            'key_prefix' => $prefix,
            'name'       => $name,
            'scopes'     => $scopes,
            'expires_at' => $expiresAt,
        ]);

        return [
            'id'          => $apiKey->id,
            'key'         => $plainKey,   // показываем ОДИН раз
            'key_prefix'  => $prefix,
            'scopes'      => $scopes,
            'expires_at'  => $expiresAt,
        ];
    }

    /**
     * Проверяет ключ и возвращает запись из БД.
     */
    public function verify(string $plainKey): ?ApiKey
    {
        $hash = hash('sha256', $plainKey);

        return ApiKey::query()
            ->where('key_hash', $hash)
            ->where('is_active', true)
            ->where(function ($q) {
                $q->whereNull('expires_at')
                  ->orWhere('expires_at', '>', now());
            })
            ->with('client')
            ->first();
    }
}

Middleware аутентификации

<?php

namespace App\Http\Middleware;

use App\Services\ApiKeyService;
use Closure;
use Illuminate\Http\Request;
use Symfony\Component\HttpFoundation\Response;

class AuthenticateApiKey
{
    public function __construct(private ApiKeyService $keyService) {}

    public function handle(Request $request, Closure $next): Response
    {
        $raw = $request->header('X-Api-Key');

        if (!$raw) {
            return response()->json(['error' => 'API key required'], 401);
        }

        $apiKey = $this->keyService->verify($raw);

        if (!$apiKey || !$apiKey->client->is_active) {
            return response()->json(['error' => 'Invalid or revoked API key'], 401);
        }

        // Обновляем last_used_at асинхронно через очередь, не блокируя ответ
        dispatch(fn() => $apiKey->update(['last_used_at' => now()]))->afterResponse();

        // Кладём данные в request для последующих слоёв
        $request->attributes->set('api_key', $apiKey);
        $request->attributes->set('api_client', $apiKey->client);

        return $next($request);
    }
}

Модель scopes: гранулярный контроль доступа

Scopes — это набор строк, описывающих права доступа. Соглашение об именовании: resource:action. Примеры для B2B-платформы:

  • orders:read — чтение заказов
  • orders:write — создание и обновление заказов
  • invoices:read — чтение счетов
  • products:* — все действия с товарами
  • webhooks:manage — управление вебхуками

Middleware проверки scope

<?php

namespace App\Http\Middleware;

use Closure;
use Illuminate\Http\Request;
use Symfony\Component\HttpFoundation\Response;

class CheckApiScope
{
    public function handle(Request $request, Closure $next, string ...$requiredScopes): Response
    {
        $apiKey = $request->attributes->get('api_key');

        if (!$apiKey) {
            return response()->json(['error' => 'Unauthenticated'], 401);
        }

        $grantedScopes = $apiKey->scopes ?? [];

        foreach ($requiredScopes as $required) {
            if (!$this->hasScope($grantedScopes, $required)) {
                return response()->json([
                    'error'    => 'Insufficient permissions',
                    'required' => $required,
                ], 403);
            }
        }

        return $next($request);
    }

    private function hasScope(array $granted, string $required): bool
    {
        if (in_array($required, $granted, true)) {
            return true;
        }

        // Поддержка wildcard: orders:* покрывает orders:read, orders:write
        [$resource] = explode(':', $required);
        return in_array($resource . ':*', $granted, true)
            || in_array('*', $granted, true);
    }
}

Регистрация в маршрутах

// routes/api.php
Route::middleware(['auth.apikey', 'throttle.client'])
    ->prefix('v1')
    ->group(function () {

        Route::get('/orders', [OrderController::class, 'index'])
            ->middleware('scope:orders:read');

        Route::post('/orders', [OrderController::class, 'store'])
            ->middleware('scope:orders:write');

        Route::get('/invoices', [InvoiceController::class, 'index'])
            ->middleware('scope:invoices:read');
    });

Rate limiting на уровне клиента с Redis

В B2B важно изолировать квоты по клиентам — один партнёр не должен влиять на другого. Используем Redis с алгоритмом скользящего окна (sliding window).

Реализация кастомного rate limiter

<?php

namespace App\Http\Middleware;

use Closure;
use Illuminate\Http\Request;
use Illuminate\Support\Facades\Redis;
use Symfony\Component\HttpFoundation\Response;

class ClientRateLimiter
{
    // Лимиты по умолчанию; можно хранить per-client в БД
    private const DEFAULT_LIMIT   = 1000;   // запросов
    private const WINDOW_SECONDS  = 60;     // за 60 секунд

    public function handle(Request $request, Closure $next): Response
    {
        $apiKey = $request->attributes->get('api_key');
        $clientId = $apiKey?->client_id ?? 'anonymous';

        $limit  = $apiKey?->client->rate_limit ?? self::DEFAULT_LIMIT;
        $window = self::WINDOW_SECONDS;

        $now     = microtime(true);
        $redisKey = "ratelimit:{$clientId}";

        // Sliding window log через sorted set
        Redis::pipeline(function ($pipe) use ($redisKey, $now, $window) {
            $pipe->zremrangebyscore($redisKey, '-inf', $now - $window);
            $pipe->zadd($redisKey, $now, $now . mt_rand());
            $pipe->expire($redisKey, (int) $window + 1);
        });

        $count = Redis::zcard($redisKey);

        $remaining = max(0, $limit - $count);
        $resetAt   = (int) ($now + $window);

        if ($count > $limit) {
            return response()->json(
                ['error' => 'Rate limit exceeded', 'retry_after' => $window],
                429
            )->withHeaders([
                'X-RateLimit-Limit'     => $limit,
                'X-RateLimit-Remaining' => 0,
                'X-RateLimit-Reset'     => $resetAt,
                'Retry-After'           => $window,
            ]);
        }

        $response = $next($request);

        return $response->withHeaders([
            'X-RateLimit-Limit'     => $limit,
            'X-RateLimit-Remaining' => $remaining,
            'X-RateLimit-Reset'     => $resetAt,
        ]);
    }
}

Аудит-лог: запись всех запросов и изменений в PostgreSQL

Схема таблицы аудита

CREATE TABLE api_audit_log (
    id           BIGSERIAL PRIMARY KEY,
    client_id    UUID REFERENCES api_clients(id),
    api_key_id   UUID REFERENCES api_keys(id),
    method       VARCHAR(10) NOT NULL,
    path         TEXT NOT NULL,
    query_params JSONB,
    request_body JSONB,       -- sanitized, без секретов
    status_code  SMALLINT NOT NULL,
    ip_address   INET,
    user_agent   TEXT,
    duration_ms  INTEGER,
    created_at   TIMESTAMPTZ NOT NULL DEFAULT NOW()
);

-- Партиционирование по месяцам для масштабируемости
CREATE TABLE api_audit_log_2026_01 PARTITION OF api_audit_log
    FOR VALUES FROM ('2026-01-01') TO ('2026-02-01');

CREATE INDEX idx_audit_client_date ON api_audit_log(client_id, created_at DESC);
CREATE INDEX idx_audit_status ON api_audit_log(status_code) WHERE status_code >= 400;

Middleware для записи в аудит

<?php

namespace App\Http\Middleware;

use App\Jobs\WriteAuditLog;
use Closure;
use Illuminate\Http\Request;
use Symfony\Component\HttpFoundation\Response;

class AuditLogger
{
    private array $sensitiveKeys = ['password', 'token', 'secret', 'card_number'];

    public function handle(Request $request, Closure $next): Response
    {
        $start = microtime(true);

        $response = $next($request);

        $duration = (int) ((microtime(true) - $start) * 1000);
        $apiKey   = $request->attributes->get('api_key');

        // Асинхронная запись через очередь, чтобы не замедлять ответ
        WriteAuditLog::dispatch([
            'client_id'    => $apiKey?->client_id,
            'api_key_id'   => $apiKey?->id,
            'method'       => $request->method(),
            'path'         => $request->path(),
            'query_params' => $request->query(),
            'request_body' => $this->sanitize($request->all()),
            'status_code'  => $response->getStatusCode(),
            'ip_address'   => $request->ip(),
            'user_agent'   => $request->userAgent(),
            'duration_ms'  => $duration,
        ]);

        return $response;
    }

    private function sanitize(array $data): array
    {
        foreach ($this->sensitiveKeys as $key) {
            if (isset($data[$key])) {
                $data[$key] = '[REDACTED]';
            }
        }
        return $data;
    }
}

Версионирование и обратная совместимость B2B API

B2B-партнёры редко обновляют интеграции оперативно, поэтому версионирование критично. Рекомендуемая стратегия — версия в URL-префиксе (/api/v1/, /api/v2/):

  • Храните все версии параллельно минимум 18 месяцев после анонса deprecation.
  • Версия в заголовке ответа: добавляйте X-Api-Version: 1.5.2 и Deprecation: true для устаревших эндпоинтов.
  • Changelog через API: эндпоинт GET /api/changelog с машиночитаемым списком изменений.
  • Семантическое версионирование контракта: breaking changes только в major-версиях.
// Пример структуры директорий для версионирования
app/
  Http/
    Controllers/
      Api/
        V1/
          OrderController.php
        V2/
          OrderController.php  // новые поля, но старая схема через трансформер

Мониторинг и алертинг: Prometheus-метрики для API

Для PHP/Laravel подключаем promphp/prometheus_client_php и экспортируем метрики через отдельный эндпоинт:

<?php

namespace App\Http\Middleware;

use Closure;
use Illuminate\Http\Request;
use Prometheus\CollectorRegistry;
use Symfony\Component\HttpFoundation\Response;

class RecordPrometheusMetrics
{
    public function __construct(private CollectorRegistry $registry) {}

    public function handle(Request $request, Closure $next): Response
    {
        $start = microtime(true);
        $response = $next($request);
        $duration = microtime(true) - $start;

        $apiKey   = $request->attributes->get('api_key');
        $clientId = $apiKey?->client_id ?? 'anonymous';
        $route    = $request->route()?->getName() ?? 'unknown';

        // Счётчик запросов
        $counter = $this->registry->getOrRegisterCounter(
            'api', 'requests_total',
            'Total API requests',
            ['client_id', 'route', 'status']
        );
        $counter->inc([$clientId, $route, (string) $response->getStatusCode()]);

        // Гистограмма времени ответа
        $histogram = $this->registry->getOrRegisterHistogram(
            'api', 'request_duration_seconds',
            'API request duration',
            ['client_id', 'route'],
            [0.01, 0.05, 0.1, 0.3, 0.5, 1, 2, 5]
        );
        $histogram->observe($duration, [$clientId, $route]);

        return $response;
    }
}

Ключевые метрики для алертов в Grafana/Alertmanager:

  • Доля 4xx/5xx ответов по клиенту выше 5% за 5 минут.
  • Время ответа p99 выше 500 мс.
  • Резкий рост количества 429 (rate limit) — признак атаки или ошибки в коде партнёра.
  • Аномальное число уникальных IP для одного API-ключа — возможная утечка ключа.

Деплой и изоляция в Docker и Kubernetes

Dockerfile для Laravel API

FROM php:8.3-fpm-alpine AS base

RUN apk add --no-cache \
    postgresql-dev \
    redis \
    && docker-php-ext-install pdo_pgsql opcache

WORKDIR /app

COPY composer.json composer.lock ./
RUN composer install --no-dev --optimize-autoloader --no-interaction

COPY . .

RUN php artisan config:cache \
    && php artisan route:cache \
    && php artisan view:cache

USER www-data
EXPOSE 9000

Kubernetes-манифест с NetworkPolicy для изоляции

apiVersion: networking.k8s.io/v1
kind: NetworkPolicy
metadata:
  name: b2b-api-isolation
  namespace: production
spec:
  podSelector:
    matchLabels:
      app: b2b-api
  policyTypes:
    - Ingress
    - Egress
  ingress:
    - from:
        - podSelector:
            matchLabels:
              role: ingress-controller
      ports:
        - protocol: TCP
          port: 9000
  egress:
    - to:
        - podSelector:
            matchLabels:
              app: postgresql
      ports:
        - protocol: TCP
          port: 5432
    - to:
        - podSelector:
            matchLabels:
              app: redis
      ports:
        - protocol: TCP
          port: 6379

Дополнительные рекомендации по инфраструктуре:

  • Используйте Kubernetes Secrets с шифрованием etcd для хранения connection strings.
  • Запускайте API-поды с readOnlyRootFilesystem: true и без root-привилегий.
  • Разделяйте деплой аудит-воркера и основного API — разные Deployment, разные квоты ресурсов.
  • Настройте PodDisruptionBudget, чтобы при обновлениях минимум 2 реплики оставались живы.

Заключение и чеклист безопасности

Построение безопасного B2B REST API на PHP и Laravel — это не единоразовая задача, а непрерывный процесс. Перед выходом в production убедитесь в следующем:

  1. API-ключи хранятся только в виде SHA-256 хешей, plain-text показывается один раз.
  2. Каждый ключ имеет минимальный набор scopes — принцип наименьших привилегий.
  3. Rate limiting работает на уровне клиента (Redis), а не глобально.
  4. Все запросы пишутся в аудит-лог в PostgreSQL асинхронно, с санацией чувствительных данных.
  5. Таблица аудита партиционирована и имеет TTL-политику очистки старых записей.
  6. API версионировано, deprecation-период задокументирован в SLA.
  7. Prometheus-метрики и алерты настроены для аномалий по каждому клиенту.
  8. Docker-образ собирается без dev-зависимостей, запускается от non-root пользователя.
  9. Kubernetes NetworkPolicy ограничивает исходящий трафик только к PostgreSQL и Redis.
  10. Регулярная ротация API-ключей задокументирована и автоматизирована через API управления.

Следуя этим принципам, вы получите B2B API, готовый к production-нагрузкам 2026 года, соответствующий требованиям compliance и удобный для интеграции партнёрами.

Технологии

Теги

Руслан Исмаилов

Senior Web / Backend разработчик. Senior web/backend разработчик с 9-летним опытом. Стек: PHP, Laravel, PostgreSQL, Redis, Docker, Kubernetes, REST, микросервисы, CI/CD. Подробнее обо мне →