接口安全设计之接口验签

背景

最近接到一个需求,在海外客户还款之前,都是通过一个还款链接去还款,但是还款链接内没有任何的客户信息,所以需要还款之前,进入一个前置信息确认页面,就需要后端先提供一个查询接口给前端,但是需要参数明文传递给前端,其中包含订单号,金额及还款类型。但是给到前端之后,在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")
        ;
    }
}

两种方式均可,先给出两种方案的设计对比:

方式请求参数映射接口验签
设计难度
持久化数据需要不需要
参数是否可以修改
  • 7
    点赞
  • 8
    收藏
    觉得还不错? 一键收藏
  • 1
    评论
Java 接口验签一般分为以下几个步骤: 1. 获取接口参数:从接口请求中获取所有参数,包括请求参数、请求时间戳、请求签名等信息。 2. 排序参数:将所有参数按照参数名的字典序进行升序排序,参数名相同的按照参数值的字典序进行升序排序。 3. 拼接参数:将排序后的参数按照“参数名=参数值”的格式拼接成一个字符串,中间用“&”连接。 4. 生成签名:使用指定的算法(例如 MD5、SHA256 等)对拼接后的参数字符串进行签名,生成一个签名值。 5. 比对签名:将生成的签名值与接口请求中传递的签名值进行比对,如果两个值相等,则说明该接口请求是有效的。 下面是一个简单的示例代码,用于对接口进行验签: ```java public boolean verifySignature(String signature, Map<String, String> params, String secretKey, String algorithm) { // 1. 获取接口参数 String timestamp = params.get("timestamp"); // 其他请求参数... // 2. 排序参数 List<String> sortedParams = new ArrayList<>(params.keySet()); Collections.sort(sortedParams); // 3. 拼接参数 StringBuilder sb = new StringBuilder(); for (String paramName : sortedParams) { sb.append(paramName).append("=").append(params.get(paramName)).append("&"); } sb.append("secret_key=").append(secretKey); String paramString = sb.toString(); // 4. 生成签名 String sign = null; try { MessageDigest md = MessageDigest.getInstance(algorithm); byte[] bytes = md.digest(paramString.getBytes("UTF-8")); sign = Hex.encodeHexString(bytes); } catch (Exception e) { e.printStackTrace(); } // 5. 比对签名 return sign != null && sign.equals(signature); } ``` 以上代码中,`verifySignature` 方法用于对接口进行验签,其中 `signature` 表示接口请求中传递的签名值,`params` 表示接口请求中的所有参数,`secretKey` 表示密钥,`algorithm` 表示签名算法。在验签过程中,首先获取接口参数,然后对参数进行排序、拼接、签名等处理,最后将生成的签名值与接口请求中传递的签名值进行比对,判断接口请求是否有效。

“相关推荐”对你有帮助么?

  • 非常没帮助
  • 没帮助
  • 一般
  • 有帮助
  • 非常有帮助
提交
评论 1
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值