近日心血来潮想做一个开源项目,目标是做一款可以适配多端、功能完备的模板工程,包含后台管理系统和前台系统,开发者基于此项目进行裁剪和扩展来完成自己的功能开发。
本项目为前后端分离开发,后端基于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>
<dependency>
<groupId>com.github.whvcsegroupId>
<artifactId>easy-captchaartifactId>
<version>1.6.2version>
dependency>
<dependency>
<groupId>org.openjdk.nashorngroupId>
<artifactId>nashorn-coreartifactId>
<version>15.4version>
dependency>
<dependency>
<groupId>org.projectlombokgroupId>
<artifactId>lombokartifactId>
<version>1.18.30version>
<optional>trueoptional>
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>