一、签名机制简介
1、如何保证数据在通信时的安全性
如果外部用户需要访问开放的 API接口,我们通过 HTTP Post或Get方式请求服务器,那么在写对外开放的 API接口如何保证数据的安全性的?
在开发中,为了保证数据在通信时的安全性,我们可以采用参数签名的方式来进行相关验证。所以后端在开发对外开放的 API接口时,一般会对参数进行签名来保证接口的安全性。
在设计签名算法时,主要考虑这几个问题:
- 请求的来源/身份是否合法?
- 请求参数是否被篡改?
- 请求是否唯一?
基于这几个问题,我们通过以下步骤来保证数据在通信时的安全性:
1.1 请求身份
通过给第三方开发者分配 AccessKey和 AccessKey Secret来验证请求者身份。
- AccessKey ID:用于标识访问者的身份,确保唯一。也可理解为用户名
- AccessKey Secret:用于接口加密,确保不易被穷举,生成算法不易被猜测。也可理解为用户密码
AccessKey ID和AccessKey Secret由 API服务方分配给访问者,必须严格保密。
1.2 防止篡改 - 参数签名
通过将请求的所有参数按照字母先后顺序排序后拼接再根据签名算法(比如MD5)加密得到新的字符串来保证请求参数不被篡改。
主要就是两点:
- 构造用于签名的规范字符串
- 将构造用于签名的规范字符串通过签名算法生成签名值
1.3 重放攻击
上面虽然解决了请求参数被篡改的隐患,但是还存在着重复使用请求参数伪造二次请求的隐患。
我们可以在请求里携带时间戳等参数来保证请求的唯一和过期或者重复的请求在指定时间内有效(可配置)。
1.3.1 timestamp+nonce方案
nonce指唯一的随机字符串,用来标识每个被签名的请求。
通过为每个请求提供一个唯一的标识符,服务器能够防止请求被多次使用(记录所有用过的 nonce以阻止它们被二次使用)。然而,对服务器来说永久存储所有接收到的nonce的代价是非常大的。可以使用 timestamp来优化 nonce的存储。
假设允许客户端和服务端最多能存在15分钟的时间差,同时追踪记录在服务端的 nonce集合。
当有新的请求进入时,
- 首先检查携带的 timestamp是否在15分钟内,如超出时间范围,则拒绝,
- 然后查询携带的 nonce,如存在已有集合,则拒绝。
- 否则,记录该 nonce,并删除集合内时间戳大于15分钟的nonce(可以使用 redis的 expire,新增 nonce的同时设置它的超时失效时间为15分钟)。
1.3.2 Token&AppKey(APP)
在 APP开放API接口的设计中,由于大多数接口涉及到用户的个人信息以及产品的敏感数据,所以要对这些接口进行身份验证,为了安全起见让用户暴露的明文密码次数越少越好,然而客户端与服务器的交互在请求之间是无状态的,也就是说,当涉及到用户状态时,每次请求都要带上身份验证信息。
Token身份验证:
- 用户登录向服务器提供认证信息(如账号和密码),服务器验证成功后返回 Token给客户端;
- 客户端将 Token保存在本地,后续发起请求时,携带此 Token;
- 服务器检查 Token的有效性,有效则放行,无效(Token错误或过期)则拒绝。
安全隐患:Token被劫持,伪造请求和篡改参数。《设计一个安全的对外接口》这篇也推荐阅读一下。
1.3.3 Token+AppKey签名验证
与上面开发平台的验证方式类似,为客户端分配 AppKey(密钥,用于接口加密,不参与传输),将AppKey和所有请求参数组合成源串,根据签名算法生成签名值,发送请求时将签名值一起发送给服务器验证。
这样,即使 Token被劫持,对方不知道 AppKey和签名算法,就无法伪造请求和篡改参数。
再结合上述的重发攻击解决方案,即使请求参数被劫持也无法伪造二次重复请求。
二、开放 API接口签名验证定义
通过对签名机制的了解,我们自己实现一个 开放 API接口签名验证。
我们API接口采用 TOKEN授权机制 + AppKey签名验证来实现进行交互。
- 第三方在进行所有业务接口请求之前,必须先通过 API接口获取到正确的授权码(TOKEN)。
- 上面AccessKey ID和AccessKey Secret可以理解为 token授权机制的用户名和密码,变量名自定义(AppKey和Code等)。
- 签名算法中 构造用于签名的规范字符串的方式后端自定义。
1、请求
第三方在进行所有业务接口请求之前,必须先通过 API接口获取到正确的授权码(TOKEN)。
每个接口都有请求方式说明,主要使用 get、post进行数据交互。所有接口采取 utf-8字符集
发送。
- get请求时,系统级参数和应用参数都以 get参数方式签名并发送。
- post请求时,系统级参数以 get参数方式,应用参数都以 post参数方式签名并发送。
具体请求参数请参见各接口说明。
2、请求参数
系统级参数:
appKey:用于标识访问者的身份,即用户名
format:响应数据格式
signMethod:签名算法
signVersion:签名版本
timestamp:请求时间
nonce:指唯一的随机字符串(比如uuid),用来标识每个被签名的请求
version:接口版本
sign:API 签名值
token:授权码(部分接口不需要,比如获取授权码。详见各接口定义)
应用级参数:见各接口规定的参数。
3、签名验签算法设计
3.1 签名生成算法
签名生成算法步骤如下:
- 生成构造用于签名的规范字符串(StringToSign)。
- 将 StringToSign字符串通过签名算法(这里使用 MD5)生成签名值,并将签名值转成为大写,然后再进行Base64编码。即得到最终 API 的签名值。
- 将 API 的签名值作为 sign参数的值添加到请求参数中,即完成对请求签名的过程。
HTTP请求的构造用于签名的规范字符串(StringToSign)伪代码如下:
StringToSign =
HTTPRequestMethod + '\n' +
CanonicalURI + '\n' +
CanonicalQueryString + '\n' +
Token + '\n' +
HexEncode(Hash(RequestPayload))
参数说明:
(1)HTTPRequestMethod的值
HTTP请求方法的值,如GET、PUT、POST等。以换行符结束。
(2)CanonicalURI的值
规范URI参数(CanonicalURI)的值,以换行符结束。
规范URI,即请求资源路径,是 URI的绝对路径部分的URI编码。
格式:
根据 RFC 3986标准化URI路径,移除冗余和相对路径部分,路径中每个部分必须为URI编码。如果URI路径不以“/”结尾,则在尾部添加“/”。
(3)CanonicalQueryString的值
将 GET参数通过西面要求拼接生成规范查询字符串(CanonicalQueryString)的值,以换行符结束。
查询字符串,即查询参数或者 GET参数。如果没有查询参数,则为空字符串。
格式:
规范查询字符串需要满足以下要求:
- 根据以下规则对每个参数名和值进行 URI编码:
- 请勿对RFC 3986定义的任何非预留字符进行URI编码,这些字符包括:A-Z、a-z、0-9、-、_、.和~。
- 使用%XY对所有非预留字符进行百分比编码,其中X和Y为十六进制字符(0-9和A-F)。例如,空格字符必须编码为%20,扩展UTF-8字符必须采用“%XY%ZA%BC”格式。
- 对于每个参数,追加“URI编码的参数名称=URI编码的参数值”。如果没有参数值,则以空字符串代替,但不能省略“=”。
注意:
这里我们定义了系统级参数与应用级参数,根据各接口规定的参数和 GET请求方式,合理的将系统级参数与应用级参数合并,来拼接查询字符串。
(4)Token的值
通过 API接口获取到正确的授权码(TOKEN)的值,以换行符结束。
(5)HexEncode(Hash(RequestPayload))的值
使用 SHA 256哈希函数请求正文中的 body体(RequestPayload),生成的小写哈希值。如果 RequestPayload为空或者 NULL时,默认空字符串来处理。
释义:
请求消息体。消息体需要做两层转换:HexEncode(Hash(RequestPayload)),其中:
- Hash表示生成消息摘要的函数,当前支持SHA-256算法。
- HexEncode表示以小写字母形式返回摘要的 Base-16编码的函数。
例如,HexEncode(“m”) 返回值为“6d”而不是“6D”。输入的每一个字节都表示为两个十六进制字符。
注意:
- 各个参数值之间使用 换行符连接,或者你可以使用其他符合都可以。
- 默认最后一行参数不需要添加换行符’\n’。
上面生成构造用于签名的规范字符串 参考了华为云,你也可以自定义生成规则。
示例:
stringToSign=GET
/sign-web-api/sign/getById.json/
appKey=zhaoyun&format=json&nonce=ae69c7a6-feaa-4b3d-b0a8-718d5c4d2a08&signMethod=MD5&signVersion=1.0×tamp=1639405259585&token=3ea308fa-14c8-4d35-9dad-ac1434f4b75f&userId=1001&version=1.0
3ea308fa-14c8-4d35-9dad-ac1434f4b75f
e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855
3.2 签名验证算法(后端)
接口提供方验证接口请求是否可信,主要算法跟生成API签名的算法是一样的。
签名验证算法步骤如下:
- 得到客户端请求携带的 API签名值(API 的签名值),非空判断。
- 检查 Token授权码的有效性
- 检查携带的 timestamp的有效性
- 检查携带的 nonce的唯一性
- 服务器端根据请求方携带的参数(注意:不包括sign参数)通过签名生成算法生成 API签名值。
- 开始签名验证,如果服务器端生成的 API签名值与客户端请求的API签名是否一致的。如果一致,则请求是可信的,放行通过;否则就是不可信的,拒绝访问。
4、响应
接口以 JSON数据格式响应,响应的固定参数格式为:
{
"success": true|false,
"errorMessage": "失败时错误信息",
"resultData": "返回结果集"
}
具体响应结果集请参见各接口说明。
5、其他
注意:
- 授权码(TOKEN),授权时长为一天。
- API接口中的地址、appKey、appKeyCode 为接口方提供,对接方请勿泄露,否则后果自付。
6、API接口参数说明
这类列举一下 获取授权码(TOKEN)接口参数说明,其他接口根据业务自己定义。
6.1 获取授权码(TOKEN)接口
获取接口授权码,在调用其他业务接口前,必须通过该接口获取授权码。
- 请求地址:xxxx
- 请求方式:GET
- 请求参数:?appKey=zhaoyun&appKeySecret=zhaoyun123456
- 响应结果:
{
"success": true,
"errorMessage": null,
"resultData": {
"token": "f834480f-2e91-405e-95e1-983d4c128e08",
"tokenExpireTime": 1639477778147
}
}
注意:
- 授权码有效期为:1天。
- 每调用一次或刷新后,旧的授权码(TOKEN)将失效。
三、Java代码实现
创建 maven 项目,下面贴一些主要代码。
1、自定义BodyReaderFilter
解决 springboot 对请求消息体中流不可重复读取问题。
@WebFilter(filterName = "bodyReaderFilter", urlPatterns = "/*")
public class BodyReaderFilter implements Filter {
@Override
public void init(FilterConfig filterConfig) throws ServletException {
// do nothing
}
@Override
public void doFilter(ServletRequest request, ServletResponse response, FilterChain chain) throws IOException, ServletException {
ServletRequest requestWrapper = null;
if (request instanceof HttpServletRequest) {
// 将请求对象包装为 可重复读取流的请求对象。注意:构造好了,但是需要在拦截器中获取
requestWrapper = new BodyReaderHttpServletRequestWrapper((HttpServletRequest) request);
}
if (requestWrapper == null) {
chain.doFilter(request, response);
} else {
chain.doFilter(requestWrapper, response);
}
}
@Override
public void destroy() {
// do nothing
}
}
public class BodyReaderHttpServletRequestWrapper extends HttpServletRequestWrapper {
private byte[] requestBody = null;// 用于将流保存下来
public BodyReaderHttpServletRequestWrapper(HttpServletRequest request) throws IOException {
super(request);
requestBody = StreamUtils.copyToByteArray(request.getInputStream());
}
@Override
public ServletInputStream getInputStream() throws IOException {
final ByteArrayInputStream bais = new ByteArrayInputStream(requestBody);
return new ServletInputStream() {
@Override
public int read() throws IOException {
return bais.read();
}
@Override
public boolean isFinished() {
return false;
}
@Override
public boolean isReady() {
return false;
}
@Override
public void setReadListener(ReadListener readListener) {
}
};
}
@Override
public BufferedReader getReader() throws IOException {
return new BufferedReader(new InputStreamReader(getInputStream()));
}
}
2、自定义拦截器
实现 签名认证拦截。这里没有进行方法封装,步骤写的很清晰。
public class SignInterceptor implements HandlerInterceptor {
Logger logger = LoggerFactory.getLogger(SignInterceptor.class);
@Autowired
private AppTokenService appTokenService;
@Autowired
private NonceService nonceService;
/**
* 15分钟
*/
private static final Long FIFTEEN_MINUTES = 1000 * 60 * 15L;
@Override
public boolean preHandle(HttpServletRequest request, HttpServletResponse response, Object handler) throws Exception {
BaseResult baseResult = new BaseResult();
request.setCharacterEncoding("UTF-8");
String method = request.getMethod();
StringBuffer requestURL = request.getRequestURL();
String canonicalURI = requestURL.substring(requestURL.indexOf(request.getContextPath()));
if (!canonicalURI.endsWith("/")) {
canonicalURI = canonicalURI + "/";
}
/**
* 签名验证算法步骤如下: <br/>
* 1. 得到客户端请求携带的 API签名值(API 的签名值),非空判断。 <br/>
* 2. 检查 Token授权码的有效性 <br/>
* 3. 检查携带的 timestamp的有效性 <br/>
* 4. 检查携带的 nonce的唯一性 <br/>
* 5. 服务器端根据请求方携带的参数(注意:不包括sign参数)通过签名生成算法生成 API签名值。 <br/>
* 6. 开始签名验证,如果服务器端生成的 API签名值与客户端请求的API签名是否一致的。如果一致,则请求是可信的,放行通过;否则就是不可信的,拒绝访问。 <br/>
*/
// 获取请求参数
Map<String, String[]> parameterMap = request.getParameterMap();
// 1. 得到客户端请求携带的 API签名值(API 的签名值),非空判断。
if (!parameterMap.containsKey("sign")) {
baseResult.setSuccess(false);
baseResult.setErrorMessage("签名(sign)请求参数缺失");
responseOutByJson(response, baseResult);
return false;
}
String[] signArr = parameterMap.get("sign");
String sign = null;
if (signArr == null || StringUtils.isBlank(sign = signArr[0])) {
baseResult.setSuccess(false);
baseResult.setErrorMessage("签名(sign)请求参数值为空");
responseOutByJson(response, baseResult);
return false;
}
// 2. 检查 Token授权码的有效性(获取授权码接口不判断)
String token = "";
if(!canonicalURI.startsWith(request.getContextPath() + "/sign/getToken.json")){
// 如果header中不存在token,则从参数中获取token
token = request.getHeader("token");
if (StringUtils.isBlank(token)) {
token = request.getParameter("token");
}
if (StringUtils.isBlank(token)) {
baseResult.setSuccess(false);
baseResult.setErrorMessage("token请求参数缺失,或者值为空");
responseOutByJson(response, baseResult);
}
// 查询token信息
AppToken appToken = appTokenService.queryByToken(token);
if (appToken == null || appToken.getTokenExpireTime() < System.currentTimeMillis()) {
baseResult.setSuccess(false);
baseResult.setErrorMessage("token已过期,请重新登录");
responseOutByJson(response, baseResult);
}
}
// 3. 检查携带的 timestamp的有效性
// 当前请求时间戳
String timestamp = parameterMap.get("timestamp")[0];
if (timestamp == null) {
baseResult.setSuccess(false);
baseResult.setErrorMessage("请求参数(timestamp)缺失,请检查后再试");
responseOutByJson(response, baseResult);
}
long now = System.currentTimeMillis();
// 判断 timestamp是否在规定时间范围内 5分钟 如超出时间范围,则拒绝
if (now - Long.parseLong(timestamp) >= FIFTEEN_MINUTES) {
baseResult.setSuccess(false);
baseResult.setErrorMessage("请求超时");
responseOutByJson(response, baseResult);
}
// 4. 检查携带的 nonce的唯一性
// 查询携带的随机字符串nonce
String nonce = parameterMap.get("nonce")[0];
if (nonce == null) {
baseResult.setSuccess(false);
baseResult.setErrorMessage("请求参数(nonce)缺失,请检查后再试");
responseOutByJson(response, baseResult);
}
// 从缓存中查找是否有相同请求,,如存在已有集合,则拒绝
if (nonceService.isExist(nonce)) {
baseResult.setSuccess(false);
baseResult.setErrorMessage("nonce已存在,请求错误,请检查后再试");
responseOutByJson(response, baseResult);
} else {
// 否则,记录该nonce,并删除集合内时间戳大于5分钟的nonce,新增nonce的同时设置它的超时失效时间为5分钟
nonceService.saveNonceAndDeleteExpireTime(nonce);
}
// 5. 服务器端根据请求方携带的参数(注意:不包括sign参数)通过签名生成算法生成 API签名值。
// 获取请求消息体
String requestBody = getRequestBody(request);
Map<String, String> getParameterMap = parameterMap.entrySet().stream().filter(m -> !"sign".equals(m.getKey()))
.collect(Collectors.toMap(m -> m.getKey(), m -> m.getValue()[0], (o, n) -> n));
getParameterMap.put("token", token);
// 生成sign,进行签名认证
String genSign = SignUtils.generateSign(method, canonicalURI, getParameterMap, token, requestBody);
logger.error("----preHandle---- -> sign={},genSign={}", sign, genSign);
if (!sign.equals(genSign)) {
baseResult.setSuccess(false);
baseResult.setErrorMessage("签名(sign)不匹配, 签名验证失败");
responseOutByJson(response, baseResult);
return false;
}
return true;
}
/**
* 获取请求消息体
*
* @param request
* @return
*/
private String getRequestBody(HttpServletRequest request) {
StringBuilder sb = new StringBuilder("");
try (BufferedReader br = request.getReader()) {
String str;
while ((str = br.readLine()) != null) {
sb.append(str);
}
} catch (IOException e) {
logger.error("系统异常 -> 获取请求消息体参数异常。e={}", e.getMessage());
}
return sb.toString();
}
/**
* 响应输出json
*
* @param response
* @param baseResult
*/
private void responseOutByJson(HttpServletResponse response, BaseResult baseResult) {
response.setCharacterEncoding("UTF-8");
response.setContentType("application/json");
try (PrintWriter out = response.getWriter()) {
out.print(JSON.toJSONString(baseResult, SerializerFeature.WriteNonStringValueAsString, SerializerFeature.WriteMapNullValue));
} catch (IOException e) {
logger.error("系统异常 -> 响应异常。e={}", e.getMessage());
}
}
}
3、签名算法工具类
public class SignUtils {
private static final Logger logger = LoggerFactory.getLogger(SignUtils.class);
private SignUtils() {
}
/**
* 生成签名
*
* @param httpRequestMethod
* @param canonicalURI
* @param getParamterMap
* @param token
* @param requestBodyStr
* @return
*/
public static String generateSign(String httpRequestMethod, String canonicalURI, Map<String, String> getParamterMap, String token, String requestBodyStr) {
/**
* 签名生成算法步骤如下: <br/>
* 1. 生成构造用于签名的规范字符串(StringToSign)。 <br/>
* 2. 将 StringToSign字符串通过签名算法(这里使用 MD5)生成签名值,并将签名值转成为大写,然后再进行Base64编码。即得到最终 API的签名值。 <br/>
*
*/
String stringToSign = structureStringToSign(httpRequestMethod, canonicalURI, getParamterMap, token, requestBodyStr);
// MD5后转成大写即为最终签名结果。
String sign = Md5Utils.MD5Upper(stringToSign).toUpperCase();
logger.info("签名算法,生成 API的签名值 -> sign={}", sign);
return sign;
}
/**
* 构造用于签名的规范字符串
*
* @param httpRequestMethod
* @param canonicalURI
* @param getParamterMap
* @param token
* @param requestBodyStr
* @return
*/
private static String structureStringToSign(String httpRequestMethod, String canonicalURI, Map<String, String> getParamterMap, String token, String requestBodyStr) {
/**
* StringToSign = <br/>
* HTTPRequestMethod + '\n' + <br/>
* CanonicalURI + '\n' + <br/>
* CanonicalQueryString + '\n' + <br/>
* Token + '\n' + <br/>
* HexEncode(Hash(RequestPayload)) <br/>
*/
String canonicalQueryString = spliceCanonicalQueryString(getParamterMap);
// 根据RFC 3986标准化URI路径,移除冗余和相对路径部分,路径中每个部分必须为URI编码。如果URI路径不以“/”结尾,则在尾部添加“/”。
if (!canonicalURI.endsWith("/")) {
canonicalURI = canonicalURI + "/";
}
// 如果请求消息体为null,直接使用空字符串""。SHA256 哈希,并小写
String sha256RequestBody = sha256RequestBody = Sha256Utils.getSHA256(StringUtils.isBlank(requestBodyStr) ? "" : requestBodyStr).toLowerCase();
;
// 构建规范字符串
StringBuffer stringToSign = new StringBuffer("");
stringToSign.append(httpRequestMethod.toUpperCase()).append("\n")
.append(canonicalURI).append("\n")
.append(canonicalQueryString).append("\n")
.append(token).append("\n")
.append(sha256RequestBody);
logger.info("生成构造用于签名的规范字符串 -> canonicalQueryString={}, stringToSign={}", canonicalQueryString, stringToSign);
return stringToSign.toString();
}
/**
* 获取拼接生成规范查询字符串,不带sign
*
* @param getParamterMap
* - 系统级参数与应用级参数合并之后的集合
* @return
*/
public static String spliceCanonicalQueryString(Map<String, String> getParamterMap) {
if (null == getParamterMap) {
return null;
}
// 字典排序
TreeMap<String, String> sortMap = new TreeMap<>(getParamterMap);
return spliceParams(sortMap);
}
/**
* 拼接参数
*
* @param treeMap
* @return
*/
private static String spliceParams(TreeMap<String, String> treeMap) {
if (null == treeMap) {
return null;
}
StringBuilder paramStr = new StringBuilder();
/**
* 去除首尾空格,符合URL编码的编码规则
*/
treeMap.forEach((key, value) -> {
key = key.trim();
key = URLEncoder.encode(key, StandardCharsets.UTF_8).replace("*", "%2A").replace("+", "%20").replace("%7E", "~");
value = StringUtils.isBlank(value) ? "" : value.trim();
value = URLEncoder.encode(value, StandardCharsets.UTF_8).replace("*", "%2A").replace("+", "%20").replace("%7E", "~");
paramStr.append("&").append(key).append("=").append(value);
});
// 去掉第一个&
return paramStr.substring(1);
}
}
4、配置类
@Configuration
public class WebConfig implements WebMvcConfigurer {
@Bean
public SignInterceptor signInterceptor() {
return new SignInterceptor();
}
/**
* 添加拦截器 https://blog.csdn.net/qq_42240485/article/details/104900009
*
* @param registry
*/
@Override
public void addInterceptors(InterceptorRegistry registry) {
registry.addInterceptor(signInterceptor())
.addPathPatterns("/sign/**")
.excludePathPatterns("/login", "/", "/index");
}
/**
* 添加过滤器
*
* @return
*/
@Bean
public FilterRegistrationBean<BodyReaderFilter> Filters() {
FilterRegistrationBean<BodyReaderFilter> registrationBean = new FilterRegistrationBean<BodyReaderFilter>();
registrationBean.setFilter(new BodyReaderFilter());
registrationBean.addUrlPatterns("/*");
registrationBean.setName("bodyReaderFilter");
return registrationBean;
}
}
参考文章:
- 阿里云-签名机制:https://help.aliyun.com/document_detail/44396.html
- 华为云-AK/SK签名认证流程:https://support.huaweicloud.com/devg-apisign/api-sign-algorithm.html
- 开放 API接口签名验证:https://blog.csdn.net/yonhu123java/article/details/108483494
– 求知若饥,虚心若愚。