208 lines
9.9 KiB
C#
208 lines
9.9 KiB
C#
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;
|
||
}
|
||
}
|