Add auth to GitHub webhooks
This commit is contained in:
parent
e879804433
commit
f3ab51d639
6
.gitignore
vendored
6
.gitignore
vendored
@ -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
98
Auth/GithubWebhookAuth.cs
Normal 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;
|
||||
}
|
||||
}
|
@ -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");
|
||||
}
|
||||
}
|
||||
|
@ -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!;
|
||||
}
|
31
Program.cs
31
Program.cs
@ -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();
|
||||
|
39
Utilities/GithubHookAttributes.cs
Normal file
39
Utilities/GithubHookAttributes.cs
Normal 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;
|
||||
}
|
@ -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();
|
||||
}
|
||||
}
|
@ -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>/dev/null || echo -n uninitialized" ConsoleToMSBuild="True" IgnoreExitCode="False">
|
||||
<Output PropertyName="SourceRevisionId" TaskParameter="ConsoleOutput" />
|
||||
</Exec>
|
||||
</Target>
|
||||
</Project>
|
||||
|
Loading…
Reference in New Issue
Block a user