登录验证码发送间隔限制的设计与实现
登录验证码发送间隔限制的设计与实现
引言
在小项目设计登录模块时,一个常见的思路是账号与邮箱/手机号绑定,通过邮件/短信验证码的方式登录/注册。相比于传统的口令式注册/登录有以下优点:
- 用户不需要记住密码:只要邮箱/手机号还能用,就可以登录。
- 系统不需要考虑太多安全问题:数据库不存储敏感信息,安全性由邮箱/电信运营商保障。
- 登录、注册合一:简化开发。
发送邮件/短信验证码一般都要通过云厂商的服务来实现,如果有人恶意刷接口就会产生大量费用。而且登录验证码本身也是一段时间内有效的,不需要频繁调用。所以我们需要对发送验证码的接口做调用时间间隔的限制。
本文用 vue3 + element plus + springboot 的技术栈举例。重点在分享思路,在使用其他技术栈的情况下也可以套用。
后端
目的:对于同一个邮箱来说,一分钟内最多发送一次验证码。
思路:使用 Caffeine 库的 Cache 类,过期策略是写后 1 分钟过期。key 存放邮箱,value 存一个 new Object()
(我们可以称之为 timer)。发送验证码之前先检查 cache 里邮箱对应的 timer 是否还在,如果还在说明 1 分钟内发过验证码,拒绝重复发送;如果不在就可以发。
实现:
/**
* 登录验证码缓存, 5 分钟有效期
*/
private final Cache<String, String> loginCodeCache = Caffeine.newBuilder().expireAfterWrite(5, TimeUnit.MINUTES).build();
/**
* 限制用户发送登录验证码的时间间隔, 如果缓存还在, 说明 1 分钟之内发过验证码, 拒绝. 否则发送
*/
private final Cache<String, Object> sendLoginCodeTimers = Caffeine.newBuilder().expireAfterWrite(1, TimeUnit.MINUTES).build();
public Result sendLoginCode(String email) {
// 表示是否是重复发送, 会影响给前端的响应. 并不会被并发访问, 只是一个可以在 lambda 中修改的 boolean 而已
AtomicBoolean isDuplicated = new AtomicBoolean(true);
// computeIfAbsent:当 key 不存在时,调用传入的 lambda 计算出 value 放入 map。
// 而 Caffeine 的 Cache 是线程安全的,其他线程会阻塞直至 value 计算完成,并且由于 value 已经有值,不会再重复调用 lambda。
// 这就相当于一个同步锁的效果
sendLoginCodeTimers.asMap().computeIfAbsent(email, targetEmail -> {
// 走到这里说明一分钟内没有发送过, 可以发送
// 生成随机数字验证码
String code = RandomStringUtils.randomNumeric(6);
// 邮件内容
String content = getLoginEmailContent(code);
// hutool 的邮件工具类
MailUtil.send(targetEmail, "登录验证码", content, true);
log.info("向{}发送了登录验证码: {}", targetEmail, code);
// 存下验证码,便于稍后登录时核对
loginCodeCache.put(targetEmail, code);
// 不是重复发送,发送成功
isDuplicated.set(false);
// 存入一个 timer,ttl 为 1 分钟
return new Object();
});
return isDuplicated.get() ? Result.error("发送过于频繁") : Result.success();
}
利用了 Cache 的线程安全的特性,不必手动写同步锁。
这样,后端层面就限制了发送间隔,不过前端用户难以感知到 1 分钟的间隔有多久,所以我们最好在前端实现一个 1 分钟的倒计时。并且在期间禁用发送按钮,避免用户连续点击发起重复的请求。
前端
为了突出重点,省略了无关的组件、样式和代码
<template>
<el-form :model="loginForm">
<el-form-item prop="email">
<el-input v-model="loginForm.email" placeholder="邮箱">
<template #append>
<el-button @click="sendLoginCode" v-if="allowSendEmail">
发送验证码
</el-button>
<el-button v-else disabled>
{{ remainSeconds + '秒后重试' }}
</el-button>
</template>
</el-input>
</el-form-item>
<el-form-item prop="code">
<el-input v-model="loginForm.code" placeholder="验证码"></el-input>
</el-form-item>
</template>
<script setup lang="ts">
import { reactive, ref } from 'vue';
import type { FormInstance } from "element-plus";
import { ElMessage } from "element-plus";
import axiosInstance from "@/axios";
const loginForm = reactive({
email: '',
code: '',
});
// 60s 内不允许重复点击
const INTERVAL = 60
// 按钮是否允许点击
const allowSendEmail = ref(true)
// 倒计时剩余秒数
const remainSeconds = ref(0)
function sendLoginCode() {
// 一开始就要禁用按钮,避免等待服务器响应期间重复点击
allowSendEmail.value = false
remainSeconds.value = INTERVAL
axiosInstance.post('/user/login/code?email=' + loginForm.email)
.then(resp => {
if (resp.data.code == 2000) {
ElMessage.success('发送成功,请注意查收')
}
})
.finally(() => {
// 无论成功与否, 都开启 60s 冷却倒计时
let timer = setInterval(() => {
if (remainSeconds.value > 0) {
remainSeconds.value--
} else {
clearInterval(timer)
// 倒计时结束,解除按钮禁用
allowSendEmail.value = true
}
}, 1000);
})
}
</script>
<style scoped></style>
效果
点击前:
点击后:
总结
本文介绍了登录验证码发送间隔限制在后端和前端的设计与实现。希望可以为有类似需求的朋友节省时间。
本文链接:
/archives/1709631601468
版权声明:
本站所有文章除特别声明外,均采用 CC BY-NC-SA 4.0 许可协议。转载请注明来自
白烛魁的小站!
喜欢就支持一下吧