Files
Deal/src/grpc-hosting/Deal.Grpc.Hosting/Interceptors/ServiceTokenInterceptor.cs
T
Rustam Khalimov bf4c021ca9 Убрать доки, повторяющие имя/значение члена
summary вида «Ключ «x»» удалены; «Ключ «x»: пояснение» сжаты до
пояснения; summary, дословно равные имени/значению, удалены.
2026-09-11 13:51:31 +03:00

131 lines
5.8 KiB
C#

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.
/// </summary>
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;
/// <summary>
/// Создаёт интерцептор.
/// </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));
}