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 CreateAsync(CaptchaRequestContext? context = null, CancellationToken cancellationToken = default); Task 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 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 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 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"; private static readonly IReadOnlyDictionary Glyphs = new Dictionary { ['0'] = ["01110", "10001", "10011", "10101", "11001", "10001", "01110"], ['1'] = ["00100", "01100", "00100", "00100", "00100", "00100", "01110"], ['2'] = ["01110", "10001", "00001", "00010", "00100", "01000", "11111"], ['3'] = ["11110", "00001", "00001", "01110", "00001", "00001", "11110"], ['4'] = ["00010", "00110", "01010", "10010", "11111", "00010", "00010"], ['5'] = ["11111", "10000", "10000", "11110", "00001", "00001", "11110"], ['6'] = ["01110", "10000", "10000", "11110", "10001", "10001", "01110"], ['7'] = ["11111", "00001", "00010", "00100", "01000", "01000", "01000"], ['8'] = ["01110", "10001", "10001", "01110", "10001", "10001", "01110"], ['9'] = ["01110", "10001", "10001", "01111", "00001", "00001", "01110"], ['A'] = ["01110", "10001", "10001", "11111", "10001", "10001", "10001"], ['B'] = ["11110", "10001", "10001", "11110", "10001", "10001", "11110"], ['C'] = ["01110", "10001", "10000", "10000", "10000", "10001", "01110"], ['D'] = ["11110", "10001", "10001", "10001", "10001", "10001", "11110"], ['E'] = ["11111", "10000", "10000", "11110", "10000", "10000", "11111"], ['F'] = ["11111", "10000", "10000", "11110", "10000", "10000", "10000"], ['G'] = ["01110", "10001", "10000", "10111", "10001", "10001", "01110"], ['H'] = ["10001", "10001", "10001", "11111", "10001", "10001", "10001"], ['J'] = ["00111", "00010", "00010", "00010", "00010", "10010", "01100"], ['K'] = ["10001", "10010", "10100", "11000", "10100", "10010", "10001"], ['L'] = ["10000", "10000", "10000", "10000", "10000", "10000", "11111"], ['M'] = ["10001", "11011", "10101", "10101", "10001", "10001", "10001"], ['N'] = ["10001", "11001", "10101", "10011", "10001", "10001", "10001"], ['P'] = ["11110", "10001", "10001", "11110", "10000", "10000", "10000"], ['Q'] = ["01110", "10001", "10001", "10001", "10101", "10010", "01101"], ['R'] = ["11110", "10001", "10001", "11110", "10100", "10010", "10001"], ['S'] = ["01111", "10000", "10000", "01110", "00001", "00001", "11110"], ['T'] = ["11111", "00100", "00100", "00100", "00100", "00100", "00100"], ['U'] = ["10001", "10001", "10001", "10001", "10001", "10001", "01110"], ['V'] = ["10001", "10001", "10001", "10001", "10001", "01010", "00100"], ['W'] = ["10001", "10001", "10001", "10101", "10101", "10101", "01010"], ['X'] = ["10001", "10001", "01010", "00100", "01010", "10001", "10001"], ['Y'] = ["10001", "10001", "01010", "00100", "00100", "00100", "00100"], ['Z'] = ["11111", "00001", "00010", "00100", "01000", "10000", "11111"], ['+'] = ["00000", "00100", "00100", "11111", "00100", "00100", "00000"], ['-'] = ["00000", "00000", "00000", "11111", "00000", "00000", "00000"], ['='] = ["00000", "11111", "00000", "11111", "00000", "00000", "00000"], ['?'] = ["01110", "10001", "00001", "00010", "00100", "00000", "00100"], [' '] = ["00000", "00000", "00000", "00000", "00000", "00000", "00000"] }; 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, 20); using var textPaint = new SKPaint { IsAntialias = true, Color = new SKColor(20, 48, 80) }; var scale = Math.Min(4f, (width - 28f) / (displayText.Length * 6.5f - 1.5f)); var glyphWidth = scale * 5; var glyphHeight = scale * 7; var gap = scale * 1.5f; var totalWidth = displayText.Length * glyphWidth + (displayText.Length - 1) * gap; var x = (width - totalWidth) / 2f; var y = (54 - glyphHeight) / 2f; for (var index = 0; index < displayText.Length; index++) { var glyph = Glyphs.GetValueOrDefault(displayText[index], Glyphs['?']); canvas.Save(); canvas.Translate(x + glyphWidth / 2, y + glyphHeight / 2); canvas.RotateDegrees(RandomNumberGenerator.GetInt32(-12, 13)); canvas.Translate(-glyphWidth / 2, -glyphHeight / 2); DrawGlyph(canvas, glyph, scale, textPaint); canvas.Restore(); x += glyphWidth + gap; } 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 DrawGlyph(SKCanvas canvas, IReadOnlyList glyph, float scale, SKPaint paint) { for (var row = 0; row < glyph.Count; row++) for (var column = 0; column < glyph[row].Length; column++) { if (glyph[row][column] == '1') canvas.DrawRect(column * scale, row * scale, scale, scale, paint); } } 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 byte RandomByte() => RandomNumberGenerator.GetBytes(1)[0]; } public static class CaptchaKitServiceCollectionExtensions { public static IServiceCollection AddCaptchaKit( this IServiceCollection services, Action? 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(); services.AddSingleton(); services.AddSingleton(); return services; } }