前言
假设我们的系统对外提供了一些公共接口,但是这些接口只针对开通了服务的用户开发,那么如何保证我们提供的接口不被未授权的用户调用,用户传递的参数未被篡改?其中一种方法就是使用接口签名的方式对外提供服务。
如果想更简单一点的话,可以只校验我们向用户提交的密匙。例如:用户的每个请求都必须包含指定的请求头
参数名称 | 参数类型 | 是否必须 | 参数描述 |
---|---|---|---|
token | String | 是 | 验证加密值 Md5(key+Timespan+SecretKey) 加密的32位大写字符串) |
Timespan | String | 是 | 精确到秒的Unix时间戳(String.valueOf(System.currentTimeMillis() / 1000)) |
这样,只需要简单简要一下token即可
接口签名
Headerd的公共参数
参数名称 | 参数类型 | 是否必须 | 参数描述 |
---|---|---|---|
x-appid | String | 是 | 分配给应用的appid。 |
x-sign | String | 是 | API输入参数签名结果,签名算法参照下面的介绍。 |
x-timestamp | String | 是 | 时间戳,格式为yyyy-MM-dd HH:mm:ss,时区为GMT+8,例如:2020-01-01 12:00:00。API服务端允许客户端请求最大时间误差为10分钟。 |
sign-method | String | 否 | 签名的摘要算法,可选值为:hmac,md5,hmac-sha256(默认)。 |
签名算法
为了防止API调用过程中被黑客恶意篡改,调用任何一个API都需要携带签名,服务端会根据请求参数,对签名进行验证,签名不合法的请求将会被拒绝。目前支持的签名算法有三种:MD5(sign-method=md5),HMAC_MD5(sign-method=hmac),HMAC_SHA256(sign-method=hmac-sha256),签名大体过程如下:
-
对API请求参数,根据参数名称的ASCII码表的顺序排序(空值不计入在内)。
Path Variable:按照path中的字典顺序将所有value进行拼接, 记做X 例如:aaabbb
Parameter:按照key=values(多个value按照字典顺序拼接)字典顺序进行拼接,记做Y 例如:kvkvkvkv
Body:按照key=value字典顺序进行拼接,记做Z 例如:namezhangsanage10 将排序好的参数名和参数值拼装在一起(规则:appsecret+X+Y+X+timestamp+appsecret)
把拼装好的字符串采用utf-8编码,使用签名算法对编码后的字节流进行摘要。
将摘要得到的字节流结果使用十六进制表示,如: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);
}
// 执行目标 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,时区为GMT+8,例如:2016-01-01 12:00:00。API服务端允许客户端请求最大时间误差为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(对应@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(对应@RequestParam)
Map<String, String[]> parameterMap = request.getParameterMap();
// 获取body(对应@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内容,那么在控制层,再使用@RequestBody注解的话,就会获取不到body的内容了,因为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对象的包装。 当我们读取请求正文时,ContentCachingRequestWrapper会缓存内容供以后使用。
*
* @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;
/**
* 解决不能重复读取使用request请求中的数据流 问题
* @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 对应@PathVariable
* @param params 对应@RequestParam
* @param body 对应@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(对应@PathVariable)
if (ArrayUtils.isNotEmpty(paths)) {
String pathValues = String.join("", Arrays.stream(paths).sorted().toArray(String[]::new));
sb.append(pathValues);
}
// parameters(对应@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(对应@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();
}
}