diff --git a/zero/Resources/cap_wasm_bg_0_0_6.wasm b/zero/Resources/cap_wasm_bg_0_0_6.wasm new file mode 100644 index 00000000..e2134b54 Binary files /dev/null and b/zero/Resources/cap_wasm_bg_0_0_6.wasm differ diff --git a/zero/Security/CaptchaOptions.cs b/zero/Security/CaptchaOptions.cs new file mode 100644 index 00000000..3405015b --- /dev/null +++ b/zero/Security/CaptchaOptions.cs @@ -0,0 +1,46 @@ +using PowCapServer; + +namespace zero.Security; + +public class CaptchaOptions : PowCapConfig +{ + public string Endpoint { get; set; } = "/api/captcha"; + + public bool Enabled { get; set; } = true; + + public string HiddenFieldName { get; set; } = "CaptchaToken"; + + public CaptchaLocalizationOptions Localization { get; set; } = CaptchaLocalizationOptions.German; +} + + +public class CaptchaLocalizationOptions : Dictionary +{ + public static CaptchaLocalizationOptions English { get; } = new() + { + ["initial-state"] = "Verify you're human", + ["verifying-label"] = "Verifying...", + ["solved-label"] = "You're human", + ["error-label"] = "Error", + ["troubleshooting-label"] = "Troubleshooting", + ["wasm-disabled"] = "Enable WASM for significantly faster solving", + ["verify-aria-label"] = "Click to verify you're a human", + ["verifying-aria-label"] = "Verifying, please wait", + ["verified-aria-label"] = "Verified", + ["error-aria-label"] = "An error occurred, please try again", + }; + + public static CaptchaLocalizationOptions German { get; } = new() + { + ["initial-state"] = "Bestätige, dass du kein Bot bist", + ["verifying-label"] = "Wird überprüft...", + ["solved-label"] = "Erfolgreich", + ["error-label"] = "Fehler", + ["troubleshooting-label"] = "Fehlerbehebung", + ["wasm-disabled"] = "Aktiviere WASM für eine deutlich schnellere Lösung", + ["verify-aria-label"] = "Klicke, um zu bestätigen, dass du ein Mensch bist", + ["verifying-aria-label"] = "Wird überprüft, bitte warten", + ["verified-aria-label"] = "Bestätigt", + ["error-aria-label"] = "Ein Fehler ist aufgetreten, bitte versuche es erneut", + }; +} \ No newline at end of file diff --git a/zero/Security/CaptchaTagHelper.cs b/zero/Security/CaptchaTagHelper.cs new file mode 100644 index 00000000..795c90f3 --- /dev/null +++ b/zero/Security/CaptchaTagHelper.cs @@ -0,0 +1,41 @@ +using System.IO; +using Microsoft.AspNetCore.Mvc.Rendering; +using Microsoft.AspNetCore.Mvc.ViewFeatures; +using Microsoft.AspNetCore.Razor.TagHelpers; +using Microsoft.Extensions.Logging; +using Microsoft.Extensions.Options; +using zero.Security; + +namespace zero.TagHelpers; + +[HtmlTargetElement("app-captcha", TagStructure = TagStructure.NormalOrSelfClosing)] +public class CaptchaTagHelper(IOptionsMonitor options) : TagHelper +{ + [HtmlAttributeNotBound] + [ViewContext] + public ViewContext ViewContext { get; set; } = null!; + + private readonly CaptchaOptions _options = options.CurrentValue; + + + public override void Process(TagHelperContext context, TagHelperOutput output) + { + if (!_options.Enabled) + { + output.SuppressOutput(); + return; + } + output.TagName = "cap-widget"; + output.Attributes.SetAttribute("class", "cap-widget"); + output.Attributes.SetAttribute("data-cap-api-endpoint", _options.Endpoint); + output.Attributes.SetAttribute("data-cap-hidden-field-name", _options.HiddenFieldName); + + string wasmFilePath =_options.Endpoint + "/cap.wasm"; + output.PreElement.AppendHtml($""); + + foreach ((string key, string value) in _options.Localization) + { + output.Attributes.SetAttribute($"data-cap-i18n-{key}", value); + } + } +} \ No newline at end of file diff --git a/zero/Security/CaptchaValidator.cs b/zero/Security/CaptchaValidator.cs new file mode 100644 index 00000000..c3dc1c03 --- /dev/null +++ b/zero/Security/CaptchaValidator.cs @@ -0,0 +1,30 @@ +using FluentValidation; +using FluentValidation.Validators; +using PowCapServer.Abstractions; +using zero.Validation.Validators; + +namespace zero.Security; + +public sealed class CaptchaValidator : AsyncPropertyValidator +{ + public override string Name => "CaptchaValidator"; + + protected override string GetDefaultMessageTemplate(string errorCode) => Localized(errorCode, Name); + + public override async Task IsValidAsync(ValidationContext context, string value, CancellationToken cancellation) + { + if (string.IsNullOrWhiteSpace(value)) + { + return false; + } + + ICaptchaService captchaService = StaticCaptchaService.Get(); + + if (captchaService is null) + { + return false; + } + + return await captchaService.ValidateCaptchaTokenAsync(value, cancellation); + } +} \ No newline at end of file diff --git a/zero/Security/StaticCaptchaService.cs b/zero/Security/StaticCaptchaService.cs new file mode 100644 index 00000000..1b791db6 --- /dev/null +++ b/zero/Security/StaticCaptchaService.cs @@ -0,0 +1,16 @@ +using Microsoft.Extensions.DependencyInjection; +using PowCapServer.Abstractions; + +namespace zero.Security; + +internal static class StaticCaptchaService +{ + private static IServiceProvider Services { get; set; } + + public static void Configure(IServiceProvider services) + { + Services = services; + } + + public static ICaptchaService Get() => Services.GetRequiredService(); +} \ No newline at end of file diff --git a/zero/Security/ValidatorExtensions.cs b/zero/Security/ValidatorExtensions.cs new file mode 100644 index 00000000..006428fd --- /dev/null +++ b/zero/Security/ValidatorExtensions.cs @@ -0,0 +1,15 @@ +using FluentValidation; +using zero.Security; + +namespace zero.Validation; + +public static partial class ValidatorExtensions +{ + /// + /// Validates a captcha token + /// + public static IRuleBuilderOptions Captcha(this IRuleBuilder ruleBuilder) + { + return ruleBuilder.SetAsyncValidator(new CaptchaValidator()); + } +} diff --git a/zero/Security/ZeroSecurityModule.cs b/zero/Security/ZeroSecurityModule.cs new file mode 100644 index 00000000..b4fe573f --- /dev/null +++ b/zero/Security/ZeroSecurityModule.cs @@ -0,0 +1,55 @@ +using System.IO; +using System.Reflection; +using Microsoft.AspNetCore.Builder; +using Microsoft.AspNetCore.Http; +using Microsoft.AspNetCore.Routing; +using Microsoft.Extensions.Configuration; +using Microsoft.Extensions.DependencyInjection; +using Microsoft.Extensions.Options; + +namespace zero.Security; + +internal class ZeroSecurityModule : ZeroModule +{ + public override void ConfigureServices(IServiceCollection services, IConfiguration configuration) + { + IConfigurationSection captchaSection = configuration.GetSection("Zero:Captcha"); + services.AddOptions().Bind(captchaSection); + + services.AddPowCapServer(options => + { + CaptchaOptions captchaOptions = captchaSection.Get() ?? new CaptchaOptions(); + options.Default = captchaOptions; + }); + } + + public override void Configure(IApplicationBuilder app, IEndpointRouteBuilder routes, IServiceProvider serviceProvider) + { + IOptionsMonitor options = serviceProvider.GetRequiredService>(); + string endpointPrefix = options.CurrentValue.Endpoint; + + app.UseRouting(); + app.MapPowCapServer(endpointPrefix); + app.UseEndpoints(endpoints => + { + endpoints.MapGet(endpointPrefix + "/cap.wasm", async context => + { + Assembly assembly = typeof(ZeroSecurityModule).GetTypeInfo().Assembly; + Stream resource = assembly.GetManifestResourceStream("zero.Resources.cap_wasm_bg_0_0_6.wasm"); + + if (resource is null) + { + context.Response.StatusCode = StatusCodes.Status404NotFound; + return; + } + + await using (resource) + { + context.Response.ContentType = "application/wasm"; + await resource.CopyToAsync(context.Response.Body, context.RequestAborted); + } + }); + }); + StaticCaptchaService.Configure(serviceProvider); + } +} \ No newline at end of file diff --git a/zero/Validation/German/FluentValidationGermanLanguage.cs b/zero/Validation/German/FluentValidationGermanLanguage.cs index f83bd567..d981f933 100644 --- a/zero/Validation/German/FluentValidationGermanLanguage.cs +++ b/zero/Validation/German/FluentValidationGermanLanguage.cs @@ -34,6 +34,7 @@ public class FluentValidationGermanLanguage { "MinimumLength_Simple", "Die Länge muss größer oder gleich {MinLength} sein." }, { "MaximumLength_Simple", "Die Länge muss kleiner oder gleich {MaxLength} sein." }, { "ExactLength_Simple", "Dieses Feld muss genau {MaxLength} lang sein." }, - { "InclusiveBetween_Simple", "Der Wert muss zwischen {From} and {To} sein." } + { "InclusiveBetween_Simple", "Der Wert muss zwischen {From} and {To} sein." }, + { "CaptchaValidator", "Das Catpcha wurde nicht erfolgreich gelöst." } }; } \ No newline at end of file diff --git a/zero/Validation/ValidatorExtensions.cs b/zero/Validation/ValidatorExtensions.cs index 99b23e69..e85c3e40 100644 --- a/zero/Validation/ValidatorExtensions.cs +++ b/zero/Validation/ValidatorExtensions.cs @@ -3,7 +3,7 @@ using zero.Validation.Validators; namespace zero.Validation; -public static class ValidatorExtensions +public static partial class ValidatorExtensions { /// /// Validate a color input as HEX (#aabbccdd or #aabbcc or #abc) diff --git a/zero/ZeroBuilder.cs b/zero/ZeroBuilder.cs index 4c251191..9b9a3643 100644 --- a/zero/ZeroBuilder.cs +++ b/zero/ZeroBuilder.cs @@ -9,6 +9,7 @@ using zero.Metadata; using zero.Mvc; using zero.Numbers; using zero.Routing; +using zero.Security; namespace zero; @@ -68,6 +69,7 @@ public class ZeroBuilder Modules.Add(); Modules.Add(); Modules.Add(); + Modules.Add(); Modules.ConfigureServices(services, configuration); diff --git a/zero/zero.csproj b/zero/zero.csproj index a7ce3195..f5253c99 100644 --- a/zero/zero.csproj +++ b/zero/zero.csproj @@ -16,7 +16,12 @@ + + + + + \ No newline at end of file