Files
CaptchaKit/src/CaptchaKit.AspNetCore/CaptchaKitService.cs
T
2026-09-12 10:46:36 +08:00

208 lines
9.9 KiB
C#
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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 bool RequireBrowserProof { get; set; }
public bool EnableMathChallenge { get; set; } = true;
public bool EnableSelectionChallenge { get; set; } = true;
}
public interface ICaptchaKitService
{
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(
ICaptchaGenerator generator,
ICaptchaChallengeStore store,
CaptchaKitOptions options) : ICaptchaKitService
{
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), BrowserBinding(context)), expiresAt, cancellationToken);
return generated.Challenge;
}
public async Task<bool> VerifyAsync(string id, string answer, CaptchaRequestContext? context = null, CancellationToken cancellationToken = default)
{
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), BrowserBinding(context)));
}
private static string Normalize(string value) => value.Trim().ToUpperInvariant();
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));
}
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 RandomCaptchaGenerator(CaptchaKitOptions options) : ICaptchaGenerator
{
private readonly SkiaTextCaptchaGenerator _text = new();
private const string SelectionAlphabet = "ABCDEFGHJKLMNPQRSTUVWXYZ23456789";
public GeneratedCaptcha Generate(CaptchaGenerationRequest request)
{
var roll = RandomNumberGenerator.GetInt32(100);
if (options.EnableMathChallenge && roll < 25)
{
var left = RandomNumberGenerator.GetInt32(2, 10);
var right = RandomNumberGenerator.GetInt32(1, 10);
var isAddition = RandomNumberGenerator.GetInt32(2) == 0;
if (!isAddition && right > left)
(left, right) = (right, left);
var expression = $"{left} {(isAddition ? '+' : '-')} {right} = ?";
var answer = (isAddition ? left + right : left - right).ToString();
return _text.CreateVisualChallenge(
request, expression, answer, CaptchaChallengeType.MathImage, "请计算图片中的算式");
}
if (options.EnableSelectionChallenge && roll < 55)
{
var target = SelectionAlphabet[RandomNumberGenerator.GetInt32(SelectionAlphabet.Length)].ToString();
var candidates = SelectionAlphabet
.Select(x => x.ToString())
.Where(x => x != target)
.OrderBy(_ => RandomNumberGenerator.GetInt32(int.MaxValue))
.Take(3)
.Append(target)
.OrderBy(_ => RandomNumberGenerator.GetInt32(int.MaxValue))
.Select(x => new CaptchaChoice($"choice-{x}", x))
.ToList();
var answer = candidates.Single(x => x.Label == target).Id;
var visual = _text.CreateVisualChallenge(
request, target, answer, CaptchaChallengeType.Selection, "请选择与图片中字符相同的一项");
return new GeneratedCaptcha(
visual.Challenge with { Choices = candidates },
visual.Answer);
}
return _text.Generate(request);
}
}
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)]));
return CreateVisualChallenge(request, answer, answer, CaptchaChallengeType.TextImage, "请输入图中的字符");
}
public GeneratedCaptcha CreateVisualChallenge(
CaptchaGenerationRequest request,
string displayText,
string answer,
CaptchaChallengeType type,
string prompt)
{
var width = Math.Max(160, 20 + displayText.Length * 27);
using var bitmap = new SKBitmap(width, 54);
using var canvas = new SKCanvas(bitmap);
canvas.Clear(new SKColor(244, 248, 252));
DrawNoise(canvas, width, 54, 16);
using var paint = new SKPaint { IsAntialias = true };
using var font = new SKFont(SKTypeface.FromFamilyName("Arial", SKFontStyle.Bold), 30);
var spacing = Math.Max(22, (width - 24) / displayText.Length);
for (var index = 0; index < displayText.Length; index++)
{
var x = 12 + index * spacing + 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(displayText[index].ToString(), x, y, SKTextAlign.Left, font, paint);
canvas.Restore();
}
DrawNoise(canvas, width, 54, 10);
using var image = SKImage.FromBitmap(bitmap);
using var data = image.Encode(SKEncodedImageFormat.Png, 100);
var challenge = new CaptchaChallenge(request.Id, type, request.ExpiresAt,
prompt, null, "image/png", data.ToArray());
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, RandomCaptchaGenerator>();
services.AddSingleton<ICaptchaChallengeStore, DistributedCaptchaChallengeStore>();
services.AddSingleton<ICaptchaKitService, CaptchaKitService>();
return services;
}
}