一、开发逻辑

二、开发需要考虑的点
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;
}