手机号验证码登录是目前很多应用首选的免密认证方式,它不需要用户记忆复杂密码,又能通过短信通道确认手机号归属。在C#技术栈下实现这一功能,通常需要前后端协同完成:前端负责手机号输入、获取验证码按钮和倒计时展示,后端负责生成验证码、调用短信服务、存储验证码、校验验证码并完成登录或注册。接下来本文将按照完整项目实现的角度,逐步拆解每个环节的关键代码和注意事项。

整个流程可以概括为:用户输入手机号点击获取验证码,前端将手机号提交到后端,后端校验格式并检查发送频率,生成随机验证码后调用短信服务商接口发送,同时将验证码与过期时间写入缓存;用户收到短信后填写验证码并提交,后端从缓存中取出验证码比对,成功则执行登录或注册逻辑并返回令牌。
一、整体流程与模块划分
从代码结构上看,验证码登录功能不应该全部堆在控制器里。至少需要划分出三个核心模块:短信发送器、验证码服务、认证控制器。短信发送器负责对接阿里云、腾讯云等第三方短信平台,验证码服务封装生成、存储、校验以及频率限制逻辑,控制器只处理HTTP请求参数绑定和返回结果。这样的划分方便替换短信服务商,也便于对验证码逻辑做单元测试。
我们可以先定义一个验证码服务接口ISmsCodeService,包含发送验证码和校验验证码两个方法。发送方法接收手机号并返回发送结果枚举,校验方法接收手机号、验证码并返回校验结果。短信发送器接口ISmsSender只关注如何把短信内容发送出去,后续可以替换为真实短信API调用,也可以先用控制台输出模拟。下面给出接口定义的完整代码。
public interface ISmsSender
{
Task SendAsync(string phone, string content);
}
public interface ISmsCodeService
{
Task<SendCodeResult> SendCodeAsync(string phone);
Task<ValidateCodeResult> ValidateCodeAsync(string phone, string code);
}
public enum SendCodeResult
{
Success,
TooFrequent,
ServiceError
}
public enum ValidateCodeResult
{
Valid,
Invalid,
Expired,
TooManyAttempts
}
这里把发送结果和校验结果都设计成枚举,而不是直接返回字符串或布尔值。这样做的好处是控制器可以根据不同枚举值给出更明确的提示,比如发送过于频繁、验证码过期、错误次数过多等,前端也能针对不同状态做交互处理。另外,验证码服务内部需要依赖缓存,ASP.NET Core内置的IMemoryCache足够单机部署使用,如果系统是分布式部署,可以替换为IDistributedCache加Redis实现。
二、验证码生成、存储与短信发送实现
验证码的生成不能使用Random类,因为它是伪随机且种子可预测,存在安全风险。推荐使用RandomNumberGenerator生成加密随机数,再转换为6位数字字符串。这样可以有效降低验证码被枚举的概率。生成的验证码需要存入缓存,并设置5分钟的绝对过期时间。同时为了防止一个手机号在短时间内频繁调用发送接口,还需要单独记录一个发送时间戳,60秒内不允许再次发送。
缓存键的设计要有规律,比如验证码键为sms_code_{phone},频率限制键为sms_limit_{phone}。这样在同一个服务实例内可以轻松读取和判断。验证码服务和短信发送器的具体实现如下,其中ISmsSender在真实项目中会调用HTTP请求到短信平台,这里用Console.WriteLine模拟发送动作。
public class SmsCodeService : ISmsCodeService
{
private readonly IMemoryCache _cache;
private readonly ISmsSender _smsSender;
public SmsCodeService(IMemoryCache cache, ISmsSender smsSender)
{
_cache = cache;
_smsSender = smsSender;
}
public async Task<SendCodeResult> SendCodeAsync(string phone)
{
string limitKey = $"sms_limit_{phone}";
if (_cache.TryGetValue(limitKey, out _))
{
return SendCodeResult.TooFrequent;
}
string code = GenerateCode();
string codeKey = $"sms_code_{phone}";
var cacheOptions = new MemoryCacheEntryOptions
{
AbsoluteExpirationRelativeToNow = TimeSpan.FromMinutes(5)
};
_cache.Set(codeKey, code, cacheOptions);
_cache.Set(limitKey, DateTime.UtcNow, TimeSpan.FromSeconds(60));
await _smsSender.SendAsync(phone, $"您的验证码是:{code},5分钟内有效。");
return SendCodeResult.Success;
}
public Task<ValidateCodeResult> ValidateCodeAsync(string phone, string code)
{
string codeKey = $"sms_code_{phone}";
if (!_cache.TryGetValue(codeKey, out string cachedCode))
{
return Task.FromResult(ValidateCodeResult.Expired);
}
if (cachedCode != code)
{
return Task.FromResult(ValidateCodeResult.Invalid);
}
_cache.Remove(codeKey);
return Task.FromResult(ValidateCodeResult.Valid);
}
private string GenerateCode()
{
using var rng = RandomNumberGenerator.Create();
byte[] data = new byte[4];
rng.GetBytes(data);
int number = BitConverter.ToInt32(data, 0) & int.MaxValue;
return (number % 1000000).ToString("D6");
}
}
上述代码中SendCodeAsync先检查60秒限制键,如果存在则直接返回TooFrequent。生成验证码后写入缓存,同时写入限制键。这样即使用户绕过前端倒计时直接请求接口,后端也能有效拦截。校验方法ValidateCodeAsync从缓存中取出验证码进行比对,正确后立即删除缓存,确保验证码一次性使用。如果缓存中不存在,说明已过期或已被使用,返回Expired。
实际项目中还需要考虑验证码错误次数的限制。攻击者可能不断尝试不同验证码,如果只缓存验证码本身,无法知道已经错误了多少次。可以在校验失败时增加一个错误计数键,超过5次则锁定该手机号一段时间,或者直接使该验证码失效。这部分逻辑可以在ValidateCodeAsync中扩展,使用IMemoryCache的计数器或IDistributedCache的原子操作实现。
三、登录注册接口与校验逻辑
控制器层主要负责接收前端传过来的手机号和验证码,先校验手机号格式,再调用验证码服务。手机号格式校验可以使用正则表达式^1[3-9]\\d{9}$,适配中国大陆手机号。发送验证码接口和登录注册接口都需要对参数做基础校验,避免无效请求进入服务层。
登录注册接口在验证码校验通过后,需要查询用户是否存在,不存在则自动创建新用户,存在则直接登录。这里为了简化示例,省略了数据库操作细节,用IUserService抽象。认证成功后返回一个令牌,可以是JWT或者自定义的访问令牌。下面给出控制器完整实现。
[ApiController]
[Route("api/auth")]
public class AuthController : ControllerBase
{
private readonly ISmsCodeService _smsCodeService;
private readonly IUserService _userService;
public AuthController(ISmsCodeService smsCodeService, IUserService userService)
{
_smsCodeService = smsCodeService;
_userService = userService;
}
[HttpPost("send-code")]
public async Task<IActionResult> SendCode([FromBody] SendCodeRequest request)
{
if (!IsValidPhone(request.Phone))
{
return BadRequest(new { message = "手机号格式不正确" });
}
var result = await _smsCodeService.SendCodeAsync(request.Phone);
return result switch
{
SendCodeResult.Success => Ok(new { message = "验证码已发送" }),
SendCodeResult.TooFrequent => BadRequest(new { message = "发送过于频繁,请稍后再试" }),
_ => StatusCode(500, new { message = "短信服务暂不可用" })
};
}
[HttpPost("login")]
public async Task<IActionResult> Login([FromBody] LoginRequest request)
{
if (!IsValidPhone(request.Phone))
{
return BadRequest(new { message = "手机号格式不正确" });
}
var validateResult = await _smsCodeService.ValidateCodeAsync(request.Phone, request.Code);
if (validateResult != ValidateCodeResult.Valid)
{
string msg = validateResult == ValidateCodeResult.Expired ? "验证码已过期" : "验证码错误";
return BadRequest(new { message = msg });
}
var user = await _userService.GetOrCreateByPhoneAsync(request.Phone);
var token = _userService.GenerateToken(user);
return Ok(new { token, user.Id, user.Phone });
}
private bool IsValidPhone(string phone)
{
return Regex.IsMatch(phone ?? "", @"^1[3-9]\d{9}$");
}
}
这里需要注意,登录接口中ValidateCodeAsync返回Valid后才继续执行用户查询和令牌生成。如果验证码已经被使用过,缓存中已经被删除,再次提交会返回Expired,保证验证码的一次性。另外对于注册场景,业务上往往会在验证码通过后检查手机号是否已注册,如果未注册则创建账号,这个逻辑可以通过IUserService的GetOrCreateByPhoneAsync方法统一处理。
DTO模型定义也很简单,SendCodeRequest包含Phone属性,LoginRequest包含Phone和Code属性。在实际项目中可能还需要增加图形验证码、设备标识等字段,以进一步提高接口安全性。
四、前端倒计时与安全加固
前端倒计时按钮是用户体验的重要组成部分,但绝不能把它当作安全措施。很多开发者会只在前端用JavaScript禁用按钮60秒,后端不做频率限制,结果接口可以被直接调用,验证码短信费用被恶意消耗。正确做法是后端强制限制频率,前端倒计时只是为了提升交互感。下面是一段原生JavaScript实现的前端交互代码,包含获取验证码和登录提交两个操作。
<div>
<input type="text" id="phone" placeholder="请输入手机号" />
<button id="sendBtn" onclick="sendCode()">获取验证码</button>
<input type="text" id="code" placeholder="请输入验证码" />
<button onclick="login()">登录/注册</button>
</div>
<script>
let countdown = 0;
let timer = null;
function sendCode() {
const phone = document.getElementById('phone').value;
if (!/^1[3-9]\d{9}$/.test(phone)) {
alert('请输入正确的手机号');
return;
}
if (countdown > 0) return;
fetch('/api/auth/send-code', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ phone: phone })
})
.then(response => response.json())
.then(data => {
if (response.ok) {
startCountdown(60);
} else {
alert(data.message);
}
});
}
function startCountdown(seconds) {
countdown = seconds;
const btn = document.getElementById('sendBtn');
btn.disabled = true;
btn.innerText = countdown + '秒后重发';
timer = setInterval(() => {
countdown--;
if (countdown <= 0) {
clearInterval(timer);
btn.disabled = false;
btn.innerText = '获取验证码';
} else {
btn.innerText = countdown + '秒后重发';
}
}, 1000);
}
function login() {
const phone = document.getElementById('phone').value;
const code = document.getElementById('code').value;
fetch('/api/auth/login', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ phone: phone, code: code })
})
.then(response => response.json())
.then(data => {
if (response.ok) {
localStorage.setItem('token', data.token);
alert('登录成功');
} else {
alert(data.message);
}
});
}
</script>
除了后端频率限制和验证码一次性使用,还可以从多个层面加固接口。比如增加图形验证码,在发送短信前要求用户先通过图形验证,能有效阻止自动化脚本。对同一IP或设备号设置每日发送上限,超过阈值则当天禁止发送。验证码错误次数达到5次后立即让验证码失效,并要求用户重新获取。这些措施可以根据项目实际需求逐步引入,核心思想是让恶意调用的成本远高于正常用户的操作成本。
另外,如果系统部署在多台服务器上,必须使用分布式的缓存方案,例如Redis来存储验证码和频率限制数据。因为单机内存缓存会导致不同服务器之间无法共享验证码状态,用户请求落到不同实例时可能校验失败。采用IDistributedCache并配置Redis连接即可无缝切换,代码改动很小,但部署架构的兼容性会大幅提升。