最易于使用的java接口签名实现


前言

在Java项目中,提供对外接口是非常常见的。如何保证接口的安全性呢?本文介绍了使用签名方式来确保接口的安全性。


一、签名需要解决哪些安全问题?

签名机制主要解决以下安全问题:

验密功能:确保请求方和被请求方之间的通信数据是安全的。
抓包篡改请求:防止黑客通过抓包工具对请求数据进行篡改。
抓包重复发送请求:防止黑客通过抓包工具对请求数据进行重复发送。

二、分析

1.思路

在这里插入图片描述

三、实现

1.项目地址

被调用方代码github链接
被调用方代码gitee链接
调用方demo代码github链接
调用方demo代码gitee链接

2.使用示例

  • 配置yml文件
signature:
  secretGroup:
    - code: TEST_CODE1
      accessKey:
        test-key1: L5nqjXlcziKIDa6b
        test-key2: mSlUAzz5ff9ViP2H
    - code: TEST_CODE2
      accessKey:
        testKeyId21: testKeySecret21
        testKeyId22: testKeySecret22
  • 配置类
@Configuration
@EnableConfigurationProperties(SignatureProperties.class)
public class SignatureConfig {

    @Bean
    @ConditionalOnMissingBean
    public RequestCachingFilter requestCachingFilter() {
        return new RequestCachingFilter();
    }

    @Bean
    @ConditionalOnMissingBean
    public SignatureAspect signatureAspect(SignatureProperties signatureProperties) {
        return new SignatureAspect(signatureProperties);
    }
}
  • 在Controller方法中加入注解@Signature(“TEST_CODE1”)
@RestController
public class TestController {

    @PostMapping("testSignature/{id}")
    @Signature("TEST_CODE1")
    public String testSignature(@RequestBody UserEntity userEntity, @PathVariable("id") String id
            , @RequestParam String companyName) {
        System.out.println(userEntity);
        System.out.println("id:" + id);
        System.out.println("companyName:" + companyName);
        return "success";
    }

    @Data
    static class UserEntity {
        private String username;
        private Integer age;
    }
}

这样对接口的验签就生效了,非常简单吧,接下来看看怎么实现的

3.被调用方开发

  • 考虑到同一接口可能需要被多个用户调用,各用户使用不同的ID和SECRET,这里采用如下属性存储配置
@ConfigurationProperties(prefix = "signature")
@Data
public class SignatureProperties {
    private Set<AccessCodeEntity> secretGroup;

    @Data
    static public class AccessCodeEntity {
        private String code;
        private Map<String, String> accessKey;
    }
}
  • 编写自定义注解
@Target({ElementType.TYPE, ElementType.METHOD})
@Retention(RetentionPolicy.RUNTIME)
@Documented
public @interface Signature {
    /**
     * 签名的配置代码
     */
    @AliasFor("signatureCode")
    String value() default "";

    /**
     * 签名的配置代码
     */
    @AliasFor("value")
    String signatureCode() default "";
}
  • 通过AOP切面拦截带有注解的请求
@Aspect
@Slf4j
public class SignatureAspect {
    @Around("execution(* com..controller..*.*(..)) " +
            "&& @annotation(signature) " +
            "&& (@annotation(org.springframework.web.bind.annotation.RequestMapping)" +
            "|| @annotation(org.springframework.web.bind.annotation.GetMapping)" +
            "|| @annotation(org.springframework.web.bind.annotation.PostMapping)" +
            "|| @annotation(org.springframework.web.bind.annotation.PutMapping)" +
            "|| @annotation(org.springframework.web.bind.annotation.DeleteMapping)" +
            "|| @annotation(org.springframework.web.bind.annotation.PatchMapping))"
    )
    public Object doAround(ProceedingJoinPoint pjp, Signature signature) throws Throwable {
    	// 验签,这里传入的是注解配置的属性值
        this.checkSign(StrUtil.isBlank(signature.signatureCode()) ? signature.value() : signature.signatureCode());
        return pjp.proceed();
    }
}
  • 接下来就是验签的实现拉
