前言
假設(shè)我們的系統(tǒng)對外提供了一些公共接口婆瓜,但是這些接口只針對開通了服務(wù)的用戶開發(fā)快集,那么如何保證我們提供的接口不被未授權(quán)的用戶調(diào)用贡羔,用戶傳遞的參數(shù)未被篡改?其中一種方法就是使用接口簽名的方式對外提供服務(wù)个初。
如果想更簡單一點的話乖寒,可以只校驗我們向用戶提交的密匙。例如:用戶的每個請求都必須包含指定的請求頭
參數(shù)名稱 | 參數(shù)類型 | 是否必須 | 參數(shù)描述 |
---|---|---|---|
token | String | 是 | 驗證加密值 Md5(key+Timespan+SecretKey) 加密的32位大寫字符串) |
Timespan | String | 是 | 精確到秒的Unix時間戳(String.valueOf(System.currentTimeMillis() / 1000)) |
這樣院溺,只需要簡單簡要一下token即可
接口簽名
Headerd的公共參數(shù)
參數(shù)名稱 | 參數(shù)類型 | 是否必須 | 參數(shù)描述 |
---|---|---|---|
x-appid | String | 是 | 分配給應(yīng)用的appid楣嘁。 |
x-sign | String | 是 | API輸入?yún)?shù)簽名結(jié)果,簽名算法參照下面的介紹珍逸。 |
x-timestamp | String | 是 | 時間戳逐虚,格式為yyyy-MM-dd HH:mm:ss,時區(qū)為GMT+8谆膳,例如:2020-01-01 12:00:00叭爱。API服務(wù)端允許客戶端請求最大時間誤差為10分鐘。 |
sign-method | String | 否 | 簽名的摘要算法漱病,可選值為:hmac买雾,md5,hmac-sha256(默認)杨帽。 |
簽名算法
為了防止API調(diào)用過程中被黑客惡意篡改漓穿,調(diào)用任何一個API都需要攜帶簽名,服務(wù)端會根據(jù)請求參數(shù)注盈,對簽名進行驗證晃危,簽名不合法的請求將會被拒絕。目前支持的簽名算法有三種:MD5(sign-method=md5)老客,HMAC_MD5(sign-method=hmac)僚饭,HMAC_SHA256(sign-method=hmac-sha256),簽名大體過程如下:
-
對API請求參數(shù)沿量,根據(jù)參數(shù)名稱的ASCII碼表的順序排序(空值不計入在內(nèi))浪慌。
Path Variable:按照path中的字典順序?qū)⑺衯alue進行拼接, 記做X 例如:aaabbb
Parameter:按照key=values(多個value按照字典順序拼接)字典順序進行拼接朴则,記做Y 例如:kvkvkvkv
Body:按照key=value字典順序進行拼接权纤,記做Z 例如:namezhangsanage10 將排序好的參數(shù)名和參數(shù)值拼裝在一起(規(guī)則:appsecret+X+Y+X+timestamp+appsecret)
把拼裝好的字符串采用utf-8編碼,使用簽名算法對編碼后的字節(jié)流進行摘要乌妒。
將摘要得到的字節(jié)流結(jié)果使用十六進制表示汹想,如:hex("helloworld".getBytes("utf-8")) = "68656C6C6F776F726C64"
說明:MD5和HMAC_MD5都是128位長度的摘要算法,用16進制表示撤蚊,一個十六進制的字符能表示4個位古掏,所以簽名后的字符串長度固定為32個十六進制字符。
密匙管理
類似于這樣的一個密匙管理模塊侦啸,具體的就省略了槽唾,本示例中使用使用配置替代
使用AOP來校驗簽名
yml的配置
apps:
open: true # 是否開啟簽名校驗
appPair:
abc: aaaaaaaaaaaaaaaaaaa
aop的代碼
import com.fasterxml.jackson.databind.ObjectMapper;
import com.google.common.base.Charsets;
import com.google.common.collect.ImmutableList;
import com.nanc.common.entity.R;
import com.nanc.common.utils.SignUtil;
import lombok.extern.slf4j.Slf4j;
import org.apache.commons.collections4.MapUtils;
import org.apache.commons.lang3.StringUtils;
import org.apache.commons.lang3.time.DateUtils;
import org.aspectj.lang.ProceedingJoinPoint;
import org.aspectj.lang.annotation.Around;
import org.aspectj.lang.annotation.Aspect;
import org.aspectj.lang.annotation.Pointcut;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.boot.autoconfigure.condition.ConditionalOnProperty;
import org.springframework.boot.context.properties.ConfigurationProperties;
import org.springframework.stereotype.Component;
import org.springframework.web.context.request.RequestAttributes;
import org.springframework.web.context.request.RequestContextHolder;
import org.springframework.web.context.request.ServletRequestAttributes;
import org.springframework.web.servlet.HandlerMapping;
import com.nanc.demo.config.filter.ContentCachingRequestWrapper;
import javax.servlet.http.HttpServletRequest;
import java.text.ParseException;
import java.util.Date;
import java.util.List;
import java.util.Map;
import java.util.Objects;
@Aspect
@Component
@ConfigurationProperties(prefix = "apps")
@Slf4j
public class SignatureAspect {
@Autowired
private ObjectMapper objectMapper;
/**
* 是否開啟簽名校驗
*/
private boolean open;
/**
* appid與appsecret對
*/
private Map<String, String> appPair;
private static final List<String> SIGN_METHOD_LISt = ImmutableList.<String>builder()
.add("MD5")
.add("md5")
.add("HMAC")
.add("hmac")
.add("HMAC-SHA256")
.add("hmac-sha256")
.build();
@Pointcut("execution(public * com.nanc.demo.modules.test.controller.MyTestController.testSignature(..))")
public void pointCut(){};
@Around("pointCut()")
public Object doAround(ProceedingJoinPoint joinPoint) throws Throwable{
try{
if (open) {
checkSign(joinPoint);
}
// 執(zhí)行目標(biāo) service
Object result = joinPoint.proceed();
return result;
}catch (Throwable e){
log.error("", e);
return R.error(e.getMessage());
}
}
/**
*
* @throws Exception
*/
private void checkSign(ProceedingJoinPoint joinPoint) throws Exception{
RequestAttributes requestAttributes = RequestContextHolder.getRequestAttributes();
ServletRequestAttributes sra = (ServletRequestAttributes)requestAttributes;
HttpServletRequest request = Objects.requireNonNull(sra).getRequest();
ContentCachingRequestWrapper requestWrapper = (ContentCachingRequestWrapper) request;
String oldSign = request.getHeader("x-sign");
if (StringUtils.isBlank(oldSign)) {
throw new RuntimeException("未獲取到簽名x-sign的信息");
}
String appid = request.getHeader("x-appid");
if (StringUtils.isBlank(appid) || !appPair.containsKey(appid)) {
throw new RuntimeException("x-appid有誤");
}
String signMethod = request.getHeader("sign-method");
if (StringUtils.isNotBlank(signMethod) && !SIGN_METHOD_LISt.contains(signMethod)) {
throw new RuntimeException("簽名算法有誤");
}
//時間戳丧枪,格式為yyyy-MM-dd HH:mm:ss,時區(qū)為GMT+8庞萍,例如:2016-01-01 12:00:00拧烦。API服務(wù)端允許客戶端請求最大時間誤差為10分鐘。
String timeStamp = request.getHeader("x-timestamp");
if (StringUtils.isBlank(timeStamp)) {
throw new RuntimeException("時間戳x-timestamp不能為空");
}
try {
Date tm = DateUtils.parseDate(timeStamp, "yyyy-MM-dd HH:mm:ss");
// tm>=new Date()-10m, tm< new Date()
if (tm.before(DateUtils.addMinutes(new Date(), -10)) || tm.after(new Date())) {
throw new RuntimeException("簽名時間過期或超期");
}
} catch (ParseException exception) {
throw new RuntimeException("時間戳x-timestamp格式有誤");
}
//獲取path variable(對應(yīng)@PathVariable)
String[] paths = new String[0];
Map<String, String> uriTemplateVars = (Map<String, String>)sra.getAttribute(HandlerMapping.URI_TEMPLATE_VARIABLES_ATTRIBUTE, RequestAttributes.SCOPE_REQUEST);
if (MapUtils.isNotEmpty(uriTemplateVars)) {
paths = uriTemplateVars.values().toArray(new String[]{});
}
//獲取parameters(對應(yīng)@RequestParam)
Map<String, String[]> parameterMap = request.getParameterMap();
// 獲取body(對應(yīng)@RequestBody)
String body = new String(IOUtils.toByteArray(requestWrapper.getInputStream()), Charsets.UTF_8);
String newSign = null;
try {
newSign = SignUtil.sign(MapUtils.getString(appPair, appid, ""), signMethod, timeStamp, paths, parameterMap, body);
if (!StringUtils.equals(oldSign, newSign)) {
throw new RuntimeException("簽名不一致");
}
} catch (Exception e) {
throw new RuntimeException("校驗簽名出錯");
}
log.info("----aop----paths---{}", objectMapper.writeValueAsString(paths));
log.info("----aop----parameters---{}", objectMapper.writeValueAsString(parameterMap));
log.info("----aop----body---{}", body);
log.info("----aop---生成簽名---{}", newSign);
}
public Map<String, String> getAppPair() {
return appPair;
}
public void setAppPair(Map<String, String> appPair) {
this.appPair = appPair;
}
public boolean isOpen() {
return open;
}
public void setOpen(boolean open) {
this.open = open;
}
}
但是這里還有一些問題需要解決钝计,在AOP中恋博,如果獲取了request的body內(nèi)容,那么在控制層私恬,再使用@RequestBody注解的話债沮,就會獲取不到body的內(nèi)容了,因為request的inputstream只能被讀取一次本鸣。解決此問題的一個簡單方式是使用reqeust的包裝對象
import java.io.IOException;
import javax.servlet.FilterChain;
import javax.servlet.ServletException;
import javax.servlet.ServletRequest;
import javax.servlet.ServletResponse;
import javax.servlet.http.HttpServletRequest;
import org.springframework.stereotype.Component;
import org.springframework.web.filter.GenericFilterBean;
/**
* 使用ContentCachingRequestWrapper類疫衩,它是原始HttpServletRequest對象的包裝。 當(dāng)我們讀取請求正文時荣德,ContentCachingRequestWrapper會緩存內(nèi)容供以后使用隧土。
*
* @date 2020/8/22 10:40
*/
@Component
public class CachingRequestBodyFilter extends GenericFilterBean {
@Override
public void doFilter(ServletRequest servletRequest, ServletResponse servletResponse, FilterChain chain)
throws IOException, ServletException {
ContentCachingRequestWrapper wrappedRequest = new ContentCachingRequestWrapper((HttpServletRequest) servletRequest);
chain.doFilter(wrappedRequest, servletResponse);
}
}
reqeust的包裝類
import javax.servlet.ReadListener;
import javax.servlet.ServletInputStream;
import javax.servlet.http.HttpServletRequest;
import javax.servlet.http.HttpServletRequestWrapper;
import java.io.BufferedReader;
import java.io.ByteArrayInputStream;
import java.io.IOException;
import java.io.InputStreamReader;
import java.nio.charset.StandardCharsets;
/**
* 解決不能重復(fù)讀取使用request請求中的數(shù)據(jù)流 問題
* @date 2022/4/6 21:50
*/
public class ContentCachingRequestWrapper extends HttpServletRequestWrapper {
private final byte[] body;
public ContentCachingRequestWrapper(HttpServletRequest request) {
super(request);
StringBuilder sb = new StringBuilder();
String enc = super.getCharacterEncoding();
enc = (enc != null ? enc : StandardCharsets.UTF_8.name());
try (BufferedReader reader = new BufferedReader(new InputStreamReader(request.getInputStream(), enc))){
String line = "";
while ((line = reader.readLine()) != null) {
sb.append(line);
}
} catch (IOException e) {
e.printStackTrace();
}
body = sb.toString().getBytes(StandardCharsets.UTF_8);
}
@Override
public ServletInputStream getInputStream() throws IOException {
final ByteArrayInputStream inputStream = new ByteArrayInputStream(body);
return 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 inputStream.read();
}
};
}
public byte[] getBody() {
return body;
}
}
工具類
使用了hutool工具包
<dependency>
<groupId>cn.hutool</groupId>
<artifactId>hutool-all</artifactId>
<version>5.4.0</version>
</dependency>
具體的工具類
import cn.hutool.crypto.digest.DigestAlgorithm;
import cn.hutool.crypto.digest.Digester;
import cn.hutool.crypto.digest.HMac;
import cn.hutool.crypto.digest.HmacAlgorithm;
import com.alibaba.fastjson.JSON;
import org.apache.commons.collections4.MapUtils;
import org.apache.commons.lang3.ArrayUtils;
import org.apache.commons.lang3.StringUtils;
import java.util.Arrays;
import java.util.Map;
import java.util.Objects;
/**
* 生成接口簽名的工具類
*/
public class SignUtil {
/**
*
* 例如: hmac_sha256(appsecret+X+Y+X+timestamp+appsecret)
* @param appsecret
* @param signMethod 默認為:HMAC_SHA256
* @param paths 對應(yīng)@PathVariable
* @param params 對應(yīng)@RequestParam
* @param body 對應(yīng)@RequestBody
* @return
*/
public static String sign(String appsecret, String signMethod, String timestamp, String[] paths,
Map<String, String[]> params, String body) {
StringBuilder sb = new StringBuilder(appsecret);
// path variable(對應(yīng)@PathVariable)
if (ArrayUtils.isNotEmpty(paths)) {
String pathValues = String.join("", Arrays.stream(paths).sorted().toArray(String[]::new));
sb.append(pathValues);
}
// parameters(對應(yīng)@RequestParam)
if (MapUtils.isNotEmpty(params)) {
params.entrySet().stream().filter(entry -> Objects.nonNull(entry.getValue())) // 為空的不計入
.sorted(Map.Entry.comparingByKey()).forEach(paramEntry -> {
String paramValue = String.join("",
Arrays.stream(paramEntry.getValue()).sorted().toArray(String[]::new));
sb.append(paramEntry.getKey()).append(paramValue);
});
}
// body(對應(yīng)@RequestBody)
if (StringUtils.isNotBlank(body)) {
Map<String, Object> map = JSON.parseObject(body, Map.class);
map.entrySet().stream().filter(entry -> Objects.nonNull(entry.getValue())) // 為空的不計入
.sorted(Map.Entry.comparingByKey()).forEach(paramEntry -> {
sb.append(paramEntry.getKey()).append(paramEntry.getValue());
});
}
sb.append(timestamp).append(appsecret);
String sign = new String();
if (StringUtils.isBlank(signMethod) || StringUtils.equalsIgnoreCase(signMethod, "HMAC-SHA256")) {
sign = new HMac(HmacAlgorithm.HmacSHA256, appsecret.getBytes()).digestHex(sb.toString());
}
else if (StringUtils.equalsIgnoreCase(signMethod, "HMAC")) {
sign = new HMac(HmacAlgorithm.HmacMD5, appsecret.getBytes()).digestHex(sb.toString());
}
else {
Digester md5 = new Digester(DigestAlgorithm.MD5);
sign = md5.digestHex(sb.toString());
}
return sign.toUpperCase();
}
}