基于 Vue 和 Spring Boot 实现滑块验证码的机器验证

本文详细介绍了如何使用 AJ-Captcha 库实现一个安全的移动端登录系统,包含滑块验证码(行为验证)功能。用户需要输入手机号,完成滑块验证,并由后端校验手机号是否存在后才能触发短信验证码发送。本实现参考了 Gitee 的手机号登录流程。

实现效果如下:

录制_2025_06_05_13_57_46_727

需求概述

登录流程如下:

  1. 用户输入手机号,前端进行格式校验。
  2. 点击“发送验证码”后触发滑块验证码。
  3. 滑块验证通过后,后端校验手机号是否存在。
  4. 手机号存在则发送短信验证码,用户输入验证码后完成登录。

以下是参考的 Gitee 登录流程时序图和界面示意图:

image-20250603100925555

机器验证登录时序图

技术选型

  • 采用 AJ-Captcha 行为验证码库,支持滑动拼图和文字点选两种验证方式。AJ-Captcha 提供前后端交互的完整解决方案,支持 Java 后端以及多种前端框架(Vue、React、Flutter 等)。

  • 功能特点

    • 嵌入式集成,接入简单,安全高效。

    • 抛弃传统字符验证码,优化用户体验。

    • 支持多种前端框架和后端缓存方式(Redis、内存等)。

    • 提供滑动拼图和文字点选两种验证码类型。

    • 在线文档AJ-Captcha 官方文档

前端实现

参考文档:vue | AJ-Captcha

1. 引入组件

view/vue3/src/components/verifition · anji-plus/AJ-Captcha - 码云 - 开源中国

# 1.复制view/vue/src/components/verifition文件夹,到自己工程对应目录下,在登录页面插入如下代码。
# 2.安装请求和加密依赖
npm install axios  crypto-js   -S

2. 修改后端请求URL

修改aixos.js中的axios.defaults.baseURL为后端服务的url

image-20250605134920919

3. 新增机器验证页面

image-20250605134736543

<!-- src/views/TestVerification.vue -->
<template>
  <Verify
      @success="success"
      :mode="'pop'"
      :captchaType="'blockPuzzle'"
      :imgSize="{ width: '330px', height: '155px' }"
      ref="verify"
  />
  <button @click="useVerify">调用验证组件</button>
</template>

<script>
import Verify from '../components/verifition/Verify.vue'

export default {
  name: 'TestVerification',
  components: { Verify },
  methods: {
    success(params) {
      console.log('验证成功,返回参数:', params)
    },
    useVerify() {
      this.$refs.verify.show()
    }
  }
}
</script>

4.首页调用验证组件

修改App.vue文件

<template>
  <verify/>
</template>

<script>
import verify from './views/TestVerification.vue'

export default {
  name: 'App',
  components: {
    verify
  }
}
</script>

<style>
#app {
  font-family: Avenir, Helvetica, Arial, sans-serif;
  -webkit-font-smoothing: antialiased;
  -moz-osx-font-smoothing: grayscale;
  text-align: center;
  color: #2c3e50;
  margin-top: 60px;
}
</style>

后端实现

流程梳理

  1. 用户输入手机号 -> 前端格式校验 -> 点击“发送验证码”

  2. 调用后端机器校验初始化接口 -> 返回滑块校验参数 -> 跳转滑块页面

  3. 前端滑块完成 -> 调用后端滑块校验接口 + 传滑块验证数据 + 手机号

  4. 后端验证滑块数据:

    • 成功 -> 校验手机号是否存在
      • 存在 -> 返回允许发送验证码
      • 不存在 -> 返回错误提示手机号不存在
    • 失败 -> 返回滑块验证失败
  5. 前端收到允许发送验证码后 -> 调用发送验证码接口(携带手机号)

  6. 用户输入验证码 -> 调用登录接口(手机号+验证码)

具体实现

1. 引入依赖

<dependency>
   <groupId>com.anji-plus</groupId>
   <artifactId>captcha-spring-boot-starter</artifactId>
   <version>1.4.0</version>
</dependency>

复制 images文件夹到resources目录下,项目地址:images · anji-plus/AJ-Captcha - 码云 - 开源中国

image-20250605105758345

image-20250605105910474

2. 增加yml配置

