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);
}