SpringBoot中,接口签名,通用方案,以确保接口的安全性

1. 为什么需要接口签名?

  • 接口签名目的:防止第三方伪造请求。
  • 请求伪造:未经授权的第三方构造合法用户的请求来执行不希望的操作。
  • 转账接口示例:展示了如果接口没有安全措施,第三方可以轻易伪造请求,例如将资金从一个账户转移到另一个账户。

2. 如何实现接口签名?

  • 引入密钥:接口调用方和服务提供方之间共享一个密钥(secretKey),此密钥必须保密。
  • 签名算法:使用密钥和请求体的内容通过MD5算法生成签名。
  • 携带签名:客户端在请求头中附加生成的签名。
  • 服务端校验:服务器接收到请求后,使用同样的算法和密钥重新计算签名并与请求中提供的签名比较,如果不一致,则拒绝请求。

3. 防止请求伪造

  • 请求伪造解决办法:通过接口签名机制,第三方不知道密钥,因此无法正确生成匹配的签名,请求会被服务器拒绝。

4. 防止请求重放

  • 请求重放定义:攻击者截获合法请求后重新发送以达到重复执行的效果。
  • 解决请求重放的办法:引入随机字符串(nonce)和时间戳(timestamp)。nonce用来确保每个请求只能被使用一次,存储在Redis中并在一段时间后过期;时间戳用来限制请求的有效时间范围。





具体实现

1 整合springboot+redis环境

2 pom.xml

<dependency>
            <groupId>cn.hutool</groupId>
            <artifactId>hutool-all</artifactId>
            <version>5.8.2</version>
</dependency>

3 yml配置

//redis 相关的配置省略了

//秘钥,要保密
secret-key: b0e8668b-bcf2-4d73-abd4-893bbc1c6079

4 类ReusableBodyRequestWrapper,该类用于包装HttpServletRequest,以便在读取请求体后仍可重复读取

import org.apache.tomcat.util.http.fileupload.IOUtils;
import javax.servlet.ReadListener;
import javax.servlet.ServletInputStream;
import javax.servlet.http.HttpServletRequest;
import javax.servlet.http.HttpServletRequestWrapper;
import java.io.*;

/**
 * 该类用于包装HttpServletRequest,以便在读取请求体后仍可重复读取
 */
public class ReusableBodyRequestWrapper extends HttpServletRequestWrapper {

    //参数字节数组,用于存储请求体的字节数据
    private byte[] requestBody;

    //Http请求对象
    private HttpServletRequest request;
    
    /**
     * 构造函数,初始化包装类
     * @param request 原始HttpServletRequest对象
     * @throws IOException 如果读取请求体时发生IO错误
     */
    public ReusableBodyRequestWrapper(HttpServletRequest request) throws IOException {
        super(request);
        this.request = request;
    }
    
    /**
     * 重写getInputStream方法,实现请求体的重复读取
     * @return 包含请求体数据的ServletInputStream对象
     * @throws IOException 如果读取请求体时发生IO错误
     */
    @Override
    public ServletInputStream getInputStream() throws IOException {
        /**
         * 每次调用此方法时将数据流中的数据读取出来,然后再回填到InputStream之中
         * 解决通过@RequestBody和@RequestParam(POST方式)读取一次后控制器拿不到参数问题
         */
        //仅当requestBody未初始化时,从请求中读取并存储到requestBody
        if (null == this.requestBody) {
            ByteArrayOutputStream baos = new ByteArrayOutputStream();
            IOUtils.copy(request.getInputStream(), baos);
            this.requestBody = baos.toByteArray();
        }
        //创建一个 ByteArrayInputStream 对象,用于重复读取requestBody
        final ByteArrayInputStream bais = new ByteArrayInputStream(requestBody);
        return new ServletInputStream() {

            @Override
            public boolean isFinished() {
                //始终返回false,表示数据流未完成
                return false;
            }

            @Override
            public boolean isReady() {
                //始终返回false,表示数据流未准备好
                return false;
            }

            @Override
            public void setReadListener(ReadListener listener) {
                //不执行任何操作,因为该数据流不支持异步操作
            }

            @Override
            public int read() {
                //从ByteArrayInputStream中读取数据
                return bais.read();
            }
        };
    }
    
    /**
     * 获取请求体的字节数组
     * @return 请求体的字节数组
     */
    public byte[] getRequestBody() {
        return requestBody;
    }
    
    /**
     * 重写getReader方法,返回一个基于getInputStream的BufferedReader
     * @return 包含请求体数据的BufferedReader对象
     * @throws IOException 如果读取请求体时发生IO错误
     */
    @Override
    public BufferedReader getReader() throws IOException {
        //基于getInputStream创建BufferedReader
        return new BufferedReader(new InputStreamReader(this.getInputStream()));
    }
}

