背景:紧接着上一篇,API中的签名认证,我通过signature签名机制保证了,参数不被修改,但是如果我们提供给外部的接口(此时我们作为第三方),如果被外部恶意重复调用怎么办?
此时,我们可以保证请求参数不会被修改,但这不能保证接口不被重复调用,因此还需要token、timestamp来辅助。
## 加入token防止表单重复提交
模拟重复提交表单
测试控制器(模拟高并发测试接口)
package com.atguigu.signcenter.controller;import com.atguigu.signcenter.service.SecurityUtilTestService;
import com.atguigu.signcenter.service.serviceImpl.SecurityUtilTestServiceImpl;
import lombok.extern.slf4j.Slf4j;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.stereotype.Controller;
import org.springframework.web.bind.annotation.PostMapping;
import org.springframework.web.bind.annotation.RequestParam;
import org.springframework.web.bind.annotation.ResponseBody;import java.io.UnsupportedEncodingException;
import java.util.HashMap;
import java.util.Map;/*** 原文链接:https://blog.csdn.net/weixin_47560078/article/details/118222785* @author: jd* @create: 2024-07-30*/@Slf4j
@Controller
public class TestController {@Autowiredprivate SecurityUtilTestService securityUtilTestService;/*** 模拟高并发重复提交的现象,* 从而引出解决办法:加入token防止表单重复提交* @param data* @return*/@PostMapping("/form/repeatSubmitTest")@ResponseBodypublic Map<String, Object> repeatSubmitTest(@RequestParam Map<String, String> data) {// 模拟提交表单信息Map<String, Object> result = new HashMap<>();result.put("code", 0);// 状态码result.put("msg", "success");// 信息log.info("提交表单[]");return result;}}
开100个线程请求测试接口
测试结果:
一共100个输出,这里导致了重复调用。在实际业务中需要避免这样的问题,如果是金钱上的扣减操作,这样会导致很严重的问题
提交表单[]
2024-08-02 16:21:29.167 INFO 17420 --- [io-8025-exec-32] c.a.s.controller.TestController : 提交表单[]
2024-08-02 16:21:29.170 INFO 17420 --- [nio-8025-exec-3] c.a.s.controller.TestController : 提交表单[]
2024-08-02 16:21:29.170 INFO 17420 --- [io-8025-exec-40] c.a.s.controller.TestController : 提交表单[]
2024-08-02 16:21:29.170 INFO 17420 --- [io-8025-exec-33] c.a.s.controller.TestController : 提交表单[]
2024-08-02 16:21:29.170 INFO 17420 --- [io-8025-exec-31] c.a.s.controller.TestController : 提交表单[]
2024-08-02 16:21:29.170 INFO 17420 --- [io-8025-exec-17] c.a.s.controller.TestController : 提交表单[]
2024-08-02 16:21:29.171 INFO 17420 --- [io-8025-exec-38] c.a.s.controller.TestController : 提交表单[]
2024-08-02 16:21:29.172 INFO 17420 --- [io-8025-exec-36] c.a.s.controller.TestController : 提交表单[]
2024-08-02 16:21:29.170 INFO 17420 --- [io-8025-exec-43] c.a.s.controller.TestController : 提交表单[]
解决表单重复提交思路
前端在提交表单之前,先调用后端接口获取临时全局唯一的token【这里可以理解成登录操作,登录获取token】,然后再postman中调用另外一个接口时,将token存入header,最后才提交表单。
后端生成token时,将token暂时缓存在redis中,设置一个有效期。当后端收到表单提交的请求时,先判断header的 token 是否在缓存中:
猜测的现象:
- 如果业务操作的请求中未携带token,则直接返回,代表未登录。登录后重新调用业务接口。
- 如果业务操作的请求中携带了token,则验证redis中存储的token和我携带的是否一致,如果一致,则继续处理业务逻辑,并且处理完业务后,删除缓存中的 token(这样如果重复调用的话,不再次调用getToken的话,则无法再次做业务操作,代表重复调用,这样就达到了避免重复调用的问题);如果不一致,则代表当前获取token后对业务的调用是不合法的,可能调用业务携带的token被修改过。
- 如果redis中无token,不让调用业务接口,问题说明,情况一:登录已经失效或者根本就未登录。情况二:之前已经提交过了,redis中的被删除了,这次是重复调用业务了,这2种的需要重新获取token后携带新token进行业务调用。
加入依赖
redis依赖:
<!-- redisson依赖--><dependency><groupId>org.redisson</groupId><artifactId>redisson-spring-boot-starter</artifactId><version>3.15.5</version></dependency><!--redis链接客户端--><dependency><groupId>redis.clients</groupId><artifactId>jedis</artifactId><version>3.2.0</version></dependency>
封装的 Redis 操作类
package com.atguigu.signcenter.util;import com.mysql.cj.util.StringUtils;
import io.netty.util.internal.StringUtil;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.data.redis.core.StringRedisTemplate;
import org.springframework.stereotype.Component;import java.util.concurrent.TimeUnit;/*** redis操作工具类,设置缓存,取出缓存、删除缓存操作*https://blog.csdn.net/weixin_47560078/article/details/118222785* @author: jd* @create: 2024-08-02*/
@Component
public class RedisTemplateUtil {@Autowiredprivate StringRedisTemplate stringRedisTemplate;/*** 设置缓存* @param key 键* @param data 数据* @param timeout 失效时间* @return*/public Boolean setString(String key,Object data,Long timeout){if(data instanceof String){if(null!=timeout){stringRedisTemplate.opsForValue().set(key,(String)data,timeout, TimeUnit.SECONDS);}else {stringRedisTemplate.opsForValue().set(key, (String) data);}return true;}else {return false;}}/*** 取缓存* @param key 缓存键* @return*/public String getString(String key){return stringRedisTemplate.opsForValue().get(key);}/*** 删除某个 key* @param key*/public void delKey(String key){stringRedisTemplate.delete(key);}}
token 工具类
package com.atguigu.signcenter.util;import com.mysql.cj.util.StringUtils;
import io.netty.util.internal.StringUtil;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.stereotype.Component;import java.util.Objects;
import java.util.UUID;/*** https://blog.csdn.net/weixin_47560078/article/details/118222785* token处理类* @author: jd* @create: 2024-08-02*/@Component
public class TokenUtil {@Autowiredprivate RedisTemplateUtil redisTemplateUtil;// 时间为 秒L ,如 30分钟 应为 60*30L ,这里设置 1分钟private static final Long TIMEOUT = 60*2L;/*** 生成 token* @return*/public String getToken(){StringBuilder token = new StringBuilder("token_");token.append(UUID.randomUUID().toString().replaceAll("-",""));redisTemplateUtil.setString(token.toString(),token.toString(),TIMEOUT);return token.toString();}/*** 判断是否有 token ,注意这个方法不验证token是否正确* @param tokenKey* @return*/public String findToken(String tokenKey){if(Objects.nonNull(tokenKey)){String token = redisTemplateUtil.getString(tokenKey);return token;}return null;}/*** 删除某个 key* @param key*/public void deleteKey(String key) {redisTemplateUtil.delKey(key);}}
测试控制器
获取token接口、模拟业务接口:
package com.atguigu.signcenter.controller;import com.atguigu.signcenter.util.TokenUtil;
import com.mysql.cj.util.StringUtils;
import lombok.extern.slf4j.Slf4j;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.http.HttpRequest;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.PostMapping;
import org.springframework.web.bind.annotation.RequestParam;
import org.springframework.web.bind.annotation.RestController;import javax.servlet.http.HttpServletRequest;
import java.util.HashMap;
import java.util.Map;/*** https://blog.csdn.net/weixin_47560078/article/details/118222785* 模拟业务接口* 获取token接口、模拟业务接口:* @author: jd* @create: 2024-08-02*/@Slf4j
@RestController
public class tokenController {@Autowiredprivate TokenUtil tokenUtil;/***** 获取 token ,这一步骤会向redis中写写入token* 这个可以理解成登录操作,如果登录了则会向redis中写入一个token,然后下面方法中每一次做业务,都会验证携带的token是否和redis中的一样,一样才可以操作,否则不可以做业务,需要重新登录后再做。* @return*/@GetMapping("/getToken")public String getToken(){return tokenUtil.getToken();}/*** 模拟高并发重复提交,根据 token 缓存防止重复提交[最初@GetMapping("/getToken")获取token的请求,会向redis中写入一个token值]* 这个请求中每访问一次,redis中如果有token会做完业务后删除,如果redis中没有token,则不让做业务。* @param request* @param data* @return*/@PostMapping("/form/repeatSubmitTest2")public Map<String,Object> repeatSubmitTest(HttpServletRequest request,@RequestParam Map<String, String> data){// 返回信息Map<String, Object> result = new HashMap<>();String paramToken = request.getHeader("token");//如果请求头中有token则进入到下面的业务操作中if(!StringUtils.isNullOrEmpty(paramToken)){//去找redis中是否有这个键对应的tokenString redisToken = tokenUtil.findToken(paramToken);//如果有redis中的token 非空,则进入if(!StringUtils.isNullOrEmpty(redisToken)){//如果参数中和redis中都有token,则验证这两个token是否一致,如果一致则可以进行业务。if(!paramToken.equals(redisToken)){log.info("token信息错误,请重新登录后操作");result.put("code", -2);// 状态码result.put("msg", "登录过期,请登录后操作业务,");// 信息 这里也就是调用完getToken() 后操作业务。 这个相当于登录。}// 模拟提交表单信息// TODO Somethingresult.put("code", 0);// 状态码result.put("msg", "业务操作成功");// 信息log.info("操作业务[]");// 删除缓存中的token ,正常的登录token是不用删除的,过期后自动删除,这里是为了避免重复提交,所以这里加了这个限制tokenUtil.deleteKey(paramToken);}else {log.info("请勿重新操作业务");result.put("code", -1);// 状态码result.put("msg", "请勿重新操作业务");// 信息}}else {log.info("表单无token[]");result.put("code", -1);// 状态码result.put("msg", "参数未携带token,请携带token后操作");// 信息}return result;}}
测试
header无token时:业务逻辑不会执行
调用请求 : http://localhost:8025/form/repeatSubmitTest2
结果:
调用获取token接口后,再调用业务
http://localhost:8025/getToken
拿到token (token_5e2eeb9bc7cd468c919a57a1887502a3),放到业务请求的header中 ,发送业务请求:
结果:
2024-08-02 16:42:19.329 INFO 17420 --- [io-8025-exec-52] c.a.s.controller.tokenController : 提交表单[]
我们将业务请求中携带的token信息修改成错误的,再次发送业务请求,
结果:提示重复操作,或者登录失效,请重新登录。
至此,通过token控制重复提交基本实现,不足的地方还请大家多多指教。
参考链接:https://blog.csdn.net/weixin_47560078/article/details/118222785