如下是用戶頁面交互。輸入手機號,即可獲取驗證碼。用戶體驗方面已經超級簡單了。
不過,簡單是要有成本的。安全控制方面,程序員得琢磨。
在系統安全、信息安全、系統安全防御領域,短信盜刷是老生常談的話題了。我們公司的系統也經歷過至少3次盜刷。每次動輒損失2萬~5萬條的短信。
近幾年,隨着qq授權登錄、微信授權登錄等登錄方式的流行,短信盜刷的情況似乎是少了。不過,互聯網企業總是習慣要留下用戶的手機號的,畢竟這么做非常利於流量獲取。
短信驗證碼登陸,通常的做法是圖形驗證碼。簡單實現的話,就是 當用戶輸入的手機號發生變化時,頁面異步請求服務端生成圖形驗證碼的接口,服務端返回圖片文件流,頁面生成驗證碼圖片。用戶輸入驗證碼,然后請求服務端獲取驗證碼的接口。服務端會校驗用戶輸入的驗證碼是否正確,正確了才會發送短信驗證碼。
因為圖形驗證碼是通過文件流傳輸的,所以很難破解。當然,倒是有識別圖片的工具,不管怎么說,還是有一定難度的。不識別圖片呢?隨機生成4位驗證碼,用撞庫的方式來惡搞?顯然,命中的幾率也很小。就是說,用圖形驗證碼的方式,惡意攻擊的難度比較大。 我們看12306或其他的互聯網網站,動不動讓選特定的圖形,或滑動拼圖,或依次選特定的文字,這種安全性都是相當高的。
據說,阿里的招數更絕!可以記錄鼠標在頁面的軌跡,進而識別出來是人在操作,而非機器模擬。
所謂安全,安防,說白了,是防君子不防小人的,道高一尺魔高一丈。我們只能做到更安全一些,最大程度減少惡意攻擊導致的短信資源浪費。沒辦法做到100%最安全。
言歸正傳!
我們這種需求是一個乘客注冊/登陸的頁面。乘客輸入手機號,然后點擊獲取驗證碼,系統會判斷,如果是新用戶,或用戶狀態正常,就會發送短信驗證碼。考慮到較好的用戶體驗,沒加圖形驗證碼。
這種簡潔的操作,如果被非正常的用戶利用,那可就麻煩了。那么,如何最大程度規避短信盜刷呢?
我們先分析一下非正常的場景:
┣ 短信接口泄露出去了。日常辦公大家疏於信息傳遞,導致接口泄露。
┣ 接口在網絡上被截獲。
┣ 短信服務商作祟。不排除這種情況呵~
┣ “內鬼”,江湖險惡呀~
以上情況,短信接口如果是裸奔的,就會被當做小白鼠為人所惡搞。
裸奔的短信驗證碼接口:
GET /api/sendSmsCode?phone=*** HTTP/1.1
Host a.b.com
只要拼一個類似於 a.b.com/api/sendSmsCode?phone=18812345678 的url就可以觸發一條短信。惡搞這種小白鼠接口,是不是很刺激?
接下來,我們對這個接口來做安全控制。
【首先】必備的參數校驗不可少
0.1 手機號合法性校驗。➀不能為空 ➁11位 ➂以1開頭 ➃校驗前兩位或前三位號段,比如13、15、18、131/2、152、183/6/8/9...(可選,稍有不慎,就有可能會過濾掉正常的號碼)。➄過濾特殊號碼,比如88888888、11111111、22222222、12345678、38383838...
【其次】我們來分析正常的瀏覽器請求:
▼Request Headers:
POST /api/passenger/sendSms HTTP/1.1
Host: che.shenbianhui.cn
Connection: keep-alive
Content-Length: 43
Accept: application/json, text/plain, */*
User-Agent: Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/83.0.4103.116 Safari/537.36
Content-Type: application/json;charset=UTF-8
Origin: http://che.shenbianhui.cn
Referer: http://che.shenbianhui.cn/
Accept-Encoding: gzip, deflate
Accept-Language: zh-CN,zh;q=0.9,en;q=0.8
Cookie: pgv_pvi=2428115968; UM_distinctid=170257b17e51b3-01ea5235ff274e-b383f66-e1000-170257b17e6177; Hm_lvt_cb56ec9ce26d8a82ead7aa15af69e6e0=1581176790,1581304635,1581499290; Hm_lvt_29c6c62e8f0bd1061bcc0e3cb6b3d53d=1583891251,1585192233,1586140252
▼Request Payload:
{"phone":"17813270522","userType":"driver"}
1.1. 方法用POST請求
1.2. 判斷請求頭參數。服務端只接收正常瀏覽器請求。
1.2.1 校驗User-Agent。使用User-Agent防止HttpClient發送http請求時403 Forbidden和安全攔截
1.2.2 校驗Reffer
1.2.3 Header里增加額外參數。后文有關於key或ticket的策略。可以把這些參數追加到請求Header里。
至於點對點的攻擊,也可以偽造User-Agent、Reffer的值,偽裝成正常的瀏覽器請求。所以,這遠遠不夠。繼續往下看。
【再次】請求次數限制
分布式系統直接利用redis的incby來實現計數即可。
redisUtil.set(key:CommonConstant.MSG_TIMES.concat(today), value:0, seconds:60*5);
redisUtil.incr(key:CommonConstant.MSG_TIMES.concat(today), delta:1L);
2.1 增加IP次數限制。B/S型的對外網站,我們無法做IP白名單控制。不過,同一IP,在指定時間段之內,請求次數要做上限控制。比如5分鍾之內不超過50次。這要根據業務情況來評估。
惡意請求有時會用代理IP,當然,使用代理IP本身是有成本的。
2.2 “一刀切”“限流” 在指定時間段之內,總的請求次數不能超過閾值。比如,5分鍾內總請求量不能超過1000次。這要根據業務情況來評估。
需要說明的是,這種對請求次數做限制的策略,時間段一定要“合理”,否則可能形同虛設。拿上面的IP限制來說,如果設定成1天內同一IP不超過500次,那估計沒什么卵用。別人要攻擊你,肯定不是細水長流那樣的,而是突襲,可能是0點突然來一炮或12點突然來一炮。
2.3 同一個手機號,特定時間內(比如30秒)不可重復請求驗證碼。我們在用戶頁面是經常可以看到的。點擊完獲取驗證碼后,會有按秒的倒計時提示。在此時間內是不能重新發起的。自然,服務端也要做這個校驗。(前后端雙重校驗)
【第四】接口參數復雜化
3.1 增加一個key參數,就像支付接口中常見的簽名一樣。
3.1.1生成key的規則:前后端約定。同時,盡量保證每次請求的key都不同。比如:手機號=18612345678,則key=MD5(手機號前3位186 + 手機號后3位678 + 當前時間/分鍾)
前后端都用這種方式生成key,前端頁面通過js腳本生成“簽名”,服務端“驗簽”。
需要注意的是:時間校驗要留buffer,客戶機時間與服務器時間並不完全相同。可以用循環或遞歸算法搞定。
3.2 更靠譜一點的方案。從以上的方案進一步腦洞大開。服務端增加一個生成key的api。一旦用戶修改了手機號,就調用api獲取一個key,獲取驗證碼的時候同時上送這個key。這樣,短驗接口每次校驗key是否一致就可以了。
我們通常的保證冪等性的方案,也是生成一個ticket,用戶端提交數據的時候,服務端校驗ticket,ticket匹配才持久化數據,然后刪除ticket;服務端一旦發現ticket不存在,則視為非法請求。
3.3 如果能做到讓短驗下發的接口里不用傳手機號,豈不是更安全呀! 我曾經讓組里的小伙伴們思考過這個問題。好,公布答案——上面3.2的方案,利用手機號生成key之后,服務端保存手機號和key的關系,然后在短信下發接口直接上送這個key,就像生物界的“擬態”,借以蒙蔽敵害,保護自身。
另外,上面提到的加key策略,可以同時加2個或者3個key,從而給人一種“視覺”混淆,更大程度防御攻擊。我們知道,很多互聯網系統的用戶密碼采用加鹽(salt)加密的方式來實現,也是利用了這種思想。
我們再看看這個api很有可能會是下面這個樣子。
POST /api/sendSmsCode HTTP/1.1
Host a.b.com
{"phone":"***","key":"092E080F5845904EBCFF5F242A87F4DD","code":"O7b1"}
Header["ticket"]:171149199774508c7f17787b1711252400
甚至華麗變身成如下的樣子。
POST /api/sendSmsCode HTTP/1.1
Host a.b.com
{"key":"092E080F5845904EBCFF5F242A87F4DD","ticket":"171149199774508c7f17787b1711252400"}
綜合以上方案的控制,我們就能很大程度保證接口的安全。
只要思想不滑坡,方法總比困難多。
BTW,3.1和3.2的方案,需要前后端配合。當我們找項目組的前端小伙伴討論時,前端小伙寫VUE、NODEJS、JavaScript腳本相當醇熟,他覺得這樣做沒有什么意義。他打開瀏覽器的調試工具,說別人一看就知道怎么回事了。這種方案,充其量也就能有1%的改善。我的觀點:1.並不是所有的人都知道這個頁面的存在;2.並不是所有的人都能找到那段js代碼;3.並不是所有的人對前端都很熟。 因為這個頁面是他親自做的,接口是他親自調用的,所以,他很了解。並不是所有的人都像他一樣了解的,包括我們這些后端的程序員。 讓我想到一句話:手里有把錘子,看什么都是釘子。 一個人的思維會影響行動。也許,有些技術偏執的人,多少都具備一點個性和不羈吧。