108 lines
4.3 KiB
C#
108 lines
4.3 KiB
C#
using System;
|
|
using System.Security.Cryptography;
|
|
using System.Text;
|
|
using System.Threading;
|
|
using System.Threading.Tasks;
|
|
using Microsoft.AspNetCore.Http;
|
|
using Microsoft.Extensions.Logging;
|
|
using Microsoft.Extensions.Options;
|
|
using StellaOps.Scheduler.WebService.Options;
|
|
|
|
namespace StellaOps.Scheduler.WebService.EventWebhooks;
|
|
|
|
public interface IWebhookRequestAuthenticator
|
|
{
|
|
Task<WebhookAuthenticationResult> AuthenticateAsync(HttpContext context, ReadOnlyMemory<byte> body, SchedulerWebhookOptions options, CancellationToken cancellationToken);
|
|
}
|
|
|
|
internal sealed class WebhookRequestAuthenticator : IWebhookRequestAuthenticator
|
|
{
|
|
private readonly ILogger<WebhookRequestAuthenticator> _logger;
|
|
|
|
public WebhookRequestAuthenticator(
|
|
ILogger<WebhookRequestAuthenticator> logger)
|
|
{
|
|
_logger = logger;
|
|
}
|
|
|
|
public async Task<WebhookAuthenticationResult> AuthenticateAsync(HttpContext context, ReadOnlyMemory<byte> body, SchedulerWebhookOptions options, CancellationToken cancellationToken)
|
|
{
|
|
if (!options.Enabled)
|
|
{
|
|
return WebhookAuthenticationResult.Success();
|
|
}
|
|
|
|
if (options.RequireClientCertificate)
|
|
{
|
|
var certificate = context.Connection.ClientCertificate ?? await context.Connection.GetClientCertificateAsync(cancellationToken).ConfigureAwait(false);
|
|
if (certificate is null)
|
|
{
|
|
_logger.LogWarning("Webhook {Name} rejected request without client certificate.", options.Name);
|
|
return WebhookAuthenticationResult.Fail(StatusCodes.Status401Unauthorized, "Client certificate required.");
|
|
}
|
|
}
|
|
|
|
if (!string.IsNullOrWhiteSpace(options.HmacSecret))
|
|
{
|
|
var headerName = string.IsNullOrWhiteSpace(options.SignatureHeader) ? "X-Scheduler-Signature" : options.SignatureHeader;
|
|
if (!context.Request.Headers.TryGetValue(headerName, out var signatureValues))
|
|
{
|
|
_logger.LogWarning("Webhook {Name} rejected request missing signature header {Header}.", options.Name, headerName);
|
|
return WebhookAuthenticationResult.Fail(StatusCodes.Status401Unauthorized, "Missing signature header.");
|
|
}
|
|
|
|
var providedSignature = signatureValues.ToString();
|
|
if (string.IsNullOrWhiteSpace(providedSignature))
|
|
{
|
|
return WebhookAuthenticationResult.Fail(StatusCodes.Status401Unauthorized, "Signature header is empty.");
|
|
}
|
|
|
|
if (!VerifySignature(body.Span, options.HmacSecret!, providedSignature))
|
|
{
|
|
_logger.LogWarning("Webhook {Name} rejected request with invalid signature.", options.Name);
|
|
return WebhookAuthenticationResult.Fail(StatusCodes.Status401Unauthorized, "Invalid signature.");
|
|
}
|
|
}
|
|
|
|
return WebhookAuthenticationResult.Success();
|
|
}
|
|
|
|
private static bool VerifySignature(ReadOnlySpan<byte> payload, string secret, string providedSignature)
|
|
{
|
|
byte[] secretBytes;
|
|
try
|
|
{
|
|
secretBytes = Convert.FromBase64String(secret);
|
|
}
|
|
catch (FormatException)
|
|
{
|
|
try
|
|
{
|
|
secretBytes = Convert.FromHexString(secret);
|
|
}
|
|
catch (FormatException)
|
|
{
|
|
secretBytes = Encoding.UTF8.GetBytes(secret);
|
|
}
|
|
}
|
|
|
|
using var hmac = new HMACSHA256(secretBytes);
|
|
var hash = hmac.ComputeHash(payload.ToArray());
|
|
var computedSignature = "sha256=" + Convert.ToHexString(hash).ToLowerInvariant();
|
|
|
|
return CryptographicOperations.FixedTimeEquals(
|
|
Encoding.UTF8.GetBytes(computedSignature),
|
|
Encoding.UTF8.GetBytes(providedSignature.Trim()));
|
|
}
|
|
}
|
|
|
|
public readonly record struct WebhookAuthenticationResult(bool Succeeded, int StatusCode, string? Message)
|
|
{
|
|
public static WebhookAuthenticationResult Success() => new(true, StatusCodes.Status200OK, null);
|
|
|
|
public static WebhookAuthenticationResult Fail(int statusCode, string message) => new(false, statusCode, message);
|
|
|
|
public IResult ToResult()
|
|
=> Succeeded ? Results.Ok() : Results.Json(new { error = Message ?? "Unauthorized" }, statusCode: StatusCode);
|
|
}
|