背景
在如今越来越多人使用互联网程序的背景下,很多的项目也为了应对高并发而想出了各种应对方案,其中就包括防刷和并发缓解,不少公司为了验证不是机器人或者绑定手机号,用的是短发发送验证码的方法。这种现在基本上是通用的方案了
但还有种情况,比如说热门的促销或者活动,使得大量的用户在一瞬间购买某项产品,这里除了要考虑经典的扣减库存问题外,还要考虑当并发量达到了项目规定的限制后,需要先缓解一下,让瞬间请求降下来,那么怎么缓解而且不影响用户体验呢?图形验证码就是经典的解决方案,相信大家在使用各种购买类型的程序时,如电商,购票等,肯定遇到过需要滑动验证码的操作。
应对用户注册而可能会产生的缓存穿透问题,可以使用图形验证码的功能,极大缓解了数据库的压力。
介绍
行为验证码采用嵌入式集成方式,接入方便,安全,高效。抛弃了传统字符型验证码展示-填写字符-比对答案的流程,采用验证码展示-采集用户行为-分析用户行为流程,用户只需要产生指定的行为轨迹,不需要键盘手动输入,极大优化了传统验证码用户体验不佳的问题;同时,快速、准确的返回人机判定结果。目前对外提供两种类型的验证码,其中包含滑动拼图、文字点选。
交互流程
① 用户访问应用页面,请求显示行为验证码
② 用户按照提示要求完成验证码拼图/点击
③ 用户提交表单,前端将第二步的输出一同提交到后台
④ 验证数据随表单提交到后台后,后台需要调用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
验证码唯一标识,调用获取验证码接口返回