5 SignatureVerificationFilter类,签名验证过滤器,用于校验请求的合法性

import cn.hutool.crypto.digest.DigestUtil;
import cn.hutool.json.JSONUtil;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.beans.factory.annotation.Value;
import org.springframework.core.Ordered;
import org.springframework.core.annotation.Order;
import org.springframework.data.redis.core.RedisTemplate;
import org.springframework.http.MediaType;
import org.springframework.stereotype.Component;
import org.springframework.util.StreamUtils;
import org.springframework.util.StringUtils;
import org.springframework.web.filter.OncePerRequestFilter;
import javax.servlet.FilterChain;
import javax.servlet.ServletException;
import javax.servlet.annotation.WebFilter;
import javax.servlet.http.HttpServletRequest;
import javax.servlet.http.HttpServletResponse;
import java.io.IOException;
import java.nio.charset.StandardCharsets;
import java.util.concurrent.TimeUnit;

/**
 * 签名验证过滤器,用于校验请求的合法性
 */
@Order(Ordered.HIGHEST_PRECEDENCE)
@WebFilter(urlPatterns = "/**", filterName = "SignatureVerificationFilter")
@Component
public class SignatureVerificationFilter extends OncePerRequestFilter {
    public static Logger logger = LoggerFactory.getLogger(SignatureVerificationFilter.class);
    
    @Override
    protected void doFilterInternal(HttpServletRequest request, HttpServletResponse response, FilterChain filterChain) throws ServletException, IOException {
        // 对request进行包装,支持重复读取body
        ReusableBodyRequestWrapper requestWrapper = new ReusableBodyRequestWrapper(request);
        // 校验签名
        if (this.verifySignature(requestWrapper, response)) {
            filterChain.doFilter(requestWrapper, response);
        }
    }
    
    @Autowired
    private RedisTemplate<String, String> redisTemplate;
    
    // 签名秘钥
    @Value("${secret-key}")
    private String secretKey;
    
    /**
     * 校验签名
     *
     * @param request  HTTP请求
     * @param response HTTP响应
     * @return 签名验证结果
     * @throws IOException 如果读取请求体失败
     */
    public boolean verifySignature(HttpServletRequest request, HttpServletResponse response) throws IOException {
        // 签名
        String sign = request.getHeader("X-Sign");
        // 随机数
        String nonce = request.getHeader("X-Nonce");
        // 时间戳
        String timestampStr = request.getHeader("X-Timestamp");
        if (!StringUtils.hasText(sign) || !StringUtils.hasText(nonce) || !StringUtils.hasText(timestampStr)) {
            this.write(response, "参数错误");
            return false;
        }
        
        // timestamp 10分钟内有效
        long timestamp = Long.parseLong(timestampStr);
        long currentTimestamp = System.currentTimeMillis() / 1000;
        if (Math.abs(currentTimestamp - timestamp) > 600) {
            this.write(response, "请求已过期");
            return false;
        }
        
        // 防止请求重放,nonce只能用一次,放在redis中,有效期 20分钟
        String nonceKey = "SignatureVerificationFilter:nonce:" + nonce;
        if (!this.redisTemplate.opsForValue().setIfAbsent(nonceKey, "1", 20, TimeUnit.MINUTES)) {
            this.write(response, "nonce无效");
            return false;
        }
        
        // 请求体
        String body = StreamUtils.copyToString(request.getInputStream(), StandardCharsets.UTF_8);
        // 需要签名的数据:secretKey+noce+timestampStr+body
        // 校验签名
        String data = String.format("%s%s%s%s", this.secretKey, nonce, timestampStr, body);
        if (!DigestUtil.md5Hex(data).equals(sign)) {
            write(response, "签名有误");
            return false;
        }
        return true;
    }
    
    /**
     * 向客户端写入响应信息
     *
     * @param response HTTP响应
     * @param msg      响应信息
     * @throws IOException 如果写入失败
     */
    private void write(HttpServletResponse response, String msg) throws IOException {
        response.setContentType(MediaType.APPLICATION_JSON_VALUE);
        response.setCharacterEncoding(StandardCharsets.UTF_8.name());
        response.getWriter().write(JSONUtil.toJsonStr(msg));
    }
}

6 自己写的一个生成签名的工具类,可选项,因为在实现中,应该是前台传参或代码里写的,这是只是方便测试调度调试

import cn.hutool.crypto.digest.DigestUtil;
import org.springframework.util.StringUtils;
import java.util.UUID;

