1. 背景
在某些场景(提供第三方API接口的调用)下为了保证数据在传输过程中的安全性,需要对接口进行签名认证。一般而言,如果不是特别需要自己设计,可以直接将接口挂在阿里API网关上(可能会有部分的限制,如流量)。这里也是参照阿里网关签名思路写的。
2. 安全措施
(1)数据加密
我们知道数据在传输过程中是很容易被抓包的,如果直接传输比如通过http协议,那么用户传输的数据可以被任何人获取;所以必须对数据加密,常见的做法对关键字段加密比如用户密码直接通过md5加密;现在主流的做法是使用https协议,在http和tcp之间添加一层加密层(SSL层),这一层负责数据的加密和解密;另外对密码这块可以适用AES、RSA等算法进行文本加密;
(2)数据加签
数据加签就是由发送者产生一段无法伪造的一段数字串,来保证数据在传输过程中不被篡改;你可能会问数据如果已经通过https加密了,还有必要进行加签吗?数据在传输过程中经过加密,理论上就算被抓包,也无法对数据进行篡改;但是我们要知道加密的部分其实只是在外网,现在很多服务在内网中都需要经过很多服务跳转,所以这里的加签可以防止内网中数据被篡改;
(3)时间戳机制
数据是很容易被抓包的,但是经过如上的加密,加签处理,就算拿到数据也不能看到真实的数据;但是有不法者不关心真实的数据,而是直接拿到抓取的数据包进行恶意请求;这时候可以使用时间戳机制,在每次请求中加入当前的时间,服务器端会拿到当前时间和消息中的时间相减,看看是否在一个固定的时间范围内比如5分钟内;这样恶意请求的数据包是无法更改里面时间的,所以5分钟后就视为非法请求了;
(4)AppId机制
大部分网站基本都需要用户名和密码才能登录,并不是谁来能使用我的网站,这其实也是一种安全机制;对应的对外提供的接口其实也需要这么一种机制,并不是谁都可以调用,需要使用接口的用户需要在后台开通appid,提供给用户相关的密钥;在调用的接口中需要提供 appid+密钥,服务器端会进行相关的验证;
(5)限流机制
本来就是真实的用户,并且开通了appid,但是出现频繁调用接口的情况;这种情况需要给相关appid限流处理,常用的限流算法有令牌桶和漏桶算法;
(6)黑名单机制
如果此appid进行过很多非法操作,或者说专门有一个中黑系统,经过分析之后直接将此appid列入黑名单,所有请求直接返回错误码;
(7)数据合法性校验
这个可以说是每个系统都会有的处理机制,只有在数据是合法的情况下才会进行数据处理;每个系统都有自己的验证规则,当然也可能有一些常规性的规则,比如身份证长度和组成,电话号码长度和组成等等;
Q:接口加签对客户端是不是不适用,客户端能直接获取加签方式,也能知道appSecret,这种加签是不是只有服务端之间调用才能保证安全性?
3. 签名生成方式
签名包含内容:请求方法,请求地址,请求头,请求参数,请求Body
(1)系统参数一般设置在请求头中,如时间戳、待加签的请求头;
(2)body内容需要MD5 + Base64(body为空跳过),然后添加换行符;
(3) 请求头,为了防止部分web容器请求头有大小写区分,因此请求头key统一改成小写。每个请求头后都需要添加换行符;
(3)请求参数需要排序,按照字母升序,这里有一个细节,就是使用了TreeMap,默认会按照key升序排(http://127.0.0.1/test?a=1&b=2&c=3);
(3)最终签名内容组合的字符串需要MD5 + Base64;
Q:MD5 + Base64组合有什么用?
A:方便表示和存储。
4. 实现
package priv.whh.std.boot.api.sign.strategy;
import com.alibaba.fastjson.JSON;
import lombok.extern.slf4j.Slf4j;
import org.apache.commons.codec.binary.Base64;
import org.apache.commons.io.IOUtils;
import org.apache.logging.log4j.util.Strings;
import org.springframework.http.HttpMethod;
import org.springframework.util.StringUtils;
import priv.whh.std.boot.api.sign.caller.AbstractRemoteCaller;
import priv.whh.std.boot.api.sign.property.ApiSignProperties;
import priv.whh.std.boot.api.sign.wrapper.RequestWrapper;
import javax.crypto.Mac;
import javax.crypto.spec.SecretKeySpec;
import javax.servlet.http.HttpServletRequest;
import java.io.ByteArrayInputStream;
import java.io.IOException;
import java.io.InputStream;
import java.nio.charset.StandardCharsets;
import java.security.MessageDigest;
import java.security.NoSuchAlgorithmException;
import java.util.*;
@Slf4j
public class DefaultSignStrategy extends AbstractSignStrategy {
private static final String HMAC_SHA256 = "HmacSHA256";
private static final String MD5 = "MD5";
private static final String COMMA = ",";
private static final String COLON = ":";
private static final String QUESTION_MARK = "?";
private static final String EQUAL_SIGN = "=";
private static final String AMPERSAND = "&";
private static final char LF = '\n';
private final ApiSignProperties apiSignProperties;
private final AbstractRemoteCaller remoteCaller;
public DefaultSignStrategy(ApiSignProperties apiSignProperties,
AbstractRemoteCaller remoteCaller) {
this.apiSignProperties = apiSignProperties;
this.remoteCaller = remoteCaller;
}
@Override
public Boolean verify(HttpServletRequest request) throws Exception {
if (apiSignProperties.getAnonymous()) {
// 签名关闭
return Boolean.TRUE;
}
String method = request.getMethod();
String uri = request.getRequestURI();
// 构建待加签的请求头
Map<String, String> headersMap = new HashMap<>(8);
for (String headerKey : apiSignProperties.getHeadersToSign().split(COMMA)) {
headersMap.get(apiSignProperties.getHttpHeaderToLowerCase() ? headerKey.toLowerCase() : headerKey);
}
// 根据appId查找appSecret
headersMap.put(apiSignProperties.getSecretHeaderKey(), apiSignProperties.getAppSecret());
// 构建待加签的请求参数
Map<String, Object> paramsMap = new HashMap<>(8);
String queryString = request.getQueryString();
if (!Strings.isEmpty(queryString)) {
for (String param : queryString.split(AMPERSAND)) {
String[] split = param.split(EQUAL_SIGN);
if (split.length < 2) {
continue;
}
paramsMap.put(split[0], split[1]);
}
}
// 构建待加签的body
String body = ((RequestWrapper) request).getBody();
String signHeader = request.getHeader(apiSignProperties.getSignHeaderKey());
String sign = sign(uri, method, headersMap, paramsMap, body.getBytes(StandardCharsets.UTF_8));
if (!Objects.equals(sign, signHeader)) {
log.warn("Failed to compare sign by sign: {}, signHeader: {}", sign, signHeader);
throw new Exception("Sign is not match");
}
// 超时判断
String startTime = request.getHeader(apiSignProperties.getTimestampHeaderKey());
long endTime = System.currentTimeMillis();
if (Objects.isNull(startTime)
|| endTime - Long.parseLong(startTime) < apiSignProperties.getTimeout()) {
log.warn("Failed to compare sign because it is time out, startTime: {}, endTime: {}", startTime, endTime);
throw new Exception("Time out");
}
return Boolean.TRUE;
}
@Override
public <T> T call(String uri, HttpMethod method, Map<String, Object> paramsMap, Object requestBody,
Class<T> responseType) throws Exception {
Map<String, String> headersMap = new HashMap<>(8);
headersMap.put(apiSignProperties.getHeadersToSign(), apiSignProperties.getHeadersToSign());
String sign = sign(uri, method.toString(), headersMap, paramsMap, JSON.toJSONString(requestBody)
.getBytes(StandardCharsets.UTF_8));
headersMap.put(apiSignProperties.getSignHeaderKey(), sign);
return remoteCaller.call(uri, method, headersMap, paramsMap, requestBody, responseType);
}
/**
* 计算HTTP请求签名
*
* @param uri 原始HTTP请求PATH(不包含Query)
* @param method 原始HTTP请求方法
* @param headersMap 原始HTTP请求所有请求头
* @param paramsMap 原始HTTP请求所有Query+Form参数
* @param bodyBytes 原始HTTP请求Body体(仅当请求为POST/PUT且非表单请求才需要设置此属性,表单形式的需要将参数放到paramsMap中)
* @return 签名结果
* @throws Exception 异常
*/
public String sign(String uri, String method, Map<String, String> headersMap,
Map<String, Object> paramsMap, byte[] bodyBytes) throws Exception {
Map<String, String> headersToSign = buildHeadersToSign(headersMap);
String bodyMd5 = buildBodyMd5(method, bodyBytes);
String resourceToSign = buildResource(uri, paramsMap);
// 加签:MD5 + base64
String stringToSign = buildStringToSign(headersToSign, resourceToSign, method, bodyMd5);
Mac hmacSha256 = Mac.getInstance(HMAC_SHA256);
String secret = apiSignProperties.getAppSecret();
byte[] keyBytes = secret.getBytes(StandardCharsets.UTF_8);
hmacSha256.init(new SecretKeySpec(keyBytes, 0, keyBytes.length, HMAC_SHA256));
return new String(Base64.encodeBase64(hmacSha256.doFinal(stringToSign.getBytes(StandardCharsets.UTF_8))),
StandardCharsets.UTF_8);
}
/**
* 构建BodyMd5
*
* @param httpMethod HTTP请求方法
* @param inputStreamBytes HTTP请求Body体字节数组
* @return Body Md5值
*/
private static String buildBodyMd5(String httpMethod, byte[] inputStreamBytes) throws IOException {
if (inputStreamBytes == null) {
return null;
}
if (!httpMethod.equalsIgnoreCase(HttpMethod.POST.toString())
&& !httpMethod.equalsIgnoreCase(HttpMethod.PUT.toString())) {
return null;
}
InputStream inputStream = new ByteArrayInputStream(inputStreamBytes);
byte[] bodyBytes = IOUtils.toByteArray(inputStream);
if (bodyBytes != null && bodyBytes.length > 0) {
return base64AndMD5(bodyBytes).trim();
}
return null;
}
/**
* 将Map转换为用&及=拼接的字符串
*/
@SuppressWarnings("all")
private static String buildMapToSign(Map<String, Object> paramMap) {
StringBuilder builder = new StringBuilder();
for (Map.Entry<String, Object> e : paramMap.entrySet()) {
if (builder.length() > 0) {
builder.append(AMPERSAND);
}
String key = e.getKey();
Object value = e.getValue();
if (value != null) {
if (value instanceof List) {
List list = (List) value;
if (list.size() == 0) {
builder.append(key);
} else {
builder.append(key).append(EQUAL_SIGN).append(list.get(0));
}
} else if (value instanceof Object[]) {
Object[] objs = (Object[]) value;
if (objs.length == 0) {
builder.append(key);
} else {
builder.append(key).append(EQUAL_SIGN).append(objs[0]);
}
} else {
builder.append(key).append(EQUAL_SIGN).append(value);
}
}
}
return builder.toString();
}
/**
* 构建参与签名的HTTP头
* <pre>
* 传入的Headers必须将默认的ISO-8859-1转换为UTF-8以支持中文
* </pre>
*
* @param headers HTTP请求头
* @return 所有参与签名计算的HTTP请求头
*/
private Map<String, String> buildHeadersToSign(Map<String, String> headers) {
Map<String, String> headersToSignMap = new TreeMap<>();
String headersToSignString = apiSignProperties.getHeadersToSign();
for (String headerKey : headersToSignString.split(COMMA)) {
headersToSignMap.put(headerKey, headers.get(apiSignProperties.getHttpHeaderToLowerCase()
? headerKey.toLowerCase() : headerKey));
}
return headersToSignMap;
}
/**
* 组织待计算签名字符串
*
* @param headers HTTP请求头
* @param resourceToSign Uri+请求参数的签名字符串
* @param method HTTP方法
* @param bodyMd5 Body Md5值
* @return 待计算签名字符串
*/
private static String buildStringToSign(Map<String, String> headers, String resourceToSign, String method, String bodyMd5) {
StringBuilder sb = new StringBuilder();
sb.append(method).append(LF);
if (StringUtils.isEmpty(bodyMd5)) {
sb.append(bodyMd5);
}
sb.append(LF);
sb.append(buildHeaders(headers));
sb.append(resourceToSign);
return sb.toString();
}
/**
* 组织Headers签名签名字符串
*
* @param headers HTTP请求头
* @return Headers签名签名字符串
*/
private static String buildHeaders(Map<String, String> headers) {
StringBuilder sb = new StringBuilder();
for (Map.Entry<String, String> e : headers.entrySet()) {
if (e.getValue() != null) {
sb.append(e.getKey().toLowerCase()).append(COLON).append(e.getValue()).append(LF);
}
}
return sb.toString();
}
/**
* 组织Uri+请求参数的签名字符串
*
* @param uri HTTP请求uri,不包含Query
* @param paramsMap HTTP请求所有参数(Query+Form参数)
* @return Uri+请求参数的签名字符串
*/
private static String buildResource(String uri, Map<String, Object> paramsMap) {
StringBuilder builder = new StringBuilder();
// uri
builder.append(uri);
// Query+Form
Map<String, Object> sortMap = new TreeMap<>(paramsMap);
// 有Query+Form参数
if (sortMap.size() > 0) {
builder.append(QUESTION_MARK);
builder.append(buildMapToSign(sortMap));
}
return builder.toString();
}
/**
* 先进行MD5摘要再进行Base64编码获取摘要字符串
*
* @param bytes 待计算字节数组
* @return 密文
*/
private static String base64AndMD5(byte[] bytes) {
if (bytes == null) {
throw new IllegalArgumentException("Bytes can not be null");
}
try {
final MessageDigest md = MessageDigest.getInstance(MD5);
md.reset();
md.update(bytes);
final Base64 base64 = new Base64();
return new String(base64.encode(md.digest()), StandardCharsets.UTF_8);
} catch (final NoSuchAlgorithmException e) {
throw new IllegalArgumentException("Unknown algorithm MD5");
}
}
}
5. FAQ
Q:限流、黑白名单如何做?
参考资料: