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));
}