summary вида «Ключ «x»» удалены; «Ключ «x»: пояснение» сжаты до пояснения; summary, дословно равные имени/значению, удалены.
131 lines
5.8 KiB
C#
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));
|
|
}
|