using System.IdentityModel.Tokens.Jwt; using System.Security.Claims; using System.Text; using Microsoft.EntityFrameworkCore; using Microsoft.IdentityModel.Tokens; using Rmsys.Api.Auth; using Rmsys.Infrastructure; namespace Rmsys.Api.Endpoints; public static class AuthEndpoints { public const int MaxFailCount = 3; // 连续失败 3 次锁定 public const int LockMinutes = 15; public const int IpMaxFail = 12; // 同一 IP 15 分钟内最多失败次数 public const int IpWindowMinutes = 15; /// /// IP 维度失败计数(进程内)。为什么要这个: /// 只按账号锁定时,知道账号名的人连错 3 次就能把王科长锁死 15 分钟, /// 会前反复操作可持续阻断关键账号。加 IP 维度后,攻击者先被自己的 IP 挡住, /// 且失败响应逐次变慢(递增延迟),暴力破解成本显著上升。 /// private static readonly System.Collections.Concurrent.ConcurrentDictionary IpFails = new(); private static bool IpBlocked(string ip, out int remainSeconds) { remainSeconds = 0; if (string.IsNullOrEmpty(ip)) return false; if (!IpFails.TryGetValue(ip, out var v)) return false; var elapsed = DateTime.Now - v.window; if (elapsed.TotalMinutes >= IpWindowMinutes) { IpFails.TryRemove(ip, out _); return false; } if (v.count < IpMaxFail) return false; remainSeconds = Math.Max(1, (int)(IpWindowMinutes * 60 - elapsed.TotalSeconds)); return true; } private static int IpRecordFail(string ip) { if (string.IsNullOrEmpty(ip)) return 0; var v = IpFails.AddOrUpdate(ip, _ => (1, DateTime.Now), (_, old) => (DateTime.Now - old.window).TotalMinutes >= IpWindowMinutes ? (1, DateTime.Now) : (old.count + 1, old.window)); return v.count; } public static void MapAuthEndpoints(this WebApplication app) { // ---------- 登录 ---------- app.MapPost("/api/auth/login", async (LoginReq req, RmsysDbContext db, IConfiguration cfg, HttpContext http, Microsoft.Extensions.Caching.Memory.IMemoryCache cache) => { var ip = http.Connection.RemoteIpAddress?.ToString() ?? ""; // 图形验证码:先校验再验口令(一次一用,错一次即作废) if (!CaptchaEndpoints.Consume(cache, req.captchaId, req.captchaCode)) { db.AuditLogs.Add(new Domain.Entities.AuditLog { At = DateTime.Now, UserName = req.account, Action = "login", Target = "验证码错误", Detail = "登录验证码错误或已过期", Ip = ip, Device = req.device, Success = 0 }); await db.SaveChangesAsync(); return Results.Ok(new { ok = false, message = "验证码错误或已过期,请重试", code = "CAPTCHA" }); } // IP 维度限流:先挡住高频尝试,避免靠错口令把别人账号锁死 if (IpBlocked(ip, out var remain)) { db.AuditLogs.Add(new Domain.Entities.AuditLog { At = DateTime.Now, UserName = req.account, Action = "login", Target = "登录被限流", Detail = $"同一 IP 失败过多,剩余 {remain} 秒", Ip = ip, Device = req.device, Success = 0 }); await db.SaveChangesAsync(); return Results.Ok(new { ok = false, message = $"登录尝试过于频繁,请 {remain} 秒后再试" }); } var u = await db.SysUsers.FirstOrDefaultAsync(x => x.Account == req.account); async Task Fail(string reason, int? userId, string? name) { var n = IpRecordFail(ip); if (n >= MaxFailCount) reason += $"(同一 IP 累计失败 {n} 次,疑似批量尝试)"; db.AuditLogs.Add(new Domain.Entities.AuditLog { At = DateTime.Now, UserId = userId, UserName = name ?? req.account, Role = "", Action = "login", Target = "登录失败", Detail = reason, Ip = ip, Device = req.device, Success = 0 }); await db.SaveChangesAsync(); // 失败响应递增延迟:第 n 次等 min(200*n, 1500) 毫秒,抬高暴力破解成本 await Task.Delay(Math.Min(1500, 200 * Math.Max(1, n))); } if (u == null) { await Fail("账号不存在", null, req.account); return Results.Ok(new { ok = false, message = "账号或口令不正确" }); } if (u.Enabled != 1) { await Fail("账号已停用", u.Id, u.RealName); return Results.Ok(new { ok = false, message = "账号已停用,请联系管理员" }); } if (u.LockedUntil.HasValue && u.LockedUntil > DateTime.Now) { await Fail("账号处于锁定期", u.Id, u.RealName); return Results.Ok(new { ok = false, message = $"账号已锁定,请于 {u.LockedUntil:HH:mm} 后重试" }); } if (!PasswordHasher.Verify(req.password ?? "", u.PwdHash)) { u.FailCount += 1; var reason = "口令错误"; if (u.FailCount >= MaxFailCount) { u.LockedUntil = DateTime.Now.AddMinutes(LockMinutes); reason = $"口令错误 {u.FailCount} 次,已锁定 {LockMinutes} 分钟"; } await db.SaveChangesAsync(); await Fail(reason, u.Id, u.RealName); return Results.Ok(new { ok = false, message = reason == "口令错误" ? "账号或口令不正确" : reason }); } // 成功 IpFails.TryRemove(ip, out _); u.FailCount = 0; u.LockedUntil = null; u.LastLoginAt = DateTime.Now; var roles = await (from ur in db.SysUserRoles join r in db.SysRoles on ur.RoleId equals r.Id where ur.UserId == u.Id select r.Name).ToListAsync(); var deptCode = u.DeptId.HasValue ? await db.Depts.Where(d => d.Id == u.DeptId).Select(d => d.Code).FirstOrDefaultAsync() : null; db.AuditLogs.Add(new Domain.Entities.AuditLog { At = DateTime.Now, UserId = u.Id, UserName = u.RealName, Role = string.Join("、", roles), Action = "login", Target = "登录成功", Detail = null, Ip = ip, Device = req.device, Success = 1 }); await db.SaveChangesAsync(); var token = IssueToken(u.Id, u.Account, u.RealName, string.Join("、", roles), deptCode ?? "all", u.TokenVersion, cfg); // 口令有效期:政务系统通常要求 90 天更换一次。这里不阻断登录, // 而是回一个 mustChangePassword 让前端强制弹改口令,避免把人直接挡在门外。 var maxAge = int.TryParse(cfg["Rmsys:PwdMaxAgeDays"], out var ma) ? ma : 90; var pwdAgeDays = u.PwdUpdatedAt.HasValue ? (DateTime.Now - u.PwdUpdatedAt.Value).TotalDays : 9999.0; var mustChange = pwdAgeDays > maxAge; return Results.Ok(new { ok = true, token, // 口令有效期:超过 90 天(可配置)要求先改口令再使用 mustChangePassword = mustChange, pwdAgeDays = (int)pwdAgeDays, user = new { acc = u.Account, name = u.RealName, role = string.Join("、", roles), dept = deptCode ?? "all", scope = u.DataScope } }); }); // ---------- 当前登录人 ---------- // ---------- 退出登录(前端清除本地令牌 + 服务端留一条审计记录)---------- app.MapPost("/api/auth/logout", async (ClaimsPrincipal p, RmsysDbContext db, HttpContext http) => { var name = p.FindFirst(ClaimTypes.Name)?.Value ?? ""; int? uid = int.TryParse(p.FindFirst(ClaimTypes.NameIdentifier)?.Value, out var v) ? v : (int?)null; db.AuditLogs.Add(new Domain.Entities.AuditLog { At = DateTime.Now, UserId = uid, UserName = string.IsNullOrWhiteSpace(name) ? "未知" : name, Action = "logout", Target = "退出登录", Detail = "后台主动退出,客户端已清除令牌", Ip = http.Connection.RemoteIpAddress?.ToString() ?? "", Success = 1 }); await db.SaveChangesAsync(); return Results.Ok(new { ok = true, message = "已退出登录" }); }).RequireAuthorization(); app.MapGet("/api/auth/me", (ClaimsPrincipal p) => Results.Ok(new { ok = true, acc = p.FindFirst(ClaimTypes.NameIdentifier)?.Value, name = p.FindFirst(ClaimTypes.Name)?.Value, role = p.FindFirst(ClaimTypes.Role)?.Value, dept = p.FindFirst("dept")?.Value })).RequireAuthorization(); // ---------- 改口令(校验复杂度 + 不得重复最近 3 次)---------- app.MapPost("/api/auth/change-password", async (ChangePwdReq req, ClaimsPrincipal p, RmsysDbContext db, IConfiguration cfg) => { var acc = p.FindFirst(ClaimTypes.NameIdentifier)?.Value; var u = await db.SysUsers.FirstOrDefaultAsync(x => x.Account == acc); if (u == null) return Results.Ok(new { ok = false, message = "账号不存在" }); if (!PasswordHasher.Verify(req.oldPassword ?? "", u.PwdHash)) return Results.Ok(new { ok = false, message = "原口令不正确" }); var (ok, reason) = PasswordHasher.CheckStrength(req.newPassword ?? ""); if (!ok) return Results.Ok(new { ok = false, message = reason }); // 不得与最近 3 次重复 var recent = await db.PwdHistories.Where(x => x.UserId == u.Id) .OrderByDescending(x => x.ChangedAt).Take(3).ToListAsync(); if (recent.Any(h => PasswordHasher.Verify(req.newPassword!, h.PwdHash))) return Results.Ok(new { ok = false, message = "新口令不得与最近 3 次使用过的口令相同" }); var (hash, salt) = PasswordHasher.Hash(req.newPassword!); u.PwdHash = hash; u.PwdSalt = salt; u.PwdUpdatedAt = DateTime.Now; // 口令一改,此前签发的所有令牌立即失效(防止口令泄露后旧会话继续可用) u.TokenVersion += 1; db.PwdHistories.Add(new Domain.Entities.PwdHistory { UserId = u.Id, PwdHash = hash, ChangedAt = DateTime.Now }); db.AuditLogs.Add(new Domain.Entities.AuditLog { At = DateTime.Now, UserId = u.Id, UserName = u.RealName, Action = "修改口令", Target = u.Account, Success = 1 }); await db.SaveChangesAsync(); // 版本号已变,旧令牌全部作废;给当前会话发一张新的,免去立即重新登录 var roles2 = await (from ur in db.SysUserRoles join r in db.SysRoles on ur.RoleId equals r.Id where ur.UserId == u.Id select r.Name).ToListAsync(); var deptCode2 = u.DeptId.HasValue ? await db.Depts.Where(d => d.Id == u.DeptId).Select(d => d.Code).FirstOrDefaultAsync() : null; var newToken = IssueToken(u.Id, u.Account, u.RealName, string.Join("、", roles2), deptCode2 ?? "all", u.TokenVersion, cfg); return Results.Ok(new { ok = true, message = "口令已修改,其他设备上的登录已失效", token = newToken }); }).RequireAuthorization(); // ---------- 强制下线(管理员)---------- // 场景:人员调离、平板丢失、发现异常登录。+1 令牌版本即可让该用户所有会话立即失效, // 不必等 8 小时自然过期。 app.MapPost("/api/admin/users/{id:int}/kick", async (int id, ClaimsPrincipal p, RmsysDbContext db) => { if (!p.IsInRole("系统管理员")) return Results.Json(new { ok = false, message = "仅系统管理员可执行强制下线" }, statusCode: 403); var u = await db.SysUsers.FirstOrDefaultAsync(x => x.Id == id); if (u == null) return Results.NotFound(new { ok = false, message = "用户不存在" }); u.TokenVersion += 1; db.AuditLogs.Add(new Domain.Entities.AuditLog { At = DateTime.Now, UserId = u.Id, UserName = p.FindFirst(ClaimTypes.Name)?.Value, Action = "强制下线", Target = u.Account, Detail = $"令牌版本 → {u.TokenVersion},该用户所有登录立即失效", Success = 1 }); await db.SaveChangesAsync(); return Results.Ok(new { ok = true, message = $"{u.RealName} 的登录已全部失效", tokenVersion = u.TokenVersion }); }).RequireAuthorization(); } private static string IssueToken(int uid, string acc, string name, string roles, string dept, int tokenVersion, IConfiguration cfg) { // 兜底串必须 ≥32 字节:HS256 要求密钥不短于 256 位,短了会抛 IDX10720 var key = cfg["Rmsys:JwtKey"] ?? "RMSYS-FALLBACK-KEY-db-unreachable-change-me!!"; var issuer = cfg["Rmsys:JwtIssuer"] ?? "rmsys"; var minutes = int.TryParse(cfg["Rmsys:JwtExpireMinutes"], out var m) ? m : 480; var claims = new List { new(ClaimTypes.NameIdentifier, acc), new(ClaimTypes.Name, name), new(ClaimTypes.Role, roles), new("dept", dept), new("uid", uid.ToString()), // 令牌版本:中间件每次请求比对,与库内不一致即视为已失效(改口令/停用/强制下线) new("tv", tokenVersion.ToString()) }; var creds = new SigningCredentials( new SymmetricSecurityKey(Encoding.UTF8.GetBytes(key)), SecurityAlgorithms.HmacSha256); var token = new JwtSecurityToken( issuer: issuer, audience: issuer, claims: claims, expires: DateTime.Now.AddMinutes(minutes), signingCredentials: creds); return new JwtSecurityTokenHandler().WriteToken(token); } public record LoginReq(string? account, string? password, string? device, string? captchaId, string? captchaCode); public record ChangePwdReq(string? oldPassword, string? newPassword); }