赞
踩
接口防止重放攻击:重放攻击是指攻击者截获了一次有效请求(如交易请求),并在之后的时间里多次发送相同的请求,从而达到欺骗系统的目的。为了防止重放攻击,通常需要在系统中引入一种机制,使得每个请求都有一个唯一的标识符(如时间戳、序列号等),并在处理请求时检查这个标识符,确保请求没有被重复处理。
接口幂等性:幂等性是指一个操作具有不变性,即多次执行该操作会产生相同的结果。例如,支付金额、提交表单等操作都具有幂等性。为了保证系统的幂等性,需要确保在处理请求时,对于相同的输入参数,系统只执行一次相应的操作,而不会对同一个输入参数进行多次处理。这可以通过在数据库中设置唯一约束、使用乐观锁等方式实现。
方案:
接口防止重放攻击:基于nonce + timestamp 的方案
nonce的意思是仅一次有效的随机字符串,要求每次请求时该参数要保证不同。实际使用用户信息+时间戳+随机数等信息做个哈希之后,作为nonce参数。此时服务端的处理流程如下:
去 redis 中查找是否有 key 为 nonce:{nonce}的 string
如果没有,则创建这个 key,把这个 key 失效的时间和验证 timestamp 失效的时间一致,比如是 60s。
如果有,说明这个 key 在 60s 内已经被使用了,那么这个请求就可以判断为重放请求。
如何保证接口安全,做到防篡改防重放?_接口防止串改-CSDN博客(下面的代码参加该博客)
我们知道http 是一种无状态的协议,服务端并不知道客户端发送的请求是否合法,也并不知道请求中的参数是否正确。
举个例子, 现在有个充值的接口,调用后可以给用户增加对应的余额。
http://localhost/api/user/recharge?user_id=1001&amount=10
如果非法用户通过抓包获取到接口参数后,修改user_id 或 amount的值就可以实现给任意账户添加余额的目的。
如何解决
采用https协议可以将传输的明文进行加密,但是黑客仍然可以截获传输的数据包,进一步伪造请求进行重放攻击。如果黑客使用特殊手段让请求方设备使用了伪造的证书进行通信,那么https加密的内容也会被解密。
一般的做法有2种:
采用https方式把接口的数据进行加密传输,即便是被黑客破解,黑客也花费大量的时间和精力去破解。
接口后台对接口的请求参数进行验证,防止被黑客篡改;
步骤1:客户端使用约定好的秘钥对传输的参数进行加密,得到签名值sign1,并且将签名值也放入请求的参数中,发送请求给服务端
步骤2:服务端接收到客户端的请求,然后使用约定好的秘钥对请求的参数再次进行签名,得到签名值sign2。
步骤3:服务端比对sign1和sign2的值,如果不一致,就认定为被篡改,非法请求。
防重放也叫防复用,简单来说就是我获取到这个请求的信息之后什么也不改,,直接拿着接口的参数 重复请求这个充值的接口
。此时我的请求是合法的,
因为所有参数都是跟合法请求一模一样的。
重放攻击会造成两种后果:
针对插入数据库接口:重放攻击,会出现大量重复数据,甚至垃圾数据会把数据库撑爆。
针对查询的接口:黑客一般是重点攻击慢查询接口,例如一个慢查询接口1s,只要黑客发起重放攻击,就必然造成系统被拖垮,数据库查询被阻塞死。
对于重放攻击一般有两种做法:
每次HTTP请求,都需要加上timestamp参数,然后把timestamp和其他参数一起进行数字签名。因为一次正常的HTTP请求,从发出到达服务器一般都不会超过60s,所以服务器收到HTTP请求之后,首先判断时间戳参数与当前时间比较,是否超过了60s,如果超过了则认为是非法请求。
一般情况下,黑客从抓包重放请求耗时远远超过了60s,所以此时请求中的timestamp参数已经失效了。如果黑客修改timestamp参数为当前的时间戳,则sign1参数对应的数字签名就会失效,因为黑客不知道签名秘钥,没有办法生成新的数字签名。
但是这种方式的漏洞也是显而易见,如果在60s之内进行重放攻击,那就没办法了,所以这种方式不能保证请求仅一次有效。
老鸟们一般会采取下面这种方案,既可以解决接口重放问题,又可以解决接口一次请求有效的问题。
nonce的意思是仅一次有效的随机字符串,要求每次请求时该参数要保证不同。实际使用用户信息+时间戳+随机数等信息做个哈希之后,作为nonce参数。
此时服务端的处理流程如下:
去 redis 中查找是否有 key 为 nonce:{nonce}
的 string
如果没有,则创建这个 key,把这个 key 失效的时间和验证 timestamp 失效的时间一致,比如是 60s。
如果有,说明这个 key 在 60s 内已经被使用了,那么这个请求就可以判断为重放请求。
这种方案nonce和timestamp参数都作为签名的一部分传到后端,基于timestamp方案可以让黑客只能在60s内进行重放攻击,加上nonce随机数以后可以保证接口只能被调用一次,可以很好的解决重放攻击问题。
接下来通过实际代码来看看如何实现接口的防篡改和防重放。
- @Data
- @Builder
- public class RequestHeader {
- private String sign ;
- private Long timestamp ;
- private String nonce;
- }
- @Slf4j
- @UtilityClass
- public class HttpDataUtil {
- /**
- * post请求处理:获取 Body 参数,转换为SortedMap
- *
- * @param request
- */
- public SortedMap<String, String> getBodyParams(final HttpServletRequest request) throws IOException {
- byte[] requestBody = StreamUtils.copyToByteArray(request.getInputStream());
- String body = new String(requestBody);
- return JsonUtil.json2Object(body, SortedMap.class);
- }
-
-
- /**
- * get请求处理:将URL请求参数转换成SortedMap
- */
- public static SortedMap<String, String> getUrlParams(HttpServletRequest request) {
- String param = "";
- SortedMap<String, String> result = new TreeMap<>();
-
- if (StringUtils.isEmpty(request.getQueryString())) {
- return result;
- }
-
- try {
- param = URLDecoder.decode(request.getQueryString(), "utf-8");
- } catch (UnsupportedEncodingException e) {
- e.printStackTrace();
- }
-
- String[] params = param.split("&");
- for (String s : params) {
- String[] array=s.split("=");
- result.put(array[0], array[1]);
- }
- return result;
- }
- }
这里的参数放入SortedMap中对其进行字典排序,前端构建签名时同样需要对参数进行字典排序。
- @Slf4j
- @UtilityClass
- public class SignUtil {
- /**
- * 验证签名
- * 验证算法:把timestamp + JsonUtil.object2Json(SortedMap)合成字符串,然后MD5
- */
- @SneakyThrows
- public boolean verifySign(SortedMap<String, String> map, RequestHeader requestHeader) {
- String params = requestHeader.getNonce() + requestHeader.getTimestamp() + JsonUtil.object2Json(map);
- return verifySign(params, requestHeader);
- }
-
- /**
- * 验证签名
- */
- public boolean verifySign(String params, RequestHeader requestHeader) {
- log.debug("客户端签名: {}", requestHeader.getSign());
- if (StringUtils.isEmpty(params)) {
- return false;
- }
- log.info("客户端上传内容: {}", params);
- String paramsSign = DigestUtils.md5DigestAsHex(params.getBytes()).toUpperCase();
- log.info("客户端上传内容加密后的签名结果: {}", paramsSign);
- return requestHeader.getSign().equals(paramsSign);
- }
- }
- public class SignRequestWrapper extends HttpServletRequestWrapper {
- //用于将流保存下来
- private byte[] requestBody = null;
-
- public SignRequestWrapper(HttpServletRequest request) throws IOException {
- super(request);
- requestBody = StreamUtils.copyToByteArray(request.getInputStream());
- }
-
- @Override
- public ServletInputStream getInputStream() throws IOException {
- final ByteArrayInputStream bais = new ByteArrayInputStream(requestBody);
-
- 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() throws IOException {
- return bais.read();
- }
- };
-
- }
-
- @Override
- public BufferedReader getReader() throws IOException {
- return new BufferedReader(new InputStreamReader(getInputStream()));
- }
- }
防篡改和防重放我们会通过SpringBoot
Filter来实现,而编写的filter过滤器需要读取request数据流,但是request数据流只能读取一次,需要自己实现HttpServletRequestWrapper对数据流包装,目的是将request流保存下来。
- @Configuration
- public class SignFilterConfiguration {
- @Value("${sign.maxTime}")
- private String signMaxTime;
-
- //filter中的初始化参数
- private Map<String, String> initParametersMap = new HashMap<>();
-
- @Bean
- public FilterRegistrationBean contextFilterRegistrationBean() {
- initParametersMap.put("signMaxTime",signMaxTime);
- FilterRegistrationBean registration = new FilterRegistrationBean();
- registration.setFilter(signFilter());
- registration.setInitParameters(initParametersMap);
- registration.addUrlPatterns("/sign/*");
- registration.setName("SignFilter");
- // 设置过滤器被调用的顺序
- registration.setOrder(1);
- return registration;
- }
-
- @Bean
- public Filter signFilter() {
- return new SignFilter();
- }
- }
- @Slf4j
- public class SignFilter implements Filter {
- @Resource
- private RedisUtil redisUtil;
-
- //从fitler配置中获取sign过期时间
- private Long signMaxTime;
-
- private static final String NONCE_KEY = "x-nonce-";
-
- @Override
- public void doFilter(ServletRequest servletRequest, ServletResponse servletResponse, FilterChain filterChain) throws IOException, ServletException {
- HttpServletRequest httpRequest = (HttpServletRequest) servletRequest;
- HttpServletResponse httpResponse = (HttpServletResponse) servletResponse;
-
- log.info("过滤URL:{}", httpRequest.getRequestURI());
-
- HttpServletRequestWrapper requestWrapper = new SignRequestWrapper(httpRequest);
- //构建请求头
- RequestHeader requestHeader = RequestHeader.builder()
- .nonce(httpRequest.getHeader("x-Nonce"))
- .timestamp(Long.parseLong(httpRequest.getHeader("X-Time")))
- .sign(httpRequest.getHeader("X-Sign"))
- .build();
-
- //验证请求头是否存在
- if(StringUtils.isEmpty(requestHeader.getSign()) || ObjectUtils.isEmpty(requestHeader.getTimestamp()) || StringUtils.isEmpty(requestHeader.getNonce())){
- responseFail(httpResponse, ReturnCode.ILLEGAL_HEADER);
- return;
- }
-
- /*
- * 1.重放验证
- * 判断timestamp时间戳与当前时间是否操过60s(过期时间根据业务情况设置),如果超过了就提示签名过期。
- */
- long now = System.currentTimeMillis() / 1000;
-
- if (now - requestHeader.getTimestamp() > signMaxTime) {
- responseFail(httpResponse,ReturnCode.REPLAY_ERROR);
- return;
- }
-
- //2. 判断nonce
- boolean nonceExists = redisUtil.hasKey(NONCE_KEY + requestHeader.getNonce());
- if(nonceExists){
- //请求重复
- responseFail(httpResponse,ReturnCode.REPLAY_ERROR);
- return;
- }else {
- redisUtil.set(NONCE_KEY+requestHeader.getNonce(), requestHeader.getNonce(), signMaxTime);
- }
-
-
- boolean accept;
- SortedMap<String, String> paramMap;
- switch (httpRequest.getMethod()){
- case "GET":
- paramMap = HttpDataUtil.getUrlParams(requestWrapper);
- accept = SignUtil.verifySign(paramMap, requestHeader);
- break;
- case "POST":
- paramMap = HttpDataUtil.getBodyParams(requestWrapper);
- accept = SignUtil.verifySign(paramMap, requestHeader);
- break;
- default:
- accept = true;
- break;
- }
- if (accept) {
- filterChain.doFilter(requestWrapper, servletResponse);
- } else {
- responseFail(httpResponse,ReturnCode.ARGUMENT_ERROR);
- return;
- }
-
- }
-
- private void responseFail(HttpServletResponse httpResponse, ReturnCode returnCode) {
- ResultData<Object> resultData = ResultData.fail(returnCode.getCode(), returnCode.getMessage());
- WebUtils.writeJson(httpResponse,resultData);
- }
-
- @Override
- public void init(FilterConfig filterConfig) throws ServletException {
- String signTime = filterConfig.getInitParameter("signMaxTime");
- signMaxTime = Long.parseLong(signTime);
- }
- }
- @Component
- public class RedisUtil {
- @Resource
- private RedisTemplate<String, Object> redisTemplate;
-
- /**
- * 判断key是否存在
- * @param key 键
- * @return true 存在 false不存在
- */
- public boolean hasKey(String key) {
- try {
- return Boolean.TRUE.equals(redisTemplate.hasKey(key));
- } catch (Exception e) {
- e.printStackTrace();
- return false;
- }
- }
-
-
- /**
- * 普通缓存放入并设置时间
- * @param key 键
- * @param value 值
- * @param time 时间(秒) time要大于0 如果time小于等于0 将设置无限期
- * @return true成功 false 失败
- */
- public boolean set(String key, Object value, long time) {
- try {
- if (time > 0) {
- redisTemplate.opsForValue().set(key, value, time, TimeUnit.SECONDS);
- } else {
- set(key, value);
- }
- return true;
- } catch (Exception e) {
- e.printStackTrace();
- return false;
- }
- }
-
- /**
- * 普通缓存放入
- * @param key 键
- * @param value 值
- * @return true成功 false失败
- */
- public boolean set(String key, Object value) {
- try {
- redisTemplate.opsForValue().set(key, value);
- return true;
- } catch (Exception e) {
- e.printStackTrace();
- return false;
- }
- }
-
- }
项目源码地址
https://github.com/jianzh5/cloud-blog/tree/main/cloud-demo
Copyright © 2003-2013 www.wpsshop.cn 版权所有,并保留所有权利。