一、前言
ruoyi
项目默认的验证码是这样的
今天来尝试增加滑块验证码,我们用到的是tianai-captcha
。
文档地址:http://doc.captcha.tianai.cloud/
源码地址:https://gitee.com/tianai/tianai-captcha
下面来看具体的步骤。
二、后端
在gateway
中引入依赖
<dependency><groupId>cloud.tianai.captcha</groupId><artifactId>tianai-captcha-springboot-starter</artifactId><version>1.4.1</version>
</dependency>
并增加相应的配置
# 客户端验证码
captcha:cache:enabled: truecache-size: 20# 二次验证secondary:enabled: false# 是否初始化默认资源init-default-resource: false
在gateway
中新增加一个SliderCaptchaHandler
处理类
import cloud.tianai.captcha.spring.application.ImageCaptchaApplication;
import cloud.tianai.captcha.spring.vo.CaptchaResponse;
import cloud.tianai.captcha.spring.vo.ImageCaptchaVO;
import com.iinplus.common.core.exception.CaptchaException;
import org.springframework.http.HttpStatus;
import org.springframework.stereotype.Component;
import org.springframework.web.reactive.function.BodyInserters;
import org.springframework.web.reactive.function.server.HandlerFunction;
import org.springframework.web.reactive.function.server.ServerRequest;
import org.springframework.web.reactive.function.server.ServerResponse;
import reactor.core.publisher.Mono;import javax.annotation.Resource;/*** 验证码获取*/
@Component
public class SliderCaptchaHandler implements HandlerFunction<ServerResponse> {@Resourceprivate ImageCaptchaApplication sca;@Overridepublic Mono<ServerResponse> handle(ServerRequest serverRequest) {CaptchaResponse<ImageCaptchaVO> res;try {// 1.生成滑块验证码(该数据返回给前端用于展示验证码数据)res = sca.generateCaptcha();} catch (CaptchaException e) {return Mono.error(e);}return ServerResponse.status(HttpStatus.OK).body(BodyInserters.fromValue(res));}
}
在RouterFunctionConfiguration
中新增一个路由
@Resource
private SliderCaptchaHandler sliderCaptchaHandler;@Bean
public RouterFunction routerFunc() {return RouterFunctions.route(RequestPredicates.GET("/captcha").and(RequestPredicates.accept(MediaType.TEXT_PLAIN)), sliderCaptchaHandler);
}
新增一个filter类,用来验证图形验证码。
import cloud.tianai.captcha.common.response.ApiResponse;
import cloud.tianai.captcha.spring.application.ImageCaptchaApplication;
import cloud.tianai.captcha.validator.common.model.dto.ImageCaptchaTrack;
import com.alibaba.fastjson2.JSON;
import com.alibaba.fastjson2.JSONObject;
import com.iinplus.common.core.exception.CaptchaException;
import com.iinplus.common.core.utils.ServletUtils;
import com.iinplus.common.core.utils.StringUtils;
import com.iinplus.gateway.config.properties.CaptchaProperties;
import org.springframework.cloud.gateway.filter.GatewayFilter;
import org.springframework.cloud.gateway.filter.factory.AbstractGatewayFilterFactory;
import org.springframework.core.io.buffer.DataBuffer;
import org.springframework.core.io.buffer.DataBufferUtils;
import org.springframework.http.server.reactive.ServerHttpRequest;
import org.springframework.stereotype.Component;
import reactor.core.publisher.Flux;import javax.annotation.Resource;
import java.nio.CharBuffer;
import java.nio.charset.StandardCharsets;
import java.util.Arrays;
import java.util.List;
import java.util.concurrent.atomic.AtomicReference;/*** 验证码过滤器*/
@Component
public class ValidateCaptchaFilter extends AbstractGatewayFilterFactory<Object> {private final static String[] VALIDATE_URL = new String[]{"/system/login"};@Resourceprivate ImageCaptchaApplication sca;@Resourceprivate CaptchaProperties captchaProperties;private static final String TRACK = "sliderCaptchaTrack";private static final String UUID = "id";@Overridepublic GatewayFilter apply(Object config) {return (exchange, chain) -> {ServerHttpRequest request = exchange.getRequest();// 非登录/注册请求或验证码关闭,不处理List<String> list = Arrays.asList(VALIDATE_URL);// 请求地址String url = request.getURI().getPath();if (!StringUtils.matches(url, list) || !captchaProperties.getEnabled()) {return chain.filter(exchange);}try {String rspStr = resolveBodyFromRequest(request);if (StringUtils.isEmpty(rspStr)) {throw new CaptchaException("验证码不能为空");}JSONObject obj = JSON.parseObject(rspStr);if (!obj.containsKey(UUID) || !obj.containsKey(TRACK)) {throw new CaptchaException("验证码不能为空");}String id = obj.getString(UUID);ImageCaptchaTrack sliderCaptchaTrack = obj.getObject(TRACK, ImageCaptchaTrack.class);ApiResponse<?> match = sca.matching(id, sliderCaptchaTrack);if (!match.isSuccess()) {throw new CaptchaException(match.getMsg());}} catch (Exception e) {e.printStackTrace();return ServletUtils.webFluxResponseWriter(exchange.getResponse(), e.getMessage());}return chain.filter(exchange);};}private String resolveBodyFromRequest(ServerHttpRequest serverHttpRequest) {// 获取请求体Flux<DataBuffer> body = serverHttpRequest.getBody();AtomicReference<String> bodyRef = new AtomicReference<>();body.subscribe(buffer -> {CharBuffer charBuffer = StandardCharsets.UTF_8.decode(buffer.asByteBuffer());DataBufferUtils.release(buffer);bodyRef.set(charBuffer.toString());});return bodyRef.get();}
}
注意:其中/system/login
为登录验证的路径,需要在网关中配置,并且需要加入白名单。
如果需要修改图形验证码默认的背景图
import cloud.tianai.captcha.common.constant.CaptchaTypeConstant;
import cloud.tianai.captcha.generator.common.constant.SliderCaptchaConstant;
import cloud.tianai.captcha.resource.common.model.dto.Resource;
import cloud.tianai.captcha.resource.common.model.dto.ResourceMap;
import cloud.tianai.captcha.resource.impl.DefaultResourceStore;
import cloud.tianai.captcha.resource.impl.provider.ClassPathResourceProvider;
import org.springframework.stereotype.Component;import static cloud.tianai.captcha.generator.impl.StandardSliderImageCaptchaGenerator.DEFAULT_SLIDER_IMAGE_TEMPLATE_PATH;/*** 自定义背景图片*/
@Component
public class ResourceStore extends DefaultResourceStore {public ResourceStore() {// 滑块验证码 模板 (系统内置)ResourceMap template1 = new ResourceMap("default", 4);template1.put(SliderCaptchaConstant.TEMPLATE_ACTIVE_IMAGE_NAME, new Resource(ClassPathResourceProvider.NAME, DEFAULT_SLIDER_IMAGE_TEMPLATE_PATH.concat("/1/active.png")));template1.put(SliderCaptchaConstant.TEMPLATE_FIXED_IMAGE_NAME, new Resource(ClassPathResourceProvider.NAME, DEFAULT_SLIDER_IMAGE_TEMPLATE_PATH.concat("/1/fixed.png")));ResourceMap template2 = new ResourceMap("default", 4);template2.put(SliderCaptchaConstant.TEMPLATE_ACTIVE_IMAGE_NAME, new Resource(ClassPathResourceProvider.NAME, DEFAULT_SLIDER_IMAGE_TEMPLATE_PATH.concat("/2/active.png")));template2.put(SliderCaptchaConstant.TEMPLATE_FIXED_IMAGE_NAME, new Resource(ClassPathResourceProvider.NAME, DEFAULT_SLIDER_IMAGE_TEMPLATE_PATH.concat("/2/fixed.png")));// 1. 添加一些模板addTemplate(CaptchaTypeConstant.SLIDER, template1);addTemplate(CaptchaTypeConstant.SLIDER, template2);// 2. 添加自定义背景图片addResource(CaptchaTypeConstant.SLIDER, new Resource("classpath", "bg/1.png", "default"));addResource(CaptchaTypeConstant.SLIDER, new Resource("classpath", "bg/2.png", "default"));addResource(CaptchaTypeConstant.SLIDER, new Resource("classpath", "bg/3.png", "default"));addResource(CaptchaTypeConstant.SLIDER, new Resource("classpath", "bg/4.png", "default"));addResource(CaptchaTypeConstant.SLIDER, new Resource("classpath", "bg/5.png", "default"));addResource(CaptchaTypeConstant.SLIDER, new Resource("classpath", "bg/6.png", "default"));}
}
图片的路径如下
网关配置增加filters
,把ValidateCaptchaFilter
加上才生效
spring:cloud:gateway:discovery:... ...routes:# 系统模块- id: systemuri: lb://systempredicates:- Path=/system/**filters:# 验证码处理- ValidateCaptchaFilter- StripPrefix=1
system
模块的登录验证
/*** 系统用户登录*/
@RestController
public class TokenController {@Autowiredprivate SysLoginService sysLoginService;@PostMapping("login")public RpcResult<?> login(@RequestBody @Validated LoginBody form) { LoginUser userInfo = sysLoginService.login(form);... ...return RpcResult.success(rspMap);}
}
import lombok.Data;
import javax.validation.constraints.NotBlank;@Data
public class LoginBody {@NotBlank(message = "用户名不能为空")private String username;@NotBlank(message = "密码不能为空")private String password;
}
三、前端
在components
下增加一个组件SliderCaptcha
<template><div class="slider rotate"><div class="content"><div class="bg-img-div"><img :src="captcha.backgroundImage" id="imgId" alt/><canvas id="canvasId" ref="canvas"></canvas></div><div class="rotate-img-div" :style="rotateImgDiv"><img :src="captcha.templateImage" alt/></div></div><div class="slider-move"><div class="slider-move-track">拖动滑块到正确位置</div><div class="slider-move-btn" :style="sliderMoveBtn" @mousedown="down" @touchstart="down"></div></div><div class="bottom"><div class="close-btn" @click="close()"></div><div class="refresh-btn" @click="refreshCaptcha"></div></div></div>
</template><script>
export default {name: "Slider",props: {captcha: {type: Object},},data() {return {currentCaptchaConfig: {},sliderMoveBtn: "",rotateImgDiv: "",checkParam: {}}},mounted() {this.initCaptcha();},methods: {refreshCaptcha() {this.$emit("init");this.initCaptcha();},initCaptcha() {this.sliderMoveBtn = "background-position: -5px 11.79625%; transform: translate(0, 0)";this.rotateImgDiv = "transform: translate(0, 0)";this.currentCaptchaConfig = {};let bgImageWidth = this.$refs.canvas.offsetWidth;let bgImageHeight = this.$refs.canvas.offsetHeight;this.checkParam = {bgImageWidth: bgImageWidth,bgImageHeight: bgImageHeight,startSlidingTime: new Date(),endSlidingTime: undefined,trackList: [],}},down(event) {let targetTouches = event.originalEvent ? event.originalEvent.targetTouches : event.targetTouches;let startX = event.pageX;let startY = event.pageY;if (startX === undefined) {startX = Math.round(targetTouches[0].pageX);startY = Math.round(targetTouches[0].pageY);}this.currentCaptchaConfig.startX = startX;this.currentCaptchaConfig.startY = startY;const pageX = this.currentCaptchaConfig.startX;const pageY = this.currentCaptchaConfig.startY;const startSlidingTime = this.checkParam.startSlidingTime;const trackList = this.checkParam.trackList;trackList.push({x: pageX - startX,y: pageY - startY,type: "down",t: (new Date().getTime() - startSlidingTime.getTime())});// pcwindow.addEventListener("mousemove", this.move);window.addEventListener("mouseup", this.up);// 手机端window.addEventListener("touchmove", this.move, false);window.addEventListener("touchend", this.up, false);this.sliderMoveBtn = `background-position:-5px 31.0092%`;},move(event) {if (event instanceof TouchEvent) {event = event.touches[0];}let pageX = Math.round(event.pageX);let pageY = Math.round(event.pageY);const startX = this.currentCaptchaConfig.startX;const startY = this.currentCaptchaConfig.startY;const startSlidingTime = this.checkParam.startSlidingTime;const end = 305;const trackList = this.checkParam.trackList;let moveX = pageX - startX;const track = {x: pageX - startX,y: pageY - startY,type: "move",t: (new Date().getTime() - startSlidingTime.getTime())};trackList.push(track);if (moveX < 0) {moveX = 0;} else if (moveX > end + 5) {moveX = end;}this.sliderMoveBtn = `transform:translate(${moveX}px, 0)`;this.rotateImgDiv = `transform:translate(${moveX}px, 0);`;},up(event) {window.removeEventListener("mousemove", this.move);window.removeEventListener("mouseup", this.up);// 手机端window.removeEventListener("touchmove", this.move);window.removeEventListener("touchend", this.up);if (event instanceof TouchEvent) {event = event.changedTouches[0];}let pageX = Math.round(event.pageX);let pageY = Math.round(event.pageY);const startX = this.currentCaptchaConfig.startX;const startY = this.currentCaptchaConfig.startY;const startSlidingTime = this.checkParam.startSlidingTime;const trackList = this.checkParam.trackList;const track = {x: pageX - startX,y: pageY - startY,type: "up",t: (new Date().getTime() - startSlidingTime.getTime())}trackList.push(track);this.checkParam.endSlidingTime = new Date();// 校验this.checkCaptcha()},close() {this.$emit("close");},checkCaptcha() {//this.checkParam = {};this.$emit("checkParam", this.checkParam)this.$emit("login");}},
}
</script>
<style scoped>
.slider {background-color: #fff;width: 380px;height: 340px;z-index: 999;box-sizing: border-box;padding: 9px;border-radius: 6px;box-shadow: 0 0 11px 0 #999999;
}.slider .content {width: 100%;height: 160px;position: relative;
}.bg-img-div {width: 100%;height: 100%;position: absolute;transform: translate(0px, 0px);
}.bg-img-div img {width: 100%;
}.bg-img-div canvas {width: 100%;position: absolute;left: 0;top: 0;
}.slider .slider-move {height: 60px;width: 100%;margin: 0;position: relative;top: 80px
}.slider .bottom {height: 25px;width: 100%;margin: 65px 10px 10px 0;
}.refresh-btn, .close-btn, .slider-move-btn {background: url(../../assets/images/sprite.1.2.4.png) no-repeat;
}.refresh-btn, .close-btn {display: inline-block;
}.slider-move .slider-move-track {line-height: 38px;font-size: 14px;text-align: center;white-space: nowrap;color: #88949d;-moz-user-select: none;-webkit-user-select: none;user-select: none;border-radius: 50px;background: #dfe1e2;width: 100%;
}.slider {user-select: none;
}.slider-move .slider-move-btn {transform: translate(0, 0);background-position: -5px 11.79625%;position: absolute;top: -12px;left: 0;width: 100%;height: 100%;
}.slider-move-btn:hover, .close-btn:hover, .refresh-btn:hover {cursor: pointer
}.bottom .close-btn {width: 25px;height: 25px;background-position: 0 44.86874%;margin: 10px 10px 10px 5px;float: left;
}.bottom .refresh-btn {width: 25px;height: 25px;background-position: 0 81.38425%;margin: 7px 10px 10px 2px;float: left;
}.rotate-img-div {height: 140%;position: absolute;transform: translate(0, 0);
}.rotate-img-div img {height: 100%;
}
</style>
修改登录和获取验证码的方法
// 登录方法
export function login(data) {return request({url: '/system/login',headers: {isToken: false},method: 'post',data: data})
}
// 获取验证码
export function captcha() {return request({url: '/captcha',headers: {isToken: false},method: 'get',timeout: 20000})
}
修改login.vue
页面
<template><div class="login"><el-form ref="loginForm" :model="loginForm" :rules="loginRules" class="login-form"><h3 class="title">xxxx管理系统</h3><el-form-item prop="username"><el-inputv-model="loginForm.username"type="text"auto-complete="off"placeholder="账号"><svg-icon slot="prefix" icon-class="user" class="el-input__icon input-icon" /></el-input></el-form-item><el-form-item prop="password"><el-inputv-model="loginForm.password"type="password"auto-complete="off"placeholder="密码"><svg-icon slot="prefix" icon-class="password" class="el-input__icon input-icon" /></el-input></el-form-item><!--注释掉原来的验证码--><!--<el-form-item prop="code" v-if="captchaEnabled" style="margin: 10px 0"><el-inputv-model="loginForm.code"auto-complete="off"placeholder="验证码"style="width: 68%"@keyup.enter.native="handleLogin"><svg-icon slot="prefix" icon-class="validCode" class="el-input__icon input-icon" /></el-input><div class="login-code"><img :src="codeUrl" @click="getCode" class="login-code-img"/></div></el-form-item>--><el-form-item style="width:100%; margin: 10px 0"><el-checkbox v-model="loginForm.rememberMe">记住密码</el-checkbox></el-form-item><el-form-item style="width:100%;margin-bottom: 10px"><el-button:loading="loading"size="medium"type="primary"style="width:100%;"@click.native.prevent="initCaptcha"><span v-if="!loading">登 录</span><span v-else>登 录 中...</span></el-button></el-form-item></el-form><!-- 滑块验证码,通过show来控制显示遮蔽层--><div v-if="show" class="mask"><SliderCaptchav-if="captchaVisible"ref="sliderCaptcha":captcha="captcha"@init="initCaptcha"@close="hideCaptcha"@login="handleLogin"/></div><!-- 底部 --><div class="el-login-footer"><span>Copyright © xxx All Rights Reserved.</span></div></div>
</template><script>
import {captcha} from '@/api/login'
import Cookies from "js-cookie";
import {decrypt, encrypt} from '@/utils/jsencrypt'
import SliderCaptcha from '@/components/SliderCaptcha'export default {name: "Login",components: {SliderCaptcha},data() {return {// codeUrl: "",show: false,captcha: {},captchaVisible: false,loginForm: {username: undefined,password: undefined,rememberMe: false,//code: "",//uuid: "",// 增加下面两个属性ImageCaptchaTrack: {},id: ''},loginRules: {username: [{ required: true, trigger: "blur", message: "请输入您的账号" }],password: [{ required: true, trigger: "blur", message: "请输入您的密码" }],// 不再需要这个验证// code: [{ required: true, trigger: "change", message: "请输入验证码" }]},loading: false,// 验证码开关captchaEnabled: true,redirect: undefined};},watch: {$route: {handler: function(route) {this.redirect = route.query && route.query.redirect;},immediate: true}},created() {//this.getCode();this.getCookie();},methods: {//注释原先的获取验证码方法/*getCode() {getCodeImg().then(res => {this.captchaEnabled = res.captchaEnabled === undefined ? true : res.captchaEnabled;if (this.captchaEnabled) {this.codeUrl = "data:image/gif;base64," + res.img;this.loginForm.uuid = res.uuid;}});},*/getCookie() {const username = Cookies.get("username");const password = Cookies.get("password");const rememberMe = Cookies.get('rememberMe')this.loginForm = {username: username === undefined ? this.loginForm.username : username,password: password === undefined ? this.loginForm.password : decrypt(password),rememberMe: rememberMe === undefined ? false : Boolean(rememberMe)};},hideCaptcha() {this.captchaVisible = falsethis.show = false;},// 获取图形验证码initCaptcha() {this.$refs.loginForm.validate(valid => {if (valid) {captcha().then(res => {if (res) {this.captcha = res["captcha"];this.loginForm.id = res["id"];this.captchaVisible = truethis.show = true;}})}})},// 登录方法handleLogin() {this.$refs.loginForm.validate(valid => {if (valid) {this.loading = true;if (this.loginForm.rememberMe) {Cookies.set("username", this.loginForm.username, { expires: 30 });Cookies.set("password", encrypt(this.loginForm.password), { expires: 30 });Cookies.set('rememberMe', this.loginForm.rememberMe, { expires: 30 });} else {Cookies.remove("username");Cookies.remove("password");Cookies.remove('rememberMe');}// 从子组件获取值this.loginForm.sliderCaptchaTrack = this.$refs.sliderCaptcha.checkParamthis.$store.dispatch("Login", this.loginForm).then(() => {// console.info("this.redirect", this.redirect)this.$router.push({ path: this.redirect || "/" }).catch(()=>{});}).catch(() => {this.loading = false;//调用子组件的刷新图形验证码的方法this.$refs.sliderCaptcha.refreshCaptcha()});}});}}
};
</script><style rel="stylesheet/scss" lang="scss">
<!--新增遮蔽层,其他省略-->
.mask {box-sizing: border-box;position: fixed;top: 0;left: 0;bottom: 0;right: 0;z-index: 1001;background: rgba(0, 0, 0, 0.3);transition: all 0.5s;display: flex;flex-direction: column;justify-content: center;align-items: center;
}
</style>
最后this.$store.dispatch("Login", this.loginForm)
调用的Login
也需要修改一下,在user.js
里面。
最终效果
点击【登录】按钮