JAVA高级工程师--若依项目增加云短信注册登录与JWT实现

一、开发逻辑

二、开发需要考虑的点

1.接入哪家云短信?

目前有腾讯云、阿里云等等,我习惯使用阿里云,所以本次接入的是阿里云。

阿里云中有短信服务、云通信号码认证服务,两者都可以发送短信,那应该选择用哪个的?对于我来说,是个人开发者,优先选择云通信号码认证服务,因为不需要资质注册。但对于企业开发,选择短信服务,进行资质注册。

复制代码
* 短信服务:SendSms(发送消息接口)--要求企业政府机关,需要资质注册 https://next.api.aliyun.com/document/Dysmsapi/2017-05-25/overview
* 云通信号码认证服务:SendSmsVerifyCode(发送验证码),适合适用场景:个人开发者在无企业资质情况下,需为 Web 应用、App 或小程序实现用户注册、登录、密码找回等环节的短信验证码功能。

2.该服务放在哪个模块下?

该服务具备的功能:(1)发送验证码;(2)校验验证码。需要使用到redis服务。而若依框架已经有redis微服务,直接使用即可。我原计划放在ruoyi-common-core,但是在ruoyi-common-corede的pom.xml引用了ruoyi-common-core的依赖,会形成循环依赖的,那就肯定不行了。第二选择放在ruoyi-system中,但是ruoyi-system主要是系统管理,所以最后还是选择单独写一个微服务。这样的话,以后与短信相关的业务全部写在该服务中,也会比较清晰。

3.登录注册与auth有关系?如何处理?

这里涉及到四个api:(1)发送验证码;(2)校验验证码(3)手机号注册(4)手机号登录。

A.(3)(4)api前端调用肯定是要在白名单里的,否则会跳转到登录界面。

B.后端,ruoyi-gateway-dev.yml中也不校验白名单需要加上这四个api配置的

C.(3)(4)api写在TokenController下。

4.如果用户频繁地获取验证码怎么处理?

处理的方式非常多,比如限流,@RepeatSubmit,缓存等等,我采用的是缓存,当缓存中已经有某号码的验证时,直接抛出异常,并在ruoyi-common-security的GlobalExceptionHandler增加

java 复制代码
    /**
     * 验证码异常
     *
     * @param e
     * @return AjaxResult
     * @author wj
     * @date 2026/1/5 14:39
     */
    @ExceptionHandler(VerifyCodeException.class)
    public AjaxResult handleVerifyCodeException(VerifyCodeException e) {
        return AjaxResult.error(e.getMessage());
    }

异常管理肯定是必须集中管理的,所以异常需要管理在ruoyi-common-core。

三、代码

1.前端:

html 复制代码
<template>
    <div class="login-container">

        <div class="login">
            <el-form ref="loginRef" :model="loginForm" :rules="loginRules" class="login-form">
                <h3 class="title">{{ title }}</h3>
                <el-form-item label="手机号码" prop="phone">
                    <el-input v-model="loginForm.phone" placeholder="请输入11位手机号码" size="large" clearable
                        @input="validatePhone">
                        <template #prefix>
                            <div class="phone-prefix">
                                <span class="country-code">+86</span>
                                <el-divider direction="vertical" />
                            </div>
                        </template>
                    </el-input>
                </el-form-item>
                <el-form-item label="验证码" prop="code">
                    <div class="code-input-group">
                        <el-input v-model="loginForm.code" placeholder="请输入6位验证码" size="large" maxlength="6"
                            show-word-limit>
                        </el-input>
                        <el-button :type="countdown > 0 ? 'info' : 'primary'" :disabled="countdown > 0 || !canSendCode"
                            size="large" @click="sendCode" class="code-btn">
                            {{ getButtonText }}
                        </el-button>
                    </div>
                </el-form-item>
                <el-form-item style="width:100%;">
                    <el-button :loading="loading" size="large" type="primary" style="width:100%;"
                        @click.prevent="handleLogin">
                        <span v-if="!loading">注 册/登 录</span>
                        <span v-else>登 录 中...</span>
                    </el-button>
                </el-form-item>
                <div style="display: block; margin: 0 auto; width: fit-content;">
                    <router-link class="link-type" :to="'/login'">账号登录</router-link>
                </div>
            </el-form>

        </div>
        <!--  底部  -->
        <div class="el-login-footer">
            <span>{{ footerContent }}</span>
        </div>
    </div>
</template>

<script setup>
import { sendMsgCode,checkMsgCode } from "@/api/phoneLogin"
import { register,registerByPhone } from "@/api/login"
import { getUserByPhone } from "@/api/system/user"
import Cookies from "js-cookie"
import useUserStore from '@/store/modules/user'
import defaultSettings from '@/settings'

const title = import.meta.env.VITE_APP_TITLE
const footerContent = defaultSettings.footerContent
const userStore = useUserStore()
const route = useRoute()
const router = useRouter()
const { proxy } = getCurrentInstance()

