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; /// /// Серверный интерцептор service-token. /// public sealed class ServiceTokenInterceptor : Interceptor { public const string ServiceTokenMetadataKey = "service-token"; // Префикс методов стандартного gRPC-health, освобождённых от проверки токена. private const string HealthMethodPrefix = "/grpc.health.v1.Health/"; private const string ServiceTokenEnvKey = "DEAL_SERVICE_TOKEN"; private const string RejectionDetail = "service-token отсутствует или неверен"; private readonly byte[] _expectedTokenBytes; /// /// Создаёт интерцептор. /// /// Конфигурация хоста (env-провайдер WebApplicationBuilder). public ServiceTokenInterceptor(IConfiguration configuration) { ArgumentNullException.ThrowIfNull(configuration); _expectedTokenBytes = Encoding.UTF8.GetBytes(configuration[ServiceTokenEnvKey] ?? string.Empty); } /// /// Проверяет токен для unary-RPC и передаёт вызов дальше. /// public override async Task UnaryServerHandler( TRequest request, ServerCallContext context, UnaryServerMethod continuation) { EnsureAuthorized(context); return await continuation(request, context).ConfigureAwait(false); } /// /// Проверяет токен для client-streaming-RPC и передаёт вызов дальше. /// public override async Task ClientStreamingServerHandler( IAsyncStreamReader requestStream, ServerCallContext context, ClientStreamingServerMethod continuation) { EnsureAuthorized(context); return await continuation(requestStream, context).ConfigureAwait(false); } /// /// Проверяет токен для server-streaming-RPC и передаёт вызов дальше. /// public override async Task ServerStreamingServerHandler( TRequest request, IServerStreamWriter responseStream, ServerCallContext context, ServerStreamingServerMethod continuation) { EnsureAuthorized(context); await continuation(request, responseStream, context).ConfigureAwait(false); } /// /// Проверяет токен для дуплексного RPC и передаёт вызов дальше. /// public override async Task DuplexStreamingServerHandler( IAsyncStreamReader requestStream, IServerStreamWriter responseStream, ServerCallContext context, DuplexStreamingServerMethod 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)); }