Разложить grpc-hosting и UrlSafeToken по назначению
grpc-hosting -> Interceptors/Models/Options/Services; UrlSafeToken -> SharedKernel/Utilities. Маркеры проектов оставлены в корнях. namespace/using/FQN мигрированы.
This commit is contained in:
@@ -0,0 +1,151 @@
|
||||
using System.Security.Cryptography;
|
||||
using System.Text;
|
||||
using Grpc.Core;
|
||||
using Grpc.Core.Interceptors;
|
||||
using Microsoft.Extensions.Configuration;
|
||||
using Deal.Grpc.Hosting.Models;
|
||||
using Deal.Grpc.Hosting.Options;
|
||||
using Deal.Grpc.Hosting.Services;
|
||||
|
||||
namespace Deal.Grpc.Hosting.Interceptors;
|
||||
|
||||
/// <summary>
|
||||
/// Серверный интерцептор service-token (Ruling 1; общий шаблон трёх Deal-сервисов — C31).
|
||||
///
|
||||
/// Каждый RPC Deal-сервиса обязан нести gRPC-metadata «service-token», равный ожидаемому значению
|
||||
/// из env DEAL_SERVICE_TOKEN (общий токен сервисов в compose, Ruling 12). Отсутствие или
|
||||
/// несовпадение токена — отказ UNAUTHENTICATED до вызова метода сервиса. Стандартный
|
||||
/// grpc.health.v1.Health токеном НЕ проверяется: это liveness инфраструктуры (docker healthcheck,
|
||||
/// Ruling 12), данных тенантов он не отдаёт.
|
||||
///
|
||||
/// Fail-closed (замечание ревью Task 2 учтено): если DEAL_SERVICE_TOKEN не задан/пуст — любой
|
||||
/// Deal-RPC отклоняется всегда. Явный гард обязателен: сравнение строк без него пропустило бы
|
||||
/// запрос с пустым значением metadata («» == «»), а env-провайдер конфигурации возвращает пустую
|
||||
/// строку вместо null для незаданного ключа.
|
||||
///
|
||||
/// Проверка выполняется для ВСЕХ видов RPC (unary/клиентский/серверный/дуплексный стриминг):
|
||||
/// метод <see cref="EnsureAuthorized"/> вызывается из каждого handler-а (замечание code-review).
|
||||
/// </summary>
|
||||
public sealed class ServiceTokenInterceptor : Interceptor
|
||||
{
|
||||
/// <summary>
|
||||
/// Ключ gRPC-metadata с токеном сервиса (контракт — README src/contracts).
|
||||
/// </summary>
|
||||
public const string ServiceTokenMetadataKey = "service-token";
|
||||
|
||||
// Префикс методов стандартного gRPC-health, освобождённых от проверки токена.
|
||||
private const string HealthMethodPrefix = "/grpc.health.v1.Health/";
|
||||
|
||||
// Env-ключ ожидаемого токена (только env; ключи/секреты не логируются — Ruling 13).
|
||||
private const string ServiceTokenEnvKey = "DEAL_SERVICE_TOKEN";
|
||||
|
||||
// Деталь отказа — общий текст для трёх сервисов этапа (шаблон T2/T3/T4).
|
||||
private const string RejectionDetail = "service-token отсутствует или неверен";
|
||||
|
||||
private readonly byte[] _expectedTokenBytes;
|
||||
|
||||
/// <summary>
|
||||
/// Создаёт интерцептор. Ожидаемый токен читается из конфигурации (env DEAL_SERVICE_TOKEN)
|
||||
/// в момент старта хоста; смена токена требует рестарта (как остальной env-конфиг). Токен
|
||||
/// хранится в UTF-8-байтах для constant-time сравнения (<see cref="CryptographicOperations"/>).
|
||||
/// </summary>
|
||||
/// <param name="configuration">Конфигурация хоста (env-провайдер WebApplicationBuilder).</param>
|
||||
public ServiceTokenInterceptor(IConfiguration configuration)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(configuration);
|
||||
_expectedTokenBytes = Encoding.UTF8.GetBytes(configuration[ServiceTokenEnvKey] ?? string.Empty);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Проверяет токен для unary-RPC и передаёт вызов дальше.
|
||||
/// </summary>
|
||||
public override async Task<TResponse> UnaryServerHandler<TRequest, TResponse>(
|
||||
TRequest request,
|
||||
ServerCallContext context,
|
||||
UnaryServerMethod<TRequest, TResponse> continuation)
|
||||
{
|
||||
EnsureAuthorized(context);
|
||||
return await continuation(request, context).ConfigureAwait(false);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Проверяет токен для client-streaming-RPC и передаёт вызов дальше.
|
||||
/// </summary>
|
||||
public override async Task<TResponse> ClientStreamingServerHandler<TRequest, TResponse>(
|
||||
IAsyncStreamReader<TRequest> requestStream,
|
||||
ServerCallContext context,
|
||||
ClientStreamingServerMethod<TRequest, TResponse> continuation)
|
||||
{
|
||||
EnsureAuthorized(context);
|
||||
return await continuation(requestStream, context).ConfigureAwait(false);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Проверяет токен для server-streaming-RPC и передаёт вызов дальше.
|
||||
/// </summary>
|
||||
public override async Task ServerStreamingServerHandler<TRequest, TResponse>(
|
||||
TRequest request,
|
||||
IServerStreamWriter<TResponse> responseStream,
|
||||
ServerCallContext context,
|
||||
ServerStreamingServerMethod<TRequest, TResponse> continuation)
|
||||
{
|
||||
EnsureAuthorized(context);
|
||||
await continuation(request, responseStream, context).ConfigureAwait(false);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Проверяет токен для дуплексного RPC и передаёт вызов дальше.
|
||||
/// </summary>
|
||||
public override async Task DuplexStreamingServerHandler<TRequest, TResponse>(
|
||||
IAsyncStreamReader<TRequest> requestStream,
|
||||
IServerStreamWriter<TResponse> responseStream,
|
||||
ServerCallContext context,
|
||||
DuplexStreamingServerMethod<TRequest, TResponse> continuation)
|
||||
{
|
||||
EnsureAuthorized(context);
|
||||
await continuation(requestStream, responseStream, context).ConfigureAwait(false);
|
||||
}
|
||||
|
||||
// Проверка токена для любого вида RPC: сначала пропускаются методы gRPC-health (безопасны), затем
|
||||
// сверяется metadata «service-token» с ожидаемым значением; несовпадение — UNAUTHENTICATED.
|
||||
// context: Контекст вызова (метод и metadata из заголовков).
|
||||
private void EnsureAuthorized(ServerCallContext context)
|
||||
{
|
||||
if (context.Method.StartsWith(HealthMethodPrefix, StringComparison.Ordinal))
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
// Fail-closed: env-токен не задан — Deal-RPC отклоняется, даже если запрос нёс «пустой» токен
|
||||
// (иначе «» == «» прошло бы сравнение ниже). Health уже пропущен выше — остаётся живым.
|
||||
if (_expectedTokenBytes.Length == 0)
|
||||
{
|
||||
throw Rejection();
|
||||
}
|
||||
|
||||
string? actualToken = context.RequestHeaders.GetValue(ServiceTokenMetadataKey);
|
||||
if (!TokenMatches(actualToken, _expectedTokenBytes))
|
||||
{
|
||||
throw Rejection();
|
||||
}
|
||||
}
|
||||
|
||||
// Сравнивает токен с ожидаемым constant-time (FixedTimeEquals по UTF-8-байтам): раннего выхода по
|
||||
// содержимому нет — время сравнения не зависит от совпадения префикса (замечание code-review).
|
||||
// actualToken: Токен из metadata (null — заголовка нет).
|
||||
// expectedTokenBytes: Ожидаемый токен в UTF-8-байтах.
|
||||
private static bool TokenMatches(string? actualToken, byte[] expectedTokenBytes)
|
||||
{
|
||||
if (actualToken is null)
|
||||
{
|
||||
return false;
|
||||
}
|
||||
|
||||
byte[] actualTokenBytes = Encoding.UTF8.GetBytes(actualToken);
|
||||
return CryptographicOperations.FixedTimeEquals(actualTokenBytes, expectedTokenBytes);
|
||||
}
|
||||
|
||||
// Создаёт отказ UNAUTHENTICATED с общим текстом детали.
|
||||
private static RpcException Rejection()
|
||||
=> new(new Status(StatusCode.Unauthenticated, RejectionDetail));
|
||||
}
|
||||
Reference in New Issue
Block a user