短信验证码“最佳实践”
1、背景
年初,从外地转移阵地到西安,转眼已两个多月。很久不写业务代码了,到了新公司,条件恶劣到前所未有,从需求,设计,架构,实现,实施,测试,bug修复,项目计划制定,项目管理,全他妈我一个人,关键是平台很大,很多技术难点,时间还又紧,要命的是,公司销售左派盛行,连技术老大都是销售出身,直属领导设计出身不懂技术。。。点到为止,剩下的大家自行脑补。吐槽归吐槽,事儿还是得干,程序猿的基本素养不是。于是一个多月,996式搞法,项目上线了,其中包括那个我半天做出来的短信验证码。。。废话大半天,终于说到今天的重点了,那就言归正传。
对于短信验证码,前阵子,看到thoughtworks洞见分享了一篇短信验证码的文章(https://insights.thoughtworks.cn/sms-authentication-login-api/),感觉可以作为一个最佳实践了,老早就决定按照文中观点实践了,奈何那阵一直996,没时间,直到最近,才忙里偷闲动手整理。原文不再赘述,这里就文中对于短信验证码的关键要点,截图如下:
2.实现
首先,直接上解决方案截图:
典型的应用层 =》 服务层调用架构,采用接口层及IOC解耦。我们先看工具库,Captcha.Util,重点说下ImageCaptchaHelper与MsgCaptchaHelper。图形验证码,这里要致敬EdiWang,图形验证码直接盗版的他的(https://edi.wang/post/2018/10/13/generate-captcha-code-aspnet-core)。整个文件中代码太长,就不贴了,这里只给几个要点:
(1)生成图形验证码的工程,需要标记unsafe,如下:
这是因为图形验证码的生成有部分用到了指针相关,熟悉C#的朋友应该对这个背景知识不陌生:
不用关心这是啥啥啥,照着设置unsafe就成了,我压根儿就懒得看这段指针代码,就是看了也不一定看得懂。。。
(2)图形验证码的位置调整:
void DrawCaptchaCode() { SolidBrush fontBrush = new SolidBrush(Color.Black); int fontSize = GetFontSize(width, captchaCode.Length); Font font = new Font(FontFamily.GenericSerif, fontSize, FontStyle.Bold, GraphicsUnit.Pixel); for (int i = 0; i < captchaCode.Length; i++) { fontBrush.Color = GetRandomDeepColor(); int shiftPx = fontSize / 6; //float x = i * fontSize + rand.Next(-shiftPx, shiftPx) + rand.Next(-shiftPx, shiftPx); float x = i * fontSize + rand.Next(-shiftPx, shiftPx) / 2; //int maxY = height - fontSize; int maxY = height - fontSize * 2; if (maxY < 0) { maxY = 0; } float y = rand.Next(0, maxY); graph.DrawString(captchaCode[i].ToString(), font, fontBrush, x, y); } }
代码中,X,Y的值,就是验证码构成字符中,各个字符的二维偏移量,越大,偏移就可能越厉害。注释掉的是原来的,下边一行是我调整过后的,因为实际使用中发现不少情况下会出现字符超出边框界限,没法儿认的情况。
(3)噪音线处理
void DrawDisorderLine() { Pen linePen = new Pen(new SolidBrush(Color.Black), 2); //for (int i = 0; i < rand.Next(3, 5); i++) for (int i = 0; i < 2; i++) { linePen.Color = GetRandomDeepColor(); Point startPoint = new Point(rand.Next(0, width), rand.Next(0, height)); Point endPoint = new Point(rand.Next(0, width), rand.Next(0, height)); graph.DrawLine(linePen, startPoint, endPoint); } }
不管是偏移也好,噪音线也好,本质上都是为了降低OCR识别率。for循环的次数,代表噪音线条数,条数越多,可能就越难辨识。之所以从3到5条随机,改为固定2条,是因为实际使用时发现,当噪音线随机成5条时,很多图形验证码基本人眼没法儿辨识,没骗过机器,估计先把人眼晃瞎喽。
以上就是图形验证码中需要注意或者自己需要调整的几个点。接下来,我们看短信验证码的生成:
/// <summary> /// 短信验证码工具类 /// </summary> public static class MsgCaptchaHelper { /// <summary> /// 生成指定位数的随机数字码 /// </summary> /// <param name="length"></param> /// <returns></returns> public static string CreateRandomNumber(int length) { Random random = new Random(); StringBuilder sbMsgCode = new StringBuilder(); for (int i = 0; i < length; i++) { sbMsgCode.Append(random.Next(0, 9)); } return sbMsgCode.ToString(); } }
简单粗暴,传入短信验证码长度,是多少位,我就拼接多少个随机生成的数字字符构成满足长度要求的验证码。
接下来,是Service层,图形验证码、短信验证码的核心逻辑都在这里,整个工程就一个服务CaptchaService。首先,我们看看服务层依赖:
#region Private Fields private readonly IMemoryCache _cache; private readonly IHostingEnvironment _hostingEnvironment; #endregion #region Constructors public CaptchaService(IMemoryCache cache, IHostingEnvironment hostingEnvironment) { _cache = cache; _hostingEnvironment = hostingEnvironment; } #endregion
其中内存缓存的作用,是缓存图形验证码、短信验证码,供后续校验、过期使用,带会让详述。这里为了演示核心主题,使用了内存缓存,如果是大型生产环境,尤其是高并发的情况,可能需要分布式缓存,甚至还可能需要搭配消息队列。core寄宿环境接口,目的是为了开发环境或测试环境下,直接返回短信验证码的值而无需真实发送短信验证码,生产环境再调用第三方运行商发送短信验证码。
接下来,我们看图形验证码的请求:
/// <summary> /// 获取图片验证码 /// </summary> /// <param name="imgCaptchaDto">图形验证码请求信息</param> /// <returns></returns> public CaptchaResult GetImageCaptcha(ImgCaptchaDto imgCaptchaDto) { var captchaCode = ImageCaptchaHelper.GenerateCaptchaCode(); var result = ImageCaptchaHelper.GenerateCaptcha(100, 36, captchaCode); _cache.Set($"ImgCaptcha{imgCaptchaDto.ImgCaptchaType}{imgCaptchaDto.Mobile}", result.CaptchaCode); return result; }
可以看见,生成随机图形验证码之后,以图形验证码类型,手机号,外加ImgCaptcha前缀拼接,作为图形验证码的key缓存图形验证码的值。控制器层的处理如下:
/// <summary> /// 获取图片验证码 /// </summary> /// <param name="imgCaptchaDto">图形验证码请求信息</param> [HttpGet("img")] public IActionResult GetImageCaptcha([FromQuery]ImgCaptchaDto imgCaptchaDto) { var result = _captchaService.GetImageCaptcha(imgCaptchaDto); var stream = new MemoryStream(result.CaptchaByteData); return new FileStreamResult(stream, "image/png"); }
拿到短信验证码结果之后,以图形验证码二进制流为基础构建FileStreamResult返回。这里需要特别注意的是,MemoryStream不能按照最佳实践用using包围起来,因为了解MVC或webapi请求处理管道的应该知道,当前FileStreamResult返回后并不是立即处理,而是在管道的某个阶段及某个特定时候才处理控制器方法的返回结果,假如这里using包起来了,那控制器方法执行完毕,memorystream也就释放了,将来FileStreamResult执行时候就会直接异常。
图形验证码的校验:
/// <summary> /// 验证图片验证码 /// </summary> /// <param name="imgCaptchaDto">图形验证码信息</param> /// <returns></returns> public bool ValidateImageCaptcha(ImgCaptchaDto imgCaptchaDto) { var cachedImageCaptcha = _cache.Get<string>($"ImgCaptcha{imgCaptchaDto.ImgCaptchaType}{imgCaptchaDto.Mobile}"); if (string.Equals(imgCaptchaDto.ImgCaptcha, cachedImageCaptcha, StringComparison.OrdinalIgnoreCase)) { return true; } else { return false; } }
/// <summary> /// 验证图片验证码 /// </summary> /// <param name="imgCaptchaDto">图形验证码信息</param> /// <returns></returns> [HttpPost("img")] public IActionResult ValidateImageCaptcha(ImgCaptchaDto imgCaptchaDto) { bool isCaptchaValid = _captchaService.ValidateImageCaptcha(imgCaptchaDto); if (isCaptchaValid) { return Ok("图形验证码验证成功"); } else { return StatusCode(StatusCodes.Status403Forbidden, "验证失败,请输入正确手机号及获取到的验证码"); } }
这里没啥好说的,就是按照同样的构造键取出图形验证码并与客户端发送过来的比对,相同就校验通过。
接下来,看看短信验证码的请求:
/// <summary> /// 获取短信验证码 /// </summary> /// <param name="msgCaptchaDto">短信验证码请求信息</param> /// <returns></returns> public (bool, string) GetMsgCaptcha(MsgCaptchaDto msgCaptchaDto) { if (string.IsNullOrWhiteSpace(msgCaptchaDto.ImgCaptcha)) { throw new BusinessException((int)ErrorCode.BadRequest, "请输入图形验证码"); } var cachedImageCaptcha = _cache.Get<string>($"ImgCaptcha{msgCaptchaDto.MsgCaptchaType}{msgCaptchaDto.Mobile}"); if (!string.Equals(msgCaptchaDto.ImgCaptcha, cachedImageCaptcha, StringComparison.OrdinalIgnoreCase)) { return (false, "验证失败,请输入正确手机号及获取到的图形验证码"); } string key = $"MsgCaptcha{msgCaptchaDto.MsgCaptchaType}{msgCaptchaDto.Mobile}"; var cachedMsgCaptcha = _cache.Get<MsgCaptchaDto>(key); if (cachedMsgCaptcha != null) { var offsetSecionds = (DateTime.Now - cachedMsgCaptcha.CreateTime).Seconds; if (offsetSecionds < 60) { return (false, $"短信验证码获取太频繁,请{60 - offsetSecionds}秒之后再获取"); } } var msgCaptcha = MsgCaptchaHelper.CreateRandomNumber(6); msgCaptchaDto.MsgCaptcha = msgCaptcha; msgCaptchaDto.CreateTime = DateTime.Now; msgCaptchaDto.ValidateCount = 0; _cache.Set(key, msgCaptchaDto, TimeSpan.FromMinutes(2)); if (_hostingEnvironment.IsProduction()) { //TODO:调用第三方SDK实际发送短信 return (true, "发送成功"); } else //非生产环境,直接将验证码返给前端,便于调查跟踪 { return (true, $"发送成功,短信验证码为:{msgCaptcha}"); } }
请求短信验证码,需要把对应的图形验证码一并随请求发过来。这里额外交代一下,图形验证码类型,短信验证码类型是需要一一对应的,实际业务中,我们可能有注册验证码,找回密码验证码,修改密码验证码,各种业务验证码等,每种业务验证码对应的图形验证码类型和短信验证码类型应该是对应的,如果为了减少错误,可以定义两个枚举,这里因为是想把验证码做成通用服务,所以类型并未根据具体业务定义枚举。回到发送短信验证码的实现上,可以看到,首先就校验图形验证码,图形验证码校验通过的情况下,按照与图形验证码Key类似的规则构建短信验证码缓存key,并从缓存找是否存在对应的短信验证码缓存对象。如果找到了,则说明相同手机号的相同业务已经获取过短信验证码且指定时间内未失效,这种情况下,是不能获取短信验证码的,否则视为短信轰炸,直接返回。示例中,或者说按照骚窝最佳实践要点中,一分钟之内是只能获取一条的, 所以我定了60s,并做时差提示。假如不存在对应短信验证码,则构造短信验证码对象,分别设置短信码、创阿金时间为当前时间、校验次数为0,并缓存。最后,根据当前是开发还是生产环境,决定是直接返验证码还是真实发送短信。
最后,看短信验证码校验:
/// <summary> /// 验证短信验证码 /// </summary> /// <param name="msgCaptchaDto">短信验证码信息</param> /// <returns></returns> public (bool, string) ValidateMsgCaptcha(MsgCaptchaDto msgCaptchaDto) { var key = $"MsgCaptcha{msgCaptchaDto.MsgCaptchaType}{msgCaptchaDto.Mobile}"; var cachedMsgCaptcha = _cache.Get<MsgCaptchaDto>(key); if (cachedMsgCaptcha == null) { return (false, "短信验证码无效,请重新获取"); } if (cachedMsgCaptcha.ValidateCount >= 3) { _cache.Remove(key); return (false, "短信验证码已失效,请重新获取"); } cachedMsgCaptcha.ValidateCount++; if (!string.Equals(cachedMsgCaptcha.MsgCaptcha, msgCaptchaDto.MsgCaptcha, StringComparison.OrdinalIgnoreCase)) { return (false, "短信验证码错误"); } else { return (true, "验证通过"); } }
逻辑蛮简单,首先按照指定键取短信验证码缓存,取到了,再看该缓存对象校验次数,如果超过3次了,则直接拦截,视为暴力攻击。未超过,则校验次数累加,并比对,相同则视为OK。这里需要特别注意的是,进程内缓存,设置完校验次数就OK了,可以不用回写缓存,但如果是分布式缓存,则需要回写修改过的短信验证码对象至缓存。至此,核心逻辑实现部分差不多了,接下来我们看实际效果。
3.运行效果:
首先,请求图形验证码
接下来,校验此图形验证码。我们先用正确的校验:
再用错误的去校验:
正确的校验成功,错误的校验失败,那么校验部分OK了。然后,我们看看,用此图形验证码去获取短信验证码,我们先用错误的图形验证码去校验:
好,已经失败了,那我们换正确的试试:
可以看到,短信验证码已经发送成功了。我们再发送一次:
这时候,系统提示,获取太频繁了,请20s后再。因为我在码字,时间过去了点儿,所以是20s,这时间是根据当前时间减去短信验证码创建时间,在与60s的频率限制求差值,来算倒计时的。好,现在我们拿刚才的短信验证码去校验:
。。。我去,码字的这会儿,短信验证码缓存过期了。。。算了,这次哥从图形验证码开始整连贯的截图吧,码字先放一边儿
(1)获取图形验证码:
(2)校验图形验证码:
(3)获取短息验证码:
(4)用正确短信验证码校验(第1次校验):
(5)用错误验证码校验(第2次):
(6)用错误验证码校验(第3次):
(7)用正确验证码校验(第4次):
注意最后几张短信验证码校验的截图结果,前3次,正确的验证码校验成功,错误的校验失败,第4次开始,因为已经达到校验上线3次,所以直接失效了,不管验证码正确与否。
好,废话的这会儿,应该又失效了,我们再重现下:
4.源码
https://github.com/KINGGUOKUN/Captcha.git。整个解决方案是服务化的,可以开箱即用。
5.总结
我们再回过头来看看骚窝的短信验证码核心要点:
这么多要点中,本方案有两个没有实现,如截图所示,同一个手机号在同一时间内可以有多个有效的短信验证码以及第三方api,第三方api说的并不明确,到底是什么,而且如果是集成第三方了,那么可能就用不上短信验证码了,直接用户名、密码、第三方api就直接了,至于另一条,同一手机号同一时间内可以有多个有效的短信验证码,个人感觉不太实用和必要。假如要实践的话,其实也简单,方案中短信验证码模型中,并不是保存单个短信验证码,而是缓存验证码列表就OK了,这点不难。
以上便是个人结合thoughtworks的最佳实践要点,个人实践了一道。早就想搞的,奈何最近一直996,无法言说吧。希望能对各位有用。