Springboot 对接口进行签名

1. API接口不安全的因素

  1. 开发者访问开放接口

  2. 多客户端访问接口

  3. 用户访问接口

    • 是不是一个合法的用户
    • 有没有访问权限
  4. 接口传输

    • http明文传输数据?
  5. 其他方面

    • 接口重放
    • 接口超时

2. 常见的保证接口安全的方式

2.1 AccessKey & SecretKey

这种设计一般用在开发接口的安全,以确保是一个合格的开发者

  • AccessKey: 开发者唯一标识

  • SecretKey: 开发者迷药

以阿里云相关产品为例:

2.2 认证和授权

从两个视角去看

第一:认证和授权,认证是访问这的合法性,授权是访问这的权限等级

第二:认证包括对客户端的认证和对用户的认证

  • 对客户端的认证

    典型的就是AppKey& AppSecret,或者ClientId & ClientSecret等

    比如OAuth2协议的client cridential模式

    request
    1
    https://api.xxxx.com/token?grant_type=client_credentials&client_id=CLIENT_ID&client_secret=CLIENT_SECRET
    • grant_type: client_credentials标识client credentials方式
    • client_id: 客户端ID
    • client_secret: 客户端密钥

    返回token后,再通过token访问其他接口

  • 对于用户的认证和授权

    比如OAuth2协议的授权码模式authorization code 和密码模式resource owner password credentials

    request
    1
    https://api.xxxx.com/token?grant_type=password&username=USERNAME&password=PASSWORD&client_id=CLIENT_ID&scope=read
    • grant_type: password表示密码方式

    • client_id: 客户端ID

    • username: 用户名

    • password: 密码

2.3 Https

从接口传输安全的角度,防止接口数据明文传输

Http有以下安全性问题:

  • 使用明文进行通信,内容可能会被窃听

  • 不验证通信方的身份,通信方可能遭遇伪装

  • 无法验证报文的完整性,报文可能被篡改

HTTPS 并不是新协议,而是让 HTTP 先和 SSL(Secure Sockets Layer)通信,再由 SSLTCP 通信,也就是说 HTTPS 使用了隧道进行通信。

通过私用SSL,HTTPS具有了加密(防窃听)、认证(防伪装)和完整性保护(防篡改)

2.4 接口签名(加密)

接口签名(加密):主要防止请求参数被篡改,特别是安全要求比较高的接口,比如支付领域的接口

  • 签名的主要流程

    首先我们需要分配给客户端一个私钥用于URL签名加密,一般签名算法如下:

    1. 首先对请求参数按照key进行字母排序放入有序集合中

    2. 对排序完的数组简直对用&进行连接,形成用户加密的参数字符串

    3. 在加密的参数字符串前面或者后面加上私钥,然后用加密算法进行加密,得到sign,随请求接口一起传给服务器

      request
      1
      https://api.xxxx.com/token?key=value&timetamp=xxxx&sign=xxxx-xxx-xxx-xxxx
    4. 服务器接收到请求后,用同样的算法获得服务器的sign,对比客户端的sign是否一致,如果一致则请求有效,反之则返回错误信息

  • 签名的内容

    1. 主要包括请求参数,这是最主要的部分,签名的目的要防止参数被篡改,就要对可能被篡改的参数签名。
    2. 同时考虑到请求参数的来源可能是请求路径path、请求header、请求body中。
    3. 如果对客户端分配了AppKey & AppSecret,也可加入签名计算中。
    4. 考虑到幂等性、token失效等,也会将涉及到的参数一并加入签名,比如timestamp、流水号nonce等(这些可能来源于header)
  • 签名算法

    一般涉及这块主要包含三点:密钥、签名算法、签名规则

    1. 密钥secret: 前后端约定的secret,要注意的是前端可能无法保存secret,比如SPA单页面应用
    2. 签名算法: 不一定是对称加密算法,堆成是反过来解析sign,这里是用同样的算法和规则计算出sign
    3. 签名规则: 比如多次加盐加密
  • 签名和加密的区别

    1. 签名:通过参数按照指定的算法、规则计算出sign,最后前后端通过同样的算法计算出的sign是否一致来防止参数篡改,所以看到的参数可以是明文的只是多加了一个sign。
    2. 加密:是对请求的参数加密,后段进行解密;同时有些情况下,也会对返回的responses进行加密,前端进行解密;这里存在加密和解密的过程,所以思路上必然是对称加密的形式+时间戳接口实效性等。
  • 签名参数放在哪里?

    可以放在请求参数中(path,body中等),更为优雅的可以放在Header中,比如X-Sign

3. 实现案例

本例子采用AOP兰姐自定义注解方式实现,主要看实现的思路而已(签名的目的是要防止参数被篡改,就要对可能被篡改的参数签名)

3.1 定义注解

1
2
3
4
@Target(ElementType.METHOD)
@Retention(RetentionPolicy.RUNTIME)
public @interface Signature {
}

