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