Add auth to GitHub webhooks

This commit is contained in:
Saphire 2022-04-24 01:45:56 +07:00
parent e879804433
commit f3ab51d639
Signed by: Saphire
GPG Key ID: B26EB7A1F07044C4
8 changed files with 302 additions and 112 deletions

6
.gitignore vendored
View File

@ -1,4 +1,8 @@
/target
/bin
/obj
appsettings.Local.json
appsettings.Local.json
/node_modules
/package.json
/package-lock.json

98
Auth/GithubWebhookAuth.cs Normal file
View File

@ -0,0 +1,98 @@
namespace Lunar.Exchange.Pigeonhole.Auth;
using System.Security.Claims;
using System.Security.Cryptography;
using System.Text.Encodings.Web;
using Lunar.Exchange.Pigeonhole.Models;
using Microsoft.AspNetCore.Authentication;
using Microsoft.AspNetCore.Authorization;
using Microsoft.Extensions.Options;
public class GithubHookAuthorizeAttribute : AuthorizeAttribute
{
public GithubHookAuthorizeAttribute()
{
Policy = GithubHookAuthorizationHandler.POLICY_NAME;
AuthenticationSchemes = GithubHookAuthorizationHandler.POLICY_NAME;
}
}
public class GithubHookRequirement : IAuthorizationRequirement { }
public class GithubHookAuthenticationHandler : AuthenticationHandler<AuthenticationSchemeOptions>
{
public GithubHookAuthenticationHandler(
IOptionsMonitor<AuthenticationSchemeOptions> options,
ILoggerFactory logger,
UrlEncoder encoder,
ISystemClock clock
) : base(options, logger, encoder, clock)
{
}
protected override Task<AuthenticateResult> HandleAuthenticateAsync()
{
if (!Request.Headers.TryGetValue(GithubHookAuthorizationHandler.SIGNATURE_HEADER, out var headers) || headers.Count != 1)
return Task.FromResult(AuthenticateResult.Fail("No required header"));
if (headers[0].Length != (7 + 64) || !headers[0].StartsWith("sha256="))
return Task.FromResult(AuthenticateResult.Fail("Invalid header format"));
var claims = new[] {
new Claim(ClaimTypes.Hash, headers[0][7..])
};
var claimsIdentity = new ClaimsIdentity(claims, GithubHookAuthorizationHandler.POLICY_NAME);
var ticket = new AuthenticationTicket(new ClaimsPrincipal(claimsIdentity), Scheme.Name);
return Task.FromResult(AuthenticateResult.Success(ticket));
}
}
public class GithubHookAuthorizationHandler : AuthorizationHandler<GithubHookRequirement>
{
public static readonly string POLICY_NAME = "GithubWebhook";
public static readonly string SIGNATURE_HEADER = "X-Hub-Signature-256";
private GithubHookOptions Options { get; set; }
public GithubHookAuthorizationHandler(IOptions<GithubHookOptions> options)
{
Options = options.Value;
}
protected override async Task HandleRequirementAsync(
AuthorizationHandlerContext handlerContext,
GithubHookRequirement requirement
)
{
if (handlerContext.Resource is not HttpContext context)
return;
if (!handlerContext.User.Identities.Any(i => i.IsAuthenticated && i.AuthenticationType == POLICY_NAME))
{
Console.Out.WriteLine("No identities");
Console.Out.WriteLine($"{handlerContext.User.Identities.FirstOrDefault()?.AuthenticationType ?? "No auth type"} vs {POLICY_NAME}");
handlerContext.Fail(new AuthorizationFailureReason(this, "Request is not authenticated"));
return;
}
var signature = Convert.FromHexString(handlerContext.User.FindFirstValue(ClaimTypes.Hash));
var hmacKey = Convert.FromBase64String(Options.SharedSecret);
if (hmacKey.Length < 20)
throw new Exception("HMAC key is too short");
using HMACSHA256 hmac = new(hmacKey);
context.Request.EnableBuffering();
context.Request.Body.Position = 0;
var bodyHash = await hmac.ComputeHashAsync(context.Request.Body);
context.Request.Body.Position = 0;
if (CryptographicOperations.FixedTimeEquals(bodyHash, signature))
handlerContext.Succeed(requirement);
return;
}
}

View File

