SpringCloud微服務(wù),如何保證對(duì)外接口的安全?
大家好,我是飄渺。如果你的微服務(wù)需要向第三方開放接口,如何確保你提供的接口是安全的呢?
1. 什么是安全接口
通常來說,要將暴露在外網(wǎng)的 API 接口視為安全接口,需要實(shí)現(xiàn)防篡改和防重放的功能。
1.1 什么是篡改問題?
由于 HTTP 是一種無狀態(tài)協(xié)議,服務(wù)端無法確定客戶端發(fā)送的請(qǐng)求是否合法,也不了解請(qǐng)求中的參數(shù)是否正確。以一個(gè)充值接口為例:
http://localhost/api/user/recharge?user_id=1001&amount=10
如果非法用戶通過抓包獲取接口參數(shù)并修改 user_id 或 amount 的值,就能為任意賬戶添加余額。
1.1.1 如何解決篡改問題?
雖然使用 HTTPS 協(xié)議能對(duì)傳輸?shù)拿魑倪M(jìn)行加密,但黑客仍可截獲數(shù)據(jù)包進(jìn)行重放攻擊。兩種通用解決方案是:
- 使用 HTTPS 加密接口數(shù)據(jù)傳輸,即使被黑客破解,也需要耗費(fèi)大量時(shí)間和精力。
- 在接口后臺(tái)對(duì)請(qǐng)求參數(shù)進(jìn)行簽名驗(yàn)證,以防止黑客篡改。
簽名的實(shí)現(xiàn)過程如下圖所示:
圖片
- 步驟1:客戶端使用約定好的規(guī)則對(duì)傳輸?shù)膮?shù)進(jìn)行加密,得到簽名值sign1,并且將簽名值也放入請(qǐng)求的參數(shù)中,隨請(qǐng)求發(fā)送至服務(wù)端。
- 步驟2:服務(wù)端接收到請(qǐng)求后,使用約定好的規(guī)則對(duì)請(qǐng)求的參數(shù)再次進(jìn)行簽名,得到簽名值 sign2。
- 步驟3:服務(wù)端比對(duì) sign1 和 sign2 的值,若不一致,則認(rèn)定為被篡改,判定為非法請(qǐng)求。
1.2. 什么是重放問題?
防重放也叫防復(fù)用。簡單來說就是我獲取到這個(gè)請(qǐng)求的信息之后什么也不改,,直接拿著接口的參數(shù)去 重復(fù)請(qǐng)求這個(gè)充值的接口。此時(shí)我的請(qǐng)求是合法的, 因?yàn)樗袇?shù)都是跟合法請(qǐng)求一模一樣的。重放攻擊會(huì)造成兩種后果:
- 針對(duì)插入數(shù)據(jù)庫接口:重放攻擊,會(huì)出現(xiàn)大量重復(fù)數(shù)據(jù),甚至垃圾數(shù)據(jù)會(huì)把數(shù)據(jù)庫撐爆。
- 針對(duì)查詢的接口:黑客一般是重點(diǎn)攻擊慢查詢接口,例如一個(gè)慢查詢接口1s,只要黑客發(fā)起重放攻擊,就必然造成系統(tǒng)被拖垮,數(shù)據(jù)庫查詢被阻塞死。
1.2.1 如何解決重放問題?
防重放,業(yè)界通?;?nonce + timestamp 方案實(shí)現(xiàn)。每次請(qǐng)求接口時(shí)生成 timestamp 和 nonce 兩個(gè)額外參數(shù),其中 timestamp 代表當(dāng)前請(qǐng)求時(shí)間,nonce 代表僅一次有效的隨機(jī)字符串。生成這兩個(gè)字段后,與其他參數(shù)一起進(jìn)行簽名,并發(fā)送至服務(wù)端。服務(wù)端接收請(qǐng)求后,先比較 timestamp 是否超過規(guī)定時(shí)間(如60秒),再查看 Redis 中是否存在 nonce,最后校驗(yàn)簽名是否一致,是否有篡改。
如果看過我DDD&微服務(wù)系列中冪等方案的文章,對(duì)于nonce方案肯定比較熟悉,這就是冪等方案中的token機(jī)制,只不過此時(shí)冪等key是由客戶端生成的。
圖片
2. 身份認(rèn)證方案
我們已經(jīng)了解了如何解決對(duì)外接口可能遇到的篡改和重放問題,但還遺漏了最關(guān)鍵的身份認(rèn)證環(huán)節(jié)。一般而言,對(duì)互聯(lián)網(wǎng)開放的接口不是任何人都能調(diào)用的,只有經(jīng)過認(rèn)證的用戶或機(jī)構(gòu)才有權(quán)限訪問。解決身份認(rèn)證問題通常通過 AppId 和 AppSecret 實(shí)現(xiàn)。
2.1 AppId + AppSecret
AppId作為一種全局唯一的標(biāo)識(shí)符,主要用于用戶身份識(shí)別。為防止其他用戶惡意使用別人的 AppId 發(fā)起請(qǐng)求,通常采用配對(duì) AppSecret 的方式,類似一種密碼。在請(qǐng)求方發(fā)起請(qǐng)求時(shí),需將 AppID 和 AppSecret 搭配上前文提到的安全方案,一并簽名提交給提供方驗(yàn)證。
現(xiàn)在,讓我們?cè)賮硎崂硪幌峦暾暮灻桨浮?/p>
1、服務(wù)方提供一組 AppId 和 AppSecret,并由客戶端保存。
2、將timestamp、nonce、AppId 與請(qǐng)求參數(shù)一起并按照字典排序,使用URL鍵值對(duì)(key1=value1&key2=value2…)的格式拼接形成字符串StringA。
3、在StringA的最后拼接上AppSecret,得到字符串StringB。
4、使用摘要算法對(duì) StringB 進(jìn)行加密,并將得到的字符串轉(zhuǎn)為大寫,得到簽名值 sign,將其與參數(shù)一起發(fā)送給服務(wù)端。
5、服務(wù)端接收請(qǐng)求后,對(duì)接口進(jìn)行校驗(yàn)(時(shí)間、隨機(jī)字符串、身份驗(yàn)證、簽名)。
在這個(gè)流程中,AppID 參與本地加密和網(wǎng)絡(luò)傳輸,而 AppSecret 僅作本地加密使用,不參與網(wǎng)絡(luò)傳輸。服務(wù)端拿到 AppID 后,從存儲(chǔ)介質(zhì)中獲取對(duì)應(yīng)的 AppSecret,然后采用與客戶端相同的簽名規(guī)則生成服務(wù)端簽名,最后比較客戶端簽名和服務(wù)端簽名是否一致。
3. 代碼實(shí)現(xiàn)
"Talk is cheap. Show me the code." 說了這么久,現(xiàn)在讓我們從代碼的角度來看看如何在 DailyMart 中將上面的理論知識(shí)串聯(lián)起來,安全地對(duì)外提供接口。
本文涉及到的所有代碼都已上傳至github,如果需要請(qǐng)參考文末方式進(jìn)行獲取。
3.1 AppId 和 AppSecret的生成
在生成 AppId 和 AppSecret 時(shí),只需確保 AppId 的全局唯一性,然后將生成的 AppId 和 AppSecret 進(jìn)行綁定。在 DailyMart 中,我們使用短鏈的生成算法來生成 AppId,再對(duì) AppId 進(jìn)行 SHA 加密后得到對(duì)應(yīng)的 AppSecret。
private static String getAppKey() {
long num = IdUtils.nextId();
StringBuilder sb = new StringBuilder();
do {
int remainder = (int) (num % 62);
sb.insert(0, BASE62_CHARACTERS.charAt(remainder));
num /= 62;
} while (num != 0);
return sb.toString();
}
通過這個(gè)算法生成的 AppId 和 AppSecret 形如:
appKey=6iYWoL2hBk9, appSecret=5de8bc4d8278ed4f14a3490c0bdd5cbe369e8ec9
3.2 API校驗(yàn)器
在一個(gè)系統(tǒng)中可能存在多種認(rèn)證邏輯,比如既要支持今天所講的開放接口校驗(yàn)邏輯,還需要支持內(nèi)部服務(wù)的 JWT 認(rèn)證邏輯。為了方便處理,我們抽象一個(gè) API 認(rèn)證接口,各種認(rèn)證邏輯獨(dú)立到自己的實(shí)現(xiàn)中,對(duì)于今天所講的開放接口認(rèn)證,主要關(guān)注 ProtectedApiAuthenticator。
圖片
//認(rèn)證接口
public interface ApiAuthenticator {
AuthenticatorResult auth(ServerWebExchange request);
}
//具體實(shí)現(xiàn)
@Slf4j
public class ProtectedApiAuthenticator implements ApiAuthenticator {
...
}
3.2 網(wǎng)關(guān)過濾器
接口的安全校驗(yàn)很適合放在網(wǎng)關(guān)層實(shí)現(xiàn),因此我們需要在網(wǎng)關(guān)服務(wù)中創(chuàng)建一個(gè)過濾器 ApiAuthenticatorFilter。
@Component
@Slf4j
public class ApiAuthenticatorFilter implements GlobalFilter, Ordered {
...
@Override
public Mono<Void> filter(ServerWebExchange exchange, GatewayFilterChain chain) {
// 獲取認(rèn)證邏輯
ApiAuthenticator apiAuthenticator = getApiAuthenticator(rawPath);
AuthenticatorResult authenticatorResult = apiAuthenticator.auth(exchange);
if (!authenticatorResult.isResult()) {
return Mono.error(new HttpServerErrorException(
HttpStatus.METHOD_NOT_ALLOWED, authenticatorResult.getMessage()));
}
return chain.filter(exchange);
}
/**
* 確定認(rèn)證策略
* @param rawPath 請(qǐng)求路徑
*/
private ApiAuthenticator getApiAuthenticator(String rawPath) {
String[] parts = rawPath.split("/");
if (parts.length >= 4) {
String parameter = parts[3];
return switch (parameter) {
case PROTECT_PATH -> new ProtectedApiAuthenticator();
case PRIVATE_PATH -> new PrivateApiAuthenticator();
case PUBLIC_PATH -> new PublicApiAuthenticator();
case DEFAULT_PATH -> new DefaultApiAuthenticator();
default -> throw new IllegalStateException("Unexpected value: " + parameter);
};
}
return new DefaultApiAuthenticator();
}
}
上面提到過,不同類型的服務(wù)其接口認(rèn)證不一樣,為了便于區(qū)分,可以規(guī)定對(duì)于外部請(qǐng)求都增加一個(gè)特定的請(qǐng)求前綴 /pt/,如 apigw.xxx.com/order-service/api/pt/creadeOrder。這樣在過濾器內(nèi)部就需要通過 getApiAuthenticator() 方法確定認(rèn)證邏輯。
3.3 接口安全認(rèn)證
正如上文所說,服務(wù)端獲取到請(qǐng)求參數(shù)以后需要檢查請(qǐng)求時(shí)間是否過期,nonce是否已經(jīng)被使用,簽名是否正確。
圖片
按照這個(gè)邏輯我們很容易在ProtectedApiAuthenticator認(rèn)證器中寫出這樣的代碼。
@Slf4j
public class ProtectedApiAuthenticator implements ApiAuthenticator {
@Override
public AuthenticatorResult auth(ServerWebExchange exchange) {
// 1. 校驗(yàn)參數(shù)
boolean checked = preAuthenticationCheck(requestHeader);
if (!checked) {
return new AuthenticatorResult(false, "請(qǐng)攜帶正確參數(shù)訪問");
}
// 2 . 重放校驗(yàn)
// 判斷timestamp時(shí)間戳與當(dāng)前時(shí)間是否操過60s(過期時(shí)間根據(jù)業(yè)務(wù)情況設(shè)置),如果超過了就提示簽名過期。
long now = System.currentTimeMillis() ;
if (now - Long.parseLong(requestHeader.getTimestamp()) > 60000) {
return new AuthenticatorResult(false, "請(qǐng)求超時(shí),請(qǐng)重新訪問");
}
// 3. 判斷nonce
boolean nonceExists = distributedCache.hasKey(NONCE_KEY + requestHeader.getNonce());
if (nonceExists) {
return new AuthenticatorResult(false, "請(qǐng)勿重復(fù)提交請(qǐng)求");
} else {
distributedCache.put(NONCE_KEY + requestHeader.getNonce(), requestHeader.getNonce(), 60000);
}
// 4. 簽名校驗(yàn)
SortedMap<String, Object> requestBody = CachedRequestUtil.resolveFromBody(exchange);
String sign = buildSign(requestHeader,requestBody);
if(!sign.equals(requestHeader.getSign())){
return new AuthenticatorResult(false, "簽名錯(cuò)誤");
}
return new AuthenticatorResult(true, "");
}
這樣的寫法雖然能夠完成校驗(yàn)邏輯,但稍顯不夠優(yōu)雅。在這種場景中,使用設(shè)計(jì)模式中的責(zé)任鏈模式是非常合適的選擇。通過責(zé)任鏈模式,將校驗(yàn)邏輯分解為多個(gè)責(zé)任鏈節(jié)點(diǎn),每個(gè)節(jié)點(diǎn)專注于一個(gè)方面的校驗(yàn),使得代碼更加清晰和易于維護(hù)。
責(zé)任鏈模式已經(jīng)在我星球設(shè)計(jì)模式專欄中有詳細(xì)介紹與說明,這里就不再贅述了~
@Slf4j
public class ProtectedApiAuthenticator implements ApiAuthenticator {
@Override
public AuthenticatorResult auth(ServerWebExchange exchange) {
...
//構(gòu)建校驗(yàn)對(duì)象
ProtectedRequest protectedRequest = ProtectedRequest.builder()
.requestHeader(requestHeader)
.requestBody(requestBody)
.build();
//責(zé)任鏈上下文
SecurityVerificationChain securityVerificationChain = SpringBeanUtils.getInstance().getBean(SecurityVerificationChain.class);
return securityVerificationChain.handler(protectedRequest);
}
}
3.4 基于責(zé)任鏈的認(rèn)證實(shí)現(xiàn)
圖片
3.4.1 創(chuàng)建責(zé)任鏈的認(rèn)證接口
public interface SecurityVerificationHandler extends Ordered {
/**
* 請(qǐng)求校驗(yàn)
*/
AuthenticatorResult handler(ProtectedRequest protectedRequest);
}
3.4.2 實(shí)現(xiàn)參數(shù)校驗(yàn)邏輯
@Component
public class RequestParamVerificationHandler implements SecurityVerificationHandler {
@Override
public AuthenticatorResult handler(ProtectedRequest protectedRequest) {
boolean checked = checkedHeader(protectedRequest.getRequestHeader());
if(!checked){
return new AuthenticatorResult(false,"請(qǐng)攜帶正確的請(qǐng)求參數(shù)");
}
return new AuthenticatorResult(true,"");
}
private boolean checkedHeader(RequestHeader requestHeader) {
return Objects.nonNull(requestHeader.getAppId()) &&
Objects.nonNull(requestHeader.getSign()) &&
Objects.nonNull(requestHeader.getNonce()) &&
Objects.nonNull(requestHeader.getTimestamp());
}
@Override
public int getOrder() {
return 1;
}
}
3.4.3 實(shí)現(xiàn)nonce的校驗(yàn)
@Component
public class NonceVerificationHandler implements SecurityVerificationHandler {
private static final String NONCE_KEY = "x-nonce-";
@Value("${dailymart.sign.timeout:60000}")
private long expireTime ;
@Resource
private DistributedCache distributedCache;
@Override
public AuthenticatorResult handler(ProtectedRequest protectedRequest) {
String nonce = protectedRequest.getRequestHeader().getNonce();
boolean nonceExists = distributedCache.hasKey(NONCE_KEY + nonce);
if (nonceExists) {
return new AuthenticatorResult(false, "請(qǐng)勿重復(fù)提交請(qǐng)求");
} else {
distributedCache.put(NONCE_KEY + nonce, nonce, expireTime);
return new AuthenticatorResult(true, "");
}
}
@Override
public int getOrder() {
return 3;
}
}
3.4.4 實(shí)現(xiàn)簽名認(rèn)證
@Component
@Slf4j
public class SignatureVerificationHandler implements SecurityVerificationHandler {
@Override
public AuthenticatorResult handler(ProtectedRequest protectedRequest) {
//1. 服務(wù)端按照規(guī)則重新簽名
String serverSign = sign(protectedRequest);
log.info("服務(wù)端簽名結(jié)果: {}", serverSign);
String clientSign = protectedRequest.getRequestHeader().getSign();
// 2、獲取客戶端傳遞的簽名
log.info("客戶端簽名: {}", clientSign);
if (!Objects.equals(serverSign,clientSign)) {
return new AuthenticatorResult(false, "請(qǐng)求簽名無效");
}
return new AuthenticatorResult(true, "");
}
/**
* 服務(wù)端重建簽名
* @param protectedRequest 請(qǐng)求體
* @return 簽名結(jié)果
*/
private String sign(ProtectedRequest protectedRequest) {
RequestHeader requestHeader = protectedRequest.getRequestHeader();
String appId = requestHeader.getAppId();
String appSecret = getAppSecret(appId);
// 1、 按照規(guī)則對(duì)數(shù)據(jù)進(jìn)行簽名
SortedMap<String, Object> requestBody = protectedRequest.getRequestBody();
requestBody.put("app_id",appId);
requestBody.put("nonce_number",requestHeader.getNonce());
requestBody.put("request_time",requestHeader.getTimestamp());
StringBuilder signBuilder = new StringBuilder();
for (Map.Entry<String, Object> entry : requestBody.entrySet()) {
signBuilder.append(entry.getKey()).append("=").append(entry.getValue()).append("&");
}
signBuilder.append("appSecret=").append(appSecret);
return DigestUtils.md5DigestAsHex(signBuilder.toString().getBytes()).toUpperCase();
}
@Override
public int getOrder() {
return 4;
}
}
3.4.5 責(zé)任鏈上下文
@Component
@Slf4j
public class SecurityVerificationChain {
@Resource
private List<SecurityVerificationHandler> securityVerificationHandlers;
public AuthenticatorResult handler(ProtectedRequest protectedRequest){
AuthenticatorResult authenticatorResult = new AuthenticatorResult(true,"");
for (SecurityVerificationHandler securityVerificationHandler : securityVerificationHandlers) {
AuthenticatorResult result = securityVerificationHandler.handler(protectedRequest);
// 有一個(gè)校驗(yàn)不通過理解返回
if(!result.isResult()){
return result;
}
}
return authenticatorResult;
}
}
組合所有的校驗(yàn)邏輯,任意一個(gè)校驗(yàn)邏輯不通過則直接返回。
小結(jié)
在本文中,我們深入研究了微服務(wù)架構(gòu)中對(duì)外開放接口的安全性保障機(jī)制。我們著重關(guān)注了那些暴露在外網(wǎng)的API接口面臨的兩個(gè)關(guān)鍵安全問題:篡改和重放。為了應(yīng)對(duì)篡改問題,我們引入了雙重手段:采用HTTPS進(jìn)行加密傳輸,并結(jié)合接口參數(shù)簽名驗(yàn)證,以確保數(shù)據(jù)傳輸?shù)耐暾院桶踩浴?duì)于重放問題,我們采納了基于nonce和timestamp的方案,以保證請(qǐng)求的唯一性和有效性。
在具體的代碼實(shí)現(xiàn)中,我們不僅考慮了文章中提到的安全認(rèn)證邏輯,還充分考慮了其他可能的校驗(yàn)規(guī)則。為了更好地組織和管理這些校驗(yàn)規(guī)則,我們將它們拆分成獨(dú)立的模塊,根據(jù)請(qǐng)求路徑動(dòng)態(tài)選擇相應(yīng)的接口校驗(yàn)器。在第三方接口校驗(yàn)邏輯中,我們通過責(zé)任鏈的設(shè)計(jì)模式實(shí)現(xiàn)了具體的校驗(yàn)規(guī)則,使得代碼邏輯更為模塊化和可擴(kuò)展。這樣的結(jié)構(gòu)不僅使得每個(gè)校驗(yàn)步驟聚焦于特定的安全性驗(yàn)證,而且提供了良好的可維護(hù)性和可擴(kuò)展性。
最后給大家一個(gè)小建議:對(duì)外提供的接口協(xié)議盡量簡單,不要使用Restful接口風(fēng)格,全部使用post+json或post+form風(fēng)格的接口協(xié)議即可,這樣對(duì)客戶端和服務(wù)端都方便。