涉及錢時,必須考慮防刷、限量和防重
任何涉及錢的代碼必須要考慮防刷、限量和防重,要做好安全兜底。涉及錢的代碼,主要有以下三類。
第一,代碼本身涉及有償使用的三方服務。如果因為代碼本身缺少授權、用量控制而被利用導致大量調用,勢必會消耗大量的錢,給公司造成損失。有些三方服務可能采用后付款方式的結算,出現問題后如果沒及時發現,下個月結算時就會收到一筆數額巨大的賬單。
第二,代碼涉及虛擬資產的發放,比如積分、優惠券等。雖然說虛擬資產不直接對應貨幣,但一般可以在平臺兌換具有真實價值的資產。比如,優惠券可以在下單時使用,積分可以兌換積分商城的商品。所以從某種意義上說,虛擬資產就是具有一定價值的錢,但因為不直接涉及錢和外部資金通道,所以容易產生隨意性發放而導致漏洞。
第三,代碼涉及真實錢的進出。比如,對用戶扣款,如果出現非正常的多次重復扣款,小則用戶投訴、用戶流失,大則被相關管理機構要求停業整改,影響業務。又比如,給用戶發放返現的付款功能,如果出現漏洞造成重復付款,涉及 B 端的可能還好,但涉及 C 端用戶的重復付款可能永遠無法追回。
前段時間拼多多一夜之間被刷了大量 100 元無門檻優惠券的事情,就是限量和防刷出了問題。
今天,我們就通過三個例子,和你說明如何在代碼層面做好安全兜底。
開放平臺資源的使用需要考慮防刷
我以真實遇到的短信服務被刷案例,和你說說防刷。
有次短信賬單月結時發現,之前每個月是幾千元的短信費用,這個月突然變為了幾萬元。查數據庫記錄發現,之前是每天發送幾千條短信驗證碼,從某天開始突然變為了每天幾萬條,但注冊用戶數并沒有激增。顯然,這是短信接口被刷了。
我們知道,短信驗證碼服務屬于開放性服務,由用戶側觸發,且因為是注冊驗證碼所以不需要登錄就可以使用。如果我們的發短信接口像這樣沒有任何防刷的防護,直接調用三方短信通道,就相當于“裸奔”,很容易被短信轟炸平臺利用:
@GetMapping("wrong")
publicvoid wrong() {
sendSMSCaptcha("13600000000");
}
privatevoid sendSMSCaptcha(String mobile) {
//調用短信通道
}
對于短信驗證碼這種開放接口,程序邏輯內需要有防刷邏輯。好的防刷邏輯是,對正常使用的用戶毫無影響,只有疑似異常使用的用戶才會感受到。對于短信驗證碼,有如下 4 種可行的方式來防刷。
第一種方式,只有固定的請求頭才能發送驗證碼。
也就是說,我們通過請求頭中網頁或 App 客戶端傳給服務端的一些額外參數,來判斷請求是不是 App 發起的。其實,這種方式“防君子不防小人”。
比如,判斷是否存在瀏覽器或手機型號、設備分辨率請求頭。對于那些使用爬蟲來抓取短信接口地址的程序來說,往往只能抓取到 URL,而難以分析出請求發送短信還需要的額外請求頭,可以看作第一道基本防御。
第二種方式,只有先到過注冊頁面才能發送驗證碼。
對于普通用戶來說,不管是通過 App 注冊還是 H5 頁面注冊,一定是先進入注冊頁面才能看到發送驗證碼按鈕,再點擊發送。我們可以在頁面或界面打開時請求固定的前置接口,為這個設備開啟允許發送驗證碼的窗口,之后的請求發送驗證碼才是有效請求。
這種方式可以防御直接繞開固定流程,通過接口直接調用的發送驗證碼請求,并不會干擾普通用戶。
第三種方式,控制相同手機號的發送次數和發送頻次。
除非是短信無法收到,否則用戶不太會請求了驗證碼后不完成注冊流程,再重新請求。因此,我們可以限制同一手機號每天的最大請求次數。驗證碼的到達需要時間,太短的發送間隔沒有意義,所以我們還可以控制發送的最短間隔。比如,我們可以控制相同手機號一天只能發送 10 次驗證碼,最短發送間隔 1 分鐘。
第四種方式,增加前置圖形驗證碼。
短信轟炸平臺一般會收集很多免費短信接口,一個接口只會給一個用戶發一次短信,所以控制相同手機號發送次數和間隔的方式不夠有效。這時,我們可以考慮對用戶體驗稍微有影響,但也是最有效的方式作為保底,即將彈出圖形驗證碼作為前置。
除了圖形驗證碼,我們還可以使用其他更友好的人機驗證手段(比如滑動、點擊驗證碼等),甚至是引入比較新潮的無感知驗證碼方案(比如,通過判斷用戶輸入手機號的打字節奏,來判斷是用戶還是機器),來改善用戶體驗。
此外,我們也可以考慮在監測到異常的情況下再彈出人機檢測。比如,短時間內大量相同遠端 IP 發送驗證碼的時候,才會觸發人機檢測。
總之,我們要確保,只有正常用戶經過正常的流程才能使用開放平臺資源,并且資源的用量在業務需求合理范圍內。此外,還需要考慮做好短信發送量的實時監控,遇到發送量激增要及時報警。
接下來,我們一起看看限量的問題。
虛擬資產并不能憑空產生無限使用
虛擬資產雖然是平臺方自己生產和控制,但如果生產出來可以立即使用就有立即變現的可能性。比如,因為平臺 Bug 有大量用戶領取高額優惠券,并立即下單使用。
在商家看來,這很可能只是一個用戶支付的訂單,并不會感知到用戶使用平臺方優惠券的情況;同時,因為平臺和商家是事后結算的,所以會馬上安排發貨。而發貨后基本就不可逆了,一夜之間造成了大量資金損失。
我們從代碼層面模擬一個優惠券被刷的例子。
假設有一個 CouponCenter 類負責優惠券的產生和發放。如下是錯誤做法,只要調用方需要,就可以憑空產生無限的優惠券:
@Slf4j
publicclass CouponCenter {
//用于統計發了多少優惠券
AtomicInteger totalSent = new AtomicInteger(0);
public void sendCoupon(Coupon coupon) {
if (coupon != null)
totalSent.incrementAndGet();
}
public int getTotalSentCoupon() {
return totalSent.get();
}
//沒有任何限制,來多少請求生成多少優惠券
public Coupon generateCouponWrong(long userId, BigDecimal amount) {
returnnew Coupon(userId, amount);
}
}
這樣一來,使用 CouponCenter 的 generateCouponWrong 方法,想發多少優惠券就可以發多少:
@GetMapping("wrong")
public int wrong() {
CouponCenter couponCenter = new CouponCenter();
//發送10000個優惠券
IntStream.rangeClosed(1, 10000).forEach(i -> {
Coupon coupon = couponCenter.generateCouponWrong(1L, new BigDecimal("100"));
couponCenter.sendCoupon(coupon);
});
return couponCenter.getTotalSentCoupon();
}
更合適的做法是,把優惠券看作一種資源,其生產不是憑空的,而是需要事先申請,理由是:
虛擬資產如果最終可以對應到真實金錢上的優惠,那么,能發多少取決于運營和財務的核算,應該是有計劃、有上限的。引言提到的無門檻優惠券,需要特別小心。有門檻優惠券的大量使用至少會帶來大量真實的消費,而使用無門檻優惠券下的訂單,可能用戶一分錢都沒有支付。
即使虛擬資產不值錢,大量不合常規的虛擬資產流入市場,也會沖垮虛擬資產的經濟體系,造成虛擬貨幣的極速貶值。有量的控制才有價值。
資產的申請需要理由,甚至需要走流程,這樣才可以追溯是什么活動需要、誰提出的申請,程序依據申請批次來發放。
接下來,我們按照這個思路改進一下程序。
首先,定義一個 CouponBatch 類,要產生優惠券必須先向運營申請優惠券批次,批次中包含了固定張數的優惠券、申請原因等信息:
//優惠券批次
@Data
publicclass CouponBatch {
privatelong id;
private AtomicInteger totalCount;
private AtomicInteger remainCount;
private BigDecimal amount;
private String reason;
}
在業務需要發放優惠券的時候,先申請批次,然后再通過批次發放優惠券:
@GetMapping("right")
public int right() {
CouponCenter couponCenter = new CouponCenter();
//申請批次
CouponBatch couponBatch = couponCenter.generateCouponBatch();
IntStream.rangeClosed(1, 10000).forEach(i -> {
Coupon coupon = couponCenter.generateCouponRight(1L, couponBatch);
//發放優惠券
couponCenter.sendCoupon(coupon);
});
return couponCenter.getTotalSentCoupon();
}
可以看到,generateCouponBatch 方法申請批次時,設定了這個批次包含 100 張優惠券。在通過 generateCouponRight 方法發放優惠券時,每發一次都會從批次中扣除一張優惠券,發完了就沒有了:
public Coupon generateCouponRight(long userId, CouponBatch couponBatch) {
if (couponBatch.getRemainCount().decrementAndGet() >= 0) {
returnnew Coupon(userId, couponBatch.getAmount());
} else {
log.info("優惠券批次 {} 剩余優惠券不足", couponBatch.getId());
returnnull;
}
}
public CouponBatch generateCouponBatch() {
CouponBatch couponBatch = new CouponBatch();
couponBatch.setAmount(new BigDecimal("100"));
couponBatch.setId(1L);
couponBatch.setTotalCount(new AtomicInteger(100));
couponBatch.setRemainCount(couponBatch.getTotalCount());
couponBatch.setReason("XXX活動");
return couponBatch;
}
這樣改進后的程序,一個批次最多只能發放 100 張優惠券:
因為是 Demo,所以我們只是憑空 new 出來一個 Coupon。在真實的生產級代碼中,一定是根據 CouponBatch 在數據庫中插入一定量的 Coupon 記錄,每一個優惠券都有唯一的 ID,可跟蹤、可注銷。
最后,我們再看看防重。
錢的進出一定要和訂單掛鉤并且實現冪等
涉及錢的進出,需要做好以下兩點。
第一,任何資金操作都需要在平臺側生成業務屬性的訂單,可以是優惠券發放訂單,可以是返現訂單,也可以是借款訂單,一定是先有訂單再去做資金操作。同時,訂單的產生需要有業務屬性。業務屬性是指,訂單不是憑空產生的,否則就沒有控制的意義。比如,返現發放訂單必須關聯到原先的商品訂單產生;再比如,借款訂單必須關聯到同一個借款合同產生。
第二,一定要做好防重,也就是實現冪等處理,并且冪等處理必須是全鏈路的。這里的全鏈路是指,從前到后都需要有相同的業務訂單號來貫穿,實現最終的支付防重。
關于這兩點,你可以參考下面的代碼示例:
//錯誤:每次使用UUID作為訂單號
@GetMapping("wrong")
publicvoid wrong(@RequestParam("orderId") String orderId) {
PayChannel.pay(UUID.randomUUID().toString(), "123", new BigDecimal("100"));
}
//正確:使用相同的業務訂單號
@GetMapping("right")
publicvoid right(@RequestParam("orderId") String orderId) {
PayChannel.pay(orderId, "123", new BigDecimal("100"));
}
//三方支付通道
publicclass PayChannel {
publicstaticvoid pay(String orderId, String account, BigDecimal amount) {
...
}
}
對于支付操作,我們一定是調用三方支付公司的接口或銀行接口進行處理的。一般而言,這些接口都會有商戶訂單號的概念,對于相同的商戶訂單號,無法進行重復的資金處理,所以三方公司的接口可以實現唯一訂單號的冪等處理。
但是,業務系統在實現資金操作時容易犯的錯是,沒有自始至終地使用一個訂單號作為商戶訂單號,透傳給三方支付接口。出現這個問題的原因是,比較大的互聯網公司一般會把支付獨立一個部門。支付部門可能會針對支付做聚合操作,內部會維護一個支付訂單號,然后使用支付訂單號和三方支付接口交互。最終雖然商品訂單是一個,但支付訂單是多個,相同的商品訂單因為產生多個支付訂單導致多次支付。
如果說,支付出現了重復扣款,我們可以給用戶進行退款操作,但給用戶付款的操作一旦出現重復付款,就很難把錢追回來了,所以更要小心。
這,就是全鏈路的意義,從一開始就需要先有業務訂單產生,然后使用相同的業務訂單號一直貫穿到最后的資金通路,才能真正避免重復資金操作。
重點回顧
今天,我從安全兜底聊起,和你分享了涉及錢的業務最需要做的三方面工作,防刷、限量和防重。
第一,使用開放的、面向用戶的平臺資源要考慮防刷,主要包括正常使用流程識別、人機識別、單人限量和全局限量等手段。
第二,虛擬資產不能憑空產生,一定是先有發放計劃、申請批次,然后通過批次來生產資產。這樣才能達到限量、有審計、能追溯的目的。
第三,真實錢的進出操作要額外小心,做好防重處理。不能憑空去操作用戶的賬戶,每次操作以真實的訂單作為依據,通過業務訂單號實現全鏈路的冪等控制。
如果程序邏輯涉及有價值的資源或是真實的錢,我們必須有敬畏之心。程序上線后,人是有休息時間的,但程序是一直運行著的,如果產生安全漏洞,就很可能在一夜之間爆發,被大量人利用導致大量的金錢損失。
除了在流程上做好防刷、限量和防重控制之外,我們還需要做好三方平臺調用量、虛擬資產使用量、交易量、交易金額等重要數據的監控報警,這樣即使出現問題也能第一時間發現。