- 从多个文件中移除未使用的 System.Linq.Dynamic.Core 引用 - 从多个文件中移除未使用的 Microsoft.EntityFrameworkCore 引用 - 从 IM 模块中移除未使用的 MongoDB.Driver.Linq 引用 - 清理 Handler 文件中的冗余命名空间引用 - 优化代码结构减少不必要的依赖引入 - 提高代码整洁性和编译效率
126 lines
5.2 KiB
C#
126 lines
5.2 KiB
C#
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;
|
||
|
||
/// <summary>
|
||
/// 登录Command处理器。执行业务校验后持久化。
|
||
/// </summary>
|
||
public class LoginCommandHandler(RagDbContext db, IConfiguration config) : IRequestHandler<LoginCommand, TokenResponse>
|
||
{
|
||
// 进程内登录失败计数器(未持久化,重启即清零)。键统一小写化,避免大小写差异绕过锁定。
|
||
private static readonly Dictionary<string, (int Count, DateTime LockedUntil)> LoginAttempts = new();
|
||
|
||
public async Task<TokenResponse> 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));
|
||
}
|
||
|
||
/// <summary>
|
||
/// 累加失败次数并按需触发账号锁定。统一返回"用户名或密码错误",避免泄露用户是否存在。
|
||
/// 达到 5 次后锁定 15 分钟(暴力破解防护阈值)。
|
||
/// </summary>
|
||
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);
|
||
}
|
||
|
||
/// <summary>
|
||
/// 用配置中的对称密钥(共享密钥 RagJwtSecretKey2026...)HS256 签发 access_token。
|
||
/// permissions claim 携带权限码列表,FastEndpoints 的 Permissions() 据此判定。
|
||
/// </summary>
|
||
private string GenerateAccessToken(User user, List<string> roles, List<string> permissions)
|
||
{
|
||
var signingKey = new SymmetricSecurityKey(Encoding.UTF8.GetBytes(config["Jwt:SigningKey"]!));
|
||
var creds = new SigningCredentials(signingKey, SecurityAlgorithms.HmacSha256);
|
||
|
||
var claims = new List<Claim>
|
||
{
|
||
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);
|
||
}
|
||
}
|