@ -1,9 +1,12 @@
using Exchange.Lunar.Pigeonhole.Models;
using Microsoft.AspNetCore.Mvc;
namespace Lunar.Exchange.Pigeonhole.Controllers;
namespace pigeonhole.Controllers;
using Models;
using Utilities;
using Microsoft.AspNetCore.Mvc;
using Lunar.Exchange.Pigeonhole.Auth;
[ApiController]
[GithubHookAuthorize]
[Route("webhook")]
public class WebhookController : ControllerBase
{
@ -14,11 +17,45 @@ public class WebhookController : ControllerBase
Logger = logger;
}
[HttpGet("github")]
public async Task<IActionResult> Get(GithubDeploymentHookModel deploymentModel)
[HttpPost("github")]
[GithubHook]
public async Task<IActionResult> Ping(GithubPingHook pingModel)
{
await Console.Out.WriteLineAsync("Webhook!");
Logger.LogInformation("Got a deploy webhook: {} in {} @ {}", deploymentModel.Action, deploymentModel.Repository.FullName, deploymentModel.Deployment.UpdatedAt);
return Ok();
Logger.LogInformation("Ping from GitHub! Initiated by {}. Zen \"{}\"", pingModel.Sender.Login, pingModel.Zen);
return Ok("Ping ok!");
}
[HttpPost("github")]
[GithubHook]
public async Task<IActionResult> Ping(GithubDeploymentStatusHook deploymentStatusModel)
{
await Console.Out.WriteLineAsync("Webhook!");
Logger.LogInformation(
"Got a deploy status update: {}/{} in {} @ {}",
deploymentStatusModel.Action,
deploymentStatusModel.DeploymentStatus.State,
deploymentStatusModel.Repository.FullName,
deploymentStatusModel.Deployment.UpdatedAt
);
return Ok("Ping ok!");
}
[HttpPost("github")]
[GithubHook]
public async Task<IActionResult> Deployment(GithubDeploymentHook deploymentModel)
{
await Console.Out.WriteLineAsync("Webhook!");
Logger.LogInformation("Got a new deploy: {} in {} @ {}", deploymentModel.Action, deploymentModel.Repository.FullName, deploymentModel.Deployment.UpdatedAt);
return Ok("Got it");
}
[HttpPost("github")]
[GithubHook]
public async Task<IActionResult> DeployKey(GithubDeployKeyHook deployKeyModel)
{
await Console.Out.WriteLineAsync("Webhook!");
return Ok("Got it");
}
}

View File

