• Java21 + SpringBoot3集成easy-captcha实现验证码显示和登录校验


    前言

    近日心血来潮想做一个开源项目,目标是做一款可以适配多端、功能完备的模板工程,包含后台管理系统和前台系统,开发者基于此项目进行裁剪和扩展来完成自己的功能开发。

    本项目为前后端分离开发,后端基于Java21SpringBoot3开发,后端使用Spring SecurityJWTSpring Data JPA等技术栈,前端提供了vueangularreactuniapp微信小程序等多种脚手架工程。

    本文主要介绍在SpringBoot3项目中如何集成easy-captcha生成验证码,JDK版本是Java21,前端使用Vue3开发。

    项目地址:https://gitee.com/breezefaith/fast-alden

    相关技术简介

    easy-captcha

    easy-captcha是生成图形验证码的Java类库,支持gif、中文、算术等类型,可用于Java Web、JavaSE等项目。

    参考地址:

    image

    实现步骤

    引入maven依赖

    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版本是Java21SpringBoot版本是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]
      ......
    

    image

    定义实体类

    为了方便后端校验,获取验证码的请求除了要返回验证码图片本身,还要返回一个验证码的唯一标识,所以笔者定义了一个实体类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;
    }
    
    

    定义登录服务类

    在登录服务类中,我们需要定义以下方法:

    1. 生成验证码

      在该方法中使用easy-captcha生成一个验证码,生成的验证码除了要返回给前端,还需要在后端进行缓存,这样才能实现前后端的验证码校验。本文中给出了两种缓存验证码的方式,一种是基于RedisTemplate缓存至Redis,一种是缓存至Session,读者可根据需要选择性使用,推荐使用**Redis**。在本文附录中给出了缓存至Session的实现方式。

    2. 登录

      在登录方法中首先校验验证码是否正确,然后再校验用户名和密码是否正确,校验通过后生成Token返回给前端。本文中该方法仅给出验证码校验相关的逻辑,其他逻辑请自行实现。

    @Service
    public class AuthService {
        private final RedisTemplate redisTemplate;
    
        public AuthService(
            RedisTemplate 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向后端发送请求,因代码较长,将其放在附录中,请移步至附录查看。

    测试和验证

    image

    总结

    本文介绍了如何基于Java21SpringBoot3集成easy-captcha实现验证码显示和登录校验,给出了详细的实现代码,如有错误,还望批评指正。

    在后续实践中我也是及时更新自己的学习心得和经验总结,希望与诸位看官一起进步。

    附录

    使用Session缓存验证码

    使用Session缓存验证码时还需要借助ScheduledExecutorServiceTimerQuartz等实现一个延迟任务,用于从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>
    
  • 相关阅读:
    http和https区别,第三方证书如何保证服务器可信
    数据分析-numpy
    springmvc第十五个练习(一个拦截器的执行)
    CF:1214D.Treasure Island(有向图必经点)
    企业为何要挖掘专利和专利布局,如何做?
    根据WebService接口地址获取接口定义文件(wsdl文件)
    Thrift、Dubbo、Spring Cloud 和 gRPC
    wpf prism左侧抽屉式菜单
    视频画面添加图片,这个方法分享给你
    抽象类和接口(Abstract and Interface)精湛细节
  • 原文地址:https://www.cnblogs.com/breezefaith/p/17980652