Bootstrap

组件讲解-图形验证码使用(应对高并发场景)

背景

在如今越来越多人使用互联网程序的背景下,很多的项目也为了应对高并发而想出了各种应对方案,其中就包括防刷和并发缓解,不少公司为了验证不是机器人或者绑定手机号,用的是短发发送验证码的方法。这种现在基本上是通用的方案了

但还有种情况,比如说热门的促销或者活动,使得大量的用户在一瞬间购买某项产品,这里除了要考虑经典的扣减库存问题外,还要考虑当并发量达到了项目规定的限制后,需要先缓解一下,让瞬间请求降下来,那么怎么缓解而且不影响用户体验呢?图形验证码就是经典的解决方案,相信大家在使用各种购买类型的程序时,如电商,购票等,肯定遇到过需要滑动验证码的操作。

应对用户注册而可能会产生的缓存穿透问题,可以使用图形验证码的功能,极大缓解了数据库的压力。

介绍

行为验证码采用嵌入式集成方式,接入方便,安全,高效。抛弃了传统字符型验证码展示-填写字符-比对答案的流程,采用验证码展示-采集用户行为-分析用户行为流程,用户只需要产生指定的行为轨迹,不需要键盘手动输入,极大优化了传统验证码用户体验不佳的问题;同时,快速、准确的返回人机判定结果。目前对外提供两种类型的验证码,其中包含滑动拼图、文字点选。

交互流程

①   用户访问应用页面,请求显示行为验证码

②   用户按照提示要求完成验证码拼图/点击

③   用户提交表单,前端将第二步的输出一同提交到后台

④   验证数据随表单提交到后台后,后台需要调用captchaService.verification做二次校验

⑤   第4步返回校验通过/失败到产品应用后端,再返回到前端。如下图所示

我们先来梳理下用户注册的完整的流程,理清楚用户注册的流程在什么时候,什么步骤进行了获取验证码,提交验证码

通过流程可以清晰的看到整个流程

  • 开始进行用户注册
  • 填写注册需要的表单数据
  • 点击注册按钮
  • 检查是否需要验证码进行验证
  • 如果上一步返回的数据需要验证,那么执行获取图形验证码
  • 将表单数据和验证码数据提交给服务端
  • 执行用户验证逻辑
  • UserRegisterVerifyCaptcha执行校验验证码的逻辑

接下来我们从此流程来详细的讲解

流程

检查是否需要验证码进行验证

@ApiOperation(value = "检查是否需要验证码")
@PostMapping(value = "/check/need")
public ApiResponse<CheckNeedCaptchaDataVo> checkNeedCaptcha(){
    return ApiResponse.ok(userCaptchaService.checkNeedCaptcha());
}
/**
* 当每秒的注册请求达到阈值触发校验验证码的操作
*/
@Value("${verify_captcha_threshold:10}")
private int verifyCaptchaThreshold;
/**
* 校验验证码id的过期时间
*/
@Value("${verify_captcha_id_expire_time:60}")
private int verifyCaptchaIdExpireTime;
/**
* 始终进行校验验证码
*/
@Value("${always_verify_captcha:0}")
private int alwaysVerifyCaptcha;

public CheckNeedCaptchaDataVo checkNeedCaptcha() {
    //当前时间戳
    long currentTimeMillis = System.currentTimeMillis();
    //验证码唯一标识id
    long id = uidGenerator.getUid();
    List<String> keys = new ArrayList<>();
    //计数器的键
    keys.add(RedisKeyBuild.createRedisKey(RedisKeyManage.COUNTER_COUNT).getRelKey());
    //计数器的时间戳的键
    keys.add(RedisKeyBuild.createRedisKey(RedisKeyManage.COUNTER_TIMESTAMP).getRelKey());
    //校验验证码唯一标识id的键
    keys.add(RedisKeyBuild.createRedisKey(RedisKeyManage.VERIFY_CAPTCHA_ID,id).getRelKey());
    String[] data = new String[4];
    //每秒的注册请求的阈值
    data[0] = String.valueOf(verifyCaptchaThreshold);
    //时间戳
    data[1] = String.valueOf(currentTimeMillis);
    //校验验证码id的过期时间
    data[2] = String.valueOf(verifyCaptchaIdExpireTime);
    //设置是否需要验证码
    data[3] = String.valueOf(alwaysVerifyCaptcha);
    //执行计数器计算
    Boolean result = checkNeedCaptchaOperate.checkNeedCaptchaOperate(keys, data);
    //将结果返回
    CheckNeedCaptchaDataVo checkNeedCaptchaDataVo = new CheckNeedCaptchaDataVo();
    checkNeedCaptchaDataVo.setCaptchaId(id);
    checkNeedCaptchaDataVo.setVerifyCaptcha(result);
    return checkNeedCaptchaDataVo;
}