@ -1,50 +1,80 @@
namespace Lunar.Exchange.Pigeonhole.Models;
using System.Text.Json.Serialization;
using Exchange.Lunar.Pigeonhole.Utilities;
using Utilities;
namespace Exchange.Lunar.Pigeonhole.Models;
// Options for config
// Probably shouldn't be in Models...
public class GithubHookOptions
{
public const string Section = "GithubHook";
public string SharedSecret { get; set; } = null!;
}
[JsonConverter(typeof(SnakeCaseConverter))]
public class GithubUser {
public class GithubUser
{
public int Id { get; set; }
public string NodeId { get; set; } = null!;
public string? NodeId { get; set; }
public string Login { get; set; } = null!;
[JsonPropertyName("avatar_url")]
public string AvatarUrl { get; set; } = null!;
public string GravatarUrl { get; set; } = null!;
[JsonPropertyName("gravatar_id")]
public string GravatarId { get; set; } = null!;
public string Url { get; set; } = null!;
[JsonPropertyName("html_url")]
public string HtmlUrl { get; set; } = null!;
public string Type { get; set; } = null!;
[JsonPropertyName("site_admin")]
public bool SiteAdmin { get; set; }
}
[JsonConverter(typeof(SnakeCaseConverter))]
public class GithubRepo {
public class GithubRepo
{
public int Id { get; set; }
public string NodeId { get; set; } = null!;
[JsonPropertyName("node_id")]
public string? NodeId { get; set; }
public string Name { get; set; } = null!;
[JsonPropertyName("full_name")]
public string FullName { get; set; } = null!;
public string Url { get; set; } = null!;
[JsonPropertyName("html_url")]
public string HtmlUrl { get; set; } = null!;
public bool Fork { get; set; }
public string Visibility { get; set; } = null!;
[JsonPropertyName("default_branch")]
public string DefaultBranch { get; set; } = null!;
public GithubUser Owner { get; set; } = null!;
}
[JsonConverter(typeof(SnakeCaseConverter))]
public class GithubDeployment {
public class GithubDeployKey
{
public int Id { get; set; }
public string NodeId { get; set; } = null!;
public string Action { get; set; } = null!;
public string Repo { get; set; } = null!;
public string Key { get; set; } = null!;
public string Title { get; set; } = null!;
public bool Verified { get; set; }
[JsonPropertyName("read_only")]
public bool ReadOnly { get; set; }
[JsonPropertyName("created_at")]
public DateTime CreatedAt { get; set; }
}
public class GithubDeployment
{
public int Id { get; set; }
[JsonPropertyName("node_id")]
public string? NodeId { get; set; }
public string Sha { get; set; } = null!;
public string Ref { get; set; } = null!;
public string Task { get; set; } = null!;
[JsonPropertyName("original_environment")]
public string OriginalEnvironment { get; set; } = null!;
public string Environment { get; set; } = null!;
public string Description { get; set; } = null!;
public string? Description { get; set; }
public GithubUser Creator { get; set; } = null!;
[JsonPropertyName("created_at")]
public DateTime CreatedAt { get; set; }
[JsonPropertyName("updated_at")]
public DateTime UpdatedAt { get; set; }
}
@ -52,39 +82,69 @@ public class GithubDeployment {
public enum GithubDeploymentStatusState
{
Pending,
Waiting,
Success,
Failure,
Error
}
[JsonConverter(typeof(SnakeCaseConverter))]
public class GithubDeploymentStatus {
public class GithubDeploymentStatus
{
public int Id { get; set; }
public string NodeId { get; set; } = null!;
[JsonPropertyName("node_id")]
public string? NodeId { get; set; }
public GithubDeploymentStatusState State { get; set; }
public string Description { get; set; } = null!;
public string Environment { get; set; } = null!;
[Obsolete("Use LogUrl instead")]
[JsonPropertyName("target_url")]
public string TargetUrl { get; set; } = null!;
[JsonPropertyName("log_url")]
public string LogUrl { get; set; } = null!;
[JsonPropertyName("environment_url")]
public string EnvironmentUrl { get; set; } = null!;
public GithubUser Creator { get; set; } = null!;
[JsonPropertyName("created_at")]
public DateTime CreatedAt { get; set; }
[JsonPropertyName("updated_at")]
public DateTime UpdatedAt { get; set; }
}
[JsonConverter(typeof(SnakeCaseConverter))]
public class GithubDeploymentHookModel {
[GithubHookModel("deploy_key")]
public class GithubDeployKeyHook
{
public string Action { get; set; } = null!;
public GithubDeployKey Key { get; set; } = null!;
public GithubRepo Repository { get; set; } = null!;
public GithubUser Sender { get; set; } = null!;
}
[GithubHookModel("deployment")]
public class GithubDeploymentHook
{
public string Action { get; set; } = null!;
public GithubDeployment Deployment { get; set; } = null!;
public GithubRepo Repository { get; set; } = null!;
public GithubUser Sender { get; set; } = null!;
}
[JsonConverter(typeof(SnakeCaseConverter))]
public class GithubDeploymentStatusHookModel {
[GithubHookModel("deployment_status")]
public class GithubDeploymentStatusHook
{
public string Action { get; set; } = null!;
public GithubDeploymentStatus DeploymentStatus { get; set; } = null!;
public GithubDeployment Deployment { get; set; } = null!;
public GithubRepo Repository { get; set; } = null!;
public GithubUser Sender { get; set; } = null!;
}
[GithubHookModel("ping")]
public class GithubPingHook
{
public string Zen { get; set; } = null!;
[JsonPropertyName("hook_id")]
public int HookId { get; set; }
public GithubRepo Repository { get; set; } = null!;
public GithubUser Sender { get; set; } = null!;
}

View File

@ -1,3 +1,8 @@
using Lunar.Exchange.Pigeonhole.Auth;
using Lunar.Exchange.Pigeonhole.Models;
using Microsoft.AspNetCore.Authentication;
using Microsoft.AspNetCore.Authorization;
var builder = WebApplication.CreateBuilder(args);
// Add services to the container.
@ -7,6 +12,27 @@ builder.Services.AddControllers();
builder.Services.AddEndpointsApiExplorer();
builder.Services.AddSwaggerGen();
builder.Services.Configure<GithubHookOptions>(
builder.Configuration.GetSection(GithubHookOptions.Section)
);
builder.Services.AddSingleton<IAuthorizationHandler, GithubHookAuthorizationHandler>();
builder.Services
.AddAuthentication()
.AddScheme<AuthenticationSchemeOptions, GithubHookAuthenticationHandler>(
GithubHookAuthorizationHandler.POLICY_NAME,
"GitHub Hook Auth",
options => {}
);
builder.Services.AddAuthorization(options =>
{
options.AddPolicy(GithubHookAuthorizationHandler.POLICY_NAME, policy =>
policy.Requirements.Add(new GithubHookRequirement())
);
});
var app = builder.Build();
// Configure the HTTP request pipeline.
@ -15,9 +41,10 @@ if (app.Environment.IsDevelopment())
app.UseSwagger();
app.UseSwaggerUI();
}
else
app.UseHttpsRedirection();
app.UseHttpsRedirection();
app.UseAuthentication();
app.UseAuthorization();
app.MapControllers();

View File

@ -0,0 +1,39 @@
namespace Lunar.Exchange.Pigeonhole.Utilities;
using System;
using Microsoft.AspNetCore.Mvc.ActionConstraints;
[AttributeUsage(AttributeTargets.Class, Inherited = false)]
sealed class GithubHookModelAttribute : Attribute
{
public string EventName { get; set; }
public GithubHookModelAttribute(string eventName)
{
EventName = eventName;
}
}
[AttributeUsage(AttributeTargets.Method)]
public class GithubHookAttribute : Attribute, IActionConstraint
{
public bool Accept(ActionConstraintContext context)
{
if (
!context.RouteContext.HttpContext.Request.Headers.TryGetValue("X-GitHub-Event", out var values)
|| values.Count != 1
|| string.IsNullOrWhiteSpace(values[0])
)
return false;
var property = context.CurrentCandidate.Action.Parameters
.SingleOrDefault(p => p.ParameterType.GetCustomAttributes(false).Any(a => a is GithubHookModelAttribute));
if (property is null)
return false;
var attribute = (GithubHookModelAttribute?)property.ParameterType.GetCustomAttributes(false).SingleOrDefault(a => a is GithubHookModelAttribute);
if (attribute is null)
return false;
return values[0] == attribute.EventName;
}
public int Order => 0;
}

View File

@ -1,73 +0,0 @@
using System.Reflection;
using System.Text.Json;
using System.Text.Json.Serialization;
namespace Exchange.Lunar.Pigeonhole.Utilities;
public class SnakeCaseConverter : JsonConverterFactory
{
public override bool CanConvert(Type typeToConvert)
{
throw new NotImplementedException();
}
public override JsonConverter? CreateConverter(Type typeToConvert, JsonSerializerOptions options)
{
return (JsonConverter?) Activator.CreateInstance(
typeof(SnakeCaseConverter<>).MakeGenericType(new Type[] { typeToConvert }),
BindingFlags.Instance | BindingFlags.Public,
binder: null,
args: Array.Empty<object>(),
culture: null
);
}
}
public class SnakeCaseConverter<T> : JsonConverter<T>
{
public override T Read(
ref Utf8JsonReader reader,
Type typeToConvert,
JsonSerializerOptions options
)
{
return (T) JsonSerializer.Deserialize(ref reader, typeToConvert, new JsonSerializerOptions {
PropertyNamingPolicy = new SnakeCaseNamingPolicy()
})!;
}
public override void Write(
Utf8JsonWriter writer,
T value,
JsonSerializerOptions options)
{
JsonSerializer.Serialize(writer, value, new JsonSerializerOptions {
PropertyNamingPolicy = new SnakeCaseNamingPolicy()
});
}
}
public class SnakeCaseNamingPolicy : JsonNamingPolicy
{
public override string ConvertName(string name)
{
if (name == null) return null!;
var capitals = name.Count(t => char.IsUpper(t));
if (char.IsUpper(name[0])) capitals--;
Span<char> buffer = new char[name.Length + capitals];
for (int i = 0, output = 0; i < name.Length; i++)
{
var @char = name[i];
buffer[output++] = i > 0 && char.IsUpper(@char) ? '_' : @char;
buffer[output++] = @char;
}
if (buffer[^1] == '\0')
throw new Exception("Null leftover in the string buffer");
return new string(buffer).ToLower();
}
}

View File

@ -1,9 +1,11 @@
<Project Sdk="Microsoft.NET.Sdk.Web">
<Project Sdk="Microsoft.NET.Sdk.Web">
<PropertyGroup>
<TargetFramework>net6.0</TargetFramework>
<Nullable>enable</Nullable>
<ImplicitUsings>enable</ImplicitUsings>
<RootNamespace>Lunar.Exchange.Pigeonhole</RootNamespace>
<UserSecretsId>90ff12a5-9b1c-4fc4-8080-f5430e34c681</UserSecretsId>
</PropertyGroup>
<ItemGroup>
@ -11,12 +13,8 @@
</ItemGroup>
<Target Name="SetSourceRevisionId" BeforeTargets="InitializeSourceControlInformation">
<Exec
Command="git describe --long --always --dirty --exclude=* --abbrev=8"
ConsoleToMSBuild="True"
IgnoreExitCode="False"
>
<Output PropertyName="SourceRevisionId" TaskParameter="ConsoleOutput"/>
<Exec Command="git describe --long --always --dirty --exclude=* --abbrev=8 2&gt;/dev/null || echo -n uninitialized" ConsoleToMSBuild="True" IgnoreExitCode="False">
<Output PropertyName="SourceRevisionId" TaskParameter="ConsoleOutput" />
</Exec>
</Target>
</Project>