背景
最近接到一个需求,在海外客户还款之前,都是通过一个还款链接去还款,但是还款链接内没有任何的客户信息,所以需要还款之前,进入一个前置信息确认页面,就需要后端先提供一个查询接口给前端,但是需要参数明文传递给前端,其中包含订单号,金额及还款类型。但是给到前端之后,在url中可以直接修改请求参数,所以就会有安全风险,为了避免这种,使用以下两种方案:
1、请求参数映射
比如我的还款参数如下:
{
"orderId": "123",
"repayAmount": 1000,
"repaymentType": 1
}
生成的明文链接为:
http://pay.com/repayment?repaymentAmount=100&repaymentType=1&orderId=123
我将在发起还款请求之前,分配一个唯一ID来标识本次请求,如ID = 67ad2778-d5e4-4466-bf9f-45b0170a8b79,所以生成的链接就是
http://pay.com/repayment?67ad2778-d5e4-4466-bf9f-45b0170a8b79
当请求到达后端之后,去查询当次请求的参数,然后去生成真实的还款链接给客户还款。
示例代码:
@RequestMapping("/repayment/{id}")
public RepaymentResp repayment(@PathVariable Long id) {
RepaymentResp repaymentResp = new RepaymentResp();
RepaymentReq req = repaymentService.checkOrder(id);
if (Objects.isNull(req)) {
throw new BusinessException(ErrorCodeEnum.DATA_NOT_EXISTS);
}
return repaymentService.repayment(req);
}
2、接口参数验签
在原本的请求链接不变的情况下,加一个sign参数,针对当前参数进行签名,在请求后端的时候,需要先验证签名,如果验签不通过,则直接返回error,否则认定参数没有被修改,继续生成还款链接。
但是常见的不单单是只有一个sign,一般还有一个商户号,商户密钥,时间戳,随机字符串,业务参数做的一个加密后的签名。对此我们做一下改造,因为密钥不适合直接给到前端,都是服务端交互才会这么使用,去除商户号,密钥之后的链接示例如下:
http://pay.com/repayment-order?repaymentAmount=2013&repaymentType=1&caseNo=123&ts=1711788063587&nonceStr=LrmxjdKD50mmhFwCG7UOa6q&sign=24c96ee54e91ee79f475c9a672758ac84ea34d84365a44b2ee23e74b
编写一个加密工具类:
@Slf4j
public class RiskSignUtils {
public static final String SYMBOLS = "0123456789abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ";
public static final Random RANDOM = new SecureRandom();
public static final long REQ_HEADER_TIMEOUT_MIN = 20;
public static final Cache<String, String> NONCE_POOL = Caffeine.newBuilder()
.expireAfterWrite(REQ_HEADER_TIMEOUT_MIN, TimeUnit.MINUTES)
.maximumSize(20000)
.build();
public static final String HEADER_API_NONCE = "x-api-nonce";
public static final String HEADER_API_TIMESTAMP = "x-api-ts";
public static final String HEADER_API_SIGN = "x-api-sign";
public static final String APP_ID = "appId";
public static final String TS = HEADER_API_TIMESTAMP;
public static final String NONCE = HEADER_API_NONCE;
public static final String SIGN = HEADER_API_SIGN;
/**
* 在header中增加如下字段:
* x-api-ts:时间戳,20分钟内有效,提交类方法20分钟失效
* x-api-nonce:防重,10位以上
* appid:线下统一分配,为每一个消息发出方配置坐标
* appSecret:线下统一分配,密钥
* signature:上述字段的加密
*/
public static String generateNonceStr() {
char[] nonceChars = new char[32];
for (int index = 0; index < nonceChars.length; ++index) {
nonceChars[index] = SYMBOLS.charAt(RANDOM.nextInt(SYMBOLS.length()));
}
return new String(nonceChars);
}
public static Long parseToLong(Object ts) {
try {
return Long.valueOf(ts.toString());
} catch (Exception e) {
return null;
}
}
/**
* 是重复请求
*
* @param nonce nonce
* @return boolean
*/
public static boolean isRepeatReq(String nonce) {
return StringUtils.hasText(NONCE_POOL.getIfPresent(nonce));
}
/**
* header 加签-------重要,重要,重要,
* 按签名算法获取sign(客户端和服务器端算法一致)
* 计算签名规则:
* sign = HMACSHA256("ts=1623388123195&nonce=d50e301d-ee2c-446e-8f28-013f0fee09fb&appSecret=9ZLEzugQHfQd11vS8pd68lxzA")
*
* @param appSecret
* @param ts 时间戳
* @param nonce 请求唯一标识
* @return
*/
public static String genSign(String appSecret, Long ts, String nonce) {
// 1.待加密字符串
StringBuilder s = new StringBuilder();
s.append("ts=").append(ts).append("&nonce=").append(nonce).append("&appSecret=").append(appSecret);
// 2.对待加密字符串进行加密
String sign = HMACSHA256.sha256_mac(s.toString(), appSecret);
return sign;
}
public static void main(String[] args) {
System.out.println(genSign("admin123",1694610972825L,"1694610972825"));
System.out.println(System.currentTimeMillis());
}
/**
* HMACSHA256 工具类,用于验签加密
*/
public static class HMACSHA256 {
private HMACSHA256() {
}
public static String sha256_mac(String message, String key) {
String outPut = null;
try {
Mac sha256_HMAC = Mac.getInstance("HmacSHA256");
SecretKeySpec secret_key = new SecretKeySpec(key.getBytes(), "HmacSHA256");
sha256_HMAC.init(secret_key);
byte[] bytes = sha256_HMAC.doFinal(message.getBytes());
outPut = byteArrayToHexString(bytes);
} catch (Exception e) {
log.info("Error HmacSHA256========" + e.getMessage());
}
return outPut;
}
public static String byteArrayToHexString(byte[] b) {
StringBuilder sb = new StringBuilder();
String stmp;
for (int n = 0; b != null && n < b.length; n++) {
stmp = Integer.toHexString(b[n] & 0XFF);
if (stmp.length() == 1)
sb.append('0');
sb.append(stmp);
}
return sb.toString().toLowerCase();
}
}
}
如果只是单接口的就直接将入参按照自己的方式去签名,然后验证参数即可。示例代码如下:
@Value("${reapayment.aesKey:}")
private String aesKey;
public RepaymentConfirmResp confirmLoanInfo(RepaymentConfirmReq req) {
String sign = RiskSignUtils.genSign(aesKey, Long.valueOf(req.getTs()), req.getNonceStr(), req.getRepaymentAmount().toPlainString() + req.getRepaymentType() + req.getOrderId());
if (!StringUtils.equals(req.getSign(),sign)) {
log.warn("verify sign error! param = {}",JSON.toJSONString(req));
return null;
}
return repaymentConfirmResp;
}
如果需要对所有的接口进行如此设计,就需要拦截器处理,示例如下:
1、编写拦截器
@Component
@Slf4j
public class RequestHeaderInterceptor implements HandlerInterceptor {
public static final String TRACE_ID = "TRACE-ID";
@Override
public boolean preHandle(HttpServletRequest request, HttpServletResponse response, Object handler) throws Exception {
Map<String,Object> map = new HashMap<>();
map.put(RiskSignUtils.APP_ID,request.getHeader(RiskSignUtils.APP_ID));
map.put(RiskSignUtils.NONCE,request.getHeader(RiskSignUtils.NONCE));
map.put(RiskSignUtils.HEADER_API_TIMESTAMP,request.getHeader(RiskSignUtils.HEADER_API_TIMESTAMP));
map.put(RiskSignUtils.SIGN,request.getHeader(RiskSignUtils.SIGN));
log.info("开始鉴权,参数为:{}",JSON.toJSONString(map));
VerifySignResultResp verifySignResult = verifyRequestHeader(map);
if (!verifySignResult.isVerified()) {
log.error("鉴权失败,{}",JSON.toJSONString(verifySignResult));
responseFail(response, verifySignResult.getCode(),verifySignResult.getMsg());
return false;
}
log.info("鉴权成功, {}",JSON.toJSONString(verifySignResult));
ThreadLocalUtil.set(verifySignResult);
return HandlerInterceptor.super.preHandle(request, response, handler);
}
@Override
public void afterCompletion(HttpServletRequest request, HttpServletResponse response, Object handler,
Exception ex) throws Exception {
ThreadLocalUtil.remove();
MDC.remove("traceId");
HandlerInterceptor.super.afterCompletion(request, response, handler, ex);
}
/**
* 响应失败
*
* @param response 回答
* @param code 密码
* @param msg 消息
* @throws IOException IOException
*/
private void responseFail(HttpServletResponse response, Integer code,String msg) throws IOException {
String responseStr = JSON.toJSONString(AjaxResult.error(code,msg));
response.setHeader("Content-Type", MediaType.APPLICATION_JSON_VALUE);
response.getOutputStream().write(responseStr.getBytes());
response.getOutputStream().flush();
}
}
public VerifySignResultResp verifyRequestHeader(Map<String, Object> reqHeaderMap) {
// 1.没有header : 无效请求
if (CollectionUtils.isEmpty(reqHeaderMap)) {
return new VerifySignResultResp(false, BusinessResultEnum.RISK_NO_HEADER_DATA);
}
// 2.没有ts(请求时间戳):无效请求
Long ts = RiskSignUtils.parseToLong(reqHeaderMap.get(TS));
if (ts == null) {
return new VerifySignResultResp(false, BusinessResultEnum.RISK_NO_HEADER_TS_DATA);
}
// 3.超过20分钟:无效请求 (时间需要双方约定)
if (System.currentTimeMillis() - ts > RiskSignUtils.REQ_HEADER_TIMEOUT_MIN * 60 * 1000) {
return new VerifySignResultResp(false, BusinessResultEnum.RISK_TIME_OUT);
}
// 4.如果带有请求唯一标识,则需要先验证此标识是否已经被处理过,防止重复请求。
// 如果处理过,返回false,如果没处理过,则把这个唯一标识存到redis(或者其它缓存中)
String nonce = (String) reqHeaderMap.get(RiskSignUtils.NONCE);
if (StringUtils.hasText(nonce)) {
// 判断是否重复请求
Boolean isRepeat = RiskSignUtils.isRepeatReq(nonce);
if (isRepeat) {
return new VerifySignResultResp(false, BusinessResultEnum.RISK_TIME_OUT);
} else {
RiskSignUtils.NONCE_POOL.put(nonce, "1");
}
} else {
return new VerifySignResultResp(false, BusinessResultEnum.RISK_NO_NONCE_DATA);
}
// 5.appId是否存在(用户是否存在),不存在则算无效请求
String appId = (String) reqHeaderMap.get(RiskSignUtils.APP_ID);
if (!StringUtils.hasText(appId)) {
return new VerifySignResultResp(false, BusinessResultEnum.RISK_NO_APP_ID_DATA);
}
// 5.1去库中或配置中获取appId对应的appSecret(这里先写死)
String appSecret = riskDataMerchantService.getSecretByAppId(appId);
RiskDataMerchantEntity riskData = riskDataMerchantService.getRiskData(appId);
if (Objects.isNull(riskData) || !StringUtils.hasText(appSecret)) {
return new VerifySignResultResp(false, BusinessResultEnum.RISK_APP_SECRET_ERROR);
}
// 6.sign验证
// 6.1 没传sign:无效请求
String sign = (String) reqHeaderMap.get(RiskSignUtils.SIGN);
if (!StringUtils.hasText(sign)) {
return new VerifySignResultResp(false, BusinessResultEnum.RISK_NO_SIGN_DATA);
}
// 6.2最后验证sign值
String srvSign = RiskSignUtils.genSign(appSecret, ts, nonce);
boolean isVerify = sign.equalsIgnoreCase(srvSign);
log.info("verifySign={}, actual sign={}, and expected sign={},merchantName is {}", isVerify, sign, srvSign,riskData.getAppName());
return new VerifySignResultResp(isVerify, isVerify ? BusinessResultEnum.SUCCESS : BusinessResultEnum.RISK_AUTH_ERROR,riskData.getCountry(),riskData.getId());
}
如果需要针对某些请求开白名单,如swagger,健康检查等,也很简单,在注册拦截器的时候,放开即可。
@Configuration
public class WebConfig implements WebMvcConfigurer {
@Autowired
private RequestHeaderInterceptor requestHeaderInterceptor;
@Override
public void addInterceptors(InterceptorRegistry registry) {
registry.addInterceptor(requestHeaderInterceptor)
.addPathPatterns("/**")
.excludePathPatterns("/webjars/**","/favicon.ico","/swagger-resources/**","/v2/api-docs","/doc.html")
.excludePathPatterns("/actuator/health")
;
}
}
两种方式均可,先给出两种方案的设计对比:
方式 | 请求参数映射 | 接口验签 |
---|---|---|
设计难度 | 低 | 高 |
持久化数据 | 需要 | 不需要 |
参数是否可以修改 | 否 | 否 |