Spring Boot如何優(yōu)雅提高接口數(shù)據(jù)安全性
1.背景
最近我司業(yè)務(wù)上需要對接第三方各大銀行平臺,調(diào)用第三方接口和提供接口供第三方調(diào)用,這時候的對外open接口安全性就得重視了,再有就是之前我在知乎上發(fā)布一篇《Spring Security實現(xiàn)后端接口權(quán)限驗證》的總結(jié),有個兄弟提出一個問題:只做接口功能菜單權(quán)限檢驗還不夠,還得做數(shù)據(jù)權(quán)限檢驗才行,舉個例子:用戶A有刪除某條數(shù)據(jù)的接口權(quán)限,這個接口的參數(shù)是傳記錄id來刪除的(ps:平時我們開發(fā)接口也是這么做的),后端執(zhí)行的邏輯就是通過登錄信息通過用戶認證,然后再判斷接口菜單權(quán)限,緊接著就執(zhí)行如下SQL邏輯:
delete from table where id=?
這里的id就是掉接口傳遞的參數(shù),這時候假如用戶B知道了怎么調(diào)接口,就根據(jù)id自增長的特性隨意傳id,就會刪掉別人的數(shù)據(jù),所以這是一個嚴重的問題,要解決這問題可以像上面說的一樣加上數(shù)據(jù)權(quán)限,執(zhí)行邏輯如下:
delete from table where id=? and user_id = userId
這樣就避免數(shù)據(jù)被別人操作了,也就是加上了數(shù)據(jù)權(quán)限判斷,但是卻給業(yè)務(wù)邏輯增加了復(fù)雜性同時老接口業(yè)務(wù)邏輯難以適配,本質(zhì)上來說web頁面上看到的數(shù)據(jù)就是根據(jù)用戶角色做過數(shù)據(jù)隔離的,可以這么理解你能看到哪些數(shù)據(jù)和你有那些功能菜單操作權(quán)限就差不多避免上面所說的情況了,但是保不準(zhǔn)懂代碼的人使用postman等工具惡意調(diào)接口而產(chǎn)生上面的情況,我們還是得正視這個問題,既然通過數(shù)據(jù)權(quán)限解決該問題不太友好,那么我們可以再思考下怎么避免這個問題???這個問題可以轉(zhuǎn)換為怎么避免別人輕易就能調(diào)通接口,解決辦法就是不能在外網(wǎng)暴露接口信息,拒絕接口裸奔,從而有效提高接口安全性,這也是今天我們這篇總結(jié)的核心主旨。當(dāng)然這里強調(diào)一下我這里說的是有效提高,不是絕對保證安全,做不到...
項目推薦:基于SpringBoot2.x、SpringCloud和SpringCloudAlibaba企業(yè)級系統(tǒng)架構(gòu)底層框架封裝,解決業(yè)務(wù)開發(fā)時常見的非功能性需求,防止重復(fù)造輪子,方便業(yè)務(wù)快速開發(fā)和企業(yè)技術(shù)??蚣芙y(tǒng)一管理。引入組件化的思想實現(xiàn)高內(nèi)聚低耦合并且高度可配置化,做到可插拔。嚴格控制包依賴和統(tǒng)一版本管理,做到最少化依賴。注重代碼規(guī)范和注釋,非常適合個人學(xué)習(xí)和企業(yè)使用
Github地址:https://github.com/plasticene/plasticene-boot-starter-parent
Gitee地址:https://gitee.com/plasticene3/plasticene-boot-starter-parent
2.Spring Boot如何提高接口安全性
在Spring Boot項目中提高接口安全的核心所在:加密和加簽,加固接口參數(shù)、驗證復(fù)雜度。
加密:對參數(shù)進行加密傳輸,拒絕接口參數(shù)直接暴露,這樣就可以有效做到防止別人輕易準(zhǔn)確地獲取到接口參數(shù)定義和傳參格式要求了。
加簽:對接口參數(shù)進行加簽,可以有效防止接口參數(shù)被篡改和接口參數(shù)被重放惡刷。
2.1 加密
現(xiàn)今有許許多多的加密算法,這里就不對算法進行過度敘述,畢竟不是我們今天的主題,但是加密算法大體分為非對稱加密和對稱加密。
非對稱加密
非對稱加密算法是一種密鑰的保密方法。非對稱加密算法需要兩個密鑰:公開密鑰(publickey:簡稱公鑰)和私有密鑰(privatekey:簡稱私鑰)。公鑰與私鑰是一對,如果用公鑰對數(shù)據(jù)進行加密,只有用對應(yīng)的私鑰才能解密。因為加密和解密使用的是兩個不同的密鑰,所以這種算法叫作非對稱加密算法。
對稱加密
加密秘鑰和解密秘鑰是一樣,當(dāng)你的密鑰被別人知道后,就沒有秘密可言了。
經(jīng)過需求分析和科學(xué)借鑒我們采用了非對稱加密算法RSA和對稱加密算法AES來完成接口加密。至于這兩種加密算法的原理與實現(xiàn)有興趣自己去查資料,我這里就說一下選它們的原因:
AES 是對稱加密算法,優(yōu)點:加密速度快;缺點:如果秘鑰丟失,就容易解密密文,安全性相對比較差
RSA 是非對稱加密算法 , 優(yōu)點:安全 ;缺點:加密速度慢
接口參數(shù)加解密的流程大致如圖所示:
圖片
具體步驟如下:
- 客戶端(調(diào)用接口方)隨機生成AES加解密的密鑰aes key,這里的AES密鑰每次調(diào)接口都需要隨機生成,可以有效提高安全性。
- 使用aes key對接口參數(shù)requestBody進行加密,data=base64(AES(json參數(shù)))
- 通過RSA加密算法加密aes key,有效保證aes算法的密鑰的可靠安全性 key=base64(RSA(aes key))
- 經(jīng)過上面的步驟,得到了加密后的業(yè)務(wù)參數(shù)及密鑰,這時候就可以發(fā)送請求調(diào)用接口了
- 服務(wù)端接收到請求之后,先通過RSA算法對key進行解密獲取到ase key, 再通過aes key解密data得到真正json參數(shù),最后映射到接口方法的參數(shù)對象上,供controller的業(yè)務(wù)方法邏輯使用。
- 業(yè)務(wù)方法執(zhí)行完成后,對響應(yīng)參數(shù)進行加密,加密流程和上面的1、2、3一樣
- 客戶端收到響應(yīng)參數(shù)之后,和步驟5一樣解密響應(yīng)參數(shù),就拿到了真正的數(shù)據(jù)結(jié)果了。
2.2 加簽
簽名驗證也是當(dāng)下提高接口安全性主要措施之一,核心就是客戶端在調(diào)用接口時按照一定規(guī)則生成簽名sign,服務(wù)端拿到簽名sign之后進行驗證操作,大致流程如下:
圖片
具體步驟:
- 對請求參數(shù)對象bean轉(zhuǎn)sortMap保證參數(shù)拼接的有序性,如果接口沒有參數(shù)也沒有關(guān)系,這里轉(zhuǎn)成一個空的sortMap
- 按照約定拼接生成字符串content = sortMap + nonce + timestamp
- 使?SHA1WithRSA算法及私鑰對concent進?簽名sign
- 服務(wù)端判斷timestamp是否超過簽名有效期和nonce是否重復(fù)使用
- 服務(wù)端和步驟2一樣規(guī)則生成字符串content
- 使?SHA1WithRSA算法及公鑰對concent和sign進行驗簽
3.優(yōu)雅實現(xiàn)接口加密、加簽
在實現(xiàn)這個需求時,考慮到全公司的多個團隊開發(fā)使用的通用性和便捷性,所以我們對加密、加簽操作進行了公共的抽取封裝,同時通過一個注解@ApiSecurity來標(biāo)識接口是否需要進行加密、加簽操作,在業(yè)務(wù)側(cè)極大程度地降低了開發(fā)使用成本,不用寫冗余代碼,做到了真正的優(yōu)雅。
@Retention(RetentionPolicy.RUNTIME)
@Target({ElementType.TYPE, ElementType.METHOD})
@Documented
public @interface ApiSecurity {
@Alias("isSign")
boolean value() default true;
/**
* 是否加簽驗證,默認開啟
* @return
*/
@Alias("value")
boolean isSign() default true;
/**
* 接口請求參數(shù)是否需要解密
* @return
*/
boolean decryptRequest() default false;
/**
* 接口響應(yīng)參數(shù)是否需要加密
* @return
*/
boolean encryptResponse() default false;
}
這里注解屬性可以看到簽名驗證默認是開啟的,因為我們認為接口安全性加簽是必須的,至于參數(shù)加解密可以視情況而定,通過屬性配置開關(guān),做到了極致的靈活性,這也是優(yōu)雅呀。
使用案例:下面就是一個需要加密加簽的接口
@PostMapping("/security")
@ApiSecurity(encryptResponse = true, decryptRequest = true)
public User testApiSecurity(@RequestBody User user) {
System.out.println(user);
return user;
}
可以看到我們在項目業(yè)務(wù)服務(wù)中只需要@ApiSecurity就可以了,就是這么簡單,至于怎么實現(xiàn)的下面我們就來看看。
為了全公司對接口加密、加簽功能實現(xiàn)統(tǒng)一和規(guī)范,我們將實現(xiàn)抽取,封裝集成在公司自定義的web starter中,這樣只要項目服務(wù)引入這個starter依賴就可以使用該功能了
首先我們對加密傳輸?shù)膮?shù)bean進行規(guī)定封裝如下:
@Data
public class ApiSecurityParam {
/**
* 應(yīng)用id
*/
private String appId;
/**
* RSA加密后的aes秘鑰,需解密
*/
private String key;
/**
* AES加密的json參數(shù)
*/
private String data;
/**
* 簽名
*/
private String sign;
/**
* 時間戳
*/
private String timestamp;
/**
* 請求唯一標(biāo)識
*/
private String nonce;
}
等于說加密、加簽的參數(shù)格式,調(diào)用方需按照上面的對象傳參,當(dāng)然為了提高拓展性,簽名的相關(guān)信息sign、timestamp、nonce可以放到請求的header里面,也能獲取到。拿到apiSecurityParam我們就可以進行請求參數(shù)解密、驗簽了,需要通過判斷是否使用了注解@ApiSecuriy來決定是否執(zhí)行請求參數(shù)解密、驗簽邏輯,這就正好可以使用基于注解的切面實現(xiàn)啦,在說切面之前,先說說一次接口請求requestBody的輸入流InputStream只能讀取一次,就是說request.getInputStream()只能使用一次,原因如下:
因為流對應(yīng)的是數(shù)據(jù),數(shù)據(jù)放在內(nèi)存中,有的是部分放在內(nèi)存中。read 一次標(biāo)記一次當(dāng)前位置(mark position),第二次read就從標(biāo)記位置繼續(xù)讀(從內(nèi)存中copy)數(shù)據(jù)。 所以這就是為什么讀了一次第二次是空了。 怎么讓它不為空呢?只要inputstream 中的pos 變成0就可以重寫讀取當(dāng)前內(nèi)存中的數(shù)據(jù)。javaAPI中有一個方法public void reset() 這個方法就是可以重置pos為起始位置,但是不是所有的IO讀取流都可以調(diào)用該方法!ServletInputStream是不能調(diào)用reset方法,這就導(dǎo)致了只能調(diào)用一次getInputStream()。
而我們需要先讀取出requestBody進行解密,然后拿到解密之前的參數(shù)映射到真正的接口方法參數(shù)對象里,所以必須解決這個問題。
解決方法就是原始的HttpServletRequest的InputStream只能讀取一下,那么我們就重新自定義封裝一個HttpServletRequest可以實現(xiàn)多次讀取。
public class RequestBodyWrapper extends HttpServletRequestWrapper {
//用于將流保存下來
private String body;
public RequestBodyWrapper(HttpServletRequest request) throws IOException {
super(request);
body = new String(StreamUtils.copyToByteArray(request.getInputStream()), StandardCharsets.UTF_8);
}
/**
* 重寫getInputStream, 從body中獲取請求參數(shù)
* @return
* @throws IOException
*/
@Override
public ServletInputStream getInputStream() throws IOException {
final ByteArrayInputStream byteArrayInputStream = new ByteArrayInputStream(body.getBytes("UTF-8"));
ServletInputStream servletInputStream = new ServletInputStream() {
@Override
public boolean isFinished() {
return false;
}
@Override
public boolean isReady() {
return false;
}
@Override
public void setReadListener(ReadListener readListener) {
}
@Override
public int read() throws IOException {
return byteArrayInputStream.read();
}
};
return servletInputStream;
}
@Override
public BufferedReader getReader() throws IOException {
return new BufferedReader(new InputStreamReader(getInputStream()));
}
public String getBody() {
return this.body;
}
public void setBody(String body) {
this.body = body;
}
}
然后通過一個過濾器filter把自定義封裝的RqequestBodyWapper傳遞下去:
@Slf4j
public class BodyTransferFilter implements Filter {
@Override
public void doFilter(ServletRequest request, ServletResponse response, FilterChain chain) throws IOException, ServletException {
RequestBodyWrapper requestBodyWrapper = null;
try {
HttpServletRequest req = (HttpServletRequest)request;
requestBodyWrapper = new RequestBodyWrapper(req);
}catch (Exception e){
log.warn("requestBodyWrapper Error:", e);
}
chain.doFilter((Objects.isNull(requestBodyWrapper) ? request : requestBodyWrapper), response);
}
}
接下來就可以來看看切面了:這里是解析請求參數(shù)和驗簽和邏輯所在:
@Aspect
@Slf4j
@Order(value = OrderConstant.AOP_API_DECRYPT)
public class ApiSecurityAspect {
@Resource
private ApiSecurityProperties apiSecurityProperties;
@Resource
private StringRedisTemplate stringRedisTemplate;
private static final String NONCE_KEY = "x-nonce-";
@Pointcut("execution(* com.plasticene..controller..*(..)) && " +
"(@annotation(com.plasticene.boot.web.core.anno.ApiSecurity) ||" +
" @target(com.plasticene.boot.web.core.anno.ApiSecurity))")
public void securityPointcut(){}
@Around("securityPointcut()")
public Object aroundApiSecurity(ProceedingJoinPoint joinPoint) throws Throwable {
//=======AOP解密切面通知=======
ApiSecurity apiSecurity = getApiSecurity(joinPoint);
boolean isSign = apiSecurity.isSign();
boolean decryptRequest = apiSecurity.decryptRequest();
// 獲取request加密傳遞的參數(shù)
HttpServletRequest request = getRequest();
// 只能針對post接口的請求參數(shù)requestBody進行統(tǒng)一加解密和加簽,這是規(guī)定
if (!Objects.equals("POST", request.getMethod())) {
throw new BizException("只能POST接口才能加密加簽操作");
}
// 獲取controller接口方法定義的參數(shù)
Object[] args = joinPoint.getArgs();
Object[] newArgs = args;
ApiSecurityParam apiSecurityParam = new ApiSecurityParam();
// 請求參數(shù)解密
if (decryptRequest) {
// 不支持多個請求,因為解密請求參數(shù)之后會json字符串,再根據(jù)請求參數(shù)的類型映射過去,如果有多個參數(shù)就不知道映射關(guān)系了
if (args.length > 1) {
throw new BizException("加密接口方法只支持一個參數(shù),請修改");
}
// args.length=0沒有請求參數(shù),就說明沒必要解密,因為接口壓根不接收參數(shù),即使使用者無腦開啟的該接口的參數(shù)加密,這里不做任何邏輯即可
if (args.length == 1) {
RequestBodyWrapper requestBodyWrapper;
if (request instanceof RequestBodyWrapper) {
requestBodyWrapper = (RequestBodyWrapper) request;
} else {
requestBodyWrapper = new RequestBodyWrapper(request);
}
String body = requestBodyWrapper.getBody();
apiSecurityParam = JSONObject.parseObject(body, ApiSecurityParam.class);
// 通過RSA私鑰解密獲取到aes秘鑰
String aesKey = RSAUtil.decryptByPrivateKey(apiSecurityParam.getKey(), apiSecurityProperties.getRsaPrivateKey());
// 通過aes秘鑰解密data參數(shù)數(shù)據(jù)
String data = AESUtil.decrypt(apiSecurityParam.getData(), aesKey);
//獲取接口入?yún)⒌念? Class<?> c = args[0].getClass();
//將獲取解密后的真實參數(shù),封裝到接口入?yún)⒌念愔? Object o = JSONObject.parseObject(data, c);
newArgs = new Object[]{o};
}
}
// 驗簽
if (isSign) {
verifySign(request, newArgs.length == 0 ? null : newArgs[0], apiSecurityParam);
}
return joinPoint.proceed(newArgs);
}
void verifySign(HttpServletRequest request, Object o, ApiSecurityParam apiSecurityParam) {
// 如果請求參數(shù)是加密傳輸?shù)模蔷拖葟腁piSecurityParam獲取簽名和時間戳等等。
// 如果請求參數(shù)不是加密傳輸?shù)模敲碅piSecurityParam的字段取值都為null,這時候在請求的header里面獲取參數(shù)信息
String sign = apiSecurityParam.getSign();
if (StringUtils.isBlank(sign)) {
sign = request.getHeader("X-Sign");
}
if (StringUtils.isBlank(sign)) {
throw new BizException("簽名不能為空");
}
String nonce = apiSecurityParam.getNonce();
if (StringUtils.isBlank(nonce)) {
nonce = request.getHeader("X-Nonce");
}
if (StringUtils.isBlank(nonce)) {
throw new BizException("唯一標(biāo)識不能為空");
}
String timestamp = apiSecurityParam.getTimestamp();
Long t;
if (StringUtils.isBlank(timestamp)) {
timestamp = request.getHeader("X-Timestamp");
}
if (StringUtils.isBlank(timestamp)) {
throw new BizException("時間戳不能為空");
} else {
try {
t = Long.valueOf(timestamp);
} catch (Exception e) {
throw new BizException("非法的時間戳");
}
}
// 判斷timestamp時間戳與當(dāng)前時間是否超過簽名有效時長(過期時間根據(jù)業(yè)務(wù)情況進行配置),如果超過了就提示簽名過期
long now = System.currentTimeMillis() / 1000;
if (now - t > apiSecurityProperties.getValidTime()) {
throw new BizException("簽名已過期");
}
// 判斷nonce
boolean nonceExists = stringRedisTemplate.hasKey(NONCE_KEY + nonce);
if (nonceExists) {
//請求重復(fù)
throw new BizException("唯一標(biāo)識nonce已存在");
}
// 驗簽
SortedMap sortedMap = SignUtil.beanToMap(o);
String content = SignUtil.getContent(sortedMap, nonce, timestamp);
boolean flag = RSAUtil.verifySignByPublicKey(content, sign, apiSecurityProperties.getRsaPublicKey());
if (!flag) {
throw new BizException("簽名驗證不通過");
}
stringRedisTemplate.opsForValue().set(NONCE_KEY+ nonce, "1", apiSecurityProperties.getValidTime(),
TimeUnit.SECONDS);
}
private HttpServletRequest getRequest() {
ServletRequestAttributes requestAttributes = (ServletRequestAttributes) RequestContextHolder.getRequestAttributes();
HttpServletRequest request = requestAttributes.getRequest();
return request;
}
private ApiSecurity getApiSecurity(JoinPoint joinPoint) {
MethodSignature methodSignature = (MethodSignature) joinPoint.getSignature();
Method method = methodSignature.getMethod();
ApiSecurity apiSecurity = method.getAnnotation(ApiSecurity.class);
if (Objects.isNull(apiSecurity)) {
apiSecurity = method.getDeclaringClass().getAnnotation(ApiSecurity.class);
}
return apiSecurity;
}
}
這代碼沒什么好講的了,就按照上面的加密、加簽流程圖邏輯實現(xiàn)的,而且注釋也很清楚,可以自己慢慢消化,這里面涉及的工具類如RSAUtil、AESUtil、SignUtil等,礙于文章代碼篇幅,我就這里就在一一展示,我會在文章后面放上全部代碼的項目github地址以供下載的。
上面的切面只完成了接口參數(shù)的解密和驗簽,至于對響應(yīng)參數(shù)的加密返回我們放到了ResponseBodyAdvice中實現(xiàn)。
@RestControllerAdvice
@Slf4j
public class ResponseResultBodyAdvice implements ResponseBodyAdvice<Object> {
@Resource
private ObjectMapper objectMapper;
@Resource
private ApiSecurityProperties apiSecurityProperties;
/**
* 判斷類或者方法是否使用了 @ResponseResultBody
*/
@Override
public boolean supports(MethodParameter returnType, Class<? extends HttpMessageConverter<?>> converterType) {
return AnnotatedElementUtils.hasAnnotation(returnType.getContainingClass(), ResponseResultBody.class)
|| returnType.hasMethodAnnotation(ResponseResultBody.class)
|| AnnotatedElementUtils.hasAnnotation(returnType.getContainingClass(), ApiSecurity.class)
|| returnType.hasMethodAnnotation(ApiSecurity.class);
}
/**
* 當(dāng)類或者方法使用了 @ResponseResultBody 就會調(diào)用這個方法
* 如果返回類型是string,那么springmvc是直接返回的,此時需要手動轉(zhuǎn)化為json
* 因為當(dāng)body都為null時,下面的非加密下的if判斷參數(shù)類型的條件都不滿足,如果接口返回類似為String,
* 會報錯com.shepherd.fast.global.ResponseVO cannot be cast to java.lang.String
*/
@SneakyThrows
@Override
public Object beforeBodyWrite(Object body, MethodParameter returnType, MediaType selectedContentType, Class<? extends HttpMessageConverter<?>> selectedConverterType, ServerHttpRequest request, ServerHttpResponse response) {
Method method = returnType.getMethod();
Class<?> returnClass = method.getReturnType();
Boolean enable = apiSecurityProperties.getEnable();
ApiSecurity apiSecurity = method.getAnnotation(ApiSecurity.class);
if (Objects.isNull(apiSecurity)) {
apiSecurity = method.getDeclaringClass().getAnnotation(ApiSecurity.class);
}
if (enable && Objects.nonNull(apiSecurity) && apiSecurity.encryptResponse() && Objects.nonNull(body)) {
// 只需要加密返回data數(shù)據(jù)內(nèi)容
if (body instanceof ResponseVO) {
body = ((ResponseVO) body).getData();
}
JSONObject jsonObject = encryptResponse(body);
body = jsonObject;
} else {
if (body instanceof String || Objects.equals(returnClass, String.class)) {
String value = objectMapper.writeValueAsString(ResponseVO.success(body));
return value;
}
// 防止重復(fù)包裹的問題出現(xiàn)
if (body instanceof ResponseVO) {
return body;
}
}
return ResponseVO.success(body);
}
JSONObject encryptResponse(Object result) {
String aseKey = AESUtil.generateAESKey();
String content = JSONObject.toJSONString(result);
String data = AESUtil.encrypt(content, aseKey);
String key = RSAUtil.encryptByPublicKey(aseKey, apiSecurityProperties.getRsaPublicKey());
JSONObject jsonObject = new JSONObject();
jsonObject.put("key", key);
jsonObject.put("data", data);
return jsonObject;
}
}
這里就是對接口返回參數(shù)格式進行統(tǒng)一ResponseVO,同時判斷是否需要進行返回參數(shù)加密執(zhí)行相應(yīng)邏輯即可。
4.總結(jié)
至此,對于Spring Boot如何提高接口安全性的思路與實現(xiàn)已講完,同時我們也盡量進行了抽取封裝,做到了極致的優(yōu)雅實現(xiàn)。當(dāng)然這里還是要再次強調(diào)一下以上的思路實現(xiàn)是不能絕對保證接口安全性的,只能做到”防君子不妨小人“,可以這么說假如不做加密加簽這些保護措施,黑客破解接口就會不費吹灰之力,自己就經(jīng)歷過我在個人的阿里云服務(wù)器部署的MySQL服務(wù),為了訪問簡單省事,我直接在外網(wǎng)暴露了3306端口,同時用戶名密碼都是root,就被黑客黑了勒索比特幣。
圖片
幸好里面數(shù)據(jù)不是很重要,我后面把暴露端口映射成其他的了,密碼也改了一個復(fù)雜的,我看你再給我破解了~~~。說這些我就想表達這里的加密、加簽就是為了加大破解接口的復(fù)雜度,有了加密、加簽保障想破解不費九牛二虎之力是不行的。
本文轉(zhuǎn)載自微信公眾號「Shepherd進階筆記」,可以通過以下二維碼關(guān)注。轉(zhuǎn)載本文請聯(lián)系公眾號。