checkNeedCaptchaOperate.checkNeedCaptchaOperate的计数器功能是通过lua + redis来实现计数的,通过lua语言在redis中执行可以保证整个过程都是原子性的

CheckNeedCaptchaOperate

 

@Slf4j
@Component
public class CheckNeedCaptchaOperate extends AbstractApplicationPostConstructHandler {
    
    @Autowired
    private RedisCache redisCache;
    
    private DefaultRedisScript<String> redisScript;
    
    @Override
    public Integer executeOrder() {
        return 1;
    }
    
    @Override
    public void executeInit(final ConfigurableApplicationContext context) {
        try {
            redisScript = new DefaultRedisScript<>();
            redisScript.setScriptSource(new ResourceScriptSource(new ClassPathResource("lua/checkNeedCaptcha.lua")));
            redisScript.setResultType(String.class);
        } catch (Exception e) {
            log.error("redisScript init lua error",e);
        }
    }
    
    public Boolean checkNeedCaptchaOperate(List<String> keys, String[] args){
        Object object = redisCache.getInstance().execute(redisScript, keys, args);
        return Boolean.parseBoolean((String)object);
    }
}
  • 当执行checkNeedCaptchaOperate方法是,会执行加载好的checkNeedCaptcha.lua脚本

checkNeedCaptcha.lua

-- 计数器的键
local counter_count_key = KEYS[1]
-- 时间戳的键
local counter_timestamp_key = KEYS[2]
-- 校验验证码id的键
local verify_captcha_id = KEYS[3]
-- 每秒最大请求次数
local verify_captcha_threshold = tonumber(ARGV[1])
-- 当前时间戳
local current_time_millis = tonumber(ARGV[2])
-- 校验验证码id过期时间
local verify_captcha_id_expire_time = tonumber(ARGV[3])
-- 始终开启校验验证码开关 0:不开启 1:开启
local always_verify_captcha = tonumber(ARGV[4])
-- 时间窗口大小,1000毫秒,即1秒
local differenceValue = 1000
-- 如果开启校验验证码开关,则直接返回结果
if always_verify_captcha == 1 then
    redis.call('set', verify_captcha_id,'yes')
    redis.call('expire',verify_captcha_id,verify_captcha_id_expire_time)
    return 'true'
end
-- 获取当前计数和上次重置时间
local count = tonumber(redis.call('get', counter_count_key) or "0")
local lastResetTime = tonumber(redis.call('get', counter_timestamp_key) or "0")
-- 检查时间窗口是否已过,如果是,则重置计数和时间戳
if current_time_millis - lastResetTime >= differenceValue then
    count = 0
    redis.call('set', counter_count_key, count)
    redis.call('set', counter_timestamp_key, current_time_millis)
end
-- 更新计数
count = count + 1
-- 超过阈值限制
if count > verify_captcha_threshold then
    -- 重置计数和时间戳
    count = 0
    redis.call('set', counter_count_key, count)
    redis.call('set', counter_timestamp_key, current_time_millis)
    -- 设置校验验证码标识 为yes,表示需要验证码操作
    redis.call('set', verify_captcha_id,'yes')
    redis.call('expire',verify_captcha_id,verify_captcha_id_expire_time)
    return 'true'
end
-- 未超过限制,更新计数
redis.call('set', counter_count_key, count)
-- 设置校验验证码标识 为no,表示不需要验证码操作
redis.call('set',verify_captcha_id,'no')
redis.call('expire',verify_captcha_id,verify_captcha_id_expire_time)
return 'false'

