447 lines
16 KiB
C#
447 lines
16 KiB
C#
using System.IdentityModel.Tokens.Jwt;
|
||
using System.Security.Claims;
|
||
using System.Security.Cryptography;
|
||
using System.Text;
|
||
using HoneyBox.Admin.Data;
|
||
using HoneyBox.Admin.Entities;
|
||
using HoneyBox.Admin.Models.Auth;
|
||
using HoneyBox.Admin.Models.Common;
|
||
using Microsoft.EntityFrameworkCore;
|
||
using Microsoft.Extensions.Configuration;
|
||
using Microsoft.IdentityModel.Tokens;
|
||
|
||
namespace HoneyBox.Admin.Services;
|
||
|
||
/// <summary>
|
||
/// 认证服务实现
|
||
/// </summary>
|
||
public class AuthService : IAuthService
|
||
{
|
||
private readonly AdminDbContext _dbContext;
|
||
private readonly IConfiguration _configuration;
|
||
private readonly ILogger<AuthService> _logger;
|
||
private readonly ICaptchaService _captchaService;
|
||
|
||
// 登录失败锁定配置
|
||
private const int MaxFailedAttempts = 5;
|
||
private const int LockoutMinutes = 30;
|
||
|
||
// Token配置
|
||
private const int RefreshTokenExpirationDays = 7;
|
||
private const int AccessTokenExpirationMinutes = 30;
|
||
|
||
public AuthService(
|
||
AdminDbContext dbContext,
|
||
IConfiguration configuration,
|
||
ILogger<AuthService> logger,
|
||
ICaptchaService captchaService)
|
||
{
|
||
_dbContext = dbContext;
|
||
_configuration = configuration;
|
||
_logger = logger;
|
||
_captchaService = captchaService;
|
||
}
|
||
|
||
/// <inheritdoc />
|
||
public async Task<LoginResponse> LoginAsync(LoginRequest request, string ipAddress)
|
||
{
|
||
// 1. 首先验证验证码(优先于密码校验)临时注释,上线在启用
|
||
#if !DEBUG
|
||
if (!_captchaService.Validate(request.CaptchaKey, request.CaptchaCode))
|
||
{
|
||
_logger.LogWarning("登录失败:验证码错误或已过期,用户名: {Username}", request.Username);
|
||
throw new AdminException(AdminErrorCodes.CaptchaInvalid, "验证码错误或已过期");
|
||
}
|
||
#endif
|
||
|
||
// 2. 查找用户
|
||
var user = await _dbContext.AdminUsers
|
||
.Include(u => u.Department)
|
||
.Include(u => u.AdminUserRoles)
|
||
.ThenInclude(ur => ur.Role)
|
||
.FirstOrDefaultAsync(u => u.Username == request.Username);
|
||
|
||
if (user == null)
|
||
{
|
||
_logger.LogWarning("登录失败:用户 {Username} 不存在", request.Username);
|
||
throw new AdminException(AdminErrorCodes.InvalidCredentials, "用户名或密码错误");
|
||
}
|
||
|
||
// 3. 检查账户是否被锁定
|
||
if (user.LockoutEnd.HasValue && user.LockoutEnd.Value > DateTime.Now)
|
||
{
|
||
var remainingMinutes = (int)(user.LockoutEnd.Value - DateTime.Now).TotalMinutes + 1;
|
||
_logger.LogWarning("登录失败:用户 {Username} 账户已锁定,剩余 {Minutes} 分钟", request.Username, remainingMinutes);
|
||
throw new AdminException(AdminErrorCodes.AccountLocked, $"账户已锁定,请在 {remainingMinutes} 分钟后重试");
|
||
}
|
||
|
||
// 4. 检查账户状态
|
||
if (user.Status == 0)
|
||
{
|
||
_logger.LogWarning("登录失败:用户 {Username} 账户已禁用", request.Username);
|
||
throw new AdminException(AdminErrorCodes.AccountDisabled, "账户已禁用");
|
||
}
|
||
|
||
// 5. 验证密码
|
||
if (!VerifyPassword(request.Password, user.PasswordHash))
|
||
{
|
||
// 增加失败次数
|
||
user.LoginFailCount++;
|
||
|
||
// 检查是否需要锁定账户
|
||
if (user.LoginFailCount >= MaxFailedAttempts)
|
||
{
|
||
user.LockoutEnd = DateTime.Now.AddMinutes(LockoutMinutes);
|
||
user.LoginFailCount = 0;
|
||
_logger.LogWarning("用户 {Username} 登录失败次数过多,账户已锁定 {Minutes} 分钟", request.Username, LockoutMinutes);
|
||
}
|
||
|
||
await _dbContext.SaveChangesAsync();
|
||
|
||
var remainingAttempts = MaxFailedAttempts - user.LoginFailCount;
|
||
_logger.LogWarning("登录失败:用户 {Username} 密码错误,剩余尝试次数 {Remaining}", request.Username, remainingAttempts);
|
||
throw new AdminException(AdminErrorCodes.InvalidCredentials, "用户名或密码错误");
|
||
}
|
||
|
||
// 6. 登录成功,重置失败次数
|
||
user.LoginFailCount = 0;
|
||
user.LockoutEnd = null;
|
||
user.LastLoginTime = DateTime.Now;
|
||
user.LastLoginIp = ipAddress;
|
||
|
||
// 7. 生成双Token
|
||
var expireMinutes = _configuration.GetValue<int>("Jwt:ExpireMinutes", AccessTokenExpirationMinutes);
|
||
var accessToken = GenerateJwtToken(user, expireMinutes);
|
||
var (refreshToken, refreshTokenEntity) = GenerateRefreshToken(user.Id, ipAddress);
|
||
|
||
// 8. 保存RefreshToken到数据库
|
||
_dbContext.RefreshTokens.Add(refreshTokenEntity);
|
||
await _dbContext.SaveChangesAsync();
|
||
|
||
// 9. 获取用户角色和权限
|
||
var roles = user.AdminUserRoles
|
||
.Where(ur => ur.Role.Status == 1)
|
||
.Select(ur => ur.Role.Code)
|
||
.ToList();
|
||
|
||
var permissions = await GetUserPermissionsAsync(user.Id);
|
||
|
||
_logger.LogInformation("用户 {Username} 登录成功,IP: {IpAddress}", request.Username, ipAddress);
|
||
|
||
return new LoginResponse
|
||
{
|
||
AccessToken = accessToken,
|
||
RefreshToken = refreshToken,
|
||
ExpiresIn = expireMinutes * 60,
|
||
UserInfo = new AdminUserInfo
|
||
{
|
||
Id = user.Id,
|
||
Username = user.Username,
|
||
RealName = user.RealName,
|
||
Avatar = user.Avatar,
|
||
Email = user.Email,
|
||
Phone = user.Phone,
|
||
DepartmentId = user.DepartmentId,
|
||
DepartmentName = user.Department?.Name,
|
||
Roles = roles,
|
||
Permissions = permissions
|
||
}
|
||
};
|
||
}
|
||
|
||
/// <inheritdoc />
|
||
public async Task<RefreshTokenResponse> RefreshTokenAsync(string refreshToken, string ipAddress)
|
||
{
|
||
// 1. 计算Token哈希
|
||
var tokenHash = HashToken(refreshToken);
|
||
|
||
// 2. 查找Token
|
||
var storedToken = await _dbContext.RefreshTokens
|
||
.Include(t => t.AdminUser)
|
||
.ThenInclude(u => u.AdminUserRoles)
|
||
.ThenInclude(ur => ur.Role)
|
||
.FirstOrDefaultAsync(t => t.TokenHash == tokenHash);
|
||
|
||
if (storedToken == null)
|
||
{
|
||
_logger.LogWarning("Token刷新失败:Token不存在");
|
||
throw new AdminException(AdminErrorCodes.InvalidRefreshToken, "无效的RefreshToken");
|
||
}
|
||
|
||
// 3. 检查Token是否有效
|
||
if (!storedToken.IsActive)
|
||
{
|
||
if (storedToken.IsRevoked)
|
||
{
|
||
_logger.LogWarning("Token刷新失败:Token已被撤销,用户ID: {UserId}", storedToken.AdminUserId);
|
||
throw new AdminException(AdminErrorCodes.RefreshTokenRevoked, "RefreshToken已被撤销");
|
||
}
|
||
if (storedToken.IsExpired)
|
||
{
|
||
_logger.LogWarning("Token刷新失败:Token已过期,用户ID: {UserId}", storedToken.AdminUserId);
|
||
throw new AdminException(AdminErrorCodes.RefreshTokenExpired, "RefreshToken已过期");
|
||
}
|
||
}
|
||
|
||
// 4. 检查用户状态
|
||
var user = storedToken.AdminUser;
|
||
if (user.Status == 0)
|
||
{
|
||
_logger.LogWarning("Token刷新失败:用户账户已禁用,用户ID: {UserId}", user.Id);
|
||
throw new AdminException(AdminErrorCodes.AccountDisabled, "账户已禁用");
|
||
}
|
||
|
||
// 5. 生成新的Token(Token轮换)
|
||
var expireMinutes = _configuration.GetValue<int>("Jwt:ExpireMinutes", AccessTokenExpirationMinutes);
|
||
var newAccessToken = GenerateJwtToken(user, expireMinutes);
|
||
var (newRefreshToken, newRefreshTokenEntity) = GenerateRefreshToken(user.Id, ipAddress);
|
||
|
||
// 6. 撤销旧Token并记录替换关系
|
||
storedToken.RevokedAt = DateTime.Now;
|
||
storedToken.RevokedByIp = ipAddress;
|
||
storedToken.ReplacedByToken = newRefreshTokenEntity.TokenHash;
|
||
|
||
// 7. 保存新Token
|
||
_dbContext.RefreshTokens.Add(newRefreshTokenEntity);
|
||
await _dbContext.SaveChangesAsync();
|
||
|
||
_logger.LogInformation("用户 {UserId} Token刷新成功,IP: {IpAddress}", user.Id, ipAddress);
|
||
|
||
return new RefreshTokenResponse
|
||
{
|
||
AccessToken = newAccessToken,
|
||
RefreshToken = newRefreshToken,
|
||
ExpiresIn = expireMinutes * 60
|
||
};
|
||
}
|
||
|
||
/// <inheritdoc />
|
||
public async Task RevokeTokenAsync(string refreshToken, string ipAddress)
|
||
{
|
||
// 1. 计算Token哈希
|
||
var tokenHash = HashToken(refreshToken);
|
||
|
||
// 2. 查找Token
|
||
var storedToken = await _dbContext.RefreshTokens
|
||
.FirstOrDefaultAsync(t => t.TokenHash == tokenHash);
|
||
|
||
if (storedToken == null)
|
||
{
|
||
_logger.LogWarning("Token撤销失败:Token不存在");
|
||
throw new AdminException(AdminErrorCodes.InvalidRefreshToken, "无效的RefreshToken");
|
||
}
|
||
|
||
// 3. 撤销Token
|
||
if (!storedToken.IsRevoked)
|
||
{
|
||
storedToken.RevokedAt = DateTime.Now;
|
||
storedToken.RevokedByIp = ipAddress;
|
||
await _dbContext.SaveChangesAsync();
|
||
}
|
||
|
||
_logger.LogInformation("Token已撤销,用户ID: {UserId},IP: {IpAddress}", storedToken.AdminUserId, ipAddress);
|
||
}
|
||
|
||
/// <inheritdoc />
|
||
public async Task RevokeAllTokensAsync(long adminUserId, string ipAddress)
|
||
{
|
||
// 查找用户所有活跃的Token
|
||
var activeTokens = await _dbContext.RefreshTokens
|
||
.Where(t => t.AdminUserId == adminUserId && t.RevokedAt == null)
|
||
.ToListAsync();
|
||
|
||
var now = DateTime.Now;
|
||
foreach (var token in activeTokens)
|
||
{
|
||
token.RevokedAt = now;
|
||
token.RevokedByIp = ipAddress;
|
||
}
|
||
|
||
await _dbContext.SaveChangesAsync();
|
||
|
||
_logger.LogInformation("用户 {UserId} 的所有Token已撤销(共{Count}个),IP: {IpAddress}",
|
||
adminUserId, activeTokens.Count, ipAddress);
|
||
}
|
||
|
||
/// <inheritdoc />
|
||
public async Task LogoutAsync(long adminUserId)
|
||
{
|
||
// 注意:完整的登出应该通过RevokeTokenAsync来撤销RefreshToken
|
||
// 这里保留原有逻辑,前端应该同时调用RevokeTokenAsync
|
||
_logger.LogInformation("用户 {UserId} 退出登录", adminUserId);
|
||
await Task.CompletedTask;
|
||
}
|
||
|
||
/// <inheritdoc />
|
||
public async Task<AdminUserInfo> GetCurrentUserInfoAsync(long adminUserId)
|
||
{
|
||
var user = await _dbContext.AdminUsers
|
||
.Include(u => u.Department)
|
||
.Include(u => u.AdminUserRoles)
|
||
.ThenInclude(ur => ur.Role)
|
||
.FirstOrDefaultAsync(u => u.Id == adminUserId);
|
||
|
||
if (user == null)
|
||
{
|
||
throw new AdminException(AdminErrorCodes.InvalidCredentials, "用户不存在");
|
||
}
|
||
|
||
var roles = user.AdminUserRoles
|
||
.Where(ur => ur.Role.Status == 1)
|
||
.Select(ur => ur.Role.Code)
|
||
.ToList();
|
||
|
||
var permissions = await GetUserPermissionsAsync(user.Id);
|
||
|
||
return new AdminUserInfo
|
||
{
|
||
Id = user.Id,
|
||
Username = user.Username,
|
||
RealName = user.RealName,
|
||
Avatar = user.Avatar,
|
||
Email = user.Email,
|
||
Phone = user.Phone,
|
||
DepartmentId = user.DepartmentId,
|
||
DepartmentName = user.Department?.Name,
|
||
Roles = roles,
|
||
Permissions = permissions
|
||
};
|
||
}
|
||
|
||
/// <inheritdoc />
|
||
public async Task ChangePasswordAsync(long adminUserId, ChangePasswordRequest request)
|
||
{
|
||
var user = await _dbContext.AdminUsers.FindAsync(adminUserId);
|
||
if (user == null)
|
||
{
|
||
throw new AdminException(AdminErrorCodes.InvalidCredentials, "用户不存在");
|
||
}
|
||
|
||
// 验证旧密码
|
||
if (!VerifyPassword(request.OldPassword, user.PasswordHash))
|
||
{
|
||
throw new AdminException(AdminErrorCodes.InvalidCredentials, "旧密码错误");
|
||
}
|
||
|
||
// 更新密码
|
||
user.PasswordHash = HashPassword(request.NewPassword);
|
||
user.UpdatedAt = DateTime.Now;
|
||
await _dbContext.SaveChangesAsync();
|
||
|
||
_logger.LogInformation("用户 {UserId} 修改密码成功", adminUserId);
|
||
}
|
||
|
||
/// <summary>
|
||
/// 生成 JWT Token
|
||
/// </summary>
|
||
private string GenerateJwtToken(AdminUser user, int expireMinutes)
|
||
{
|
||
var secret = _configuration["Jwt:Secret"] ?? throw new InvalidOperationException("JWT Secret not configured");
|
||
var issuer = _configuration["Jwt:Issuer"] ?? "HoneyBox.Admin";
|
||
var audience = _configuration["Jwt:Audience"] ?? "HoneyBox.Admin.Client";
|
||
|
||
var key = new SymmetricSecurityKey(Encoding.UTF8.GetBytes(secret));
|
||
var credentials = new SigningCredentials(key, SecurityAlgorithms.HmacSha256);
|
||
|
||
var claims = new List<Claim>
|
||
{
|
||
new(ClaimTypes.NameIdentifier, user.Id.ToString()),
|
||
new(ClaimTypes.Name, user.Username),
|
||
new("real_name", user.RealName ?? ""),
|
||
new(JwtRegisteredClaimNames.Jti, Guid.NewGuid().ToString())
|
||
};
|
||
|
||
// 添加角色声明
|
||
foreach (var userRole in user.AdminUserRoles.Where(ur => ur.Role.Status == 1))
|
||
{
|
||
claims.Add(new Claim(ClaimTypes.Role, userRole.Role.Code));
|
||
}
|
||
|
||
var token = new JwtSecurityToken(
|
||
issuer: issuer,
|
||
audience: audience,
|
||
claims: claims,
|
||
expires: DateTime.Now.AddMinutes(expireMinutes),
|
||
signingCredentials: credentials
|
||
);
|
||
|
||
return new JwtSecurityTokenHandler().WriteToken(token);
|
||
}
|
||
|
||
/// <summary>
|
||
/// 生成 Refresh Token
|
||
/// </summary>
|
||
/// <returns>返回原始Token和Token实体</returns>
|
||
private (string Token, RefreshToken Entity) GenerateRefreshToken(long adminUserId, string ipAddress)
|
||
{
|
||
// 生成安全随机Token
|
||
var randomBytes = new byte[64];
|
||
using var rng = RandomNumberGenerator.Create();
|
||
rng.GetBytes(randomBytes);
|
||
var token = Convert.ToBase64String(randomBytes);
|
||
|
||
// 创建Token实体
|
||
var refreshToken = new RefreshToken
|
||
{
|
||
AdminUserId = adminUserId,
|
||
TokenHash = HashToken(token),
|
||
ExpiresAt = DateTime.Now.AddDays(RefreshTokenExpirationDays),
|
||
CreatedAt = DateTime.Now,
|
||
CreatedByIp = ipAddress
|
||
};
|
||
|
||
return (token, refreshToken);
|
||
}
|
||
|
||
/// <summary>
|
||
/// 哈希Token(用于安全存储)
|
||
/// </summary>
|
||
private static string HashToken(string token)
|
||
{
|
||
using var sha256 = SHA256.Create();
|
||
var hashedBytes = sha256.ComputeHash(Encoding.UTF8.GetBytes(token));
|
||
return Convert.ToBase64String(hashedBytes);
|
||
}
|
||
|
||
/// <summary>
|
||
/// 获取用户权限列表
|
||
/// </summary>
|
||
private async Task<List<string>> GetUserPermissionsAsync(long adminUserId)
|
||
{
|
||
// 获取用户的所有角色ID
|
||
var roleIds = await _dbContext.AdminUserRoles
|
||
.Where(ur => ur.AdminUserId == adminUserId)
|
||
.Select(ur => ur.RoleId)
|
||
.ToListAsync();
|
||
|
||
// 获取角色关联的权限
|
||
var permissions = await _dbContext.RolePermissions
|
||
.Where(rp => roleIds.Contains(rp.RoleId))
|
||
.Include(rp => rp.Permission)
|
||
.Select(rp => rp.Permission.Code)
|
||
.Distinct()
|
||
.ToListAsync();
|
||
|
||
return permissions;
|
||
}
|
||
|
||
/// <summary>
|
||
/// 哈希密码
|
||
/// </summary>
|
||
public static string HashPassword(string password)
|
||
{
|
||
using var sha256 = SHA256.Create();
|
||
var hashedBytes = sha256.ComputeHash(Encoding.UTF8.GetBytes(password));
|
||
return Convert.ToBase64String(hashedBytes);
|
||
}
|
||
|
||
/// <summary>
|
||
/// 验证密码
|
||
/// </summary>
|
||
private static bool VerifyPassword(string password, string passwordHash)
|
||
{
|
||
var hash = HashPassword(password);
|
||
return hash == passwordHash;
|
||
}
|
||
}
|