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,無法言說吧。希望能對各位有用。