v0.0.1 alpha
This commit is contained in:
commit
0631f17bf5
87 files changed
+3226
No files matched your search
@@ -0,0 +1,22 @@
|
||||
<Project Sdk="Microsoft.NET.Sdk">
|
||||
<PropertyGroup>
|
||||
<TargetFramework>net10.0</TargetFramework>
|
||||
<ImplicitUsings>enable</ImplicitUsings>
|
||||
<Nullable>enable</Nullable>
|
||||
<GeneratePackageOnBuild>false</GeneratePackageOnBuild>
|
||||
<PackageId>CaptchaKit.AspNetCore</PackageId>
|
||||
<Version>0.1.0-preview.1</Version>
|
||||
<Authors>CaptchaKit Contributors</Authors>
|
||||
<Description>ASP.NET Core and distributed-cache integration for CaptchaKit.</Description>
|
||||
<PackageLicenseExpression>MIT</PackageLicenseExpression>
|
||||
<PackageTags>captcha;security;bot-protection;aspnetcore</PackageTags>
|
||||
<PackageReadmeFile>README.md</PackageReadmeFile>
|
||||
</PropertyGroup>
|
||||
<ItemGroup>
|
||||
<ProjectReference Include="..\CaptchaKit.Core\CaptchaKit.Core.csproj" />
|
||||
<FrameworkReference Include="Microsoft.AspNetCore.App" />
|
||||
<PackageReference Include="SkiaSharp" Version="4.151.1" />
|
||||
<None Include="..\..\README.md" Pack="true" PackagePath="\" />
|
||||
<None Include="..\..\LICENSE" Pack="true" PackagePath="\" />
|
||||
</ItemGroup>
|
||||
</Project>
|
||||
@@ -0,0 +1,141 @@
|
||||
using System.Security.Cryptography;
|
||||
using System.Text;
|
||||
using CaptchaKit;
|
||||
using Microsoft.Extensions.Caching.Distributed;
|
||||
using Microsoft.Extensions.DependencyInjection;
|
||||
using SkiaSharp;
|
||||
|
||||
namespace CaptchaKit.AspNetCore;
|
||||
|
||||
public sealed class CaptchaKitOptions
|
||||
{
|
||||
public int CodeLength { get; set; } = 5;
|
||||
public TimeSpan Lifetime { get; set; } = TimeSpan.FromMinutes(2);
|
||||
public string CacheKeyPrefix { get; set; } = "captchakit:";
|
||||
}
|
||||
|
||||
public interface ICaptchaKitService
|
||||
{
|
||||
Task<CaptchaChallenge> CreateAsync(CancellationToken cancellationToken = default);
|
||||
Task<bool> VerifyAsync(string id, string answer, CancellationToken cancellationToken = default);
|
||||
}
|
||||
|
||||
public sealed class CaptchaKitService(
|
||||
ICaptchaGenerator generator,
|
||||
ICaptchaChallengeStore store,
|
||||
CaptchaKitOptions options) : ICaptchaKitService
|
||||
{
|
||||
public async Task<CaptchaChallenge> CreateAsync(CancellationToken cancellationToken = default)
|
||||
{
|
||||
var id = Convert.ToHexString(RandomNumberGenerator.GetBytes(24));
|
||||
var expiresAt = DateTimeOffset.UtcNow.Add(options.Lifetime);
|
||||
var generated = generator.Generate(new CaptchaGenerationRequest(id, expiresAt, options.CodeLength));
|
||||
if (!string.Equals(generated.Challenge.Id, id, StringComparison.Ordinal))
|
||||
throw new InvalidOperationException("The captcha generator must preserve the requested challenge ID.");
|
||||
await store.StoreAsync(id, Hash(id, Normalize(generated.Answer)), expiresAt, cancellationToken);
|
||||
return generated.Challenge;
|
||||
}
|
||||
|
||||
public async Task<bool> VerifyAsync(string id, string answer, CancellationToken cancellationToken = default)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(id) || string.IsNullOrWhiteSpace(answer))
|
||||
return false;
|
||||
var expected = await store.TakeAsync(id, cancellationToken);
|
||||
return expected is not null && FixedEquals(expected, Hash(id, Normalize(answer)));
|
||||
}
|
||||
|
||||
private static string Normalize(string value) => value.Trim().ToUpperInvariant();
|
||||
private static string Hash(string id, string answer) =>
|
||||
Convert.ToHexString(SHA256.HashData(Encoding.UTF8.GetBytes($"{id}:{answer}")));
|
||||
private static bool FixedEquals(string left, string right) =>
|
||||
CryptographicOperations.FixedTimeEquals(Encoding.UTF8.GetBytes(left), Encoding.UTF8.GetBytes(right));
|
||||
}
|
||||
|
||||
public sealed class DistributedCaptchaChallengeStore(
|
||||
IDistributedCache cache,
|
||||
CaptchaKitOptions options) : ICaptchaChallengeStore
|
||||
{
|
||||
public Task StoreAsync(string id, string verificationValue, DateTimeOffset expiresAt, CancellationToken cancellationToken = default) =>
|
||||
cache.SetStringAsync(Key(id), verificationValue, new DistributedCacheEntryOptions
|
||||
{
|
||||
AbsoluteExpiration = expiresAt
|
||||
}, cancellationToken);
|
||||
|
||||
public async Task<string?> TakeAsync(string id, CancellationToken cancellationToken = default)
|
||||
{
|
||||
var key = Key(id);
|
||||
var answer = await cache.GetStringAsync(key, cancellationToken);
|
||||
await cache.RemoveAsync(key, cancellationToken);
|
||||
return answer;
|
||||
}
|
||||
|
||||
private string Key(string id) => options.CacheKeyPrefix + id;
|
||||
}
|
||||
|
||||
public sealed class SkiaTextCaptchaGenerator : ICaptchaGenerator
|
||||
{
|
||||
private const string Alphabet = "ABCDEFGHJKLMNPQRSTUVWXYZ23456789";
|
||||
|
||||
public GeneratedCaptcha Generate(CaptchaGenerationRequest request)
|
||||
{
|
||||
var answer = string.Concat(Enumerable.Range(0, request.CodeLength)
|
||||
.Select(_ => Alphabet[RandomNumberGenerator.GetInt32(Alphabet.Length)]));
|
||||
using var bitmap = new SKBitmap(160, 54);
|
||||
using var canvas = new SKCanvas(bitmap);
|
||||
canvas.Clear(new SKColor(244, 248, 252));
|
||||
DrawNoise(canvas, 160, 54, 13);
|
||||
using var paint = new SKPaint { IsAntialias = true };
|
||||
using var font = new SKFont(SKTypeface.FromFamilyName("Arial", SKFontStyle.Bold), 30);
|
||||
for (var index = 0; index < answer.Length; index++)
|
||||
{
|
||||
var x = 14 + index * 29 + RandomNumberGenerator.GetInt32(-2, 3);
|
||||
var y = 38 + RandomNumberGenerator.GetInt32(-4, 5);
|
||||
paint.Color = RandomColor(35, 145);
|
||||
canvas.Save();
|
||||
canvas.RotateDegrees(RandomNumberGenerator.GetInt32(-20, 21), x, y);
|
||||
canvas.DrawText(answer[index].ToString(), x, y, SKTextAlign.Left, font, paint);
|
||||
canvas.Restore();
|
||||
}
|
||||
DrawNoise(canvas, 160, 54, 8);
|
||||
using var image = SKImage.FromBitmap(bitmap);
|
||||
using var data = image.Encode(SKEncodedImageFormat.Png, 100);
|
||||
var challenge = new CaptchaChallenge(request.Id, "image/png", data.ToArray(), request.ExpiresAt);
|
||||
return new GeneratedCaptcha(challenge, answer);
|
||||
}
|
||||
|
||||
private static void DrawNoise(SKCanvas canvas, int width, int height, int count)
|
||||
{
|
||||
using var paint = new SKPaint { IsAntialias = true, StrokeWidth = 1 };
|
||||
for (var index = 0; index < count; index++)
|
||||
{
|
||||
paint.Color = new SKColor(RandomByte(), RandomByte(), RandomByte(), (byte)RandomNumberGenerator.GetInt32(50, 130));
|
||||
canvas.DrawLine(RandomNumberGenerator.GetInt32(width), RandomNumberGenerator.GetInt32(height), RandomNumberGenerator.GetInt32(width), RandomNumberGenerator.GetInt32(height), paint);
|
||||
}
|
||||
}
|
||||
|
||||
private static SKColor RandomColor(int min, int max) => new(
|
||||
(byte)RandomNumberGenerator.GetInt32(min, max),
|
||||
(byte)RandomNumberGenerator.GetInt32(min, max),
|
||||
(byte)RandomNumberGenerator.GetInt32(min, max));
|
||||
private static byte RandomByte() => RandomNumberGenerator.GetBytes(1)[0];
|
||||
}
|
||||
|
||||
public static class CaptchaKitServiceCollectionExtensions
|
||||
{
|
||||
public static IServiceCollection AddCaptchaKit(
|
||||
this IServiceCollection services,
|
||||
Action<CaptchaKitOptions>? configure = null)
|
||||
{
|
||||
var options = new CaptchaKitOptions();
|
||||
configure?.Invoke(options);
|
||||
if (options.CodeLength is < 4 or > 8 ||
|
||||
options.Lifetime < TimeSpan.FromSeconds(30) ||
|
||||
options.Lifetime > TimeSpan.FromMinutes(10))
|
||||
throw new ArgumentOutOfRangeException(nameof(configure), "Code length must be 4–8 and lifetime must be 30 seconds–10 minutes.");
|
||||
services.AddSingleton(options);
|
||||
services.AddSingleton<ICaptchaGenerator, SkiaTextCaptchaGenerator>();
|
||||
services.AddSingleton<ICaptchaChallengeStore, DistributedCaptchaChallengeStore>();
|
||||
services.AddSingleton<ICaptchaKitService, CaptchaKitService>();
|
||||
return services;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,116 @@
|
||||
{
|
||||
"runtimeTarget": {
|
||||
"name": ".NETCoreApp,Version=v10.0",
|
||||
"signature": ""
|
||||
},
|
||||
"compilationOptions": {},
|
||||
"targets": {
|
||||
".NETCoreApp,Version=v10.0": {
|
||||
"CaptchaKit.AspNetCore/0.1.0-preview.1": {
|
||||
"dependencies": {
|
||||
"CaptchaKit.Core": "0.1.0-preview.1",
|
||||
"SkiaSharp": "4.151.1"
|
||||
},
|
||||
"runtime": {
|
||||
"CaptchaKit.AspNetCore.dll": {}
|
||||
}
|
||||
},
|
||||
"SkiaSharp/4.151.1": {
|
||||
"dependencies": {
|
||||
"SkiaSharp.NativeAssets.Win32": "4.151.1",
|
||||
"SkiaSharp.NativeAssets.macOS": "4.151.1"
|
||||
},
|
||||
"runtime": {
|
||||
"lib/net10.0/SkiaSharp.dll": {
|
||||
"assemblyVersion": "4.151.0.0",
|
||||
"fileVersion": "4.151.1.0"
|
||||
}
|
||||
}
|
||||
},
|
||||
"SkiaSharp.NativeAssets.macOS/4.151.1": {
|
||||
"runtimeTargets": {
|
||||
"runtimes/osx/native/libSkiaSharp.dylib": {
|
||||
"rid": "osx",
|
||||
"assetType": "native",
|
||||
"fileVersion": "0.0.0.0"
|
||||
}
|
||||
}
|
||||
},
|
||||
"SkiaSharp.NativeAssets.Win32/4.151.1": {
|
||||
"runtimeTargets": {
|
||||
"runtimes/win-arm64/native/libSkiaSharp.dll": {
|
||||
"rid": "win-arm64",
|
||||
"assetType": "native",
|
||||
"fileVersion": "0.0.0.0"
|
||||
},
|
||||
"runtimes/win-arm64/native/libSkiaSharp.pdb": {
|
||||
"rid": "win-arm64",
|
||||
"assetType": "native",
|
||||
"fileVersion": "0.0.0.0"
|
||||
},
|
||||
"runtimes/win-x64/native/libSkiaSharp.dll": {
|
||||
"rid": "win-x64",
|
||||
"assetType": "native",
|
||||
"fileVersion": "0.0.0.0"
|
||||
},
|
||||
"runtimes/win-x64/native/libSkiaSharp.pdb": {
|
||||
"rid": "win-x64",
|
||||
"assetType": "native",
|
||||
"fileVersion": "0.0.0.0"
|
||||
},
|
||||
"runtimes/win-x86/native/libSkiaSharp.dll": {
|
||||
"rid": "win-x86",
|
||||
"assetType": "native",
|
||||
"fileVersion": "0.0.0.0"
|
||||
},
|
||||
"runtimes/win-x86/native/libSkiaSharp.pdb": {
|
||||
"rid": "win-x86",
|
||||
"assetType": "native",
|
||||
"fileVersion": "0.0.0.0"
|
||||
}
|
||||
}
|
||||
},
|
||||
"CaptchaKit.Core/0.1.0-preview.1": {
|
||||
"runtime": {
|
||||
"CaptchaKit.Core.dll": {
|
||||
"assemblyVersion": "0.1.0.0",
|
||||
"fileVersion": "0.1.0.0"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"libraries": {
|
||||
"CaptchaKit.AspNetCore/0.1.0-preview.1": {
|
||||
"type": "project",
|
||||
"serviceable": false,
|
||||
"sha512": ""
|
||||
},
|
||||
"SkiaSharp/4.151.1": {
|
||||
"type": "package",
|
||||
"serviceable": true,
|
||||
"sha512": "sha512-FNlVsGZ9to3o9pObVqhIZanHjUGOoPb/Zxk5svCSLMJnKwxPKV7sIJrjs9BpB/jF3j9OdsaMOb4cfwSJbjueSw==",
|
||||
"path": "skiasharp/4.151.1",
|
||||
"hashPath": "skiasharp.4.151.1.nupkg.sha512"
|
||||
},
|
||||
"SkiaSharp.NativeAssets.macOS/4.151.1": {
|
||||
"type": "package",
|
||||
"serviceable": true,
|
||||
"sha512": "sha512-nLXwdhi0oB7pqqNLbSOLX+FIZQOMAU/yFPspzcDTJjTmPbMWyp0JWyeVj8sFJ/cweurEfT0J/lvfgdXX2EFHXA==",
|
||||
"path": "skiasharp.nativeassets.macos/4.151.1",
|
||||
"hashPath": "skiasharp.nativeassets.macos.4.151.1.nupkg.sha512"
|
||||
},
|
||||
"SkiaSharp.NativeAssets.Win32/4.151.1": {
|
||||
"type": "package",
|
||||
"serviceable": true,
|
||||
"sha512": "sha512-G+nln30ZX2ZuhH+ungf6x/QMXCje38KTS0VTfQeMyeY8uyMBmsZwFz2D30ZxUjZqsVbEIcaC2bsYF65876s4Yg==",
|
||||
"path": "skiasharp.nativeassets.win32/4.151.1",
|
||||
"hashPath": "skiasharp.nativeassets.win32.4.151.1.nupkg.sha512"
|
||||
},
|
||||
"CaptchaKit.Core/0.1.0-preview.1": {
|
||||
"type": "project",
|
||||
"serviceable": false,
|
||||
"sha512": ""
|
||||
}
|
||||
}
|
||||
}
|
||||
Loaded 3 of 87 files, more files were not shown because too many files have changed in this diff.
Show more
Reference in new issue
Block a user