基于Spring AOP 的开放接口签名机制 | 保护接口安全
在 Web 应用程序中,接口安全是至关重要的。一个普遍的做法是使用 API 密钥或 token 进行身份验证和授权,以确保只有授权用户才能访问接口。然而,这些方法并不足以保证接口的完整性和安全性。接口签名是一种更加安全的方法,可以防止未经授权的访问,同时还可以防止 API 请求被篡改。
在本文中,我们将介绍如何使用 Spring 的 AOP 实现接口签名,保护接口安全。
什么是接口签名
接口签名是一种验证 API 请求完整性和防止篡改的方法。在每个请求中,客户端使用一个密钥和一组参数生成一个签名。服务端使用同样的密钥和参数,生成一个自己的签名。如果两个签名不一致,则说明请求被篡改,应该被拒绝。
接口签名通常使用哈希算法生成签名,如 SHA-256。由于哈希算法是不可逆的,因此攻击者无法通过已知的签名来计算出原始数据,从而保证数据的安全性。
我们在对接第三方系统时,像微信、支付宝的接口在调用时,都要求调用者先用提供的AppId和Secret对数据进行签名,然后把签名结果一并附带到请求中,发送过去,这样做的目的,是保证请求来源的合法性,防止接口被篡改。
这里使用常用的签名方案,涉及的参数有:
appId
:标识应用的身份信息,由服务器提供appSecret
:密钥信息,由服务器提供timestamp
:请求时的时间信息,调用者生成nonce
:随机参数,调用者生成sign
:调用者用上面的参数 + 请求的参数进行签名后生成的一串字符,传递给服务器,服务器再用相同的方法进行签名,然后两个签名进行比较,判断数据是否被篡改,是否合法。
关于 sign
的签名规则:
将本次请求的所有参数(URL上,Body中)提取出来,加上 appId
、 timestamp
、 nonce
,按照字母顺序(ASCII)进行排序,逐个按照固定格式([key1][value1][key2][value2]...[appSecret]
)拼接成字符串,再将字符串使用算法(SHA256或MD5等)进行加密,生成最终的 sign
。
实践
下面只贴出关键代码,示例源码可在文章结尾处获取。
注解
/**
* 注解 - 接口签名校验
*/
@Target({ElementType.METHOD, ElementType.TYPE})
@Retention(RetentionPolicy.RUNTIME)
public @interface ApiSignValid {
}
切面
签名校验的核心逻辑,只实现最基础的校验规则,实际情况应当根据业务需求加强安全逻辑。
import cn.hutool.core.util.ObjectUtil;
import cn.hutool.crypto.SecureUtil;
import com.suimz.example.api.signature.sign.core.ApiSignNonceStrCache;
import com.suimz.example.api.signature.sign.core.ApiSignProperties;
import com.suimz.example.api.signature.sign.core.ApiSignValidException;
import com.suimz.example.api.signature.sign.filter.ApiSignRequestWrapper;
import com.suimz.example.api.signature.sign.util.HttpRequestUtil;
/**
* 切面 - 接口签名校验
*/
@Aspect
@Component
public class ApiSignValidAspect {
private final Logger log = LoggerFactory.getLogger(ApiSignValidAspect.class);
@Autowired
private ApiSignProperties properties;
/**
* 切入点
*/
@Pointcut("@annotation(com.suimz.example.api.signature.sign.aop.ApiSignValid)")
public void pointCut() { }
/**
* 方法运行之前调用 - 校验签名
*/
@Before("pointCut()")
public void before(JoinPoint joinPoint) throws ApiSignValidException {
try {
HttpServletRequest request = getHttpRequest();
// 从header取出签名相关参数
String appIdStr = request.getHeader(properties.getHeaderAppId());
String sign = request.getHeader(properties.getHeaderSign());
String nonce = request.getHeader(properties.getHeaderNonce());
String timestampStr = request.getHeader(properties.getHeaderTimestamp());
log.info("【签名校验】 appId:{}, nonce:{}, timestamp:{}, sign:{}", appIdStr, nonce, timestampStr, sign);
// 校验参数
if (ObjectUtil.isEmpty(appIdStr)) throw new Exception("invalid appId");
if (ObjectUtil.isEmpty(timestampStr) || ObjectUtil.isEmpty(nonce) || nonce.length() != properties.getNonceLen() || ObjectUtil.isEmpty(sign)) throw new ApiSignValidException("illegal parameter");
ApiSignProperties.App app = properties.getAppById(Integer.valueOf(appIdStr));
if (app == null) throw new ApiSignValidException("invalid appId");
// 判断过期失效 - 防止重放攻击,可以判断传入的时间戳大于服务器时间,直接拒绝
long timestamp = Long.valueOf(timestampStr);
long now = System.currentTimeMillis() / 1000;
if (now - timestamp > properties.getExpireTime()) {
throw new ApiSignValidException("expire time");
}
// 判断随机字符串
ApiSignNonceStrCache nonceStrCache = ApiSignNonceStrCache.getInstance(properties.getExpireTime());
if (nonceStrCache.isExist(app.getId(), nonce)) throw new Exception();
nonceStrCache.put(app.getId(), nonce); // 缓存随机字符串
// 签名校验
boolean isSigned = verifySign(app, sign, timestamp, nonce, request);
if (!isSigned) throw new Exception();
} catch (ApiSignValidException e) {
log.error(e.getMessage(), e);
throw e;
} catch (Exception e) {
log.error(e.getMessage(), e);
throw new ApiSignValidException("signature check failure");
}
}
private HttpServletRequest getHttpRequest() {
ServletRequestAttributes servletRequestAttributes = (ServletRequestAttributes) RequestContextHolder.getRequestAttributes();
return servletRequestAttributes.getRequest();
}
/**
* 校验签名
*/
private boolean verifySign(ApiSignProperties.App app, String signStr, long timestamp, String nonce, HttpServletRequest request) {
try {
ApiSignRequestWrapper requestWrapper = new ApiSignRequestWrapper(request);
// 获取全部参数(包括 URL 和 body 上的),默认按ASCII对Key进行排序
SortedMap<String, String> allParams = HttpRequestUtil.getAllParams(requestWrapper);
// 加入签名信息
allParams.put("appId", app.getStrId());
allParams.put("timestamp", String.valueOf(timestamp));
allParams.put("nonce", nonce);
// 将参数转为字符串,格式:[key1][value1][key2][value2]...[secret]
StringBuilder sbd = new StringBuilder("");
for (Map.Entry<String, String> entry : allParams.entrySet()) {
// 排除空val的参数
if (ObjectUtil.isEmpty(entry.getValue())) continue;
sbd.append(entry.getKey()).append(entry.getValue());
}
String params = sbd.append(app.getSecret()).toString();
// 服务器对参数进行签名
String sign = sign(params);
// 前端传过来的sign作比较
return sign.equals(signStr);
} catch (Exception e) {
log.error(e.getMessage(), e);
}
return false;
}
/**
* 签名 - 当前使用的 SHA256 摘要算法
*/
private String sign(String str) {
return SecureUtil.sha256(str).toUpperCase();
}
}
使用
在需要签名校验的接口上添加注解:
// ...
/**
* 读取用户
*/
@ApiSignValid
@GetMapping("/{uid}")
public ApiResult get(@PathVariable Integer uid) {
User user = users.get(uid);
if (user == null) return ApiResult.fail("404", "not found user");
return ApiResult.success(user);
}
// ...
客户端
提供一个前端对接口进行签名的工具类(TS版本):
typescript 89行
/**
* 使用步骤:
* 1. 安装依赖: npm i crypto-js
* 2. 引入工具: import { sign } from '../ApiSignUtil';
* 3. 签名参数: const signResult = sign(appId, appSecret, {...参数对象});
* 4. 打印结果:console.log('签名数据', signResult);
*/
import sha256 from 'crypto-js/sha256'
/**
* 签名结果
*/
type SignResult = {
sign: string
timestamp: number
nonce: string
}
/**
* 对参数进行签名
* @param appId
* @param appSecret
* @param params
*/
const sign = (appId: string, appSecret: string, params: any): SignResult => {
// 当前秒级时间戳
const timestamp = getSecondTimestamp();
// 18位随机字符串
const nonce = randomNonce();
// 追加签名参数
params.appId = appId;
params.timestamp = timestamp;
params.nonce = nonce;
// 对参数按ASCII进行排序
const sortParams = sortParamsAscii(params);
// 将参数组装成字符串,剔除空value的参数
let paramsStr = '';
for (let key in sortParams) {
const val = sortParams[key];
if (val) paramsStr += `${key}${val}`;
}
// 将secret拼接到最后
paramsStr += appSecret;
// 签名
const sign = sha256(paramsStr).toString().toUpperCase();
return {
sign: sign,
timestamp: timestamp,
nonce: nonce
}
}
/**
* 对参数按 ASCII 进行排序
*/
const sortParamsAscii = (params: Object): Object => {
const arr = new Array();
let num = 0;
for (let i in params) {
arr[num] = i;
num++;
}
const sortArr = arr.sort();
const sortParams = {};
for (let i in sortArr) {
sortParams[sortArr[i]] = params[sortArr[i]];
}
return sortParams;
}
/**
* 获取当前秒级时间戳
*/
const getSecondTimestamp = (): number => {
return parseInt(String(new Date().getTime() / 1000), 10);
}
/**
* 生成随机字符串
* @param len,默认生成18位长度
*/
const randomNonce = (len: number = 18): string => {
const str = '0123456789abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ';
let result = '';
for (let i = len; i > 0; --i) result += str[Math.floor(Math.random() * str.length)];
return result;
}
export { sign }