aj:
  captcha:
    jigsaw: classpath:images/jigsaw
    #滑动验证,底图路径,不配置将使用默认图片
    ##支持全路径
    # 支持项目路径,以classpath:开头,取resource目录下路径,例:classpath:images/pic-click
    pic-click: classpath:images/pic-click
    # 对于分布式部署的应用,我们建议应用自己实现CaptchaCacheService,比如用Redis或者memcache,
    # 参考CaptchaCacheServiceRedisImpl.java
    # 如果应用是单点的,也没有使用redis,那默认使用内存。
    # 内存缓存只适合单节点部署的应用,否则验证码生产与验证在节点之间信息不同步,导致失败。
    # !!! 注意啦,如果应用有使用spring-boot-starter-data-redis,
    # 请打开CaptchaCacheServiceRedisImpl.java注释。
    # redis ----->  SPI: 在resources目录新建META-INF.services文件夹(两层),参考当前服务resources。
    # 缓存local/redis...
    cache-type: local
    # local缓存的阈值,达到这个值,清除缓存
    cache-number: 1000
    # local定时清除过期缓存(单位秒),设置为0代表不执行
    timing-clear: 180
    # 验证码类型default两种都实例化。
    type: default
    # 汉字统一使用Unicode,保证程序通过@value读取到是中文,可通过这个在线转换;yml格式不需要转换
    # https://tool.chinaz.com/tools/unicode.aspx 中文转Unicode
    # 右下角水印文字(我的水印)
    water-mark: water-mark
    # 右下角水印字体(不配置时,默认使用文泉驿正黑)
    # 由于宋体等涉及到版权,我们jar中内置了开源字体【文泉驿正黑】
    # 方式一:直接配置OS层的现有的字体名称,比如:宋体
    # 方式二:自定义特定字体,请将字体放到工程resources下fonts文件夹,支持ttf\ttc\otf字体
    # aj.captcha.water-font=WenQuanZhengHei.ttf
    # water-font: SourceHanSansCN-Normal.otf
    # 点选文字验证码的文字字体(文泉驿正黑)
    # aj.captcha.font-type=WenQuanZhengHei.ttf
    # font-type: SourceHanSansCN-Normal.otf
    # 校验滑动拼图允许误差偏移量(默认5像素)
    slip-offset: 5
    # aes加密坐标开启或者禁用(true|false)
    aes-status: true
    # 滑动干扰项(0/1/2)
    interference-options: 1
    history-data-clear-enable: true
    # 接口请求次数一分钟限制是否开启 true|false
    req-frequency-limit-enable: true
    # 验证失败5次,get接口锁定
    req-get-lock-limit: 5
    # 验证失败后,锁定时间间隔,s
    req-get-lock-seconds: 60
    # get接口一分钟内请求数限制
    req-get-minute-limit: 30
    # check接口一分钟内请求数限制
    req-check-minute-limit: 60
    # verify接口一分钟内请求数限制
    req-verify-minute-limit: 60

3. 代码实现

新增CaptchaController.java类

package com.zhou.demo.controller;

import com.anji.captcha.model.common.ResponseModel;
import com.anji.captcha.model.vo.CaptchaVO;
import com.anji.captcha.service.CaptchaService;
import org.springframework.web.bind.annotation.PostMapping;
import org.springframework.web.bind.annotation.RequestBody;
import org.springframework.web.bind.annotation.RestController;

import javax.annotation.Resource;

/**
 * 验证码操作处理
 *
 * @author cxstar
 */
@RestController
public class CaptchaController {

    @Resource
    private CaptchaService captchaService;

    /**
     * 获取验证码接口
     *
     * @param captchaVO 验证码参数
     *                  "captchaType": "blockPuzzle",
     *                  "clientUid": "唯一标识"
     */
    @PostMapping("/captcha/get")
    public ResponseModel get(@RequestBody CaptchaVO captchaVO) {
        return captchaService.get(captchaVO);
    }

    /**
     * 校验滑动验证
     *
     * @param captchaVO 验证码参数
     *                  "captchaType": "blockPuzzle",
     *                  "pointJson": "QxIVdlJoWUi04iM+65hTow==",  //aes加密坐标信息
     *                  "token": "71dd26999e314f9abb0c635336976635"  //get请求返回的token
     */
    @PostMapping("/captcha/check")
    public ResponseModel check(@RequestBody CaptchaVO captchaVO) {
        return captchaService.check(captchaVO);
    }

}

4.跨域配置(可选)

如果报错如下:

Access to XMLHttpRequest at 'http://localhost:9001/captcha/get' from origin 'http://localhost:8080' has been blocked by CORS policy: Response to preflight request doesn't pass access control check: No 'Access-Control-Allow-Origin' header is present on the requested resource.

在后端增加过滤配置:

image-20250605114805579

package com.zhou.demo;