整个lua执行的脚本的验证逻辑:

  • 判断两次请求时间是否大于1秒,大于了说明请求不频繁,将计数器重置
  • 判断每秒的请求数是否超过了配置的verify_captcha_threshold阈值
    • 超过了则将计数器,更新时间戳重置,校验标识设置为需要验证,返回true结果
    • 如果没有超过,则将计数器更新计数,校验标识设置为不需要验证,返回false结果

其实总结起来,检查是否需要验证码进行验证的核心逻辑就是在redis计算每秒的请求数是否达到规定的阈值,然后把设置一个标识存到redis中,在用户注册时根据此标识,判断是否需要校验验证码,当执行脚本后,将结果返回

始终进行校验验证码

如果想模拟验证码的操作,可通过配置项always_verify_captcha = 1进行开启,开启后,无论计数器是否达到阈值,都会开启校验验证码

CheckNeedCaptchaDataVo

@Data
@ApiModel(value="CheckNeedCaptchaDataVo", description ="是否需要进行校验验证码")
public class CheckNeedCaptchaDataVo {
    
    @ApiModelProperty(name ="verifyCaptcha", dataType ="Boolean", value ="是否需要验证码 true:是 false:否")
    private Boolean verifyCaptcha;
    
    @ApiModelProperty(name ="id", dataType ="captchaId", value ="唯一标识id,用户注册接口需要传入此id")
    private Long captchaId;
}

CheckNeedCaptchaDataVo为返回结果,captchaId唯一标识id,在调用用户注册接口时需要传入,以此判断是否需要进行校验验证码

获取验证码

如果需要进行校验验证码操作的话,那么调用获取验证码接口

@ApiOperation(value = "获取验证码")
@PostMapping(value = "/get")
public ResponseModel getCaptcha(@RequestBody CaptchaVO captchaVO){
    return userCaptchaService.getCaptcha(captchaVO);
}

返回结果

 

{
          "repCode":  "0000",
          "repMsg":  null,
          "repData":  {
                    "captchaId":  null,
                    "projectCode":  null,
                    "captchaType":  null,
                    "captchaOriginalPath":  null,
                    "captchaFontType":  null,
                    "captchaFontSize":  null,
                    "secretKey":  "Ja43N2mgGDMEf61Y",
                    "originalImageBase64":  "VBORw0KGgoAAAANSUhEUg...",
                    "point":  null,
                    "jigsawImageBase64":  "iVBORw0KGgoAAAANSUhEUg...",
                    "wordList":  null,
                    "pointList":  null,
                    "pointJson":  null,
                    "token":  "5740b8dba2c742beae014598a95f9968",
                    "result":  false,
                    "captchaVerification":  null,
                    "clientUid":  null,
                    "ts":  null,
                    "browserInfo":  null
          },
          "success":  true
}

originalImageBase64 原始图片的base64
●jigsawImageBase64 拼接图片的base64
●token 加密的token值,校验验证码时需要

当获取验证码,用户执行滑动或者拼接完后,调用用户注册接口,我们看下入参实体中关于验证码的参数

UserRegisterDto

 

@Data
@ApiModel(value="UserRegisterDto", description ="注册用户")
public class UserRegisterDto implements Serializable {

    // 省略...
    
    @ApiModelProperty(name ="id", dataType ="captchaId", value ="captchaId 调用是否需要校验验证码接口返回")
    @NotBlank
    private String captchaId;
    
    @ApiModelProperty(name ="captchaType", dataType ="String", value ="验证码类型:(clickWord,blockPuzzle)")
    private String captchaType;
    
    @ApiModelProperty(name ="pointJson", dataType ="String", value ="点坐标(base64加密传输)")
    private String pointJson;
    
    @ApiModelProperty(name ="token", dataType ="String", value ="UUID(每次请求的验证码唯一标识)")
    private String token;
    
}
  • captchaId 校验验证码id,调用是否需要验证码进行验证接口返回
  • captchaType验证码类型 clickWord:点击  blockPuzzle:滑动
  • pointJson 用户执行滑动或者点击验证码后产生的坐标
  • token 验证码唯一标识,调用获取验证码接口返回

;