登录验证码发送间隔限制的设计与实现

引言

在小项目设计登录模块时,一个常见的思路是账号与邮箱/手机号绑定,通过邮件/短信验证码的方式登录/注册。相比于传统的口令式注册/登录有以下优点:

  1. 用户不需要记住密码:只要邮箱/手机号还能用,就可以登录。
  2. 系统不需要考虑太多安全问题:数据库不存储敏感信息,安全性由邮箱/电信运营商保障。
  3. 登录、注册合一:简化开发。

发送邮件/短信验证码一般都要通过云厂商的服务来实现,如果有人恶意刷接口就会产生大量费用。而且登录验证码本身也是一段时间内有效的,不需要频繁调用。所以我们需要对发送验证码的接口做调用时间间隔的限制。

本文用 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>

效果

点击前:

image-20240305172935474

点击后:

image-20240305173008834

总结

本文介绍了登录验证码发送间隔限制在后端和前端的设计与实现。希望可以为有类似需求的朋友节省时间。

文章作者: 白烛魁
本文链接:
版权声明: 本站所有文章除特别声明外,均采用 CC BY-NC-SA 4.0 许可协议。转载请注明来自 白烛魁的小站
喜欢就支持一下吧