3.2 AOP拦截

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
@Aspect
@Component
public class SignAspect {
private static final String SIGN_HEADER = "X-SIGN";

/**
* pointcut.
*/
@Pointcut("execution(@org.example.annoations.Signature * *(..))")
private void verifySignPointCut() {
// nothing
}

/**
* verify sign.
*/
@Before("verifySignPointCut()")
public void verify() throws BusinessException {
HttpServletRequest request = ((ServletRequestAttributes) Objects.requireNonNull(RequestContextHolder.getRequestAttributes())).getRequest();
String sign = request.getHeader(SIGN_HEADER);

// must have sign in header
if (CharSequenceUtil.isBlank(sign)) {
throw new BusinessException("no signature in header: " + SIGN_HEADER);
}

// check signature
try {
String generatedSign = generatedSignature(request);
if (!sign.equals(generatedSign)) {
throw new BusinessException("invalid signature");
}
} catch (Throwable throwable) {
throw new BusinessException("invalid signature");
}
}

public String generatedSignature(HttpServletRequest request) throws IOException {
// @RequestBody
String bodyParam = null;
if (request instanceof ContentCachingRequestWrapper) {
bodyParam = new String(((ContentCachingRequestWrapper) request).getContentAsByteArray(), StandardCharsets.UTF_8);
}

// @RequestParam
Map<String, String[]> requestParameterMap = request.getParameterMap();

// @PathVariable
String[] paths = null;
ServletWebRequest webRequest = new ServletWebRequest(request, null);
Map<String, String> uriTemplateVars = (Map<String, String>) webRequest.getAttribute(
HandlerMapping.URI_TEMPLATE_VARIABLES_ATTRIBUTE, RequestAttributes.SCOPE_REQUEST);
if (!CollectionUtils.isEmpty(uriTemplateVars)) {
paths = uriTemplateVars.values().toArray(new String[0]);
}

return SignUtil.sign(bodyParam, requestParameterMap, paths);
}
}

3.3 过滤器

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
/**
* @author xiaoyuge
* OncePerRequestFilter 确保在一次请求只通过一次filter,而不需要重复执行
* 抽象类的出现是为了兼容不同的web容器。因为在不同的servlet版本中,执行过程也不同。例如:
*/
@Slf4j
public class RequestCachingFilter extends OncePerRequestFilter {
@Override
protected void doFilterInternal(HttpServletRequest request, HttpServletResponse response, FilterChain filterChain) throws ServletException, IOException {
//isAsyncDispatch: Servlet 3.0中引入的异步意味着可以在一个请求过程中在多个线程中调用过滤器。如果筛选器当前正在异步分派中执行,则此方法返回true。
boolean isFirstRequest = !isAsyncDispatch(request);
HttpServletRequest requestWrapper = request;
if (isFirstRequest && !(request instanceof ContentCachingRequestWrapper)) {
requestWrapper = new ContentCachingRequestWrapper(request);
}
try {
filterChain.doFilter(requestWrapper, response);
} catch (Exception e) {
e.printStackTrace();
}
}
}

3.4 注册

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
/**
* 注册
* @author xiaoyuge
*/
@Configuration
public class FilterConfig {

@Bean
public RequestCachingFilter requestCachingFilter() {
return new RequestCachingFilter();
}

@Bean
public FilterRegistrationBean requestCachingFilterRegistration(RequestCachingFilter requestCachingFilter){
FilterRegistrationBean bean = new FilterRegistrationBean(requestCachingFilter);
bean.setOrder(1);
return bean;
}
}

3.5 定义签名工具类

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
/**
* 签名工具类
* @author xiaoyuge
*/
public class SignUtil {

private static final String DEFAULT_SECRET = "1qaz@WSX#$%&";

/**
* 根据参数获取签名
* @param body body
* @param params 参数
* @param paths
* @return
*/
public static String sign(String body, Map<String, String[]> params, String[] paths) {
StringBuilder sb = new StringBuilder();
if (CharSequenceUtil.isNotBlank(body)) {
sb.append(body).append('#');
}

if (!CollectionUtils.isEmpty(params)) {
params.entrySet()
.stream()
.sorted(Map.Entry.comparingByKey())
.forEach(paramEntry -> {
String paramValue = String.join(",", Arrays.stream(paramEntry.getValue()).sorted().toArray(String[]::new));
sb.append(paramEntry.getKey()).append("=").append(paramValue).append('#');
});
}

if (ArrayUtil.isNotEmpty(paths)) {
String pathValues = String.join(",", Arrays.stream(paths).sorted().toArray(String[]::new));
sb.append(pathValues);
}
return SecureUtil.sha256(String.join("#", DEFAULT_SECRET, sb.toString()));
}

}

3.6 测试

1
2
3
4
5
6
7
8
9
10
@RestController
@RequestMapping("/sign")
public class SignController {

@Signature
@PostMapping("/test/{id}")
public ResponseResult<String> test(@PathVariable String id) {
return ResponseResult.success(String.join(",", id));
}
}