0.1.0-preview.2

This commit is contained in:
biss committed 2026-09-12 08:32:43 +08:00
1 parent 0631f17bf5
commit 9122699c8b
63 files changed
+165 -46

No files matched your search

@@ -5,7 +5,7 @@
<Nullable>enable</Nullable>
<GeneratePackageOnBuild>false</GeneratePackageOnBuild>
<PackageId>CaptchaKit.AspNetCore</PackageId>
<Version>0.1.0-preview.1</Version>
<Version>0.1.0-preview.2</Version>
<Authors>CaptchaKit Contributors</Authors>
<Description>ASP.NET Core and distributed-cache integration for CaptchaKit.</Description>
<PackageLicenseExpression>MIT</PackageLicenseExpression>
+57 -11
View File
@@ -12,12 +12,15 @@ 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 bool RequireBrowserProof { get; set; }
public bool EnableOneClickChallenge { get; set; } = true;
public bool EnableSelectionChallenge { get; set; } = true;
}
public interface ICaptchaKitService
{
Task<CaptchaChallenge> CreateAsync(CancellationToken cancellationToken = default);
Task<bool> VerifyAsync(string id, string answer, CancellationToken cancellationToken = default);
Task<CaptchaChallenge> CreateAsync(CaptchaRequestContext? context = null, CancellationToken cancellationToken = default);
Task<bool> VerifyAsync(string id, string answer, CaptchaRequestContext? context = null, CancellationToken cancellationToken = default);
}
public sealed class CaptchaKitService(
@@ -25,28 +28,35 @@ public sealed class CaptchaKitService(
ICaptchaChallengeStore store,
CaptchaKitOptions options) : ICaptchaKitService
{
public async Task<CaptchaChallenge> CreateAsync(CancellationToken cancellationToken = default)
public async Task<CaptchaChallenge> CreateAsync(CaptchaRequestContext? context = null, CancellationToken cancellationToken = default)
{
if (options.RequireBrowserProof && !HasBrowserProof(context))
throw new ArgumentException("A browser proof is required for this captcha challenge.", nameof(context));
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);
await store.StoreAsync(id, Hash(id, Normalize(generated.Answer), BrowserBinding(context)), expiresAt, cancellationToken);
return generated.Challenge;
}
public async Task<bool> VerifyAsync(string id, string answer, CancellationToken cancellationToken = default)
public async Task<bool> VerifyAsync(string id, string answer, CaptchaRequestContext? context = null, CancellationToken cancellationToken = default)
{
if (string.IsNullOrWhiteSpace(id) || string.IsNullOrWhiteSpace(answer))
if (string.IsNullOrWhiteSpace(id) || string.IsNullOrWhiteSpace(answer) ||
(options.RequireBrowserProof && !HasBrowserProof(context)))
return false;
var expected = await store.TakeAsync(id, cancellationToken);
return expected is not null && FixedEquals(expected, Hash(id, Normalize(answer)));
return expected is not null && FixedEquals(expected, Hash(id, Normalize(answer), BrowserBinding(context)));
}
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 HasBrowserProof(CaptchaRequestContext? context) =>
!string.IsNullOrWhiteSpace(context?.BrowserProof) && context.BrowserProof.Length <= 256;
private static string BrowserBinding(CaptchaRequestContext? context) =>
HasBrowserProof(context) ? Hash(context!.BrowserProof!, "browser", "") : "unbound";
private static string Hash(string id, string answer, string browserBinding) =>
Convert.ToHexString(SHA256.HashData(Encoding.UTF8.GetBytes($"{id}:{answer}:{browserBinding}")));
private static bool FixedEquals(string left, string right) =>
CryptographicOperations.FixedTimeEquals(Encoding.UTF8.GetBytes(left), Encoding.UTF8.GetBytes(right));
}
@@ -72,6 +82,41 @@ public sealed class DistributedCaptchaChallengeStore(
private string Key(string id) => options.CacheKeyPrefix + id;
}
public sealed class RandomCaptchaGenerator(CaptchaKitOptions options) : ICaptchaGenerator
{
private readonly SkiaTextCaptchaGenerator _text = new();
public GeneratedCaptcha Generate(CaptchaGenerationRequest request)
{
var roll = RandomNumberGenerator.GetInt32(100);
if (options.EnableOneClickChallenge && roll < 15)
{
return new GeneratedCaptcha(
new CaptchaChallenge(request.Id, CaptchaChallengeType.OneClick, request.ExpiresAt,
"请点击确认按钮继续登录"),
"CONFIRM");
}
if (options.EnableSelectionChallenge && roll < 45)
{
var correct = RandomNumberGenerator.GetInt32(2, 10).ToString();
var candidates = Enumerable.Range(1, 9)
.Where(x => x.ToString() != correct)
.OrderBy(_ => RandomNumberGenerator.GetInt32(int.MaxValue))
.Take(3)
.Append(int.Parse(correct))
.OrderBy(_ => RandomNumberGenerator.GetInt32(int.MaxValue))
.Select(x => new CaptchaChoice($"choice-{x}", x.ToString()))
.ToList();
var answer = candidates.Single(x => x.Label == correct).Id;
return new GeneratedCaptcha(
new CaptchaChallenge(request.Id, CaptchaChallengeType.Selection, request.ExpiresAt,
$"请选择数字 {correct}", candidates),
answer);
}
return _text.Generate(request);
}
}
public sealed class SkiaTextCaptchaGenerator : ICaptchaGenerator
{
private const string Alphabet = "ABCDEFGHJKLMNPQRSTUVWXYZ23456789";
@@ -99,7 +144,8 @@ public sealed class SkiaTextCaptchaGenerator : ICaptchaGenerator
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);
var challenge = new CaptchaChallenge(request.Id, CaptchaChallengeType.TextImage, request.ExpiresAt,
"请输入图中的字符", null, "image/png", data.ToArray());
return new GeneratedCaptcha(challenge, answer);
}
@@ -133,7 +179,7 @@ public static class CaptchaKitServiceCollectionExtensions
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<ICaptchaGenerator, RandomCaptchaGenerator>();
services.AddSingleton<ICaptchaChallengeStore, DistributedCaptchaChallengeStore>();
services.AddSingleton<ICaptchaKitService, CaptchaKitService>();
return services;
Loaded 3 of 63 files, more files were not shown because too many files have changed in this diff. Show more