短信驗證碼「最佳實踐」

一、背景前端

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

  對於短信驗證碼,前陣子,看到騷窩洞見分享了一篇短信驗證碼的文章(https://insights.thoughtworks.cn/sms-authentication-login-api/),感受能夠做爲一個最佳實踐了,老早就決定按照文中觀點實踐了,奈何那陣一直996,沒時間,直到最近,才忙裏偷閒動手整理。原文再也不贅述,這裏就文中對於短信驗證碼的關鍵要點,截圖以下:github

2.實現web

  首先,直接上解決方案截圖:api

  典型的應用層 =》 服務層調用架構,採用接口層及IOC解耦。咱們先看工具庫,Captcha.Util,重點說下ImageCaptchaHelper與MsgCaptchaHelper。圖形驗證碼,這裏要致敬EdiWang,圖形驗證碼直接盜版的他的(https://edi.wang/post/2018/10/13/generate-captcha-code-aspnet-core)。整個文件中代碼太長,就不貼了,這裏只給幾個要點:緩存

(1)生成圖形驗證碼的工程,須要標記unsafe,以下:架構

 

這是由於圖形驗證碼的生成有部分用到了指針相關,熟悉C#的朋友應該對這個背景知識不陌生:併發

 

 

不用關心這是啥啥啥,照着設置unsafe就成了,我他媽壓根兒就懶得看這段指針代碼,就是看了也不必定看得懂。。。dom

(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,沒法言說吧。但願能對各位有用。

相關文章
相關標籤/搜索