const loginForm = reactive({
    phone: "",
    code: "",
    loginRules: {
        phone: [
            { required: true, message: '请输入手机号码', trigger: 'blur' },
            { pattern: /^1[3-9]\d{9}$/, message: '请输入正确的手机号码', trigger: 'blur' }
        ],
        code: [
            { required: true, message: '请输入验证码', trigger: 'blur' },
            { pattern: /^\d{6}$/, message: '验证码为6位数字', trigger: 'blur' }
        ]
    }
});

const { phone, code, loginRules } = toRefs(loginForm)


// 错误提示
const phoneError = ref('')
const codeError = ref('')

// 加载状态
const loading = ref(false)
const sendingCode = ref(false)

// 验证码倒计时
const countdown = ref(0)
let countdownTimer = null

// 计算属性
const canSendCode = computed(() => {
    const phoneValid = /^1[3-9]\d{9}$/.test(phone.value)
    return phoneValid && !sendingCode.value
})


const getButtonText = computed(() => {
    if (countdown.value > 0) {
        return `${countdown.value}秒后重新获取`
    }
    if (sendingCode.value) {
        return '发送中...'
    }
    return '获取验证码'
})

// 手机号输入验证
const validatePhone = () => {
    // 只允许输入数字
    phone.value = phone.value.replace(/\D/g, '')

    // 限制长度为11位
    if (phone.value.length > 11) {
        phone.value = phone.value.substring(0, 11)
    }

    // 实时验证格式
    if (phone.value.length === 11) {
        validatePhoneFormat()
    } else {
        phoneError.value = ''
    }
}

// 手机号格式验证
const validatePhoneFormat = () => {
    if (!phone.value) {
        phoneError.value = '请输入手机号码'
        return false
    }

    const phoneRegex = /^1[3-9]\d{9}$/
    if (!phoneRegex.test(phone.value)) {
        phoneError.value = '请输入正确的手机号码'
        return false
    }
    phoneError.value = ''
    return true
}

function sendCode() {
    if (!canSendCode.value) {
        ElMessage.warning('请输入正确的手机号码')
        return
    }
    if (validatePhoneFormat) {
        sendMsgCode(phone.value).then(res => {
            if (res.code == 200) {
                // 成功后开始倒计时
                startCountdown();
            }
        })
    }

}

const registerForm = computed(() => ({
    username: loginForm.phone,
    password: "123456",
    phone: loginForm.phone
}))

function handleLogin() {
    if (loginForm.code != null && loginForm.phone != null) {
        // 1.验证码验证通过
        checkMsgCode(loginForm.phone, loginForm.code).then(res => {
            if (res.data) {
                // 验证通过
                // 2.判断号码是否在系统中
                //    2.1 在:登录
                //    2.2 不在:注册并登录
                // loading.value = true
                getUserByPhone(loginForm.phone).then(res => {
                    if (res.data == null) {
                        // 注册并登录
                        registerByPhone(registerForm.value).then(res => {
                            userStore.loginByPhone(registerForm.value).then(() => {
                                const query = route.query
                                const otherQueryParams = Object.keys(query).reduce((acc, cur) => {
                                    if (cur !== "redirect") {
                                        acc[cur] = query[cur]
                                    }
                                    return acc
                                }, {})
                                router.push({ path: redirect.value || "/", query: otherQueryParams })
                            }).catch(() => {
                                loading.value = false
                            })
                        });
                    } else {
                        // 登录
                        userStore.loginByPhone(registerForm.value).then(() => {
                            const query = route.query
                            const otherQueryParams = Object.keys(query).reduce((acc, cur) => {
                                if (cur !== "redirect") {
                                    acc[cur] = query[cur]
                                }
                                return acc
                            }, {})
                            router.push({ path: redirect.value || "/", query: otherQueryParams })
                        }).catch(() => {
                            loading.value = false
                        })
                    }
                })

            }
        })

    }
}

// 开始倒计时
const startCountdown = () => {
    countdown.value = 60*10
    countdownTimer = setInterval(() => {
        countdown.value--
        if (countdown.value <= 0) {
            clearInterval(countdownTimer)
            countdownTimer = null
        }
    }, 1000)
}

// 清理倒计时
const clearCountdown = () => {
    if (countdownTimer) {
        clearInterval(countdownTimer)
        countdownTimer = null
    }
    countdown.value = 0
}


const codeUrl = ref("")
// const loading = ref(false)
// 验证码开关
const captchaEnabled = ref(true)
// 注册开关
const redirect = ref(undefined)

watch(route, (newRoute) => {
    redirect.value = newRoute.query && newRoute.query.redirect
}, { immediate: true })
</script>

<style lang='scss' scoped>
.login-container {
    display: flex;
    justify-content: center;
    align-items: center;
    height: 100%;
    background-image: url("../assets/images/login-background.jpg");
    background-size: cover;
}


