背景
前幾天項(xiàng)目上需要對(duì)一個(gè)正常登陸接口,以及忘記密碼的接口進(jìn)行防爆破處理,這里我用nginx,redis,以及前端的一些簡(jiǎn)單的圖形拖動(dòng)來(lái)做一個(gè)簡(jiǎn)單的安全機(jī)制,可能有不完善的地方,大家可以提出來(lái)意見。
技術(shù)分析
其實(shí)一個(gè)接口是無(wú)法完全避免接口爆破的,區(qū)分人和機(jī)器或許可以使用谷歌的圖片驗(yàn)證機(jī)制,但是我們一般簡(jiǎn)單項(xiàng)目沒必要做那么復(fù)雜的,只需要確保不正常的訪問(wèn)頻率不會(huì)爆破出我們的用戶信息,以及讓我們機(jī)器的處理流量保存在可控范圍即可。
實(shí)現(xiàn)的效果有下面這幾點(diǎn):
驗(yàn)證碼只能60s獲取一次 并且3小時(shí)內(nèi)只能獲取三次,超過(guò)次數(shù)提升獲取頻繁,稍后再試。
正常登錄1小時(shí)內(nèi)失敗6次賬號(hào)自動(dòng)鎖定,1小時(shí)之后自動(dòng)解鎖。
獲取驗(yàn)證碼無(wú)論輸入的賬號(hào)存在不存在均顯示發(fā)送成功,但是實(shí)際不存在的賬號(hào)不會(huì)正常發(fā)送。
4.登錄失敗,賬號(hào)不存在密碼錯(cuò)誤不再提示賬號(hào)不存在等等,而是統(tǒng)一顯示賬號(hào)或密碼錯(cuò)誤。5.忘記密碼前端部分增加滑動(dòng)校驗(yàn),60倒計(jì)時(shí)無(wú)法點(diǎn)擊發(fā)送驗(yàn)證碼。前后端共同校驗(yàn)。6.技術(shù)限制系統(tǒng)此接口的訪問(wèn)頻率。
前端部分


前端部分可以在這個(gè)地址看看這幾個(gè)簡(jiǎn)單的組件,這次我們就使用最簡(jiǎn)單的,滑動(dòng)拖動(dòng)即可。
<drag-verify
? ? ? ? ? ? ? ref="dragVerify"
? ? ? ? ? ? ? :width="width"
? ? ? ? ? ? ? :height="height"
? ? ? ? ? ? ? text="請(qǐng)按住滑塊拖動(dòng)"
? ? ? ? ? ? ? successText="驗(yàn)證通過(guò)"
? ? ? ? ? ? ? :isPassing.sync="isPassing"
? ? ? ? ? ? ? background="#ccc"
? ? ? ? ? ? ? completedBg="rgb(105, 231, 251)"
? ? ? ? ? ? ? handlerIcon="el-icon-d-arrow-right"
? ? ? ? ? ? ? successIcon="el-icon-circle-check"
? ? ? ? ? ? ? @passcallback="passcallback"
? ? ? ? ? >
? ? ? ? ? </drag-verify>
用戶滑動(dòng)之后需要加上60s倒計(jì)時(shí),這塊我們使用定時(shí)器實(shí)現(xiàn)即可,以及郵箱和手機(jī)號(hào)的正確性校驗(yàn),不正確則彈窗提示。
this.countDown = 60;
? ? ? timer = setInterval(() => {
? ? ? ? if (this.countDown - 1 >= 0) {
? ? ? ? ? this.countDown -= 1;
? ? ? ? } else {
? ? ? ? ? clearInterval(timer);
? ? ? ? ? timer = null;
? ? ? ? }
? ? ? }, 1000);
<el-button disabled type="text" v-show="time > 0">
{{ time > 0 ? `${time}` : "" }} s之后重試</el-button>
驗(yàn)證郵箱手機(jī)號(hào)可以使用正則校驗(yàn)進(jìn)行。
mobileReg = /^1\d{10}$/;
? ? ? emailReg = /^([A-Za-z0-9_\-\.\u4e00-\u9fa5])+\@([A-Za-z0-9_\-\.])+\.([A-Za-z]{2,8})$/;
前端大體思路就是,進(jìn)行滑塊驗(yàn)證,拖到右邊之后,60s之內(nèi)無(wú)法操作,60s到期之后自動(dòng)復(fù)原,
顯示倒計(jì)時(shí)時(shí)間。這個(gè)只能防止用戶在頁(yè)面上多次點(diǎn)擊,造成一個(gè)驗(yàn)證的假象,如果直接對(duì)后端接口爆破,則無(wú)法避免。
后端
這是大概的流程圖,圖中還有些細(xì)節(jié)問(wèn)題下面慢慢講解。