import org.springframework.context.annotation.Configuration;
import org.springframework.web.servlet.config.annotation.CorsRegistry;
import org.springframework.web.servlet.config.annotation.WebMvcConfigurer;

@Configuration
public class CorsConfig implements WebMvcConfigurer {

    @Override
    public void addCorsMappings(CorsRegistry registry) {
        registry.addMapping("/**") // 匹配所有接口
                .allowedOriginPatterns("*") // 允许所有来源(Spring Boot 2.4+ 推荐用 allowedOriginPatterns)
                .allowedMethods("GET", "POST", "PUT", "DELETE", "OPTIONS")
                .allowedHeaders("*")
                .allowCredentials(true) // 允许携带 Cookie
                .maxAge(3600); // 预检请求缓存时间,单位秒
    }
}

实现效果

录制_2025_06_05_13_57_46_727

如上所述已经实现了基础的验证功能

二次验证的实现

为什么需要二次验证?

若不进行后端二次验证,攻击者可直接构造验证码发送请求,绕过滑块验证,导致短信接口被恶意刷取。二次验证通过后端校验滑块结果,确保请求合法性。

前端实现

以下内容基于上述基础实现

1.APP.vue

主要去除了原先的测试组件

<template>
  <router-view/>
</template>

<style>
#app {
  font-family: Avenir, Helvetica, Arial, sans-serif;
  -webkit-font-smoothing: antialiased;
  -moz-osx-font-smoothing: grayscale;
  text-align: center;
  color: #2c3e50;
}

nav {
  padding: 30px;
}

nav a {
  font-weight: bold;
  color: #2c3e50;
}

nav a.router-link-exact-active {
  color: #42b983;
}
</style>

2.新增登录和验证页面

LoginPage.vue 登录页面

<template>
  <div class="login-container">
    <h2>登录</h2>
    <div class="input-group">
      <input type="tel" v-model="phoneNumber" placeholder="请输入手机号" @input="validatePhone" />
      <span v-if="phoneError" class="error-message">{{ phoneError }}</span>
    </div>
    <div class="captcha-group">
      <input type="text" v-model="captcha" placeholder="请输入验证码" />
      <button @click="goToSlider" class="captcha-btn" :disabled="!!phoneError">获取验证码</button>
    </div>
    <button @click="login" class="login-btn">登录</button>
  </div>
</template>

<script setup>
import { ref } from 'vue';
import { useRouter } from 'vue-router';

const phoneNumber = ref('');
const captcha = ref('');
const phoneError = ref('');
const router = useRouter();

const validatePhone = () => {
  const phoneRegex = /^\d{11}$/;
  if (!phoneNumber.value) {
    phoneError.value = '手机号不能为空';
  } else if (!phoneRegex.test(phoneNumber.value)) {
    phoneError.value = '请输入有效的11位手机号';
  } else {
    phoneError.value = '';
  }
};

const goToSlider = () => {
  if (!phoneError.value) {
    console.log('跳转到滑块验证页面:', phoneNumber.value)
    router.push({ path: '/slider', query: { phone: phoneNumber.value } });
  } else {
    console.log('手机号格式错误,未跳转:', phoneError.value);
  }
};

const login = () => {
  if (!phoneNumber.value || !captcha.value || phoneError.value) {
    alert('请填写正确的手机号和验证码!');
    return;
  }
  console.log('登录中...', {
    phoneNumber: phoneNumber.value,
    captcha: captcha.value
  });
};
</script>

<style scoped>
/* 保持原有样式不变 */
.login-container {
  width: 300px;
  margin: 100px auto;
  padding: 20px;
  border: 1px solid #ccc;
  border-radius: 5px;
  box-shadow: 0 2px 5px rgba(0, 0, 0, 0.1);
  text-align: center;
}

h2 {
  color: #333;
  margin-bottom: 20px;
}

.input-group {
  margin-bottom: 15px;
  position: relative;
}

input {
  width: 100%;
  padding: 10px;
  border: 1px solid #ccc;
  border-radius: 5px;
  font-size: 14px;
}

.captcha-group {
  display: flex;
  align-items: center;
  margin-bottom: 15px;
}

.captcha-group input {
  flex: 1;
  margin-right: 10px;
}

.captcha-btn {
  padding: 10px 20px;
  background-color: #ff8c00;
  color: white;
  border: none;
  border-radius: 5px;
  cursor: pointer;
  font-size: 14px;
}

.captcha-btn:hover {
  background-color: #e07b00;
}

