xiangning 17b7e9996e chore(dependencies): 移除未使用的 using 语句
- 从多个文件中移除未使用的 System.Linq.Dynamic.Core 引用
- 从多个文件中移除未使用的 Microsoft.EntityFrameworkCore 引用
- 从 IM 模块中移除未使用的 MongoDB.Driver.Linq 引用
- 清理 Handler 文件中的冗余命名空间引用
- 优化代码结构减少不必要的依赖引入
- 提高代码整洁性和编译效率
2026-07-02 14:53:56 +08:00

126 lines
5.2 KiB
C#
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

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