using BuildFlow.Shared.Domain.Exceptions; using System.IdentityModel.Tokens.Jwt; using System.Security.Claims; using System.Security.Cryptography; using System.Text; using MediatR; using Microsoft.Extensions.Configuration; using Microsoft.IdentityModel.Tokens; using BuildFlow.Application.Auth.DTOs; using BuildFlow.Domain.Entities; using BuildFlow.Infrastructure.Persistence; using BuildFlow.Application.Auth.Commands; namespace BuildFlow.Application.Auth.Handlers; /// /// 登录Command处理器。执行业务校验后持久化。 /// public class LoginCommandHandler(RagDbContext db, IConfiguration config) : IRequestHandler { // 进程内登录失败计数器(未持久化,重启即清零)。键统一小写化,避免大小写差异绕过锁定。 private static readonly Dictionary LoginAttempts = new(); public async Task Handle(LoginCommand request, CancellationToken ct) { var key = request.Username.Trim().ToLowerInvariant(); // 账号已被锁定:直接拒绝,不消耗数据库查询 if (LoginAttempts.TryGetValue(key, out var attempt) && attempt.LockedUntil > DateTime.UtcNow) throw new BusinessException($"登录失败次数过多,请在 {(attempt.LockedUntil - DateTime.UtcNow).Minutes + 1} 分钟后重试"); // 一次 Include 取齐角色→权限,避免后续多次查询 var user = await db.Users .Include(u => u.UserRoles).ThenInclude(ur => ur.Role).ThenInclude(r => r.RolePermissions).ThenInclude(rp => rp.Permission) .FirstOrDefaultAsync(u => u.Username == request.Username, ct) ?? throw FailLogin(key, "用户名或密码错误"); // 用恒定时间的 BCrypt 校验,避免基于响应耗时的时序攻击 if (!BCrypt.Net.BCrypt.Verify(request.Password, user.PasswordHash)) throw FailLogin(key, "用户名或密码错误"); if (!user.IsActive) throw new BusinessException("用户已被停用"); // 登录成功,清除失败计数 LoginAttempts.Remove(key); var roles = user.UserRoles.Select(ur => ur.Role.Name).ToList(); var permissions = user.UserRoles.SelectMany(ur => ur.Role.RolePermissions).Select(rp => rp.Permission.Code).Distinct().ToList(); var accessToken = GenerateAccessToken(user, roles, permissions); var refreshToken = GenerateRefreshToken(); await db.RefreshTokens.AddAsync(new RefreshToken { Id = Guid.NewGuid(), UserId = user.Id, Token = refreshToken, ExpiresAt = DateTime.UtcNow.AddDays(7), }, ct); await db.SaveChangesAsync(ct); return new TokenResponse(accessToken, refreshToken, DateTime.UtcNow.AddMinutes(15)); } /// /// 累加失败次数并按需触发账号锁定。统一返回"用户名或密码错误",避免泄露用户是否存在。 /// 达到 5 次后锁定 15 分钟(暴力破解防护阈值)。 /// private static BusinessException FailLogin(string key, string message) { if (!LoginAttempts.TryGetValue(key, out var attempt)) attempt = (0, DateTime.MinValue); attempt.Count++; if (attempt.Count >= 5) { var lockedUntil = DateTime.UtcNow.AddMinutes(15); LoginAttempts[key] = (attempt.Count, lockedUntil); return new BusinessException($"登录失败次数过多,请 15 分钟后重试"); } LoginAttempts[key] = attempt; return new BusinessException(message); } /// /// 用配置中的对称密钥(共享密钥 RagJwtSecretKey2026...)HS256 签发 access_token。 /// permissions claim 携带权限码列表,FastEndpoints 的 Permissions() 据此判定。 /// private string GenerateAccessToken(User user, List roles, List permissions) { var signingKey = new SymmetricSecurityKey(Encoding.UTF8.GetBytes(config["Jwt:SigningKey"]!)); var creds = new SigningCredentials(signingKey, SecurityAlgorithms.HmacSha256); var claims = new List { new(JwtRegisteredClaimNames.Sub, user.Id.ToString()), new(JwtRegisteredClaimNames.UniqueName, user.Username), new(JwtRegisteredClaimNames.Email, user.Email), }; claims.AddRange(roles.Select(r => new Claim(ClaimTypes.Role, r))); claims.AddRange(permissions.Select(p => new Claim("permissions", p))); var token = new JwtSecurityToken( issuer: config["Jwt:Issuer"], audience: config["Jwt:Audience"], claims: claims, expires: DateTime.UtcNow.AddMinutes(15), signingCredentials: creds ); return new JwtSecurityTokenHandler().WriteToken(token); } // 用加密强随机数生成器产生 64 字节熵,再 Base64 编码为不透明 refresh_token(不可猜测、不可推导) private static string GenerateRefreshToken() { var bytes = new byte[64]; using var rng = RandomNumberGenerator.Create(); rng.GetBytes(bytes); return Convert.ToBase64String(bytes); } }