private void checkSign(String signatureCode) throws Exception {
    HttpServletRequest request = ((ServletRequestAttributes) (RequestContextHolder.currentRequestAttributes())).getRequest();
    String headAccessKeyId = request.getHeader(SignatureConstant.SIGNATURE_ACCESS_KEY_ID_KEY);
    String timestamp = request.getHeader(SignatureConstant.SIGNATURE_TIMESTAMP_KEY);
    String sign = request.getHeader(SignatureConstant.SIGNATURE_SIGN_KEY);
    // 系统中读取accessKeySecret
    Map<String, String> signatureAccessKeyMap = this.signatureAccessKeyGroupMap.get(signatureCode);
    // 系统中读取accessKeySecret
    String accessKeySecret = signatureAccessKeyMap.get(headAccessKeyId);
    if (StringUtils.isBlank(accessKeySecret)) {
        throw new SignException("验签失败,无效的accessKeyId");
    }
    // 校验签名的头信息是否合法
    checkAccessKeyHeaders(headAccessKeyId, signatureAccessKeyMap, timestamp, sign);
    //获取body(对应@RequestBody)
    String body = getBodyString(request);
    //获取parameters(对应@RequestParam)
    Map<String, String[]> params = getParamsMap(request);
    //获取path variable(对应@PathVariable)
    Collection<String> paths = getPaths(request);
    // 验证签名
    SignUtil.checkSign(body, params, paths, headAccessKeyId, accessKeySecret, Long.parseLong(timestamp), sign);
}
  • 首先读取到我们yml中的配置,这里做了一点转换,方便我们后续使用
    private final Map<String, Map<String, String>> signatureAccessKeyGroupMap;

    public SignatureAspect(SignatureProperties signatureProperties) {
        this.signatureAccessKeyGroupMap = signatureProperties.getSecretGroup()
                .stream().collect(Collectors.toMap(SignatureProperties.AccessCodeEntity::getCode, SignatureProperties.AccessCodeEntity::getAccessKey));
    }
  • 签名读取请求头中的参数,同时通过上一步的signatureAccessKeyGroupMap,根据注解的配置读取到我们系统中保存的accessKeyId和accessKeySecret的所有组合
  • 校验请求头信息 checkAccessKeyHeaders方法,校验参数必须完整,请求时间合法
    /**
     * 请求过期时间 10分钟
     */
    public static final int EXPIRE_TIME = 10 * 60 * 1000;
    /**
     * 服务器误差时间 2分钟
     */
    public static final int ERROR_LIMIT = -2 * 60 * 1000;
    
    private void checkAccessKeyHeaders(String headAccessKeyId, Map<String, String> signatureAccessKeyMap, String timestamp, String sign) {
        if (StringUtils.isAnyBlank(headAccessKeyId, timestamp, sign)) {
            throw new SignException("未获取到完整签名信息");
        }

        if (signatureAccessKeyMap == null || !signatureAccessKeyMap.containsKey(headAccessKeyId)) {
            throw new SignException("验证失败,错误的accessKeyId:" + headAccessKeyId);
        }
        long timestampLongVal;
        try {
            timestampLongVal = Long.parseLong(timestamp);
        } catch (NumberFormatException e) {
            throw new SignException("不支持的时间戳格式");
        }
        long l = System.currentTimeMillis() - timestampLongVal;
        // 允许服务求误差2分钟
        if (l < ERROR_LIMIT || l >= EXPIRE_TIME) {
            throw new SignException("请求签名已过期");
        }
    }
  • 获取所有的请求参数,进行自定义规则的拼接。请求参数分为3类@RequestBody、@RequestParam、@PathVariable。其中@RequestParam、@PathVariable直接从request中读取,这里我就不再赘述了,我们主要看一下@RequestBody。
    private Collection<String> getPaths(HttpServletRequest request) {
        Collection<String> paths = null;
        ServletWebRequest webRequest = new ServletWebRequest(request, null);
        @SuppressWarnings("unchecked")
        Map<String, String> uriTemplateVars = (Map<String, String>) webRequest.getAttribute(
                HandlerMapping.URI_TEMPLATE_VARIABLES_ATTRIBUTE, RequestAttributes.SCOPE_REQUEST);
        if (!CollectionUtils.isEmpty(uriTemplateVars)) {
            paths = uriTemplateVars.values();
        }
        return paths;
    }

    private Map<String, String[]> getParamsMap(HttpServletRequest request) {
        Map<String, String[]> params = null;
        if (!CollectionUtils.isEmpty(request.getParameterMap())) {
            params = request.getParameterMap();
        }
        return params;
    }
  • @RequestBody读取参数主要是通过request中的ServletInputStream传输,SpirngMvc通过@RequestBody读取流中的数据封装到对象中。因为stream只能被读取一次,如果这里我们通过request读取,后面的SpringMvc就读取不到了。所以这里对原生request做一下增强
@Slf4j
@Order(1)
public class RequestCachingFilter extends OncePerRequestFilter {

    @Override
    protected void doFilterInternal(@NonNull HttpServletRequest request, @NonNull HttpServletResponse response
            , FilterChain filterChain) {
        try {
        	HttpServletRequest customRequest = request;
        	String contentType = request.getContentType();
        	// 这里必须判断,否则上传文件时会出现异常
        	if(StringUtils.isNotBlank(contentType)&&contentType.contains(MediaType.APPLICATION_JSON_VALUE)){
        		customRequest = new BodyReaderRequestWrapper(request);
        	}
            filterChain.doFilter(customRequest, response);
        } catch (IOException | ServletException e) {
            log.error("RequestCachingFilter异常:", e);
            printRequest(request);
        }
    }
}
  • 接下来看看BodyReaderRequestWrapper的实现。非常简单,构建时将流转为字节数组保存在本地。每次读取时重新封装一个Stream
public class BodyReaderRequestWrapper extends HttpServletRequestWrapper {

    private final byte[] bodyBuffer;

