996 lines
36 KiB
C#
996 lines
36 KiB
C#
using System.Security.Cryptography;
|
||
using System.Text;
|
||
using HoneyBox.Core.Interfaces;
|
||
using HoneyBox.Model.Data;
|
||
using HoneyBox.Model.Entities;
|
||
using HoneyBox.Model.Models.Auth;
|
||
using Microsoft.EntityFrameworkCore;
|
||
using Microsoft.Extensions.Logging;
|
||
|
||
namespace HoneyBox.Core.Services;
|
||
|
||
/// <summary>
|
||
/// 认证服务实现
|
||
/// </summary>
|
||
public class AuthService : IAuthService
|
||
{
|
||
private readonly HoneyBoxDbContext _dbContext;
|
||
private readonly IUserService _userService;
|
||
private readonly IJwtService _jwtService;
|
||
private readonly IWechatService _wechatService;
|
||
private readonly IIpLocationService _ipLocationService;
|
||
private readonly IRedisService _redisService;
|
||
private readonly JwtSettings _jwtSettings;
|
||
private readonly ILogger<AuthService> _logger;
|
||
|
||
// Redis key prefixes
|
||
private const string LoginDebounceKeyPrefix = "login:debounce:";
|
||
private const string SmsCodeKeyPrefix = "sms:code:";
|
||
private const int DebounceSeconds = 3;
|
||
|
||
// Refresh Token 配置
|
||
private const int RefreshTokenLength = 64;
|
||
|
||
public AuthService(
|
||
HoneyBoxDbContext dbContext,
|
||
IUserService userService,
|
||
IJwtService jwtService,
|
||
IWechatService wechatService,
|
||
IIpLocationService ipLocationService,
|
||
IRedisService redisService,
|
||
JwtSettings jwtSettings,
|
||
ILogger<AuthService> logger)
|
||
{
|
||
_dbContext = dbContext ?? throw new ArgumentNullException(nameof(dbContext));
|
||
_userService = userService ?? throw new ArgumentNullException(nameof(userService));
|
||
_jwtService = jwtService ?? throw new ArgumentNullException(nameof(jwtService));
|
||
_wechatService = wechatService ?? throw new ArgumentNullException(nameof(wechatService));
|
||
_ipLocationService = ipLocationService ?? throw new ArgumentNullException(nameof(ipLocationService));
|
||
_redisService = redisService ?? throw new ArgumentNullException(nameof(redisService));
|
||
_jwtSettings = jwtSettings ?? throw new ArgumentNullException(nameof(jwtSettings));
|
||
_logger = logger ?? throw new ArgumentNullException(nameof(logger));
|
||
}
|
||
|
||
|
||
/// <summary>
|
||
/// 微信小程序登录
|
||
/// Requirements: 1.1-1.8
|
||
/// </summary>
|
||
public async Task<LoginResult> WechatMiniProgramLoginAsync(string code, int? pid, string? clickId)
|
||
{
|
||
_logger.LogInformation("[AuthService] 微信登录开始,code={Code}, pid={Pid}, clickId={ClickId}", code, pid, clickId);
|
||
|
||
if (string.IsNullOrWhiteSpace(code))
|
||
{
|
||
_logger.LogWarning("[AuthService] 微信登录失败:code为空");
|
||
return new LoginResult
|
||
{
|
||
Success = false,
|
||
ErrorMessage = "授权code不能为空"
|
||
};
|
||
}
|
||
|
||
try
|
||
{
|
||
// 1.6 防抖机制 - 3秒内不允许重复登录
|
||
var debounceKey = $"{LoginDebounceKeyPrefix}wechat:{code}";
|
||
_logger.LogInformation("[AuthService] 检查防抖锁: {Key}", debounceKey);
|
||
var lockAcquired = await _redisService.TryAcquireLockAsync(debounceKey, "1", TimeSpan.FromSeconds(DebounceSeconds));
|
||
if (!lockAcquired)
|
||
{
|
||
_logger.LogWarning("[AuthService] 防抖触发,拒绝重复登录请求: {Code}", code);
|
||
return new LoginResult
|
||
{
|
||
Success = false,
|
||
ErrorMessage = "请勿频繁登录"
|
||
};
|
||
}
|
||
_logger.LogInformation("[AuthService] 防抖锁获取成功");
|
||
|
||
// 1.1 调用微信API获取openid和unionid
|
||
_logger.LogInformation("[AuthService] 开始调用微信API获取openid...");
|
||
var wechatResult = await _wechatService.GetOpenIdAsync(code);
|
||
_logger.LogInformation("[AuthService] 微信API调用完成,Success={Success}, OpenId={OpenId}, UnionId={UnionId}, Error={Error}",
|
||
wechatResult.Success,
|
||
wechatResult.OpenId ?? "null",
|
||
wechatResult.UnionId ?? "null",
|
||
wechatResult.ErrorMessage ?? "null");
|
||
|
||
if (!wechatResult.Success)
|
||
{
|
||
_logger.LogWarning("[AuthService] 微信API调用失败: {Error}", wechatResult.ErrorMessage);
|
||
return new LoginResult
|
||
{
|
||
Success = false,
|
||
ErrorMessage = wechatResult.ErrorMessage ?? "登录失败,请稍后重试"
|
||
};
|
||
}
|
||
|
||
var openId = wechatResult.OpenId!;
|
||
var unionId = wechatResult.UnionId;
|
||
|
||
// 1.2 查找用户 - 优先通过unionid查找,其次通过openid查找
|
||
User? user = null;
|
||
if (!string.IsNullOrWhiteSpace(unionId))
|
||
{
|
||
_logger.LogInformation("[AuthService] 尝试通过unionid查找用户: {UnionId}", unionId);
|
||
user = await _userService.GetUserByUnionIdAsync(unionId);
|
||
_logger.LogInformation("[AuthService] unionid查找结果: {Found}", user != null ? $"找到用户ID={user.Id}" : "未找到");
|
||
}
|
||
if (user == null)
|
||
{
|
||
_logger.LogInformation("[AuthService] 尝试通过openid查找用户: {OpenId}", openId);
|
||
user = await _userService.GetUserByOpenIdAsync(openId);
|
||
_logger.LogInformation("[AuthService] openid查找结果: {Found}", user != null ? $"找到用户ID={user.Id}" : "未找到");
|
||
}
|
||
|
||
if (user == null)
|
||
{
|
||
// 1.3 用户不存在,创建新用户
|
||
_logger.LogInformation("[AuthService] 用户不存在,开始创建新用户...");
|
||
var createDto = new CreateUserDto
|
||
{
|
||
OpenId = openId,
|
||
UnionId = unionId,
|
||
Nickname = $"用户{Random.Shared.Next(100000, 999999)}",
|
||
Headimg = GenerateDefaultAvatar(openId),
|
||
Pid = pid ?? 0,
|
||
ClickId = ParseClickId(clickId)
|
||
};
|
||
|
||
user = await _userService.CreateUserAsync(createDto);
|
||
_logger.LogInformation("[AuthService] 新用户创建成功: UserId={UserId}, OpenId={OpenId}", user.Id, openId);
|
||
}
|
||
else
|
||
{
|
||
// 1.4 用户存在,更新unionid(如果之前为空)
|
||
if (string.IsNullOrWhiteSpace(user.UnionId) && !string.IsNullOrWhiteSpace(unionId))
|
||
{
|
||
_logger.LogInformation("[AuthService] 更新用户unionid: UserId={UserId}", user.Id);
|
||
await _userService.UpdateUserAsync(user.Id, new UpdateUserDto { UnionId = unionId });
|
||
_logger.LogInformation("[AuthService] unionid更新成功");
|
||
}
|
||
}
|
||
|
||
// 1.5 生成双 Token(Access Token + Refresh Token)
|
||
_logger.LogInformation("[AuthService] 开始生成双 Token: UserId={UserId}", user.Id);
|
||
var loginResponse = await GenerateLoginResponseAsync(user, null);
|
||
_logger.LogInformation("[AuthService] 双 Token 生成成功,AccessToken长度={Length}", loginResponse.AccessToken?.Length ?? 0);
|
||
|
||
// 3.6 同时在数据库UserAccount表中存储account_token用于兼容旧系统
|
||
_logger.LogInformation("[AuthService] 更新UserAccount表...");
|
||
await CreateOrUpdateAccountTokenAsync(user.Id, loginResponse.AccessToken);
|
||
_logger.LogInformation("[AuthService] UserAccount更新成功");
|
||
|
||
_logger.LogInformation("[AuthService] 微信登录成功: UserId={UserId}", user.Id);
|
||
|
||
return new LoginResult
|
||
{
|
||
Success = true,
|
||
Token = loginResponse.AccessToken, // 兼容旧版
|
||
UserId = user.Id,
|
||
LoginResponse = loginResponse
|
||
};
|
||
}
|
||
catch (Exception ex)
|
||
{
|
||
_logger.LogError(ex, "[AuthService] 微信登录异常: code={Code}, Message={Message}, StackTrace={StackTrace}",
|
||
code, ex.Message, ex.StackTrace);
|
||
return new LoginResult
|
||
{
|
||
Success = false,
|
||
ErrorMessage = "网络故障,请稍后再试"
|
||
};
|
||
}
|
||
}
|
||
|
||
|
||
/// <summary>
|
||
/// 手机号验证码登录
|
||
/// Requirements: 2.1-2.7
|
||
/// </summary>
|
||
public async Task<LoginResult> MobileLoginAsync(string mobile, string code, int? pid, string? clickId)
|
||
{
|
||
if (string.IsNullOrWhiteSpace(mobile))
|
||
{
|
||
return new LoginResult
|
||
{
|
||
Success = false,
|
||
ErrorMessage = "手机号不能为空"
|
||
};
|
||
}
|
||
|
||
if (string.IsNullOrWhiteSpace(code))
|
||
{
|
||
return new LoginResult
|
||
{
|
||
Success = false,
|
||
ErrorMessage = "验证码不能为空"
|
||
};
|
||
}
|
||
|
||
try
|
||
{
|
||
// 2.6 防抖机制 - 3秒内不允许重复登录
|
||
var debounceKey = $"{LoginDebounceKeyPrefix}mobile:{mobile}";
|
||
var lockAcquired = await _redisService.TryAcquireLockAsync(debounceKey, "1", TimeSpan.FromSeconds(DebounceSeconds));
|
||
if (!lockAcquired)
|
||
{
|
||
_logger.LogWarning("Login debounce triggered for mobile: {Mobile}", MaskMobile(mobile));
|
||
return new LoginResult
|
||
{
|
||
Success = false,
|
||
ErrorMessage = "请勿频繁登录"
|
||
};
|
||
}
|
||
|
||
// 2.1 从Redis获取并验证验证码
|
||
var smsCodeKey = $"{SmsCodeKeyPrefix}{mobile}";
|
||
var storedCode = await _redisService.GetStringAsync(smsCodeKey);
|
||
|
||
if (string.IsNullOrWhiteSpace(storedCode) || storedCode != code)
|
||
{
|
||
_logger.LogWarning("SMS code verification failed for mobile: {Mobile}", MaskMobile(mobile));
|
||
return new LoginResult
|
||
{
|
||
Success = false,
|
||
ErrorMessage = "验证码错误"
|
||
};
|
||
}
|
||
|
||
// 2.2 验证码验证通过后,删除Redis中的验证码
|
||
await _redisService.DeleteAsync(smsCodeKey);
|
||
|
||
// 查找用户
|
||
var user = await _userService.GetUserByMobileAsync(mobile);
|
||
|
||
if (user == null)
|
||
{
|
||
// 2.3 用户不存在,创建新用户
|
||
var createDto = new CreateUserDto
|
||
{
|
||
Mobile = mobile,
|
||
Nickname = $"用户{Random.Shared.Next(100000, 999999)}",
|
||
Headimg = GenerateDefaultAvatar(mobile),
|
||
Pid = pid ?? 0,
|
||
ClickId = ParseClickId(clickId)
|
||
};
|
||
|
||
user = await _userService.CreateUserAsync(createDto);
|
||
_logger.LogInformation("New user created via mobile login: UserId={UserId}, Mobile={Mobile}", user.Id, MaskMobile(mobile));
|
||
}
|
||
|
||
// 2.4 生成双 Token(Access Token + Refresh Token)
|
||
var loginResponse = await GenerateLoginResponseAsync(user, null);
|
||
|
||
// 3.6 同时在数据库UserAccount表中存储account_token用于兼容旧系统
|
||
await CreateOrUpdateAccountTokenAsync(user.Id, loginResponse.AccessToken);
|
||
|
||
_logger.LogInformation("Mobile login successful: UserId={UserId}", user.Id);
|
||
|
||
return new LoginResult
|
||
{
|
||
Success = true,
|
||
Token = loginResponse.AccessToken, // 兼容旧版
|
||
UserId = user.Id,
|
||
LoginResponse = loginResponse
|
||
};
|
||
}
|
||
catch (Exception ex)
|
||
{
|
||
_logger.LogError(ex, "Mobile login failed for mobile: {Mobile}", MaskMobile(mobile));
|
||
return new LoginResult
|
||
{
|
||
Success = false,
|
||
ErrorMessage = "网络故障,请稍后再试"
|
||
};
|
||
}
|
||
}
|
||
|
||
|
||
/// <summary>
|
||
/// 验证码绑定手机号
|
||
/// Requirements: 5.1-5.5
|
||
/// </summary>
|
||
public async Task<BindMobileResponse> BindMobileAsync(int userId, string mobile, string code)
|
||
{
|
||
if (string.IsNullOrWhiteSpace(mobile))
|
||
{
|
||
throw new ArgumentException("手机号不能为空", nameof(mobile));
|
||
}
|
||
|
||
if (string.IsNullOrWhiteSpace(code))
|
||
{
|
||
throw new ArgumentException("验证码不能为空", nameof(code));
|
||
}
|
||
|
||
// 5.1 验证短信验证码
|
||
var smsCodeKey = $"{SmsCodeKeyPrefix}{mobile}";
|
||
var storedCode = await _redisService.GetStringAsync(smsCodeKey);
|
||
|
||
if (string.IsNullOrWhiteSpace(storedCode) || storedCode != code)
|
||
{
|
||
_logger.LogWarning("SMS code verification failed for bind mobile: UserId={UserId}, Mobile={Mobile}", userId, MaskMobile(mobile));
|
||
throw new InvalidOperationException("验证码错误");
|
||
}
|
||
|
||
// 验证码验证通过后删除
|
||
await _redisService.DeleteAsync(smsCodeKey);
|
||
|
||
// 获取当前用户
|
||
var currentUser = await _userService.GetUserByIdAsync(userId);
|
||
if (currentUser == null)
|
||
{
|
||
throw new InvalidOperationException("用户不存在");
|
||
}
|
||
|
||
// 检查手机号是否已被其他用户绑定
|
||
var existingUser = await _userService.GetUserByMobileAsync(mobile);
|
||
|
||
if (existingUser != null && existingUser.Id != userId)
|
||
{
|
||
// 5.2 手机号已被其他用户绑定,需要合并账户
|
||
return await MergeAccountsAsync(currentUser, existingUser);
|
||
}
|
||
|
||
// 5.4 手机号未被绑定,直接更新当前用户的手机号
|
||
await _userService.UpdateUserAsync(userId, new UpdateUserDto { Mobile = mobile });
|
||
_logger.LogInformation("Mobile bound successfully: UserId={UserId}, Mobile={Mobile}", userId, MaskMobile(mobile));
|
||
|
||
return new BindMobileResponse { Token = null };
|
||
}
|
||
|
||
/// <summary>
|
||
/// 微信授权绑定手机号
|
||
/// Requirements: 5.1-5.5
|
||
/// </summary>
|
||
public async Task<BindMobileResponse> WechatBindMobileAsync(int userId, string wechatCode)
|
||
{
|
||
if (string.IsNullOrWhiteSpace(wechatCode))
|
||
{
|
||
throw new ArgumentException("微信授权code不能为空", nameof(wechatCode));
|
||
}
|
||
|
||
// 调用微信API获取手机号
|
||
var mobileResult = await _wechatService.GetMobileAsync(wechatCode);
|
||
if (!mobileResult.Success || string.IsNullOrWhiteSpace(mobileResult.Mobile))
|
||
{
|
||
_logger.LogWarning("WeChat get mobile failed: UserId={UserId}, Error={Error}", userId, mobileResult.ErrorMessage);
|
||
throw new InvalidOperationException(mobileResult.ErrorMessage ?? "获取手机号失败");
|
||
}
|
||
|
||
var mobile = mobileResult.Mobile;
|
||
|
||
// 获取当前用户
|
||
var currentUser = await _userService.GetUserByIdAsync(userId);
|
||
if (currentUser == null)
|
||
{
|
||
throw new InvalidOperationException("用户不存在");
|
||
}
|
||
|
||
// 检查手机号是否已被其他用户绑定
|
||
var existingUser = await _userService.GetUserByMobileAsync(mobile);
|
||
|
||
if (existingUser != null && existingUser.Id != userId)
|
||
{
|
||
// 5.2 手机号已被其他用户绑定,需要合并账户
|
||
return await MergeAccountsAsync(currentUser, existingUser);
|
||
}
|
||
|
||
// 5.4 手机号未被绑定,直接更新当前用户的手机号
|
||
await _userService.UpdateUserAsync(userId, new UpdateUserDto { Mobile = mobile });
|
||
_logger.LogInformation("Mobile bound via WeChat successfully: UserId={UserId}, Mobile={Mobile}", userId, MaskMobile(mobile));
|
||
|
||
return new BindMobileResponse { Token = null };
|
||
}
|
||
|
||
|
||
/// <summary>
|
||
/// 记录登录信息
|
||
/// Requirements: 6.1, 6.3, 6.4
|
||
/// </summary>
|
||
public async Task<RecordLoginResponse> RecordLoginAsync(int userId, string? device, string? deviceInfo)
|
||
{
|
||
var user = await _userService.GetUserByIdAsync(userId);
|
||
if (user == null)
|
||
{
|
||
throw new InvalidOperationException("用户不存在");
|
||
}
|
||
|
||
try
|
||
{
|
||
// 获取客户端IP(这里使用空字符串作为占位符,实际IP应从Controller传入)
|
||
var clientIp = deviceInfo ?? string.Empty;
|
||
|
||
// 6.2 解析IP地址获取地理位置
|
||
IpLocationResult? locationResult = null;
|
||
if (!string.IsNullOrWhiteSpace(clientIp))
|
||
{
|
||
locationResult = await _ipLocationService.GetLocationAsync(clientIp);
|
||
}
|
||
|
||
var now = DateTime.UtcNow;
|
||
var today = DateOnly.FromDateTime(now);
|
||
|
||
// 6.1 记录登录日志
|
||
var loginLog = new UserLoginLog
|
||
{
|
||
UserId = userId,
|
||
LoginDate = today,
|
||
LoginTime = now,
|
||
LastLoginTime = now,
|
||
Device = device,
|
||
Ip = clientIp,
|
||
Location = locationResult?.Success == true
|
||
? $"{locationResult.Province}{locationResult.City}"
|
||
: null,
|
||
Year = now.Year,
|
||
Month = now.Month,
|
||
Week = GetWeekOfYear(now)
|
||
};
|
||
|
||
await _dbContext.UserLoginLogs.AddAsync(loginLog);
|
||
|
||
// 6.3 更新UserAccount表中的最后登录时间和IP信息
|
||
var userAccount = await _dbContext.UserAccounts.FirstOrDefaultAsync(ua => ua.UserId == userId);
|
||
if (userAccount != null)
|
||
{
|
||
userAccount.LastLoginTime = now;
|
||
userAccount.LastLoginIp = clientIp;
|
||
if (locationResult?.Success == true)
|
||
{
|
||
userAccount.IpProvince = locationResult.Province;
|
||
userAccount.IpCity = locationResult.City;
|
||
userAccount.IpAdcode = locationResult.Adcode;
|
||
}
|
||
_dbContext.UserAccounts.Update(userAccount);
|
||
}
|
||
|
||
// 更新用户最后登录时间
|
||
user.LastLoginTime = now;
|
||
_dbContext.Users.Update(user);
|
||
|
||
await _dbContext.SaveChangesAsync();
|
||
|
||
_logger.LogInformation("Login recorded: UserId={UserId}, Device={Device}, IP={IP}", userId, device, clientIp);
|
||
|
||
// 6.4 返回用户的uid、昵称和头像
|
||
return new RecordLoginResponse
|
||
{
|
||
Uid = user.Uid,
|
||
Nickname = user.Nickname,
|
||
Headimg = user.HeadImg
|
||
};
|
||
}
|
||
catch (Exception ex)
|
||
{
|
||
_logger.LogError(ex, "Failed to record login: UserId={UserId}", userId);
|
||
throw;
|
||
}
|
||
}
|
||
|
||
/// <summary>
|
||
/// H5绑定手机号(无需验证码)
|
||
/// Requirements: 13.1
|
||
/// </summary>
|
||
public async Task<BindMobileResponse> BindMobileH5Async(int userId, string mobile)
|
||
{
|
||
if (string.IsNullOrWhiteSpace(mobile))
|
||
{
|
||
throw new ArgumentException("手机号不能为空", nameof(mobile));
|
||
}
|
||
|
||
// 获取当前用户
|
||
var currentUser = await _userService.GetUserByIdAsync(userId);
|
||
if (currentUser == null)
|
||
{
|
||
throw new InvalidOperationException("用户不存在");
|
||
}
|
||
|
||
// 检查手机号是否已被其他用户绑定
|
||
var existingUser = await _userService.GetUserByMobileAsync(mobile);
|
||
|
||
if (existingUser != null && existingUser.Id != userId)
|
||
{
|
||
// 手机号已被其他用户绑定,需要合并账户
|
||
return await MergeAccountsAsync(currentUser, existingUser);
|
||
}
|
||
|
||
// 手机号未被绑定,直接更新当前用户的手机号
|
||
await _userService.UpdateUserAsync(userId, new UpdateUserDto { Mobile = mobile });
|
||
_logger.LogInformation("H5 Mobile bound successfully: UserId={UserId}, Mobile={Mobile}", userId, MaskMobile(mobile));
|
||
|
||
return new BindMobileResponse { Token = null };
|
||
}
|
||
|
||
/// <summary>
|
||
/// 账号注销
|
||
/// Requirements: 7.1-7.3
|
||
/// </summary>
|
||
public async Task LogOffAsync(int userId, int type)
|
||
{
|
||
var user = await _userService.GetUserByIdAsync(userId);
|
||
if (user == null)
|
||
{
|
||
throw new InvalidOperationException("用户不存在");
|
||
}
|
||
|
||
try
|
||
{
|
||
// 7.1 记录注销请求日志
|
||
var action = type == 0 ? "注销账号" : "取消注销";
|
||
_logger.LogInformation("User log off request: UserId={UserId}, Type={Type}, Action={Action}", userId, type, action);
|
||
|
||
// 这里可以添加更多的注销逻辑,比如:
|
||
// - 将用户状态设置为已注销
|
||
// - 清理用户相关的缓存
|
||
// - 发送通知等
|
||
|
||
if (type == 0)
|
||
{
|
||
// 注销账号 - 可以设置用户状态为禁用
|
||
user.Status = 0;
|
||
_dbContext.Users.Update(user);
|
||
await _dbContext.SaveChangesAsync();
|
||
_logger.LogInformation("User account deactivated: UserId={UserId}", userId);
|
||
}
|
||
else if (type == 1)
|
||
{
|
||
// 取消注销 - 恢复用户状态
|
||
user.Status = 1;
|
||
_dbContext.Users.Update(user);
|
||
await _dbContext.SaveChangesAsync();
|
||
_logger.LogInformation("User account reactivated: UserId={UserId}", userId);
|
||
}
|
||
|
||
// 7.2 返回注销成功的消息(通过不抛出异常来表示成功)
|
||
}
|
||
catch (Exception ex)
|
||
{
|
||
_logger.LogError(ex, "Failed to process log off: UserId={UserId}, Type={Type}", userId, type);
|
||
throw;
|
||
}
|
||
}
|
||
|
||
|
||
#region Refresh Token Methods
|
||
|
||
/// <summary>
|
||
/// 生成 Refresh Token 并存储到数据库
|
||
/// Requirements: 1.4, 1.5, 4.1
|
||
/// </summary>
|
||
/// <param name="userId">用户ID</param>
|
||
/// <param name="ipAddress">客户端 IP 地址</param>
|
||
/// <returns>生成的 Refresh Token 明文</returns>
|
||
private async Task<string> GenerateRefreshTokenAsync(int userId, string? ipAddress)
|
||
{
|
||
// 生成随机 Refresh Token
|
||
var refreshToken = GenerateSecureRandomString(RefreshTokenLength);
|
||
|
||
// 计算 SHA256 哈希值用于存储
|
||
var tokenHash = ComputeSha256Hash(refreshToken);
|
||
|
||
// 计算过期时间(7天)
|
||
var expiresAt = DateTime.Now.AddDays(_jwtSettings.RefreshTokenExpirationDays);
|
||
|
||
// 创建数据库记录
|
||
var userRefreshToken = new UserRefreshToken
|
||
{
|
||
UserId = userId,
|
||
TokenHash = tokenHash,
|
||
ExpiresAt = expiresAt,
|
||
CreatedAt = DateTime.Now,
|
||
CreatedByIp = ipAddress
|
||
};
|
||
|
||
await _dbContext.UserRefreshTokens.AddAsync(userRefreshToken);
|
||
await _dbContext.SaveChangesAsync();
|
||
|
||
_logger.LogInformation("Generated refresh token for user {UserId}, expires at {ExpiresAt}", userId, expiresAt);
|
||
|
||
return refreshToken;
|
||
}
|
||
|
||
/// <summary>
|
||
/// 生成登录响应(包含双 Token)
|
||
/// Requirements: 1.1, 1.2, 1.3, 1.4, 1.5
|
||
/// </summary>
|
||
/// <param name="user">用户实体</param>
|
||
/// <param name="ipAddress">客户端 IP 地址</param>
|
||
/// <returns>登录响应</returns>
|
||
private async Task<LoginResponse> GenerateLoginResponseAsync(User user, string? ipAddress)
|
||
{
|
||
// 生成 Access Token (JWT)
|
||
var accessToken = _jwtService.GenerateToken(user);
|
||
|
||
// 生成 Refresh Token 并存储
|
||
var refreshToken = await GenerateRefreshTokenAsync(user.Id, ipAddress);
|
||
|
||
// 计算 Access Token 过期时间(秒)
|
||
var expiresIn = _jwtSettings.ExpirationMinutes * 60;
|
||
|
||
return new LoginResponse
|
||
{
|
||
AccessToken = accessToken,
|
||
RefreshToken = refreshToken,
|
||
ExpiresIn = expiresIn,
|
||
UserId = user.Id
|
||
};
|
||
}
|
||
|
||
/// <summary>
|
||
/// 刷新 Token
|
||
/// Requirements: 2.1-2.6
|
||
/// </summary>
|
||
public async Task<RefreshTokenResult> RefreshTokenAsync(string refreshToken, string? ipAddress)
|
||
{
|
||
if (string.IsNullOrWhiteSpace(refreshToken))
|
||
{
|
||
_logger.LogWarning("Refresh token is empty");
|
||
return RefreshTokenResult.Fail("刷新令牌不能为空");
|
||
}
|
||
|
||
try
|
||
{
|
||
// 计算 Token 哈希值
|
||
var tokenHash = ComputeSha256Hash(refreshToken);
|
||
|
||
// 查找 Token 记录
|
||
var storedToken = await _dbContext.UserRefreshTokens
|
||
.Include(t => t.User)
|
||
.FirstOrDefaultAsync(t => t.TokenHash == tokenHash);
|
||
|
||
if (storedToken == null)
|
||
{
|
||
_logger.LogWarning("Refresh token not found: {TokenHash}", tokenHash.Substring(0, 8) + "...");
|
||
return RefreshTokenResult.Fail("无效的刷新令牌");
|
||
}
|
||
|
||
// 检查是否已过期
|
||
if (storedToken.IsExpired)
|
||
{
|
||
_logger.LogWarning("Refresh token expired for user {UserId}", storedToken.UserId);
|
||
return RefreshTokenResult.Fail("刷新令牌已过期");
|
||
}
|
||
|
||
// 检查是否已撤销
|
||
if (storedToken.IsRevoked)
|
||
{
|
||
_logger.LogWarning("Refresh token revoked for user {UserId}", storedToken.UserId);
|
||
return RefreshTokenResult.Fail("刷新令牌已失效");
|
||
}
|
||
|
||
// 检查用户是否存在且有效
|
||
var user = storedToken.User;
|
||
if (user == null)
|
||
{
|
||
_logger.LogWarning("User not found for refresh token");
|
||
return RefreshTokenResult.Fail("用户不存在");
|
||
}
|
||
|
||
if (user.Status == 0)
|
||
{
|
||
_logger.LogWarning("User {UserId} is disabled", user.Id);
|
||
return RefreshTokenResult.Fail("账号已被禁用");
|
||
}
|
||
|
||
// Token 轮换:生成新的 Refresh Token
|
||
var newRefreshToken = GenerateSecureRandomString(RefreshTokenLength);
|
||
var newTokenHash = ComputeSha256Hash(newRefreshToken);
|
||
|
||
// 撤销旧 Token 并记录关联关系
|
||
storedToken.RevokedAt = DateTime.Now;
|
||
storedToken.RevokedByIp = ipAddress;
|
||
storedToken.ReplacedByToken = newTokenHash;
|
||
|
||
// 创建新的 Token 记录
|
||
var newUserRefreshToken = new UserRefreshToken
|
||
{
|
||
UserId = user.Id,
|
||
TokenHash = newTokenHash,
|
||
ExpiresAt = DateTime.Now.AddDays(_jwtSettings.RefreshTokenExpirationDays),
|
||
CreatedAt = DateTime.Now,
|
||
CreatedByIp = ipAddress
|
||
};
|
||
|
||
await _dbContext.UserRefreshTokens.AddAsync(newUserRefreshToken);
|
||
await _dbContext.SaveChangesAsync();
|
||
|
||
// 生成新的 Access Token
|
||
var accessToken = _jwtService.GenerateToken(user);
|
||
var expiresIn = _jwtSettings.ExpirationMinutes * 60;
|
||
|
||
// 更新 UserAccount 表中的 token(兼容旧系统)
|
||
await CreateOrUpdateAccountTokenAsync(user.Id, accessToken);
|
||
|
||
_logger.LogInformation("Token refreshed successfully for user {UserId}", user.Id);
|
||
|
||
return RefreshTokenResult.Ok(new LoginResponse
|
||
{
|
||
AccessToken = accessToken,
|
||
RefreshToken = newRefreshToken,
|
||
ExpiresIn = expiresIn,
|
||
UserId = user.Id
|
||
});
|
||
}
|
||
catch (Exception ex)
|
||
{
|
||
_logger.LogError(ex, "Error refreshing token");
|
||
return RefreshTokenResult.Fail("刷新令牌失败,请稍后重试");
|
||
}
|
||
}
|
||
|
||
/// <summary>
|
||
/// 撤销 Token
|
||
/// Requirements: 4.4
|
||
/// </summary>
|
||
public async Task RevokeTokenAsync(string refreshToken, string? ipAddress)
|
||
{
|
||
if (string.IsNullOrWhiteSpace(refreshToken))
|
||
{
|
||
_logger.LogWarning("Cannot revoke empty refresh token");
|
||
return;
|
||
}
|
||
|
||
try
|
||
{
|
||
// 计算 Token 哈希值
|
||
var tokenHash = ComputeSha256Hash(refreshToken);
|
||
|
||
// 查找 Token 记录
|
||
var storedToken = await _dbContext.UserRefreshTokens
|
||
.FirstOrDefaultAsync(t => t.TokenHash == tokenHash);
|
||
|
||
if (storedToken == null)
|
||
{
|
||
_logger.LogWarning("Refresh token not found for revocation");
|
||
return;
|
||
}
|
||
|
||
// 如果已经撤销,直接返回
|
||
if (storedToken.IsRevoked)
|
||
{
|
||
_logger.LogInformation("Refresh token already revoked");
|
||
return;
|
||
}
|
||
|
||
// 撤销 Token
|
||
storedToken.RevokedAt = DateTime.Now;
|
||
storedToken.RevokedByIp = ipAddress;
|
||
|
||
await _dbContext.SaveChangesAsync();
|
||
|
||
_logger.LogInformation("Refresh token revoked for user {UserId}", storedToken.UserId);
|
||
}
|
||
catch (Exception ex)
|
||
{
|
||
_logger.LogError(ex, "Error revoking refresh token");
|
||
throw;
|
||
}
|
||
}
|
||
|
||
/// <summary>
|
||
/// 撤销用户的所有 Token
|
||
/// Requirements: 4.4
|
||
/// </summary>
|
||
public async Task RevokeAllUserTokensAsync(int userId, string? ipAddress)
|
||
{
|
||
try
|
||
{
|
||
// 查找用户所有有效的 Token
|
||
var activeTokens = await _dbContext.UserRefreshTokens
|
||
.Where(t => t.UserId == userId && t.RevokedAt == null)
|
||
.ToListAsync();
|
||
|
||
if (!activeTokens.Any())
|
||
{
|
||
_logger.LogInformation("No active tokens found for user {UserId}", userId);
|
||
return;
|
||
}
|
||
|
||
var now = DateTime.Now;
|
||
foreach (var token in activeTokens)
|
||
{
|
||
token.RevokedAt = now;
|
||
token.RevokedByIp = ipAddress;
|
||
}
|
||
|
||
await _dbContext.SaveChangesAsync();
|
||
|
||
_logger.LogInformation("Revoked {Count} tokens for user {UserId}", activeTokens.Count, userId);
|
||
}
|
||
catch (Exception ex)
|
||
{
|
||
_logger.LogError(ex, "Error revoking all tokens for user {UserId}", userId);
|
||
throw;
|
||
}
|
||
}
|
||
|
||
#endregion
|
||
|
||
#region Private Helper Methods
|
||
|
||
/// <summary>
|
||
/// 合并账户 - 将当前用户的openid迁移到手机号用户
|
||
/// </summary>
|
||
private async Task<BindMobileResponse> MergeAccountsAsync(User currentUser, User mobileUser)
|
||
{
|
||
using var transaction = await _dbContext.Database.BeginTransactionAsync();
|
||
try
|
||
{
|
||
_logger.LogInformation("Merging accounts: CurrentUserId={CurrentUserId}, MobileUserId={MobileUserId}",
|
||
currentUser.Id, mobileUser.Id);
|
||
|
||
// 5.2 将当前用户的openid迁移到手机号用户
|
||
if (!string.IsNullOrWhiteSpace(currentUser.OpenId))
|
||
{
|
||
mobileUser.OpenId = currentUser.OpenId;
|
||
}
|
||
if (!string.IsNullOrWhiteSpace(currentUser.UnionId))
|
||
{
|
||
mobileUser.UnionId = currentUser.UnionId;
|
||
}
|
||
mobileUser.UpdatedAt = DateTime.UtcNow;
|
||
_dbContext.Users.Update(mobileUser);
|
||
|
||
// 删除当前用户的账户记录
|
||
var currentUserAccount = await _dbContext.UserAccounts.FirstOrDefaultAsync(ua => ua.UserId == currentUser.Id);
|
||
if (currentUserAccount != null)
|
||
{
|
||
_dbContext.UserAccounts.Remove(currentUserAccount);
|
||
}
|
||
|
||
// 删除当前用户
|
||
_dbContext.Users.Remove(currentUser);
|
||
|
||
await _dbContext.SaveChangesAsync();
|
||
await transaction.CommitAsync();
|
||
|
||
// 5.3 生成新的token
|
||
var newToken = _jwtService.GenerateToken(mobileUser);
|
||
await CreateOrUpdateAccountTokenAsync(mobileUser.Id, newToken);
|
||
|
||
_logger.LogInformation("Accounts merged successfully: NewUserId={NewUserId}", mobileUser.Id);
|
||
|
||
return new BindMobileResponse { Token = newToken };
|
||
}
|
||
catch (Exception ex)
|
||
{
|
||
await transaction.RollbackAsync();
|
||
_logger.LogError(ex, "Failed to merge accounts: CurrentUserId={CurrentUserId}, MobileUserId={MobileUserId}",
|
||
currentUser.Id, mobileUser.Id);
|
||
throw;
|
||
}
|
||
}
|
||
|
||
/// <summary>
|
||
/// 创建或更新账户Token(用于兼容旧系统)
|
||
/// </summary>
|
||
private async Task CreateOrUpdateAccountTokenAsync(int userId, string jwtToken)
|
||
{
|
||
var tokenNum = GenerateRandomString(10);
|
||
var time = DateTimeOffset.UtcNow.ToUnixTimeSeconds();
|
||
var accountToken = ComputeMd5($"{userId}{tokenNum}{time}");
|
||
|
||
var userAccount = await _dbContext.UserAccounts.FirstOrDefaultAsync(ua => ua.UserId == userId);
|
||
|
||
if (userAccount == null)
|
||
{
|
||
userAccount = new UserAccount
|
||
{
|
||
UserId = userId,
|
||
AccountToken = accountToken,
|
||
TokenNum = tokenNum,
|
||
TokenTime = DateTime.UtcNow,
|
||
LastLoginTime = DateTime.UtcNow,
|
||
LastLoginIp = string.Empty
|
||
};
|
||
await _dbContext.UserAccounts.AddAsync(userAccount);
|
||
}
|
||
else
|
||
{
|
||
userAccount.AccountToken = accountToken;
|
||
userAccount.TokenNum = tokenNum;
|
||
userAccount.TokenTime = DateTime.UtcNow;
|
||
userAccount.LastLoginTime = DateTime.UtcNow;
|
||
_dbContext.UserAccounts.Update(userAccount);
|
||
}
|
||
|
||
await _dbContext.SaveChangesAsync();
|
||
}
|
||
|
||
/// <summary>
|
||
/// 生成默认头像URL
|
||
/// </summary>
|
||
private static string GenerateDefaultAvatar(string seed)
|
||
{
|
||
// 使用种子生成一个简单的默认头像URL
|
||
// 实际项目中可以使用Identicon库或其他头像生成服务
|
||
var hash = ComputeMd5(seed);
|
||
return $"https://api.dicebear.com/7.x/identicon/svg?seed={hash}";
|
||
}
|
||
|
||
/// <summary>
|
||
/// 计算MD5哈希
|
||
/// </summary>
|
||
private static string ComputeMd5(string input)
|
||
{
|
||
var inputBytes = Encoding.UTF8.GetBytes(input);
|
||
var hashBytes = MD5.HashData(inputBytes);
|
||
return Convert.ToHexString(hashBytes).ToLowerInvariant();
|
||
}
|
||
|
||
/// <summary>
|
||
/// 生成随机字符串
|
||
/// </summary>
|
||
private static string GenerateRandomString(int length)
|
||
{
|
||
const string chars = "ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789";
|
||
var result = new char[length];
|
||
for (int i = 0; i < length; i++)
|
||
{
|
||
result[i] = chars[Random.Shared.Next(chars.Length)];
|
||
}
|
||
return new string(result);
|
||
}
|
||
|
||
/// <summary>
|
||
/// 解析ClickId
|
||
/// </summary>
|
||
private static int? ParseClickId(string? clickId)
|
||
{
|
||
if (string.IsNullOrWhiteSpace(clickId))
|
||
return null;
|
||
|
||
return int.TryParse(clickId, out var result) ? result : null;
|
||
}
|
||
|
||
/// <summary>
|
||
/// 脱敏手机号
|
||
/// </summary>
|
||
private static string MaskMobile(string mobile)
|
||
{
|
||
if (string.IsNullOrWhiteSpace(mobile) || mobile.Length < 7)
|
||
return "***";
|
||
|
||
return $"{mobile.Substring(0, 3)}****{mobile.Substring(mobile.Length - 4)}";
|
||
}
|
||
|
||
/// <summary>
|
||
/// 获取年份中的周数
|
||
/// </summary>
|
||
private static int GetWeekOfYear(DateTime date)
|
||
{
|
||
var cal = System.Globalization.CultureInfo.CurrentCulture.Calendar;
|
||
return cal.GetWeekOfYear(date, System.Globalization.CalendarWeekRule.FirstDay, DayOfWeek.Monday);
|
||
}
|
||
|
||
/// <summary>
|
||
/// 计算 SHA256 哈希值
|
||
/// Requirements: 4.1
|
||
/// </summary>
|
||
private static string ComputeSha256Hash(string input)
|
||
{
|
||
var inputBytes = Encoding.UTF8.GetBytes(input);
|
||
var hashBytes = SHA256.HashData(inputBytes);
|
||
return Convert.ToHexString(hashBytes).ToLowerInvariant();
|
||
}
|
||
|
||
/// <summary>
|
||
/// 生成安全的随机字符串(用于 Refresh Token)
|
||
/// </summary>
|
||
private static string GenerateSecureRandomString(int length)
|
||
{
|
||
var randomBytes = new byte[length];
|
||
using var rng = RandomNumberGenerator.Create();
|
||
rng.GetBytes(randomBytes);
|
||
return Convert.ToBase64String(randomBytes)
|
||
.Replace("+", "-")
|
||
.Replace("/", "_")
|
||
.Replace("=", "")
|
||
.Substring(0, length);
|
||
}
|
||
|
||
#endregion
|
||
}
|