提供三方API接口、调用第三方接口API接口、模拟API接口(二)通过token实现防止业务接口的重复调用

背景:紧接着上一篇,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

  • 18
    点赞
  • 18
    收藏
    觉得还不错? 一键收藏
  • 打赏
    打赏
  • 0
    评论
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包

打赏作者

执键行天涯

码你的鼓励将是我创作的最大动力

¥1 ¥2 ¥4 ¥6 ¥10 ¥20
扫码支付:¥1
获取中
扫码支付

您的余额不足,请更换扫码支付或充值

打赏作者

实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

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

余额充值