You need to enable JavaScript to run this app.
优惠活动
大模型
产品
解决方案
定价
更多

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

相关产品推荐
方舟 Agent Plan

超全模态模型 × Harness 升级,最新支持 Deepseek-V4.1-Flash、GLM-5.3 系列、Doubao-Seedream-5.0-pro、Kimi-K3 (部分), 限时 9.9 元起

最近更新时间:2026.10.04 19:27:05