v0.0.1 alpha

This commit is contained in:
biss committed 2026-09-12 08:14:34 +08:00
commit 0631f17bf5
87 files changed
+3226

No files matched your search

@@ -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;
}
}