.NET Core 6 Web API邮件确认Token重复编码问题及重发功能困惑
我正在开发.NET Core 6 Web API的注册功能,已实现邮件确认且功能正常,但作为C#和.NET Core新手,想实现邮件重发功能(用户未收到邮件或未及时确认时),因此创建了ConfirmationToken实体来存储UserManager.GenerateEmailConfirmationTokenAsync()生成的确认Token:
public class ConfirmationToken { [Key] [DatabaseGenerated(DatabaseGeneratedOption.Identity)] public int Id { get; set; } public string Token { get; set; } public string? ResendToken { get; set; } public bool? isUsed { get; set; } public DateTime? CreatedAt { get; set; } = DateTime.Now; public DateTime? ExpiredAt { get; set; } = DateTime.Now.AddHours(24); public virtual User? User { get; set; } public string UserId { get; set; } }
该实体可存储.NET生成的Token及关联用户ID,但存在Token存储问题:调用GenerateEmailConfirmationTokenAsync()生成的Token经URL编码后存入数据库,示例如下:
CfDJ8MhQpKYkLedOlUKK9%2bU10PcPTorIiCUSmUQ9vyl48rr91cCZnkYEuKKZLf4rLt90Dag6OWpkfbjwyhV1FbGNGuUZ90pUL7vi%2fe1T0JaXoV8SSiyZx41lWHwkb71rLTl3xYaDd6Bq6MaFClUjAEfyoorXZZ3K9ddmKb6Byf28%2bKRBtt2vlzayqxNkZbl43thYM%2fuzn8oCwkD8fc%2fhByV0wFCSgkUQKUHv3FKf5n%2bNbG3%2b%2bpwczzsooqoGvuyUSjsvqA%3d%3d
但调用确认端点时,回调URL中的Token与数据库存储的不一致,示例URL:
https://localhost:7050/api/Auth/ConfirmEmail?userId=cc985a22-53dd-4e69-a0fe-70b82b6a9925&confirmationToken=CfDJ8MhQpKYkLedOlUKK9%252bU10PcPTorIiCUSmUQ9vyl48rr91cCZnkYEuKKZLf4rLt90Dag6OWpkfbjwyhV1FbGNGuUZ90pUL7vi%252fe1T0JaXoV8SSiyZx41lWHwkb71rLTl3xYaDd6Bq6MaFClUjAEfyoorXZZ3K9ddmKb6Byf28%252bKRBtt2vlzayqxNkZbl43thYM%252fuzn8oCwkD8fc%252fhByV0wFCSgkUQKUHv3FKf5n%252bNbG3%252b%252bpwczzsooqoGvuyUSjsvqA%253d%253d
这导致无法通过Token值从数据库查询对应记录,虽然邮件确认功能正常,但添加重发邮件功能(生成新Token并验证旧Token无效)时遇到阻碍,无法理解Token不一致的原因。相关代码如下:
RegisterUser() - 服务方法
注册用户账户并生成确认链接
public async Task<User> RegisterUser(UserRegisterRequest request) { var userNameExists = await _userManager.FindByNameAsync(request.UserName); var emailExists = await _userManager.FindByEmailAsync(request.EmailAddress); var user = new User() { UserName = request.UserName, Email = request.EmailAddress, //EmailConfirmed = false }; var result = await _userManager.CreateAsync(user, request.Password); if (result.Succeeded) { var confirmationToken = await _confirmationTokenService.GenerateConfirmationToken(user.Id); // 指向确认邮件的端点,传递userId和token var callback_url = "https://localhost:7050" + _urlHelper.Action("ConfirmEmail", "Auth", new { userId = confirmationToken.UserId, confirmationToken = confirmationToken.Token }); return user; } return null; }
GenerateConfirmationToken(string userId) - 服务方法
生成邮件Token并存入数据库及关联用户ID
public async Task<ConfirmationToken> GenerateConfirmationToken(string userId) { var user = await _userManager.FindByIdAsync(userId); var token = await _userManager.GenerateEmailConfirmationTokenAsync(user); var encodedToken = HttpUtility.UrlEncode(token); var emailConfirmationToken = new ConfirmationToken { Token = encodedToken, UserId = user.Id, isUsed = true }; // 将编码后的Token及关联用户ID存入数据库表 await _unitOfWork.ConfirmationTokens.Add(emailConfirmationToken); int result = _unitOfWork.Save(); return emailConfirmationToken; }
ConfirmToken(string userId, string token) - 服务方法
确认传递的解码后邮件Token和用户ID,供ConfirmEmail端点调用
public async Task<bool> ConfirmToken(string userId, string token) { var user = await _userManager.FindByIdAsync(userId); var decodedToken = HttpUtility.UrlDecode(token); var result = await _userManager.ConfirmEmailAsync(user, decodedToken); return result.Succeeded; }
问题原因
核心问题是双重URL编码:
- 在
GenerateConfirmationToken方法中,手动对Token执行了HttpUtility.UrlEncode(token),并将编码后的Token存入数据库。 - 生成回调URL时,
_urlHelper.Action方法会自动对所有参数进行URL编码,导致已经编码过的Token被再次编码(例如%2b被转成%252b),最终URL中的Token和数据库存储的编码后Token不一致。
修复方案
方案1:存储原始Token,仅在生成URL时自动编码
修改GenerateConfirmationToken方法,去掉手动URL编码,直接存储UserManager生成的原始Token:
public async Task<ConfirmationToken> GenerateConfirmationToken(string userId) { var user = await _userManager.FindByIdAsync(userId); var token = await _userManager.GenerateEmailConfirmationTokenAsync(user); // 移除手动URL编码,存储原始Token var emailConfirmationToken = new ConfirmationToken { Token = token, UserId = user.Id, isUsed = false // 初始状态应为未使用 }; await _unitOfWork.ConfirmationTokens.Add(emailConfirmationToken); int result = _unitOfWork.Save(); return emailConfirmationToken; }
生成回调URL时,直接传递原始Token,_urlHelper.Action会自动完成URL编码:
var callback_url = "https://localhost:7050" + _urlHelper.Action("ConfirmEmail", "Auth", new { userId = confirmationToken.UserId, confirmationToken = confirmationToken.Token });
此时数据库存储的是原始Token,确认端点接收到编码后的Token后,通过HttpUtility.UrlDecode(token)解码即可匹配数据库中的原始Token。
方案2:若坚持存储编码后的Token,生成URL时先解码
如果必须保留数据库中存储编码后的Token,生成URL时需要先解码原始编码Token,再让_urlHelper.Action自动编码:
var callback_url = "https://localhost:7050" + _urlHelper.Action("ConfirmEmail", "Auth", new { userId = confirmationToken.UserId, confirmationToken = HttpUtility.UrlDecode(confirmationToken.Token) });
重发邮件功能实现建议
实现重发时需处理旧Token失效与新Token生成:
public async Task<bool> ResendConfirmationEmail(string userId) { var user = await _userManager.FindByIdAsync(userId); if (user == null || user.EmailConfirmed) return false; // 标记用户所有未过期、未使用的旧Token为已使用 var oldValidTokens = await _unitOfWork.ConfirmationTokens .Where(t => t.UserId == userId && t.isUsed == false && t.ExpiredAt > DateTime.Now) .ToListAsync(); foreach (var token in oldValidTokens) { token.isUsed = true; _unitOfWork.ConfirmationTokens.Update(token); } // 生成新Token并保存 var newToken = await GenerateConfirmationToken(userId); // 发送包含新Token链接的邮件逻辑... _unitOfWork.Save(); return true; }
内容的提问来源于stack exchange,提问作者ServletException

