前言
小C他們團隊最近在做一個文章社羣平臺,由於人手不夠,後端部分也是由前端同學開發,用的框架是 nest.js
。
他們平臺上線之後,註冊的使用者量日漸增長,老闆十分開心。
由於註冊的時候需要傳送簡訊驗證碼來校驗使用者身份的真實性,所以老闆一口氣又去買了 10W
條簡訊,幻想著哪一天到達 10W
使用者。
好景不長的是,剛買了沒多久,就收到了簡訊平臺的告警,說的是簡訊快用完了?
老闆又震驚又憤怒,找到小C:怎麼這麼快就用完了?我看也沒幾個新使用者註冊啊?
小C看了一下:老闆,我們發短信驗證碼的介面只是做了一層前端的驗證校驗,沒有做其他的任何限制,抓個包就能呼叫傳送,我們的簡訊被盜刷了!
老闆:😠,那你還不趕緊修復一下,不然我後續買多少條簡訊都不夠用的。
PS:在本文行文中,以傳送郵件驗證碼來替代傳送簡訊驗證碼。
前端校驗
前端的驗證碼校驗用的是rc-slider-captcha這個庫,這是一個滑塊驗證碼相關的前端庫。
它從互動上支援兩種形式的滑塊驗證,一種是滑動缺口圖去完成拼圖
另一種是純軌跡滑動,沒有拼圖
這個庫的作者還很貼心的做了一個客戶端的拼圖生成器,結合用起來確實非常舒服。
我這邊使用的是純軌跡滑動的接入方式,實現起來也很簡單,當點選傳送驗證碼的時候會彈出彈窗,然後讓使用者進行滑塊驗證。
透過滑動的軌跡判斷使用者驗證是否透過,如果透過就執行傳送驗證碼的邏輯。
<Modal open={visible} onCancel={() => setVisible(false)} title="安全驗證" destroyOnClose footer={false} centered width={368} style={{ maxWidth: "100%" }} > <SliderCaptcha autoRefreshOnError={true} mode="slider" onVerify={(data) => { if (data.x >= 260) { setTimeout(() => { setVisible(false); sendPhoneCode(); // 傳送驗證碼 }); return Promise.resolve(); } return Promise.reject(); }} /> </Modal>
但是呢,這種做法只能說是掩耳盜鈴,別人一抓包找到你真正傳送簡訊驗證碼的介面就可以開始搞事情了。本質上的原因是我們傳送驗證碼的時候,沒有校驗使用者是否透過了前端的滑塊驗證。
防介面盜刷
這個時候我們就需要一種前後端一起驗證的機制,我這裏使用的是Google reCAPTCHA。
你當然也可以使用別的第三方的服務,這個可以透過google搜「驗證碼」進行查詢
在這裏註冊好一個網站
註冊好之後會有一對金鑰對,我們需要自行記錄一下
然後就可以在我們的網站中接入這個谷歌驗證了:
可以透過下面這個script引入驗證碼元件:
<script src="https://www.google.com/recaptcha/api.js" async defer></script>
然後寫一個驗證碼容器 div
:
<div id="robot"></div>
然後在點選傳送驗證碼的時候喚起谷歌驗證元件:
const handleClick = async () => { const res = await form.validateFields(["email"]); try { window.grecaptcha.reset(); } catch (error) {} window.grecaptcha.render("robot", { sitekey: "你的sitekey", //公鑰 callback: function (code: string) { doSend(res.email, code); }, "expired-callback": () => { message.error("驗證過期"); }, "error-callback": () => { message.error("驗證錯誤"); }, }); };
在驗證透過之後,我們就把使用者輸入的郵箱以及谷歌驗證碼元件的返回值一起傳送給後端。後端去呼叫谷歌驗證的介面,來判斷使用者的驗證是否透過。如果透過,則走傳送驗證碼(我這裏使用傳送郵件驗證碼的服務來替代簡訊驗證碼,大家懂這個意思就行)的邏輯:
async sendVerifyCode( email: string, googleCode: string, remoteip: string, ): Promise<string> { if (!googleCode) { throw new Error('驗證code不可為空'); } const res = await axios.post( 'https://www.google.com/recaptcha/api/siteverify', { secret: this.siteKey, // 私鑰 response: googleCode, }, { headers: { 'Content-Type': 'application/x-www-form-urlencoded', }, }, ); if (!res.data.success) { throw Error('非法驗證'); } const code = generateRandomNumber(); const text = `您的驗證碼是:${code},5分鐘內有效`; await this.emailService.sendMail(email, 'jueyin註冊', text); await this.redisService.set(`${VERIFY_CODE_PREFIX}:${email}`, code, 5 * 60); return code; }
按鈕倒計時
一般在傳送完驗證碼之後,會有一個倒計時的流程,不會讓使用者馬上又能重新發送驗證碼
這裏可以用到 redis
進行一個限流,在 redis
中設定一個 60s
過期的 key
。當請求過來時,檢查一下這個 key
是否還存在,如果存在,則丟擲異常,如果不存在則設定這個 key
,併發送驗證碼。
const cache = await this.redisService.get(`code::cache::${email}`); if (cache) { throw Error('請稍後再發送驗證碼'); } await this.redisService.set(`code::cache::${email}`, true, 60);
賬號限頻
同樣的,我們可以透過 redis
來對傳送的賬號進行限制頻率,比如說我只讓一個郵箱或者手機號每天只能傳送 5
條驗證碼。
我可以這麼寫:
private async refreshCount(email: string) { const redisClient = this.redisService.getClient(); const key = `code::count::${email}`; let countRes = await redisClient.get(key); const exist = await redisClient.exists(key); const count = Number(countRes); if (exist && count > 5) { throw Error('請求太過頻繁,請稍後再試'); } if (exist) { const ttl = await redisClient.ttl(key); await redisClient.set(key, count + 1); await redisClient.expire(key, ttl); } else { await redisClient.setex(key, 1, 60 * 60 * 12); // 一天 } }
在使用者第一次請求時,初始化
redis
的key
的值為1
,並設定過期時間為一天後續的請求時,增加這個
key
的值,如果超過了閾值,則拋異常