.login {
    width: 100%;
    max-width: 450px;
    padding: 40px 35px 30px;
    border-radius: 12px;
    box-shadow: 0 15px 35px rgba(0, 0, 0, 0.2);
    background: rgba(255, 255, 255, 0.95);
    backdrop-filter: blur(10px);
    position: relative;
    overflow: hidden;
}

.login::before {
    content: '';
    position: absolute;
    top: -50%;
    left: -50%;
    width: 200%;
    height: 200%;
    background: linear-gradient(45deg,
            transparent,
            rgba(255, 255, 255, 0.1),
            transparent);
    transform: rotate(45deg);
    animation: shine 3s infinite linear;
}

@keyframes shine {
    0% {
        transform: translateX(-100%) translateY(-100%) rotate(45deg);
    }

    100% {
        transform: translateX(100%) translateY(100%) rotate(45deg);
    }
}

.login-form {
    position: relative;
    z-index: 1;
}

.title {
    margin: 0 auto 30px;
    text-align: center;
    color: #303133;
    font-size: 28px;
    font-weight: 600;
    background: linear-gradient(45deg, #409eff, #67c23a);
    -webkit-background-clip: text;
    -webkit-text-fill-color: transparent;
    background-clip: text;
}

:deep(.el-form-item) {
    margin-bottom: 22px;
}

:deep(.el-form-item__label) {
    font-weight: 500;
    color: #606266;
    margin-bottom: 8px;
    display: block;
    font-size: 14px;
}

.phone-input,
.code-input {
    transition: all 0.3s ease;
}

.phone-input:focus-within,
.code-input:focus-within {
    transform: translateY(-1px);
    box-shadow: 0 2px 12px rgba(64, 158, 255, 0.2);
}

.phone-prefix {
    display: flex;
    align-items: center;
    gap: 8px;
}

.country-code {
    font-weight: 500;
    color: #409eff;
}

.code-input-group {
    display: flex;
    gap: 12px;
    align-items: center;
}

.code-btn {
    min-width: 120px;
    white-space: nowrap;
    transition: all 0.3s ease;
}

.code-btn:hover:not(:disabled) {
    transform: translateY(-1px);
    box-shadow: 0 4px 12px rgba(64, 158, 255, 0.3);
}

.code-btn:disabled {
    cursor: not-allowed;
}

.login-btn {
    height: 48px;
    font-size: 16px;
    font-weight: 500;
    letter-spacing: 1px;
    transition: all 0.3s ease;
    border: none;
    background: linear-gradient(45deg, #409eff, #67c23a);
}

.login-btn:hover {
    transform: translateY(-2px);
    box-shadow: 0 8px 20px rgba(64, 158, 255, 0.3);
}

.login-btn:active {
    transform: translateY(0);
}

.switch-login {
    text-align: center;
    margin-top: 25px;
    padding-top: 20px;
    border-top: 1px solid #ebeef5;
}

.link-type {
    display: inline-flex;
    align-items: center;
    gap: 6px;
    color: #409eff;
    text-decoration: none;
    font-size: 14px;
    transition: all 0.3s ease;
    padding: 8px 16px;
    border-radius: 4px;
}

.link-type:hover {
    background-color: #f0f7ff;
    color: #337ecc;
    text-decoration: none;
}

.link-type i {
    font-size: 16px;
}

.el-login-footer {
    margin-top: 40px;
    text-align: center;
    font-size: 12px;
    color: #909399;
    padding-top: 20px;
    border-top: 1px solid #ebeef5;
}

.error-tip {
    color: #f56c6c;
    font-size: 12px;
    margin-top: 6px;
    animation: shake 0.5s ease;
}

@keyframes shake {

    0%,
    100% {
        transform: translateX(0);
    }

    10%,
    30%,
    50%,
    70%,
    90% {
        transform: translateX(-5px);
    }

    20%,
    40%,
    60%,
    80% {
        transform: translateX(5px);
    }
}

/* 响应式设计 */
@media (max-width: 768px) {
    .login {
        width: 90%;
        padding: 30px 25px;
        margin: 20px;
    }

    .title {
        font-size: 24px;
    }

    .code-input-group {
        flex-direction: column;
        gap: 10px;
    }

    .code-btn {
        width: 100%;
    }
}

@media (max-width: 480px) {
    .login {
        padding: 25px 20px;
    }

    .title {
        font-size: 22px;
    }

    :deep(.el-button--large) {
        height: 44px;
        font-size: 14px;
    }
}

.el-login-footer {
    height: 40px;
    line-height: 40px;
    position: fixed;
    bottom: 0;
    width: 100%;
    text-align: center;
    color: #fff;
    font-family: Arial;
    font-size: 12px;
    letter-spacing: 1px;
}
</style>

这段代码其实可以让AI帮忙写,你直接在上面修改。

手机号注册,手机号登录主要是在原来的基础上修改就行了。

2.后端

(1)msg微服务

html 复制代码
   <!-- 短信验证码-->
        <dependency>
            <groupId>com.aliyun</groupId>
            <artifactId>aliyun-java-sdk-core</artifactId>
            <version>4.4.0</version>
        </dependency>
        <dependency>
            <groupId>com.aliyun</groupId>
            <artifactId>aliyun-java-sdk-dysmsapi</artifactId>
            <version>1.0.0</version>
        </dependency>
        <dependency>
            <groupId>com.aliyun</groupId>
            <artifactId>alibabacloud-dypnsapi20170525</artifactId>
            <version>2.0.0</version>
        </dependency>
        <!--    云通信号码认证服务    -->
        <dependency>
            <groupId>com.aliyun</groupId>
            <artifactId>tea-openapi</artifactId>
            <version>0.2.8</version>
        </dependency>
        <dependency>
            <groupId>com.aliyun</groupId>
            <artifactId>dysmsapi20170525</artifactId>
            <version>2.0.24</version>
        </dependency>
        <dependency>
            <groupId>com.ruoyi</groupId>
            <artifactId>ruoyi-common-core</artifactId>
        </dependency>
        <dependency>
            <groupId>com.ruoyi</groupId>
            <artifactId>ruoyi-common-redis</artifactId>
        </dependency>
        <!-- SpringCloud Alibaba Nacos -->
        <dependency>
            <groupId>com.alibaba.cloud</groupId>
            <artifactId>spring-cloud-starter-alibaba-nacos-discovery</artifactId>
        </dependency>

        <!-- SpringCloud Alibaba Nacos Config -->
        <dependency>
            <groupId>com.alibaba.cloud</groupId>
            <artifactId>spring-cloud-starter-alibaba-nacos-config</artifactId>
        </dependency>

        <!-- SpringCloud Alibaba Sentinel -->
        <dependency>
            <groupId>com.alibaba.cloud</groupId>
            <artifactId>spring-cloud-starter-alibaba-sentinel</artifactId>
        </dependency>

        <!-- SpringBoot Actuator -->
        <dependency>
            <groupId>org.springframework.boot</groupId>
            <artifactId>spring-boot-starter-actuator</artifactId>
        </dependency>
        <dependency>
            <groupId>com.ruoyi</groupId>
            <artifactId>ruoyi-common-log</artifactId>
        </dependency>

        <!-- RuoYi Common Swagger -->
        <dependency>
            <groupId>com.ruoyi</groupId>
            <artifactId>ruoyi-common-swagger</artifactId>
        </dependency>
        <dependency>
            <groupId>org.springframework.boot</groupId>
            <artifactId>spring-boot-starter-web</artifactId>
        </dependency>
        <!-- 开发工具,热部署用 -->
        <dependency>
            <groupId>org.springframework.boot</groupId>
            <artifactId>spring-boot-devtools</artifactId>
            <optional>true</optional>
        </dependency>

bootstrap.yml

XML 复制代码
server:
  port: 9209
aliyun:
  region-id: ap-southeast-1
  access-key-id: xxxxx
  access-key-secret: xxxx
  sign-name: "速通互联验证码"
  template-code: 100001
  valid-minius: 10

spring:
  banner:
    charset: UTF-8
  messages:
    encoding: UTF-8
  application:
    # 应用名称
    name: wj-msg
  profiles:
    # 环境配置
    active: dev
  cloud:
    nacos:
      discovery:
        # 服务注册地址
        server-addr: 192.168.146.130:8848
        username: nacos
        password: nacos
      config:
        username: nacos
        password: nacos
#        # 配置中心地址
#        server-addr: 192.168.146.130:8848
#        # 配置文件格式
#        file-extension: yml
#        # 共享配置
#        shared-configs:
#          - application-${spring.profiles.active}.${spring.cloud.nacos.config.file-extension}
  # springdoc配置
springdoc:
  gatewayUrl: http://192.168.146.130:8080/${spring.application.name}
  api-docs:
    # 是否开启接口文档
    enabled: true
  info:
    # 标题
    title: '阿里云消息模块接口文档'
    # 描述
    description: '阿里云消息模块接口描述'
    # 作者信息
    contact:
      name: RuoYi
      url: https://ruoyi.vip

其中异常管理在该模块ruoyi-common-core。这里不要异常,否则拦截器那里会形成循环引用。

java 复制代码
package cc.wj.common.msg.util;

import cc.wj.common.msg.config.MsgConfig;
import com.aliyuncs.CommonRequest;
import com.aliyuncs.CommonResponse;
import com.aliyuncs.DefaultAcsClient;
import com.aliyuncs.IAcsClient;
import com.aliyuncs.exceptions.ClientException;
import com.aliyuncs.exceptions.ServerException;
import com.aliyuncs.http.MethodType;
import com.aliyuncs.profile.DefaultProfile;
import com.ruoyi.common.redis.service.RedisService;
import org.apache.commons.lang3.RandomStringUtils;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.stereotype.Component;

import java.util.concurrent.TimeUnit;

/**
 * 阿里云短信服务工具,当然短信服务也可以发送验证码
 * 区分:
 * 短信服务:SendSms(发送消息接口)--要求企业政府机关,需要资质注册 https://next.api.aliyun.com/document/Dysmsapi/2017-05-25/overview
 * 云通信号码认证服务:SendSmsVerifyCode(发送验证码),适合适用场景:个人开发者在无企业资质情况下,需为 Web 应用、App 或小程序实现用户注册、登录、密码找回等环节的短信验证码功能。
 *
 * @author wj
 * @version 1.0
 * @date 2025/12/23 16:34
 */
@Component
public class AliyunSmsUtils {


//    @Value("${aliyun.regionId}")
//    private String regionId;
//
//    @Value("${aliyun.AccessKeyID}")
//    private String AccessKeyID;
//
//    @Value("${aliyun.AccessKeySecret}")
//    private String AccessKeySecret;
//
//    @Value("${aliyun.SignName}")
//    private String SignName;
//
//    @Value("${aliyun.TemplateCode}")
//    private String TemplateCode;

    @Autowired
    private RedisService redisService;

    @Autowired
    private MsgConfig smsConfig;

    /**
     * 阿里云短信发送服务
     * 主要组织资质注册
     *
     * @param u_phone
     * @param message
     * @return void
     * @author wj
     * @date 2025/12/25 9:02
     */
    public void messagePost(String u_phone, String message) {
        DefaultProfile profile = DefaultProfile.getProfile(smsConfig.getRegionId(), smsConfig.getAccessKeyId(), smsConfig.getAccessKeySecret());
        IAcsClient client = new DefaultAcsClient(profile);

        CommonRequest request = new CommonRequest();
        request.setSysMethod(MethodType.POST);
        request.setSysDomain("dysmsapi.aliyuncs.com");
        request.setSysVersion("2017-05-25");
//        短信服务:SendSms(发送消息接口)--要求企业政府机关,需要资质注册 https://next.api.aliyun.com/document/Dysmsapi/2017-05-25/overview
//       云通信号码认证服务:SendSmsVerifyCode(发送验证码),适合适用场景:个人开发者在无企业资质情况下,需为 Web 应用、App 或小程序实现用户注册、登录、密码找回等环节的短信验证码功能。
        request.setSysAction("SendSms");
        request.putQueryParameter("RegionId", smsConfig.getRegionId());
        request.putQueryParameter("PhoneNumbers", u_phone);
        request.putQueryParameter("SignName", smsConfig.getSignName());
        request.putQueryParameter("TemplateCode", smsConfig.getTemplateCode());
        request.putQueryParameter("TemplateParam", "{\"code\":" + message + "}");
        try {
            CommonResponse response = client.getCommonResponse(request);
            System.out.println(response.getData());
        } catch (ServerException e) {
            e.printStackTrace();
        } catch (ClientException e) {
            e.printStackTrace();
        }
    }


    /**
     * 发送验证码
     *
     * @param phone
     * @return String
     * @author wj
     * @date 2025/12/24 20:48
     */
    public String sendPhoneCode(String phone) {
        if (redisService.getCacheObject(phone) == null) {
            String authcode = "1" + RandomStringUtils.randomNumeric(5);//生成随机数,我发现生成5位随机数时,如果开头为0,发送的短信只有4位,这里开头加个1,保证短信的正确性
            redisService.setCacheObject(phone, authcode, 60L, TimeUnit.SECONDS);
            messagePost(phone, authcode);//发送短息
            return "验证码发送成功";
        }
        return "该手机号已经发送过验证码,验证码时间还未到期";
    }

    //验证码登录
    public String authcode_login(String u_phone, String authcode) {
        String validCode = redisService.getCacheObject(u_phone);
        if (validCode.equals(authcode)) {
            return "验证码正确,登录成功";
        }
        return "验证码错误,登录失败";
    }

}
java 复制代码
package cc.wj.common.msg.util;

import cc.wj.common.msg.config.MsgConfig;
import com.aliyun.auth.credentials.Credential;
import com.aliyun.auth.credentials.provider.StaticCredentialProvider;
import com.aliyun.sdk.service.dypnsapi20170525.AsyncClient;
import com.aliyun.sdk.service.dypnsapi20170525.models.*;
import com.ruoyi.common.redis.service.RedisService;
import darabonba.core.client.ClientOverrideConfiguration;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.stereotype.Component;

import java.util.concurrent.CompletableFuture;
import java.util.concurrent.TimeUnit;

import static cc.wj.common.msg.exception.VerifyCodeException.throwErrorCode;

/**
 * 阿里云云通信号码认证服务 - 短信验证码发送工具类
 * 区分:
 * 短信服务:SendSms(发送消息接口)--要求企业政府机关,需要资质注册 https://next.api.aliyun.com/document/Dysmsapi/2017-05-25/overview
 * 云通信号码认证服务:SendSmsVerifyCode(发送验证码),适合适用场景:个人开发者在无企业资质情况下,需为 Web 应用、App 或小程序实现用户注册、登录、密码找回等环节的短信验证码功能。
 *
 * @author wj
 * @version 1.0
 * @date 2025/12/25 10:17
 */
@Component
public class AliyunVerifyCodeUtils {

    @Autowired
    private RedisService redisService;

    @Autowired
    private MsgConfig smsConfig;

    /**
     * 发送短信验证码
     *
     * @param phoneNumber 手机号码
     * @return 发送结果
     */
    public SendSmsVerifyCodeResponse sendSmsVerifyCode(String phoneNumber) throws Exception {
        String verifyCode = redisService.getCacheObject(phoneNumber);
        if (verifyCode == null || "".equals(verifyCode)) {
            Credential credential = Credential.builder()
                    .accessKeyId(smsConfig.getAccessKeyId()) // 你的 AccessKeyId
                    .accessKeySecret(smsConfig.getAccessKeySecret()) // 你的 AccessKeySecret
                    .build();

            StaticCredentialProvider provider = StaticCredentialProvider.create(credential);

            AsyncClient client = AsyncClient.builder()
                    .region(smsConfig.getRegionId())
                    .credentialsProvider(provider)
                    .overrideConfiguration(ClientOverrideConfiguration.create()
                            .setEndpointOverride("dypnsapi.aliyuncs.com"))
                    .build();
            verifyCode = generateRandomCode(6);
            int expireMinutes = Integer.parseInt(smsConfig.getValidMinius());

            redisService.setCacheObject(phoneNumber, verifyCode, (long) expireMinutes, TimeUnit.MINUTES);
            SendSmsVerifyCodeRequest request = buildSendSmsVerifyCodeRequest(
                    phoneNumber, verifyCode, expireMinutes);
            CompletableFuture<SendSmsVerifyCodeResponse> response = client.sendSmsVerifyCode(request);
            SendSmsVerifyCodeResponse resp = response.get();
            SendSmsVerifyCodeResponseBody body = resp.getBody();
            if (!"OK".equals(body.getCode())) {
                throwErrorCode(body.getCode(), body.getMessage());
            } else {
                redisService.setCacheObject(phoneNumber, verifyCode, (long) expireMinutes, TimeUnit.MINUTES);
            }
            client.close();
            return resp;
        } else {
            throwErrorCode("VERIFY_CODE_VALID", "");
        }
        return null;
    }

    /**
     * 校验短信验证码:适用于{"code":"##code##","min":"5"}
     * <p>
     * SendSmsVerifyCode 接口的字段 TemplateParam,配置方式有 2 种:
     * {"code":"##code##","min":"5"}
     * {"code":"123456","min":"5"}
     * {"code":"##code##","min":"5"}验证码是 api 动态生成的,阿里云接口可以完成校验。
     * {"code":"123456","min":"5"}验证码是用户配置的不是 api 动态生成,阿里云接口无法校验。(我使用的是该方式,所以不能校验)
     * 请您按照实际情况传入对应的验证码。
     *
     * @param phoneNumber 手机号码
     * @return 发送结果
     */
    public Boolean checkSmsVerifyCode(String phoneNumber, String verifyCode) throws Exception {
        Credential credential = Credential.builder()
                .accessKeyId(smsConfig.getAccessKeyId()) // 你的 AccessKeyId
                .accessKeySecret(smsConfig.getAccessKeySecret()) // 你的 AccessKeySecret
                .build();

        StaticCredentialProvider provider = StaticCredentialProvider.create(credential);

        AsyncClient client = AsyncClient.builder()
                .region(smsConfig.getRegionId())
                .credentialsProvider(provider)
                .overrideConfiguration(ClientOverrideConfiguration.create()
                        .setEndpointOverride("dypnsapi.aliyuncs.com"))
                .build();
        CheckSmsVerifyCodeRequest checkSmsVerifyCodeRequest = CheckSmsVerifyCodeRequest.builder()
                .verifyCode(verifyCode)
                .phoneNumber(phoneNumber)
                .build();
        CompletableFuture<CheckSmsVerifyCodeResponse> response = client.checkSmsVerifyCode(checkSmsVerifyCodeRequest);
        CheckSmsVerifyCodeResponse resp = response.get();
        CheckSmsVerifyCodeResponseBody body = resp.getBody();
        client.close();
        if (!"OK".equals(body.getCode())) {
            throwErrorCode(body.getCode(), body.getMessage());
        } else {
            if ("PASS".equals(body.getModel().getVerifyResult())) {
                return true;
            }
        }
        return false;
    }

    /**
     * 校验短信验证码:适用于{"code":"123456","min":"5"}
     *
     * @param phoneNumber
     * @param verifyCode
     * @return Boolean
     * @author wj
     * @date 2026/1/4 11:17
     */
    public Boolean checkSmsVerifyGdCode(String phoneNumber, String verifyCode) throws Exception {
        String standardVerifyCode = redisService.getCacheObject(phoneNumber);
        if (standardVerifyCode == null || "".equals(standardVerifyCode)) {
            throwErrorCode("VERIFY_CODE_BLANK", "");
        }
        if (verifyCode == null || "".equals(verifyCode)) {
            throwErrorCode("VERIFY_CODE_BLANK", "");
        }
        if (standardVerifyCode.equals(verifyCode)) {
            return true;
        }
        return false;
    }

    /**
     * 构建发送验证码请求
     */
    private SendSmsVerifyCodeRequest buildSendSmsVerifyCodeRequest(
            String phoneNumber, String verifyCode, int expireMinutes) {
        String templateParam = " {\"code\":\"" + verifyCode + "\",\"min\":\"" + expireMinutes + "\"}";
        SendSmsVerifyCodeRequest request = SendSmsVerifyCodeRequest.builder()
                .phoneNumber(phoneNumber)
                .signName(smsConfig.getSignName())
                .templateCode(smsConfig.getTemplateCode())
                .templateParam(templateParam)
                // 设置验证码有效期(单位:秒)
                .smsUpExtendCode(String.valueOf(expireMinutes * 60))
                .codeLength(6L)
                .build();
        return request;
    }

    /**
     * 生成随机验证码
     */
    private String generateRandomCode(int length) {
        StringBuilder sb = new StringBuilder();
        for (int i = 0; i < length; i++) {
            sb.append((int) (Math.random() * 10));
        }
        return sb.toString();
    }

    /**
     * 生成外部流水号
     */
    private String generateOutId() {
        return "SMS_" + System.currentTimeMillis() + "_" + (int) (Math.random() * 10000);
    }

}
java 复制代码
package cc.wj.common.msg.controller;

import cc.wj.common.msg.util.AliyunVerifyCodeUtils;
import com.aliyun.sdk.service.dypnsapi20170525.models.SendSmsVerifyCodeResponse;
import com.ruoyi.common.core.web.domain.AjaxResult;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.web.bind.annotation.*;

import java.util.Map;

/**
 * 短信验证码
 *
 * @author wj
 * @version 1.0
 * @date 2025/12/24 10:12
 */
@RestController
@RequestMapping("/verify")
public class MsgCodeController {

    @Autowired
    private AliyunVerifyCodeUtils verifyCodeUtils;

    @PostMapping("/code")
    public AjaxResult getMsgCode(@RequestParam("phone") String phone) throws Exception {
        SendSmsVerifyCodeResponse msgResult = verifyCodeUtils.sendSmsVerifyCode(phone);
        if (msgResult == null || !msgResult.getBody().getSuccess()) {
            return AjaxResult.error("验证码获取失败!");
        } else {
            return AjaxResult.success(msgResult);
        }
    }

    @PostMapping("/check")
    public AjaxResult checkMsgCode(@RequestBody Map<String, String> param) throws Exception {
        String phone = param.get("phone");
        String code = param.get("code");
        if (!"".equals(phone) && !"".equals(code)) {
            boolean checkResult = verifyCodeUtils.checkSmsVerifyGdCode(phone, code);
            return AjaxResult.success(checkResult);
        }
        return AjaxResult.error(400, "参数不全");
    }
}

TokenController中接口:

java 复制代码
    @PostMapping("phoneLogin")
    public R<?> loginByPhone(@RequestBody LoginBody form) {
        // 用户登录
        LoginUser userInfo = sysLoginService.login(form.getUsername(), form.getPassword());
        // 获取登录token
        redisService.deleteObject(form.getUsername()); //删除验证码
        return R.ok(tokenService.createToken(userInfo));
    }

    @PostMapping("phoneRegister")
    public R<?> registerByPhone(@RequestBody RegisterBody registerBody) {
        // 用户注册
        sysLoginService.register(registerBody.getUsername(), registerBody.getPassword());
        return R.ok();
    }

gateway配置需要把路由加上。

XML 复制代码
         # 阿里云验证码服务
        - id: wj-msg
          uri: lb://wj-msg
          predicates:
            - Path=/msg/**
          filters:
            - StripPrefix=1

四、安全漏洞问题

不过,我这个代码是有漏洞的,漏洞在哪里呢?

在手机登录、手机注册那个接口,我把逻辑写在了前端

html 复制代码
                // 1.验证码验证,若通过
                // 2.判断号码是否在系统中
                //    2.1 在:登录
                //    2.2 不在:注册并登录

那直接通过auth/phoneLogin请求,使用默认密码就可以随意登录你知道电话号码的新用户了。同理,我认为注册也是一样的问题,而且若依项目原来的auth/register也同样存在这个问题,那其他接口是不是也同理存在这个问题,那可不一定了哈,毕竟,auth/phoneLogin接口是白名单接口,没有特殊的都会进行token验证的。那注册,这类的接口,如果被攻击,可以不停地向你系统进行新增一些无效用户。

1.首先先说auth/phoneLogin请求问题怎么处理?

html 复制代码
                // 1.验证码验证,若通过
                // 2.判断号码是否在系统中
                //    2.1 在:登录
                //    2.2 不在:注册并登录

这一块逻辑肯定是需要把验证码逻辑放在后端,不要放在前端。

2.注册接口

其实是没有很好的方式来解决的,毕竟是白名单接口,还是写操作,你使用限流啊,图形码等等这些方式顶多减少恶意新增用户的频次。尤其是若依原来那个接口,最好的方式关闭这个接口,使用手机验证码的方式,防止恶意刷接接口。

还有注册,我使用了默认密码,这也是个风险知道号码,就可以随意登录了。后续改进,我不想开发修改界面,使用了一次性的随机密码。

前端:

javascript 复制代码
function handleLogin() {
    if (loginForm.code != null && loginForm.phone != null) {
                registerForm.verifyCode.value = loginForm.code
                userStore.loginByPhone(registerForm.value).then(() => {
                    const query = route.query
                    const otherQueryParams = Object.keys(query).reduce((acc, cur) => {
                        if (cur !== "redirect") {
                            acc[cur] = query[cur]
                        }
                        return acc
                    }, {})
                    router.push({ path: redirect.value || "/", query: otherQueryParams })
                }).catch(() => {
                    loading.value = false
                })
    }
}

后端:

接口一共两个:手机登录,根据手机号查询是否存在用户(上面有,不再写了)

java 复制代码
    @PostMapping("phoneLogin")
    public R<?> loginByPhone(@RequestBody PhoneLoginBody form) {
        // 用户登录
        LoginUser userInfo = sysLoginService.loginByPhone(form);

        // 获取登录token
        return R.ok(tokenService.createToken(userInfo));
    }
java 复制代码
public LoginUser loginByPhone(PhoneLoginBody form) {
        String verifyCode = form.getVerifyCode();
        String username = form.getUsername();
//        验证码登陆,使用一次性随机密码,否则:增加页面。修改密码,不能使用简单默认密码
        String initPassword = EnhancedPasswordGenerator.generatePassword(8);
        // 1.验证码验证,若通过
        String standardVerifyCode = redisService.getCacheObject(username);
        if (standardVerifyCode == null || "".equals(standardVerifyCode)) {
            throwErrorCode("VERIFY_CODE_BLANK", "");
        }
        if (verifyCode == null || "".equals(verifyCode)) {
            throwErrorCode("VERIFY_CODE_BLANK", "");
        }
        if (standardVerifyCode.equals(verifyCode)) {
            // 2.判断号码是否在系统中
            R<AjaxResult> byPhoneResult = remoteUserService.getInfoByPhone(username, SecurityConstants.INNER);
            if (R.FAIL == byPhoneResult.getCode()) {
                throw new ServiceException(byPhoneResult.getMsg());
            } else {
                LoginUser userInfo;
                if (byPhoneResult.getData() == null) {
//              2.1不在:注册并登录
                    register(username, initPassword);
                    remoteUserService.updateUser(username, initPassword, SecurityConstants.INNER);
                    userInfo = login(username, initPassword);
                } else {
//              2.2在:登陆
                    remoteUserService.updateUser(username, initPassword, SecurityConstants.INNER);
                    userInfo = login(username, initPassword);
                }
                redisService.deleteObject(form.getUsername());
                return userInfo;
            }
        } else {
            throwErrorCode("VERIFY_CODE_NO_PASS", "");
        }
        return null;
    }
相关推荐
程序员小假18 分钟前
我们来说一下无锁队列 Disruptor 的原理
java·后端
charlie11451419123 分钟前
嵌入式现代C++教程: 构造函数优化:初始化列表 vs 成员赋值
开发语言·c++·笔记·学习·嵌入式·现代c++
wjs202426 分钟前
Bootstrap5 消息弹窗
开发语言
资生算法程序员_畅想家_剑魔32 分钟前
Kotlin常见技术分享-02-相对于Java 的核心优势-协程
java·开发语言·kotlin
ProgramHan36 分钟前
Spring Boot 3.2 新特性:虚拟线程的落地实践
java·jvm·spring boot
IT=>小脑虎1 小时前
C++零基础衔接进阶知识点【详解版】
开发语言·c++·学习
nbsaas-boot1 小时前
Go vs Java 的三阶段切换路线图
java·开发语言·golang
码农小韩1 小时前
基于Linux的C++学习——指针
linux·开发语言·c++·学习·算法
微露清风1 小时前
系统性学习C++-第十九讲-unordered_map 和 unordered_set 的使用
开发语言·c++·学习
BBBBBAAAAAi1 小时前
Claude Code安装记录
开发语言·前端·javascript