Session
缓存验证码近日心血来潮想做一个开源项目,目标是做一款可以适配多端、功能完备的模板工程,包含后台管理系统和前台系统,开发者基于此项目进行裁剪和扩展来完成自己的功能开发。
本项目为前后端分离开发,后端基于Java21
和SpringBoot3
开发,后端使用Spring Security
、JWT
、Spring Data JPA
等技术栈,前端提供了vue
、angular
、react
、uniapp
、微信小程序
等多种脚手架工程。
本文主要介绍在SpringBoot3
项目中如何集成easy-captcha
生成验证码,JDK版本是Java21
,前端使用Vue3
开发。
项目地址:https://gitee.com/breezefaith/fast-alden
easy-captcha是生成图形验证码的Java类库,支持gif、中文、算术等类型,可用于Java Web、JavaSE等项目。
参考地址:
在pom.xml
中添加easy-captcha
以及相关依赖,并引入Lombok用于简化代码。
<dependencies> <!-- easy-captcha --> <dependency> <groupId>com.github.whvcse</groupId> <artifactId>easy-captcha</artifactId> <version>1.6.2</version> </dependency> <!-- 解决easy-captcha算术验证码报错问题 --> <dependency> <groupId>org.openjdk.nashorn</groupId> <artifactId>nashorn-core</artifactId> <version>15.4</version> </dependency> <!-- Lombok --> <dependency> <groupId>org.projectlombok</groupId> <artifactId>lombok</artifactId> <version>1.18.30</version> <optional>true</optional> </dependency> </dependencies>
笔者使用的JDK版本是Java21
,SpringBoot
版本是3.2.0
,如果不引入nashorn-core
,生成验证码时会报错java.lang.NullPointerException: Cannot invoke "javax.script.ScriptEngine.eval(String)" because "engine" is null
。有开发者反馈使用Java 17
时也遇到了同样的问题,手动引入nashorn-core
后即可解决该问题。
详细堆栈和截图如下:
java.lang.NullPointerException: Cannot invoke "javax.script.ScriptEngine.eval(String)" because "engine" is null at com.wf.captcha.base.ArithmeticCaptchaAbstract.alphas(ArithmeticCaptchaAbstract.java:42) ~[easy-captcha-1.6.2.jar:na] at com.wf.captcha.base.Captcha.checkAlpha(Captcha.java:156) ~[easy-captcha-1.6.2.jar:na] at com.wf.captcha.base.Captcha.text(Captcha.java:137) ~[easy-captcha-1.6.2.jar:na] at com.fast.alden.admin.service.impl.AuthServiceImpl.generateVerifyCode(AuthServiceImpl.java:72) ~[classes/:na] ......
为了方便后端校验,获取验证码的请求除了要返回验证码图片本身,还要返回一个验证码的唯一标识,所以笔者定义了一个实体类VerifyCodeEntity
。
/** * 验证码实体 */ @Data @NoArgsConstructor @AllArgsConstructor public class VerifyCodeEntity implements Serializable { /** * 验证码Key */ private String key; /** * 验证码图片,base64压缩后的字符串 */ private String image; /** * 验证码文本值 */ @JsonProperty(access = JsonProperty.Access.WRITE_ONLY) private String text; }
使用
@JsonProperty(access = JsonProperty.Access.WRITE_ONLY)
注解可以使text
属性不会被序列化后返回给前端。
为实现登录功能,还要定义一个登录参数类LoginParam
。
@Data public class LoginParam { /** * 用户名 */ private String username; /** * 密码 */ private String password; /** * 验证码Key */ private String verifyCodeKey; /** * 验证码 */ private String verifyCode; }
在登录服务类中,我们需要定义以下方法:
生成验证码
在该方法中使用easy-captcha生成一个验证码,生成的验证码除了要返回给前端,还需要在后端进行缓存,这样才能实现前后端的验证码校验。本文中给出了两种缓存验证码的方式,一种是基于RedisTemplate
缓存至Redis
,一种是缓存至Session
,读者可根据需要选择性使用,推荐使用**Redis**
。在本文附录中给出了缓存至Session
的实现方式。
登录
在登录方法中首先校验验证码是否正确,然后再校验用户名和密码是否正确,校验通过后生成Token返回给前端。本文中该方法仅给出验证码校验相关的逻辑,其他逻辑请自行实现。
@Service public class AuthService { private final RedisTemplate<String, Object> redisTemplate; public AuthService( RedisTemplate<String, Object> redisTemplate ) { this.redisTemplate = redisTemplate; } public VerifyCodeEntity generateVerifyCode() throws IOException { // 创建验证码对象 Captcha captcha = new ArithmeticCaptcha(); // 生成验证码编号 String verifyCodeKey = UUID.randomUUID().toString(); String verifyCode = captcha.text(); // 获取验证码图片,构造响应结果 VerifyCodeEntity verifyCodeEntity = new VerifyCodeEntity(verifyCodeKey, captcha.toBase64(), verifyCode); // 存入Redis,设置120s过期 redisTemplate.opsForValue().set(verifyCodeKey, verifyCode, 120, TimeUnit.SECONDS); return verifyCodeEntity; } public String login(LoginParam param) { // 校验验证码 // 获取用户输入的验证码 String actual = param.getVerifyCode(); // 判断验证码是否过期 if (redisTemplate.getExpire(param.getVerifyCodeKey(), TimeUnit.SECONDS) < 0) { throw new RuntimeException("验证码过期"); } // 从redis读取验证码并删除缓存 String expect = (String) redisTemplate.opsForValue().get(param.getVerifyCodeKey()); redisTemplate.delete(param.getVerifyCodeKey()); // 比较用户输入的验证码和缓存中的验证码是否一致,不一致则抛错 if (!StringUtils.hasText(expect) || !StringUtils.hasText(actual) || !actual.equalsIgnoreCase(expect)) { throw new RuntimeException("验证码错误"); } // 校验用户名和密码,校验成功后生成token返回给前端,具体逻辑省略 String token = ""; return token; } }
/** * 登录控制器 */ @RestController("/auth") public class AuthController { private final AuthService authService; public AuthController(AuthService authService) { this.authService = authService; } /** * 获取验证码 */ @GetMapping("/verify-code") public VerifyCodeEntity generateVerifyCode() throws IOException { return authService.generateVerifyCode(); } /** * 登录 */ @PostMapping("/login") public String login(@RequestBody @Validated LoginParam param) { return authService.login(param); } }
此前端页面基于Vue3
的组合式API和Element Plus
开发,使用Axios
向后端发送请求,因代码较长,将其放在附录中,请移步至附录查看。
本文介绍了如何基于Java21
和SpringBoot3
集成easy-captcha
实现验证码显示和登录校验,给出了详细的实现代码,如有错误,还望批评指正。
在后续实践中我也是及时更新自己的学习心得和经验总结,希望与诸位看官一起进步。
Session
缓存验证码使用Session
缓存验证码时还需要借助ScheduledExecutorService
、Timer
、Quartz
等实现一个延迟任务,用于从Session
中删除超时的验证码。
@Service public class AuthService { private final ScheduledExecutorService scheduledExecutorService; public AuthService( ScheduledExecutorService scheduledExecutorService ) { this.scheduledExecutorService = scheduledExecutorService; } public VerifyCodeEntity generateVerifyCode() throws IOException { // 创建验证码对象 Captcha captcha = new ArithmeticCaptcha(); // 生成验证码编号 String verifyCodeKey = UUID.randomUUID().toString(); String verifyCode = captcha.text(); // 获取验证码图片,构造响应结果 VerifyCodeEntity verifyCodeEntity = new VerifyCodeEntity(verifyCodeKey, captcha.toBase64(), verifyCode); // 存入session,设置120s过期 ServletRequestAttributes attributes = (ServletRequestAttributes) RequestContextHolder.getRequestAttributes(); HttpSession session = attributes.getRequest().getSession(); session.setAttribute(verifyCodeKey, verifyCode); // 超时后删除验证码缓存 // 以下是使用ScheduledExecutorService实现 scheduledExecutorService.schedule(() -> { session.removeAttribute(verifyCode); }, 120, TimeUnit.SECONDS); // // 以下是使用Timer实现超时后删除验证码 // Timer timer = new Timer(); // timer.schedule(new TimerTask() { // @Override // public void run() { // session.removeAttribute(verifyCode); // } // }, 120 * 1000L); return verifyCodeEntity; } public String login(LoginParam param) { // 校验验证码 // 获取用户输入的验证码 String actual = param.getVerifyCode(); // 从Session读取验证码并删除缓存 ServletRequestAttributes attributes = (ServletRequestAttributes) RequestContextHolder.getRequestAttributes(); HttpSession session = attributes.getRequest().getSession(); String expect = (String) session.getAttribute(param.getVerifyCodeKey()); session.removeAttribute(param.getVerifyCodeKey()); // 比较用户输入的验证码和缓存中的验证码是否一致,不一致则抛错 if (!StringUtils.hasText(expect) || !StringUtils.hasText(actual) || !actual.equalsIgnoreCase(expect)) { throw new RuntimeException("验证码错误"); } // 校验用户名和密码,校验成功后生成token返回给前端,具体逻辑省略 String token = ""; return token; } }
以上代码中使用ScheduledExecutorService设置了一个延迟任务,120s后从Session中删除验证码,还需要声明一个ScheduledExecutorService
的Bean。
/** * 线程池配置 */ @Configuration public class ThreadPoolConfig { /** * 核心线程池大小 */ private final int corePoolSize = 50; @Bean public ScheduledExecutorService scheduledExecutorService() { return new ScheduledThreadPoolExecutor(corePoolSize); } }
<script setup> import { onBeforeUnmount, onMounted, reactive, ref } from 'vue'; import { useRouter } from 'vue-router'; import { ElMessage, ElForm, ElFormItem, ElInput, ElButton, ElCheckbox } from 'element-plus'; import { CircleCheck, Lock, User, Search, Refresh, Plus, Edit, Delete, View, Upload, Download, Share, Close } from "@element-plus/icons-vue"; import axios, { AxiosError } from 'axios'; import bg from "@/assets/login/bg.png"; const router = useRouter(); const entity = ref({}); const rememberMe = ref(true); const REMEMBER_ME_KEY = "remember_me"; const formRef = ref(); const loading = ref(false); const verifyCodeUrl = ref(""); const rules = reactive({ username: [ { required: true, message: '请输入用户名', trigger: 'blur' } ], password: [ { validator: (rule, value, callback) => { if (!value) { callback(new Error("请输入密码")); } else { callback(); } }, trigger: "blur" } ], verifyCode: [ { required: true, message: '请输入验证码', trigger: 'blur' }, ], }); // 点击登录按钮 const login = async () => { const formEl = formRef.value; loading.value = true; if (!formEl) { loading.value = false; return; } await formEl.validate(async (valid, fields) => { if (valid) { try { const res = await login$(entity.value); // 从响应中获取token const token = res.data.data; if (token) { // 将token存入Pinia,authStore请自行定义 // authStore.authenticate({ token }); // warning: 此方式直接将用户名密码明文存入localStorage,并不安全 // todo:寻找更合理方式实现“记住我” if (rememberMe.value) { localStorage.setItem(REMEMBER_ME_KEY, JSON.stringify({ username: entity.value.username, password: entity.value.password, })); } else { localStorage.removeItem(REMEMBER_ME_KEY); } ElMessage({ message: "登录成功", type: "success" }); router.push("/"); }else{ ElMessage({ message: "登录失败", type: "error" }); } } catch (err) { if (err instanceof AxiosError) { const msg = err.response?.data?.message || err.message; ElMessage({ message: msg, type: "error" }); } updateVerifyCode(); throw err; } finally { loading.value = false; } } else { loading.value = false; return fields; } }); }; // 获取验证码请求 const getVerifyCode$ = async () => { return axios.get(`/api/v1.0/admin/auth/verify-code?timestamp=${new Date().getTime()}`, false); } // 登录请求 const login$ = async (param) => { return axios.post(`/api/v1.0/admin/auth/login`, { ...param, }); } // 更新验证码图片 const updateVerifyCode = async () => { const res = await getVerifyCode$(); verifyCodeUrl.value = `${res.data.data?.image}`; entity.value.verifyCodeKey = res.data.data?.key; } /** 使用公共函数,避免`removeEventListener`失效 */ function onkeypress({ code }) { if (code === "Enter" || code === "NumpadEnter") { login(); } } // 页面加载时读取localStorage,如果有记住的用户名密码则加载至界面 const load = async () => { const tmp = localStorage.getItem(REMEMBER_ME_KEY); if (tmp) { const e = JSON.parse(tmp); entity.value.username = e.username; entity.value.password = e.password; } } onMounted(async () => { window.document.addEventListener("keypress", onkeypress); updateVerifyCode(); load(); }); onBeforeUnmount(() => { window.document.removeEventListener("keypress", onkeypress); }); </script> <template> <img class="login-bg" :src="bg" /> <div class="login-container"> <div class="login-box"> <ElForm class="login-form" ref="formRef" :model="entity" :rules="rules" size="large"> <h3 class="title">后台管理系统</h3> <ElFormItem prop="username"> <ElInput clearable v-model="entity.username" placeholder="用户名/手机号/邮箱" :prefix-icon="User" /> </ElFormItem> <ElFormItem prop="password"> <ElInput clearable show-password v-model="entity.password" placeholder="密码" :prefix-icon="Lock" /> </ElFormItem> <ElFormItem class="verify-code-row" prop="verifyCode"> <ElInput clearable v-model="entity.verifyCode" placeholder="验证码" :prefix-icon="CircleCheck"> <template #append> <img :src="verifyCodeUrl" class="verify-code" @click="updateVerifyCode()" /> </template> </ElInput> </ElFormItem> <ElFormItem> <ElCheckbox v-model="rememberMe" label="记住我"></ElCheckbox> </ElFormItem> <ElFormItem> <ElButton class="w-full" style="width: 100%" size="default" type="primary" :loading="loading" @click="login()"> 登录 </ElButton> </ElFormItem> </ElForm> </div> </div> </template> <style lang="scss"> .login-bg { position: fixed; height: 100%; left: 0; bottom: 0; z-index: -1; } .login-container { top: 0; left: 0; width: 100%; height: 100%; position: absolute; display: flex; justify-items: center; justify-content: center; .login-box { display: flex; align-items: center; text-align: center; .login-form { width: 360px; .verify-code-row { .el-input-group__append { padding: 0; } .verify-code { height: 40px; } } } } } </style>