ASP.NET Core MVC中不使用2FA如何通过OTP实现手机号注册登录
基于ASP.NET Core Identity实现手机号OTP注册+自动登录方案
核心思路
复用ASP.NET Core Identity的用户认证体系,跳过密码字段的业务逻辑,以手机号作为用户唯一标识,OTP校验通过后直接为用户签发登录Cookie,全程不涉及2FA相关逻辑。
具体实现步骤
1. 调整Identity基础配置
首先在Program.cs中修改Identity的默认规则,关闭邮箱相关要求,开启手机号确认校验:
builder.Services.AddDefaultIdentity<IdentityUser>(options => { // 关闭邮箱唯一性、邮箱确认要求 options.User.RequireUniqueEmail = false; options.SignIn.RequireConfirmedEmail = false; // 仅要求手机号已验证 options.SignIn.RequireConfirmedPhoneNumber = true; }) .AddEntityFrameworkStores<你的数据库上下文类名>();
2. OTP存储逻辑实现
OTP不要明文存储、也不要返回给客户端,统一存在服务端缓存并绑定有效期,推荐使用分布式缓存(单实例部署也可先用内存缓存),存储结构为:
- 缓存Key:
OTP:{手机号} - 缓存值:OTP的哈希值(避免缓存泄露导致验证码被盗用)
- 过期时间:3-5分钟即可
3. 发送OTP接口实现
[HttpPost] public async Task<IActionResult> SendVerificationCode(string phoneNumber) { // 先校验手机号格式合法性 if (!Regex.IsMatch(phoneNumber, @"^1[3-9]\d{9}$")) return BadRequest("手机号格式错误"); // 增加发送频率限制,避免被刷短信 var limitKey = $"SmsLimit:{phoneNumber}"; if (await _distributedCache.GetStringAsync(limitKey) != null) return BadRequest("验证码发送过于频繁,请1分钟后再试"); // 生成4位安全随机OTP var otp = RandomNumberGenerator.GetInt32(1000, 9999).ToString(); // 哈希后存储 var otpHash = BCrypt.Net.BCrypt.HashPassword(otp); await _distributedCache.SetStringAsync($"Otp:{phoneNumber}", otpHash, new DistributedCacheEntryOptions { AbsoluteExpirationRelativeToNow = TimeSpan.FromMinutes(5) }); // 标记1分钟内不可重复发送 await _distributedCache.SetStringAsync(limitKey, "1", new DistributedCacheEntryOptions { AbsoluteExpirationRelativeToNow = TimeSpan.FromMinutes(1) }); // 调用你已实现的Twilio短信发送逻辑 await _twilioSmsService.SendAsync(phoneNumber, $"您的登录验证码是{otp},5分钟内有效"); return Ok(); }
4. OTP校验+注册+自动登录实现
[HttpPost] public async Task<IActionResult> VerifyAndLogin(string phoneNumber, string otp) { // 基础参数校验 if (!Regex.IsMatch(phoneNumber, @"^1[3-9]\d{9}$") || otp.Length != 4) return BadRequest("参数错误"); // 读取缓存中的OTP哈希 var otpKey = $"Otp:{phoneNumber}"; var storedOtpHash = await _distributedCache.GetStringAsync(otpKey); if (storedOtpHash == null) return BadRequest("验证码已过期"); // 校验OTP是否匹配 if (!BCrypt.Net.BCrypt.Verify(otp, storedOtpHash)) return BadRequest("验证码错误"); // 校验通过后立即删除OTP,防止重复使用 await _distributedCache.RemoveAsync(otpKey); // 查询是否已经存在对应手机号的用户 var existUser = await _userManager.Users.FirstOrDefaultAsync(u => u.PhoneNumber == phoneNumber); if (existUser == null) { // 不存在则创建新用户 existUser = new IdentityUser { UserName = phoneNumber, PhoneNumber = phoneNumber, PhoneNumberConfirmed = true // 已通过OTP验证,直接标记手机号为已确认 }; // 生成随机复杂密码,仅满足Identity必填约束,用户不会使用密码登录 var dummyPassword = Guid.NewGuid().ToString("N") + "aA1!"; var createResult = await _userManager.CreateAsync(existUser, dummyPassword); if (!createResult.Succeeded) { return BadRequest("注册失败:" + string.Join(",", createResult.Errors.Select(e => e.Description))); } } // 直接为用户签发登录凭证,完成自动登录,不需要密码校验 await _signInManager.SignInAsync(existUser, isPersistent: true); // isPersistent参数设为true则记住登录状态,关闭浏览器下次打开仍有效,按需调整即可 return RedirectToAction("Index", "Home"); }
注意事项
- 生产环境务必使用安全随机数生成器生成OTP,不要用默认的
Random类,降低验证码被预测的风险 - 增加OTP错误次数限制,同一个手机号连续输错3次验证码后锁定10分钟,防止暴力破解
- 多实例部署必须用分布式缓存(如Redis)存储OTP,不要使用本地内存缓存,否则会出现跨实例校验失败的问题
- 不要在日志中打印明文OTP,避免泄露用户验证信息
- 如果不需要保留密码相关逻辑,可以自定义
UserStore移除密码字段的必填约束,减少冗余代码
内容的提问来源于stack exchange,提问作者Andres
相关产品推荐
相关产品推荐

