簡訊驗證碼“最佳實踐”

GUOKUN發表於2019-06-20

1、背景

  年初,從外地轉移陣地到西安,轉眼已兩個多月。很久不寫業務程式碼了,到了新公司,條件惡劣到前所未有,從需求,設計,架構,實現,實施,測試,bug修復,專案計劃制定,專案管理,全他媽我一個人,關鍵是平臺很大,很多技術難點,時間還又緊,要命的是,公司銷售左派盛行,連技術老大都是銷售出身,直屬領導設計出身不懂技術。。。點到為止,剩下的大家自行腦補。吐槽歸吐槽,事兒還是得幹,程式猿的基本素養不是。於是一個多月,996式搞法,專案上線了,其中包括那個我半天做出來的簡訊驗證碼。。。廢話大半天,終於說到今天的重點了,那就言歸正傳。

  對於簡訊驗證碼,前陣子,看到騷窩洞見分享了一篇簡訊驗證碼的文章(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了,這點不難。

  以上便是個人結合騷窩的最佳實踐要點,個人實踐了一道。早就想搞的,奈何最近一直996,無法言說吧。希望能對各位有用。

相關文章