這塊本來(lái)我想用java或者kotlin寫,但是歷史項(xiàng)目用go寫的,重寫的話還有其他一些改動(dòng),所以繼續(xù)使用golang完成這部分邏輯。
先定義一個(gè)結(jié)構(gòu)體,然后我們來(lái)分析下需要哪些字段來(lái)實(shí)現(xiàn)我們的業(yè)務(wù)。
我們需要一個(gè)首次登陸時(shí)間,最后一次登陸時(shí)間,和總的登錄次數(shù)足以判斷:
驗(yàn)證碼間隔
登陸失敗次數(shù)
距離第一次登陸失敗次數(shù)間隔
type CommonLogin struct {
? ? CreateTime time.Time
? ? LastTime? time.Time
? ? Times? ? ? uint8
}
正常登陸的前置校驗(yàn)
// 登錄的前置校驗(yàn)
func beforeCommonLoginValid(key string, r *redis.Client, field string) (bool, error) {
? ? // redis中是否存在賬號(hào)
? ? result, err := r.HExists(field, key).Result()
? ? if err != nil {
? ? ? ? ? ? fmt.Printf("從redis中獲取用戶賬戶失敗,賬戶為: %s", key)
? ? ? ? ? ? return false, err
? ? }
? ? if result {
? ? ? ? ? ? login := &CommonLogin{}
? ? ? ? ? ? // 存在賬號(hào) 說(shuō)明之前登錄失敗過(guò) 且自從上次失敗未登錄成功過(guò)
? ? ? ? ? ? commonLogin, err := r.HGet(field, key).Result()
? ? ? ? ? ? if err != nil {
? ? ? ? ? ? ? ? ? ? return false, err
? ? ? ? ? ? }
? ? ? ? ? ? json.Unmarshal([]byte(commonLogin), login)
? ? ? ? ? ? if login.Times < 6 {
? ? ? ? ? ? ? ? ? ? return true, nil
? ? ? ? ? ? }
? ? ? ? ? ? // 是否在1小時(shí)內(nèi)失敗了6次
? ? ? ? ? ? if login.Times >= 6 {
? ? ? ? ? ? ? ? ? ? // 否
? ? ? ? ? ? ? ? ? ? if time.Now().Sub(login.CreateTime) > time.Hour*1 {
? ? ? ? ? ? ? ? ? ? ? ? ? ? // 連續(xù)輸錯(cuò)6次時(shí)長(zhǎng)大于1小時(shí) 解鎖
? ? ? ? ? ? ? ? ? ? ? ? ? ? r.HDel(field, key)
? ? ? ? ? ? ? ? ? ? ? ? ? ? return true, nil
? ? ? ? ? ? ? ? ? ? } else {
? ? ? ? ? ? ? ? ? ? ? ? ? ? fmt.Printf("用戶%s于1小時(shí)之內(nèi)連續(xù)登錄失敗6次,賬號(hào)鎖定,1小時(shí)后重試。", key)
? ? ? ? ? ? ? ? ? ? ? ? ? ? return false, nil
? ? ? ? ? ? ? ? ? ? }
? ? ? ? ? ? }
? ? }
? ? // redis中不存在重試記錄
? ? return true, nil
}
在所有的登錄判斷的出口,調(diào)用此方法即可,例如用戶名密碼錯(cuò)誤,acl校驗(yàn)未通過(guò)等等。
忘記密碼的登錄前置校驗(yàn)
其實(shí)原理差不多,唯一的區(qū)別就是多了一個(gè)獲取驗(yàn)證碼時(shí)間間隔校驗(yàn)。
func beforeForgotPasswordValid(key string, r *redis.Client, field string) (bool, error) {
? ? // redis中是否存在賬號(hào)
? ? result, err := r.HExists(field, key).Result()
? ? if err != nil {
? ? ? ? ? ? fmt.Printf("從redis中獲取用戶賬戶失敗,賬戶為: %s", key)
? ? ? ? ? ? return false, err
? ? }
? ? login := &CommonLogin{}
? ? // 賬號(hào)存在
? ? if result {
? ? ? ? ? ? commonLogin, err := r.HGet(field, key).Result()
? ? ? ? ? ? if err != nil {
? ? ? ? ? ? ? ? ? ? return false, err
? ? ? ? ? ? }
? ? ? ? ? ? json.Unmarshal([]byte(commonLogin), login)
? ? ? ? ? ? // 獲取驗(yàn)證碼間隔時(shí)長(zhǎng)不能小于60s
? ? ? ? ? ? if time.Now().Sub(login.LastTime) < time.Second*60 {
? ? ? ? ? ? ? ? ? ? fmt.Printf("用戶獲取驗(yàn)證碼間隔小于60s")
? ? ? ? ? ? ? ? ? ? return false, nil
? ? ? ? ? ? }
? ? ? ? ? ? if login.Times < 3 {
? ? ? ? ? ? ? ? ? ? return true, nil
? ? ? ? ? ? }
? ? ? ? ? ? // 是否在1小時(shí)內(nèi)獲取了3次
? ? ? ? ? ? if login.Times >= 3 {
? ? ? ? ? ? ? ? ? ? // 否
? ? ? ? ? ? ? ? ? ? if time.Now().Sub(login.CreateTime) > time.Hour*3 {
? ? ? ? ? ? ? ? ? ? ? ? ? ? // 連續(xù)輸錯(cuò)6次時(shí)長(zhǎng)大于1小時(shí) 解鎖
? ? ? ? ? ? ? ? ? ? ? ? ? ? r.HDel(field, key)
? ? ? ? ? ? ? ? ? ? ? ? ? ? return true, nil
? ? ? ? ? ? ? ? ? ? } else {
? ? ? ? ? ? ? ? ? ? ? ? ? ? fmt.Printf("用戶%s于3小時(shí)之內(nèi)連續(xù)獲取驗(yàn)證碼3次,賬號(hào)鎖定,3小時(shí)后重試。", key)
? ? ? ? ? ? ? ? ? ? ? ? ? ? return false, nil
? ? ? ? ? ? ? ? ? ? }
? ? ? ? ? ? }
? ? }
? ? return true, nil
}
忘記密碼的后置校驗(yàn)
// 更新獲取驗(yàn)證碼的時(shí)間
func afterForgotPasswordValid(key string, r *redis.Client, field string) {
? ? login := &CommonLogin{}
? ? commonLogin, _ := r.HGet(field, key).Result()
? ? json.Unmarshal([]byte(commonLogin), login)
? ? // 驗(yàn)證碼發(fā)送成功
? ? result, _ := r.HExists(field, key).Result()
? ? if result {
? ? ? ? ? ? login.Times = login.Times + 1
? ? ? ? ? ? login.LastTime = time.Now()
? ? ? ? ? ? data, _ := json.Marshal(login)
? ? ? ? ? ? r.HSet(field, key, data)
? ? } else {
? ? ? ? ? ? login.Times = 1
? ? ? ? ? ? login.LastTime = time.Now()
? ? ? ? ? ? login.CreateTime = login.LastTime
? ? ? ? ? ? data, _ := json.Marshal(login)
? ? ? ? ? ? r.HSet(field, key, data)
? ? }
}
使用nginx進(jìn)行接口訪問(wèn)頻率限制
nginx是一個(gè)非常強(qiáng)大的中間價(jià),在安全方面,我們可以用它來(lái)限制來(lái)自于同一機(jī)器的訪問(wèn)頻率,可以做黑名單功能等等,當(dāng)然有人會(huì)說(shuō)ip代{過(guò)}{濾}理池之類的,我們此次演示的只是簡(jiǎn)單demo,惡意攻擊當(dāng)然需要專業(yè)防護(hù)了。
具體google一下,看這兩篇官方文檔。
https://docs.nginx.com/nginx/admin-guide/security-controls/controlling-access-proxied-http
https://www.nginx.com/blog/rate-limiting-nginx/
具體的配置其實(shí)很簡(jiǎn)單了。
限制遠(yuǎn)程同ip訪問(wèn)頻率。
limit_req_zone$binary_remote_addrzone=perip:10mrate=1r/s;
解釋下這段配置的參數(shù):
$binary_remote_addr 表示通過(guò)remote
addr這個(gè)標(biāo)識(shí)來(lái)做限制,“binary”的目的是縮寫內(nèi)存占用量,是限制同一客戶端ip地址
zone=one:10m表示生成一個(gè)大小為10M,名字為one的內(nèi)存區(qū)域,用來(lái)存儲(chǔ)訪問(wèn)的頻次信息
rate=1r/s表示允許相同標(biāo)識(shí)的客戶端的訪問(wèn)頻次,這里限制的是每秒1次,還可以有比如30r/m的
location ^~ /api/xxx {
? ? ? ? limit_req zone=perip nodelay;
? ? ? ? limit_req_status 503;
? ? ? ? proxy_pass http://正確地址;
? ? }
上面配置意思就是超過(guò)頻率返回503,服務(wù)不可用。
使用jmeter進(jìn)行壓力測(cè)試:1s 10個(gè)請(qǐng)求,我們預(yù)期只有1個(gè)請(qǐng)求成功,其他的返回503.
