using System.Security.Cryptography; using Microsoft.Extensions.Caching.Memory; namespace Rmsys.Api.Endpoints; /// /// 图形验证码:服务端生成 SVG(不依赖 System.Drawing,Linux/Windows 都能跑), /// 验证码文本存内存缓存,3 分钟过期、校验一次即作废(防重放)。 /// public static class CaptchaEndpoints { private const string Chars = "ABCDEFGHJKLMNPQRSTUVWXYZ23456789"; // 去掉 0/O/1/I/L 易混字符 private const int Length = 4; private const int TtlSeconds = 180; public static void MapCaptchaEndpoints(this WebApplication app) { app.MapGet("/api/auth/captcha", (IMemoryCache cache) => { var code = NewCode(); var id = Guid.NewGuid().ToString("N"); cache.Set("captcha:" + id, code, TimeSpan.FromSeconds(TtlSeconds)); var svg = BuildSvg(code); // 只有显式设置 RMSYS_CAPTCHA_DEBUG=1 才回传答案(仅供联调/自测)。 // 安装包部署时不设置该变量,生产环境永远不会泄露答案。 var debug = Environment.GetEnvironmentVariable("RMSYS_CAPTCHA_DEBUG") == "1"; if (debug) return Results.Ok(new { ok = true, id, svg, code, ttl = TtlSeconds }); return Results.Ok(new { ok = true, id, svg, ttl = TtlSeconds }); }); /// 登录时校验:命中即删除(一次一用) app.MapPost("/api/auth/captcha/check", (CaptchaCheckReq req, IMemoryCache cache) => { var ok = Consume(cache, req.id, req.code); return Results.Ok(new { ok }); }); } /// 校验并从缓存移除;失败也移除(避免被反复猜) public static bool Consume(IMemoryCache cache, string? id, string? input) { if (string.IsNullOrWhiteSpace(id) || string.IsNullOrWhiteSpace(input)) return false; var key = "captcha:" + id; if (!cache.TryGetValue(key, out var code) || string.IsNullOrEmpty(code)) return false; cache.Remove(key); return string.Equals(code, input.Trim(), StringComparison.OrdinalIgnoreCase); } private static string NewCode() { var buf = new char[Length]; for (var i = 0; i < Length; i++) buf[i] = Chars[RandomNumberGenerator.GetInt32(Chars.Length)]; return new string(buf); } private static string BuildSvg(string code) { var rnd = RandomNumberGenerator.Create(); int R(int min, int max) { var b = new byte[4]; rnd.GetBytes(b); return min + (int)(BitConverter.ToUInt32(b, 0) % (uint)(max - min)); } double D(double min, double max) => min + (rnd.NextDoubleLike() * (max - min)); const int w = 132, h = 44; var sb = new System.Text.StringBuilder(); sb.Append($""); sb.Append($""); // 干扰线 for (var i = 0; i < 3; i++) { var c = i % 2 == 0 ? "#D8C7A8" : "#C9B99B"; sb.Append($""); } // 干扰点 for (var i = 0; i < 26; i++) sb.Append($""); // 字符:逐个随机旋转、轻微上下浮动,颜色在深红/墨黑/棕色间取 var colors = new[] { "#8C1515", "#3A3A3A", "#7A4B12", "#1F4E79" }; var glyphs = new List(); for (var i = 0; i < code.Length; i++) { var x = 22 + i * 27 + R(-8, 9); // 抖动足以打乱「按 x 排序」,又不影响人眼阅读 var y = 31 + R(-3, 4); var rot = R(-20, 21); var col = colors[R(0, colors.Length)]; var size = R(24, 29); glyphs.Add($"{System.Security.SecurityElement.Escape(code[i].ToString())}"); } // 反脚本提取(一):混入与底色同色的诱饵字符,照 DOM 顺序读会读错 for (var i = 0; i < 2; i++) { var dx = R(6, w - 20); var dy = R(20, h - 6); glyphs.Add($"" + Chars[R(0, Chars.Length)] + ""); } // 反脚本提取(二):打乱绘制顺序,可见字符也不再是 DOM 里的先后顺序 for (var i = glyphs.Count - 1; i > 0; i--) { var j = R(0, i + 1); (glyphs[i], glyphs[j]) = (glyphs[j], glyphs[i]); } foreach (var g in glyphs) sb.Append(g); sb.Append(""); return sb.ToString(); } private static double NextDoubleLike(this RandomNumberGenerator r) { var b = new byte[4]; r.GetBytes(b); return BitConverter.ToUInt32(b, 0) / (double)uint.MaxValue; } public record CaptchaCheckReq(string? id, string? code); }