    public BodyReaderRequestWrapper(HttpServletRequest request) throws IOException {
        super(request);
        bodyBuffer = IoUtil.readBytes(request.getInputStream());
    }

    @Override
    public BufferedReader getReader() {
        return new BufferedReader(new InputStreamReader(this.getInputStream()));
    }

    @Override
    public ServletInputStream getInputStream() {
        final ByteArrayInputStream byteArrayInputStream = new ByteArrayInputStream(bodyBuffer);
        return new ServletInputStream() {
            @Override
            public boolean isFinished() {
                return false;
            }

            @Override
            public boolean isReady() {
                return false;
            }

            @Override
            public void setReadListener(ReadListener readListener) {
            }

            @Override
            public int read() {
                return byteArrayInputStream.read();
            }
        };
    }
}
  • 接下来就是重中之重了,验签。
public static void checkSign(String body, Map<String, String[]> params, Collection<String> paths
            , String accessKeyId, String accessKeySecret, long timestamp, String sign) {
   String allParamsString = getAllParamsString(body, params, paths, accessKeyId, timestamp);

    String newSign = generatorSign(allParamsString, accessKeySecret);
    if (!StrUtil.equals(sign, newSign)) {
        throw new RuntimeException("签名验证失败");
    }
}
  • 先看getAllParamsString,就是把所有参数按照我们的顺序拼接起来,这里要注意顺序问题。必须保证调用方和我们是同样的参数顺序,所以数组的参数要排序一下
private static String getAllParamsString(String body, Map<String, String[]> params, Collection<String> paths, String accessKeyId, long timestamp) {
    StringBuilder sb = new StringBuilder();
    if (StrUtil.isNotBlank(body)) {
        sb.append(body).append('#');
    }

    if (CollectionUtil.isNotEmpty(params)) {
        params.entrySet()
                .stream()
                .sorted(Map.Entry.comparingByKey())
                .forEach(paramEntry -> {
                    String paramValue = Arrays.stream(paramEntry.getValue()).sorted().collect(Collectors.joining(","));
                    sb.append(paramEntry.getKey()).append("=").append(paramValue).append('#');
                });
    }

    if (CollectionUtil.isNotEmpty(paths)) {
        String pathValues = String.join(",", paths);
        sb.append(pathValues).append('#');
    }

    // 拼接secret和时间戳
    sb.append("accessKeyId=")
            .append(accessKeyId)
            .append("#timestamp=").append(timestamp);
    return sb.toString();
}
  • 现在生成签名,我们这里用的HmacSha256算法.用的hutool的工具类,非常方便。
public static String generatorSign(String allParamsString, String accessKeySecret) {
    HMac hMac = new HMac(HmacAlgorithm.HmacSHA256, accessKeySecret.getBytes(StandardCharsets.UTF_8));
    return hMac.digestHex(allParamsString);
}
  • 比较调用方传过来的签名完成验签了

4.调用方开发

  • 生成签名的方法和被调用方相同.这里就直接贴代码拉
public class TestClient {
    public static void main(String[] args) {
//        String accessKeyId = "test-key1";
//        String accessKeySecret = "L5nqjXlcziKIDa6b";
        String accessKeyId = "test-key2";
        String accessKeySecret = "mSlUAzz5ff9ViP2H";

        // @RequestBody 读取的JSON参数
        Map<String, Object> bodyMap = new HashMap<>();
        bodyMap.put("username", "张三");
        bodyMap.put("age", 18);
        String companyName = "一家很强的公司";
        String bodyJson = JSONUtil.toJsonStr(bodyMap);

        // @PathVariable 读取的参数
        int id = 3;

        // @RequestParam (问号拼接的参数)
        Map<String, String[]> params = new HashMap<>();
        params.put("companyName", new String[]{companyName});

        // 当前时间的时间戳
        long timestamp = System.currentTimeMillis();

        // 生成签名字符串
        String sign = SignUtil.generatorSign(bodyJson, params, CollectionUtil.newArrayList(String.valueOf(id))
                , accessKeyId, accessKeySecret, timestamp);
        // 请求url
        String url = "http://localhost:8080/testSignature/" + id + "?companyName=" + companyName;

        HttpResponse httpResponse = HttpRequest.post(url)
                .header("timestamp", String.valueOf(timestamp))
                .header("accessKeyId", accessKeyId)
                .header("sign", sign)
                .form("companyName",companyName)
                .body(bodyJson)
                .execute();
        System.out.println(httpResponse);
    }
}

4. 防止请求重复发送

上面我们没有开发防止重复发送的功能,但是实现也很简单。就是在我们请求头中增加一个随机字符串的参数,要求短时间内唯一即可。被调用方验签成功后,方法调用完成没有异常时缓存这个随机串,设置一定时间后过期。每次验签前校验一下缓存中是否已经存在这个随机串,如果存在说明是重复调用。后续我会在项目gitee上更新上防止重复调用的功能。

总结

验签功能最重要的是生成签名,和解决@RequestBody流读取后就没有的问题。如果对文章有什么疑问,欢迎评论区留言

评论 12
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

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

抵扣说明:

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

余额充值