0.1.0-preview.2
This commit is contained in:
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>
|
||||
|
||||
@@ -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;
|
||||
|
||||
Binary file not shown.
Loaded 3 of 63 files, more files were not shown because too many files have changed in this diff.
Show more
Reference in new issue
Block a user