背景:紧接着上一篇,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 {
@Autowired
private SecurityUtilTestService securityUtilTestService;
/**
* 模拟高并发重复提交的现象,
* 从而引出解决办法:加入token防止表单重复提交
* @param data
* @return
*/
@PostMapping("/form/repeatSubmitTest")
@ResponseBody
public 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 {
@Autowired
private 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 {
@Autowired
private 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 {
@Autowired
private 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中是否有这个键对应的token
String 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 Something
result.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