public class SignatureUtil {
    
    /**
     * 生成签名
     *
     * @param body      请求体
     * @param secretKey 密钥
     * @param nonce     随机数
     * @param timestamp 时间戳
     * @return 签名
     */
    public static String generateSignature(String body, String secretKey, String nonce, String timestamp) {
        if (!StringUtils.hasText(body) || !StringUtils.hasText(secretKey) || !StringUtils.hasText(nonce) || !StringUtils.hasText(timestamp)) {
            throw new IllegalArgumentException("参数不能为空");
        }
        
        // 按照 secretKey + nonce + timestamp + body 的顺序拼接字符串
        String data = String.format("%s%s%s%s", secretKey, nonce, timestamp, body);
        System.out.println("data = " + data);
        
        // 使用MD5算法计算签名
        String sign = DigestUtil.md5Hex(data);
        
        return sign;
    }
    
    public static void main(String[] args) {
        // 示例参数
        String body = "{\n" +
                "  \"fromAccountId\": \"张三\",\n" +
                "  \"toAccountId\": \"李四\",\n" +
                "  \"transferPrice\": 100\n" +
                "}";
        
        //秘钥
        String secretKey = "b0e8668b-bcf2-4d73-abd4-893bbc1c6079";
        // 随机数
        String nonce = UUID.randomUUID().toString().replace("-", "");
        // 时间戳
        long timestamp = System.currentTimeMillis() / 1000;
        
        // 生成签名
        String sign = generateSignature(body, secretKey, nonce, String.valueOf(timestamp));
        
        // 输出生成的签名
        System.out.println("X-Sign: " + sign);
        System.out.println("X-Nonce: " + nonce);
        System.out.println("X-Timestamp: " + timestamp);
    }
}

7 写一个接口,用于调用

import lombok.*;
import java.math.BigDecimal;

@Getter
@Setter
@NoArgsConstructor
@AllArgsConstructor
@Builder
public class TransferRequest {
    //付款人账户id
    private String fromAccountId;
    //收款人账号id
    private String toAccountId;
    //转账金额
    private BigDecimal transferPrice;
}

///

import cn.hutool.json.JSONUtil;
import com.example.demo_26.dto.TransferRequest;
import lombok.extern.slf4j.Slf4j;
import org.springframework.web.bind.annotation.RequestBody;
import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.bind.annotation.RestController;

@RestController
@Slf4j
public class AccountController {
    @RequestMapping("/account/transfer")
    public Object transfer(@RequestBody TransferRequest request) {
        log.info("转账成功:{}", JSONUtil.toJsonStr(request));
        return "转账成功";
    }
}

8 测试
最后的效果,只能发一次请求,重复发送请求,就会失败,需要用新的随机数,时间戳,生成新的签名才可以

Spring Boot接口添加签名是一种常见的安全性增强措施。通过签名,可以确保接口请求的完整性和身份验证,防止请求被篡改或伪造。 以下是一个简单的示例,演示如何为Spring Boot接口添加签名: 1. 定义一个自定义的拦截器(Interceptor)类,用于在请求前后进行处理。可以实现`HandlerInterceptor`接口,并重写`preHandle`和`postHandle`方法。 ```java @Component public class SignatureInterceptor implements HandlerInterceptor { @Override public boolean preHandle(HttpServletRequest request, HttpServletResponse response, Object handler) throws Exception { // 在请求前进行签名验证 // 根据接口定义的规则生成签名 // 将生成的签名添加到请求头或请求参数 return true; // 返回true继续执行请求,返回false止请求 } @Override public void postHandle(HttpServletRequest request, HttpServletResponse response, Object handler, ModelAndView modelAndView) throws Exception { // 在请求后进行其他处理 } } ``` 2. 配置拦截器,将自定义的拦截器添加到Spring Boot应用程序。 ```java @Configuration public class WebMvcConfig implements WebMvcConfigurer { @Autowired private SignatureInterceptor signatureInterceptor; @Override public void addInterceptors(InterceptorRegistry registry) { registry.addInterceptor(signatureInterceptor).addPathPatterns("/**"); } } ``` 3. 在拦截器的`preHandle`方法实现签名验证逻辑。根据接口定义的规则,生成签名并将其添加到请求头或请求参数。 4. 在接口的处理方法,验证请求签名是否合法。可以通过解析请求头或请求参数签名,并与服务器端生成的签名进行比对。 通过以上步骤,你可以为Spring Boot接口添加签名验证,提高接口安全性。请注意,在实际应用签名算法的选择、密钥管理、签名参数的传递方式等都需要根据具体情况进行合理设计和实现。
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

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

抵扣说明:

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

余额充值