.captcha-btn:disabled {
  background-color: #ccc;
  cursor: not-allowed;
}

.login-btn {
  width: 100%;
  padding: 10px;
  background-color: #ff8c00;
  color: white;
  border: none;
  border-radius: 5px;
  font-size: 16px;
  cursor: pointer;
}

.login-btn:hover {
  background-color: #e07b00;
}

.error-message {
  color: #ff0000;
  font-size: 12px;
  position: absolute;
  top: 100%;
  left: 0;
  margin-top: 5px;
}
</style>

SliderVerification.vue 验证页面

<template>
  <div>
    <div class="phone-input">
      <input
          v-model="phone"
          type="text"
          placeholder="请输入手机号码"
          @input="validatePhone"
      />
      <span v-if="phoneError" class="error">{{ phoneError }}</span>
    </div>
    <Verify
        @success="success"
        :mode="'pop'"
        :captchaType="'blockPuzzle'"
        :imgSize="{ width: '330px', height: '155px' }"
        ref="verify"
    />
  </div>
</template>

<script>
import Verify from "./../components/verifition/Verify";
import request from "@/components/verifition/utils/axios";

export default {
  name: "App",
  components: {
    Verify,
  },
  data() {
    return {
      phone: "",
      phoneError: "",
    };
  },
  mounted() {
    // 显示验证弹窗
    this.$refs.verify.show();
  },
  methods: {
    validatePhone() {
      // 基本的手机号码验证(示例:10-12位数字)
      const phoneRegex = /^\d{10,12}$/;
      this.phoneError = phoneRegex.test(this.phone)
          ? ""
          : "请输入有效的手机号码(10-12位数字)";
    },
    success(params) {
      // 构建 SmsRequest 数据
      const smsRequest = {
        // phone: this.phone, // 来自用户输入,这里只做二次验证的演示,因此不传入phone参数
        captchaVerification: params.captchaVerification, // 来自 Verify 组件
      };

      debugger
      // 发送请求到后端
      request({
        url: "/sendLoginCode",
        method: "post",
        data: smsRequest,
      })
          .then((response) => {
            console.log("短信请求成功:", response.data);
            // 跳转到登录页面
            this.$router.push("/login");
          })
          .catch((error) => {
            console.error("短信请求失败:", error);
            alert("发送验证码失败,请重试。");
          });
    },
  },
};
</script>

<style scoped>
.phone-input {
  margin-bottom: 20px;
}

input {
  padding: 10px;
  width: 200px;
  border: 1px solid #ccc;
  border-radius: 4px;
}

.error {
  color: red;
  font-size: 12px;
  margin-top: 5px;
  display: block;
}
</style>

3.配置路由

src/router/index.js

import { createRouter, createWebHistory } from 'vue-router';
import LoginPage from '../views/LoginPage.vue';
import SliderVerification from '../views/SliderVerification.vue';

const routes = [
  { path: '/login', component: LoginPage },
  {
    path: '/slider',
    component: SliderVerification,
    props: (route) => ({ phone: route.query.phone })
  },
  { path: '/', redirect: '/login' }
];

const router = createRouter({
  history: createWebHistory(),
  routes
});

export default router;

后端实现

新增发送验证码接口

/**
 * 发送验证码接口,需要二次验证
 *
 * @param captchaVO
 * @return
 */
@RequestMapping(value = "/sendLoginCode", method = RequestMethod.POST)
public ResponseModel sendRegisterCode(@RequestBody CaptchaVO captchaVO) {
    // 判断是否已经通过滑块验证
    ResponseModel response = captchaService.verification(captchaVO);
    System.out.println(response.isSuccess() ? "二次验证通过" : "二次验证未通过");

    // todo 发送验证码

    return response;
}

访问与实现效果

访问地址:http://localhost:8081/login

录制_2025_06_05_17_20_30_669

其他问题整合

移动端滑动与浏览器默认左滑返回上一页冲突

解决方案:限制滑动区域不贴边

避免滑块靠近屏幕左侧边缘(<20px),可在布局上做调整,例如:

.slider-container {
  margin-left: 20px; /* 留出边缘,避免触发左滑返回 */
  max-width: 90%;
}

参考链接:阻止移动端H5开发浏览器默认左右滑动行为_h5 touch 与 浏览器左右滑冲突-CSDN博客

Demo地址

https://zhouquanquan.lanzn.com/iTLVG2y754ng
密码:hb4d
评论 1
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

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

打赏作者

徐州蔡徐坤

又要到饭了兄弟们

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

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

打赏作者

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

抵扣说明:

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

余额充值