Merge pull request #1312 from xinnan-tech/manager-api-aly-message

修复短信并发漏洞
This commit is contained in:
欣南科技
2025-05-19 21:23:49 +08:00
committed by GitHub
15 changed files with 472 additions and 17 deletions
@@ -6,12 +6,14 @@ import java.util.List;
import java.util.Map;
import java.util.concurrent.TimeUnit;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.data.redis.core.HashOperations;
import org.springframework.data.redis.core.RedisTemplate;
import org.springframework.data.redis.core.script.DefaultRedisScript;
import org.springframework.stereotype.Component;
import jakarta.annotation.Resource;
import xiaozhi.common.utils.ResourcesUtils;
/**
* Redis工具类
@@ -23,6 +25,9 @@ public class RedisUtils {
@Resource
private RedisTemplate<String, Object> redisTemplate;
@Autowired
private ResourcesUtils resourceUtils;
/**
* 默认过期时长为24小时,单位:秒
*/
@@ -40,6 +45,24 @@ public class RedisUtils {
*/
public final static long NOT_EXPIRE = -1L;
public Long increment(String key, long expire) {
Long increment = redisTemplate.opsForValue().increment(key, 1L);
if (expire != NOT_EXPIRE) {
expire(key, expire);
}
return increment;
}
public Long increment(String key) {
return redisTemplate.opsForValue().increment(key, 1L);
}
public Long decrement(String key) {
return redisTemplate.opsForValue().decrement(key, 1L);
}
public void set(String key, Object value, long expire) {
redisTemplate.opsForValue().set(key, value);
if (expire != NOT_EXPIRE) {
@@ -134,7 +157,7 @@ public class RedisUtils {
*/
public void emptyAll() {
// Lua 脚本 FLUSHALL是redis清空所有库的命令
String luaScript ="redis.call('FLUSHALL')";
String luaScript =resourceUtils.loadString("lua/emptyAll.lua");
// 创建 DefaultRedisScript 对象
DefaultRedisScript<Void> redisScript = new DefaultRedisScript<>();
@@ -147,4 +170,26 @@ public class RedisUtils {
}
/**
* 获取在redis指定key的值,如果值为空,着设置key的默认值
* @param key redis的key
* @param defaultValue 默认值
* @param expiresInSecond 过期时间
* @return 返回key的值
*/
public String getKeyOrCreate(String key, String defaultValue,Long expiresInSecond) {
// Lua 脚本
String luaScript = resourceUtils.loadString("lua/getKeyOrCreate.lua");
DefaultRedisScript<String> redisScript = new DefaultRedisScript<>();
redisScript.setScriptText(luaScript);
redisScript.setResultType(String.class);
// 执行 Lua 脚本
List<String> keys = Collections.singletonList(key);
return redisTemplate.execute(redisScript, keys, defaultValue,expiresInSecond);
}
}
@@ -0,0 +1,44 @@
package xiaozhi.common.utils;
import lombok.AllArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import org.springframework.core.io.ResourceLoader;
import org.springframework.core.io.Resource;
import org.springframework.stereotype.Component;
import xiaozhi.common.exception.RenException;
import java.io.BufferedReader;
import java.io.IOException;
import java.io.InputStreamReader;
/**
* 资源处理工具
*/
@AllArgsConstructor
@Slf4j
@Component
public class ResourcesUtils {
private ResourceLoader resourceLoader;
/**
* 读取资源,返回字符串
* @param fileName 资源路径:resources下开始
* @return 字符串
*/
public String loadString(String fileName) {
Resource resource = resourceLoader.getResource("classpath:" + fileName);
StringBuilder luaScriptBuilder = new StringBuilder();
try (BufferedReader reader = new BufferedReader(
new InputStreamReader(resource.getInputStream()))) {
String line;
while ((line = reader.readLine()) != null) {
luaScriptBuilder.append(line).append("\n");
}
} catch (IOException e){
log.error("方法:loadString()读取资源失败--{}",e.getMessage());
throw new RenException("读取资源失败");
}
return luaScriptBuilder.toString();
}
}
@@ -84,6 +84,7 @@ public class ShiroConfig {
filterMap.put("/user/login", "anon");
filterMap.put("/user/pub-config", "anon");
filterMap.put("/user/register", "anon");
filterMap.put("/user/retrieve-password", "anon");
// 将config路径使用server服务过滤器
filterMap.put("/config/**", "server");
filterMap.put("/agent/chat-history/report", "server");
@@ -174,19 +174,19 @@ public class LoginController {
// 验证用户是否是手机号码
boolean validPhone = ValidatorUtils.isValidPhone(dto.getPhone());
if (!validPhone) {
throw new RenException("用户名不是手机号码,无法提供找回密码服务");
throw new RenException("输入的手机号码格式不正确");
}
// 按照用户名获取用户
SysUserDTO userDTO = sysUserService.getByUsername(dto.getPhone());
if (userDTO == null) {
throw new RenException("用户名或验证码错误");
throw new RenException("输入的手机号码未注册");
}
// 验证短信验证码是否正常
boolean validate = captchaService.validateSMSValidateCode(dto.getPhone(), dto.getCode(), false);
// 判断是否通过验证
if (!validate) {
throw new RenException("用户名或验证码错误");
throw new RenException("输入的手机验证码错误");
}
sysUserService.changePasswordDirectly(userDTO.getId(), dto.getPassword());
@@ -79,7 +79,10 @@ public class CaptchaServiceImpl implements CaptchaService {
public void sendSMSValidateCode(String phone) {
// 检查发送间隔
String lastSendTimeKey = RedisKeys.getSMSLastSendTimeKey(phone);
String lastSendTime = (String) redisUtils.get(lastSendTimeKey);
// 获取是否发送过,如果没有设置最后发送时间(60秒)
String lastSendTime = redisUtils
.getKeyOrCreate(lastSendTimeKey,
String.valueOf(System.currentTimeMillis()), 60L);
if (lastSendTime != null) {
long lastSendTimeLong = Long.parseLong(lastSendTime);
long currentTime = System.currentTimeMillis();
@@ -114,15 +117,11 @@ public class CaptchaServiceImpl implements CaptchaService {
// 设置验证码
setCache(key, validateCodes);
// 设置最后发送时间(60秒)
redisUtils.set(lastSendTimeKey, String.valueOf(System.currentTimeMillis()), 60);
// 更新今日发送次数
if (todayCount == 0) {
// 如果是今天第一次发送,设置24小时过期
redisUtils.set(todayCountKey, 1, RedisUtils.DEFAULT_EXPIRE);
redisUtils.increment(todayCountKey, RedisUtils.DEFAULT_EXPIRE);
} else {
redisUtils.set(todayCountKey, todayCount + 1, RedisUtils.DEFAULT_EXPIRE);
redisUtils.increment(todayCountKey);
}
// 发送验证码短信
@@ -10,6 +10,8 @@ import lombok.extern.slf4j.Slf4j;
import org.springframework.stereotype.Service;
import xiaozhi.common.constant.Constant;
import xiaozhi.common.exception.RenException;
import xiaozhi.common.redis.RedisKeys;
import xiaozhi.common.redis.RedisUtils;
import xiaozhi.modules.sms.service.SmsService;
import xiaozhi.modules.sys.service.SysParamsService;
@@ -18,6 +20,7 @@ import xiaozhi.modules.sys.service.SysParamsService;
@Slf4j
public class ALiYunSmsService implements SmsService {
private final SysParamsService sysParamsService;
private final RedisUtils redisUtils;
@Override
public void sendVerificationCodeSms(String phone, String VerificationCode) {
@@ -37,6 +40,9 @@ public class ALiYunSmsService implements SmsService {
SendSmsResponse sendSmsResponse = client.sendSmsWithOptions(sendSmsRequest, runtime);
log.info("发送短信响应的requestID: {}", sendSmsResponse.getBody().getRequestId());
} catch (Exception e) {
// 如果发送失败了退还这次发送数
String todayCountKey = RedisKeys.getSMSTodayCountKey(phone);
redisUtils.delete(todayCountKey);
// 错误 message
log.error(e.getMessage());
throw new RenException("短信发送失败");
@@ -1,9 +1,6 @@
package xiaozhi.modules.sys.service.impl;
import java.util.Arrays;
import java.util.List;
import java.util.Map;
import java.util.UUID;
import java.util.*;
import org.apache.commons.lang3.StringUtils;
import org.springframework.stereotype.Service;
@@ -82,7 +79,7 @@ public class SysParamsServiceImpl extends BaseServiceImpl<SysParamsDao, SysParam
@Transactional(rollbackFor = Exception.class)
public void update(SysParamsDTO dto) {
validateParamValue(dto);
detectingSMSParameters(dto.getParamCode(),dto.getParamValue());
SysParamsEntity entity = ConvertUtils.sourceToTarget(dto, SysParamsEntity.class);
updateById(entity);
@@ -204,4 +201,40 @@ public class SysParamsServiceImpl extends BaseServiceImpl<SysParamsDao, SysParam
updateValueByCode(Constant.SERVER_SECRET, newSecret);
}
}
/**
* 检测短信参数是否符合要求
* @param paramCode 参数编码
* @param paramValue 参数值
* @return 是否通过
*/
private boolean detectingSMSParameters(String paramCode, String paramValue){
// 判断是否是开启手机注册的参数编码,如果不是参数编码,着不需要检测其他短信参数,直接返回true
if (!Constant.SysMSMParam.SYSTEM_ENABLE_MOBILE_REGISTER.getValue().equals(paramCode)){
return true;
}
// 判断是否为关闭,如果是关闭短信注册,着不需要检测其他短信参数,直接返回true
if ("false".equalsIgnoreCase(paramValue)) {
return true;
}
// 检测短信关联参数是否为空
ArrayList<String> list = new ArrayList<String>();
list.add(Constant.SysMSMParam.SYSTEM_SMS_MAX_SEND_COUNT.getValue());
list.add(Constant.SysMSMParam.ALIYUN_SMS_ACCESS_KEY_ID.getValue());
list.add(Constant.SysMSMParam.ALIYUN_SMS_ACCESS_KEY_SECRET.getValue());
list.add(Constant.SysMSMParam.ALIYUN_SMS_SIGN_NAME.getValue());
list.add(Constant.SysMSMParam.ALIYUN_SMS_SMS_CODE_TEMPLATE_CODE.getValue());
StringBuilder str = new StringBuilder();
list.forEach( item -> {
if (!StringUtils.isNoneBlank(item)) {
str.append(",").append(item);
}
});
if (!str.isEmpty()) {
String promptStr = "%s这些参数不可以为空";
String substring = str.substring(1, str.length());
throw new RenException(promptStr.formatted(substring));
}
return true;
}
}
@@ -133,6 +133,10 @@ public class SysUserServiceImpl extends BaseServiceImpl<SysUserDao, SysUserEntit
@Override
@Transactional(rollbackFor = Exception.class)
public void changePasswordDirectly(Long userId, String password) {
// 新密码强度
if (!isStrongPassword(password)) {
throw new RenException(ErrorCode.PASSWORD_WEAK_ERROR);
}
SysUserEntity sysUserEntity = new SysUserEntity();
sysUserEntity.setId(userId);
sysUserEntity.setPassword(PasswordUtils.encode(password));
@@ -0,0 +1 @@
redis.call('FLUSHALL')
@@ -0,0 +1,11 @@
local value = redis.call('GET', KEYS[1])
-- value 如果为空着设置值
if not value then
local result = redis.call('SET', KEYS[1], ARGV[1])
-- 检查 ARGV[2] 是否存在且大于 0
local expireTime = tonumber(ARGV[2])
if expireTime and expireTime > 0 then
redis.call('EXPIRE', KEYS[1], expireTime)
end
end
return value
+24
View File
@@ -169,4 +169,28 @@ export default {
});
}).send();
},
// 找回用户密码
retrievePassword(passwordData, callback, failCallback) {
RequestService.sendRequest()
.url(`${getServiceUrl()}/user/retrieve-password`)
.method('PUT')
.data({
phone: passwordData.phone,
code: passwordData.code,
password: passwordData.password
})
.success((res) => {
RequestService.clearRequestTime();
callback(res);
})
.fail((err) => {
RequestService.clearRequestTime();
failCallback(err);
})
.networkFail(() => {
RequestService.reAjaxFun(() => {
this.retrievePassword(passwordData, callback, failCallback);
});
}).send()
}
}
+7
View File
@@ -39,6 +39,13 @@ const routes = [
return import('../views/register.vue')
}
},
{
path: '/retrieve-password',
name: 'RetrievePassword',
component: function () {
return import('../views/retrievePassword.vue')
}
},
// 设备管理页面路由
{
path: '/device-management',
+5 -1
View File
@@ -56,12 +56,13 @@
<div
style="font-weight: 400;font-size: 14px;text-align: left;color: #5778ff;display: flex;justify-content: space-between;margin-top: 20px;">
<div v-if="allowUserRegister" style="cursor: pointer;" @click="goToRegister">新用户注册</div>
<div style="cursor: pointer;" @click="goToForgetPassword" v-if="enableMobileRegister">忘记密码?</div>
</div>
</div>
<div class="login-btn" @click="login">登录</div>
<!-- 登录方式切换按钮 -->
<div class="login-type-container">
<div class="login-type-container" v-if="enableMobileRegister">
<el-tooltip content="手机号码登录" placement="bottom">
<el-button :type="isMobileLogin ? 'primary' : 'default'" icon="el-icon-mobile" circle
@click="switchLoginType('mobile')"></el-button>
@@ -214,6 +215,9 @@ export default {
goToRegister() {
goToPage('/register')
},
goToForgetPassword() {
goToPage('/retrieve-password')
},
}
}
</script>
+1
View File
@@ -201,6 +201,7 @@ export default {
// 验证图形验证码
if (!this.validateInput(this.form.captcha, '请输入图形验证码')) {
this.fetchCaptcha();
return;
}
@@ -0,0 +1,275 @@
<template>
<div class="welcome" @keyup.enter="retrievePassword">
<el-container style="height: 100%;">
<!-- 保持相同的头部 -->
<el-header>
<div style="display: flex;align-items: center;margin-top: 15px;margin-left: 10px;gap: 10px;">
<img loading="lazy" alt="" src="@/assets/xiaozhi-logo.png" style="width: 45px;height: 45px;" />
<img loading="lazy" alt="" src="@/assets/xiaozhi-ai.png" style="height: 18px;" />
</div>
</el-header>
<div class="login-person">
<img loading="lazy" alt="" src="@/assets/login/register-person.png" style="width: 100%;" />
</div>
<el-main style="position: relative;">
<form @submit.prevent="retrievePassword">
<div class="login-box">
<!-- 修改标题部分 -->
<div style="display: flex;align-items: center;gap: 20px;margin-bottom: 39px;padding: 0 30px;">
<img loading="lazy" alt="" src="@/assets/login/hi.png" style="width: 34px;height: 34px;" />
<div class="login-text">重置密码</div>
<div class="login-welcome">
PASSWORD RETRIEVE
</div>
</div>
<div style="padding: 0 30px;">
<!-- 手机号输入 -->
<div class="input-box">
<div style="display: flex; align-items: center; width: 100%;">
<el-select v-model="form.areaCode" style="width: 220px; margin-right: 10px;">
<el-option v-for="item in mobileAreaList" :key="item.key" :label="`${item.name} (${item.key})`"
:value="item.key" />
</el-select>
<el-input v-model="form.mobile" placeholder="请输入手机号码" />
</div>
</div>
<div style="display: flex; align-items: center; margin-top: 20px; width: 100%; gap: 10px;">
<div class="input-box" style="width: calc(100% - 130px); margin-top: 0;">
<img loading="lazy" alt="" class="input-icon" src="@/assets/login/shield.png" />
<el-input v-model="form.captcha" placeholder="请输入验证码" style="flex: 1;" />
</div>
<img loading="lazy" v-if="captchaUrl" :src="captchaUrl" alt="验证码"
style="width: 150px; height: 40px; cursor: pointer;" @click="fetchCaptcha" />
</div>
<!-- 手机验证码 -->
<div style="display: flex; align-items: center; margin-top: 20px; width: 100%; gap: 10px;">
<div class="input-box" style="width: calc(100% - 130px); margin-top: 0;">
<img loading="lazy" alt="" class="input-icon" src="@/assets/login/phone.png" />
<el-input v-model="form.mobileCaptcha" placeholder="请输入手机验证码" style="flex: 1;" maxlength="6" />
</div>
<el-button type="primary" class="send-captcha-btn" :disabled="!canSendMobileCaptcha"
@click="sendMobileCaptcha">
<span>
{{ countdown > 0 ? `${countdown}秒后重试` : '发送验证码' }}
</span>
</el-button>
</div>
<!-- 新密码 -->
<div class="input-box">
<img loading="lazy" alt="" class="input-icon" src="@/assets/login/password.png" />
<el-input v-model="form.newPassword" placeholder="请输入新密码" type="password" />
</div>
<!-- 确认新密码 -->
<div class="input-box">
<img loading="lazy" alt="" class="input-icon" src="@/assets/login/password.png" />
<el-input v-model="form.confirmPassword" placeholder="请确认新密码" type="password" />
</div>
<!-- 修改底部链接 -->
<div style="font-weight: 400;font-size: 14px;text-align: left;color: #5778ff;margin-top: 20px;">
<div style="cursor: pointer;" @click="goToLogin">返回登录</div>
</div>
</div>
<!-- 修改按钮文本 -->
<div class="login-btn" @click="retrievePassword">立即修改</div>
<!-- 保持相同的协议声明 -->
<div style="font-size: 14px;color: #979db1;">
同意
<div style="display: inline-block;color: #5778FF;cursor: pointer;">用户协议</div>
<div style="display: inline-block;color: #5778FF;cursor: pointer;">隐私政策</div>
</div>
</div>
</form>
</el-main>
<!-- 保持相同的页脚 -->
<el-footer>
<version-footer />
</el-footer>
</el-container>
</div>
</template>
<script>
import Api from '@/apis/api';
import VersionFooter from '@/components/VersionFooter.vue';
import { getUUID, goToPage, showDanger, showSuccess, validateMobile } from '@/utils';
import { mapState } from 'vuex';
export default {
name: 'retrieve',
components: {
VersionFooter
},
computed: {
...mapState({
allowUserRegister: state => state.pubConfig.allowUserRegister,
mobileAreaList: state => state.pubConfig.mobileAreaList
}),
canSendMobileCaptcha() {
return this.countdown === 0 && validateMobile(this.form.mobile, this.form.areaCode);
}
},
data() {
return {
form: {
areaCode: '+86',
mobile: '',
captcha: '',
captchaId: '',
smsCode: '',
newPassword: '',
confirmPassword: ''
},
captchaUrl: '',
countdown: 0,
timer: null
}
},
mounted() {
this.fetchCaptcha();
},
methods: {
// 复用验证码获取方法
fetchCaptcha() {
this.form.captchaId = getUUID();
Api.user.getCaptcha(this.form.captchaId, (res) => {
if (res.status === 200) {
const blob = new Blob([res.data], { type: res.data.type });
this.captchaUrl = URL.createObjectURL(blob);
} else {
console.error('验证码加载异常:', error);
showDanger('验证码加载失败,点击刷新');
}
});
},
// 封装输入验证逻辑
validateInput(input, message) {
if (!input.trim()) {
showDanger(message);
return false;
}
return true;
},
// 发送手机验证码
sendMobileCaptcha() {
if (!validateMobile(this.form.mobile, this.form.areaCode)) {
showDanger('请输入正确的手机号码');
return;
}
// 验证图形验证码
if (!this.validateInput(this.form.captcha, '请输入图形验证码')) {
this.fetchCaptcha();
return;
}
// 清除可能存在的旧定时器
if (this.timer) {
clearInterval(this.timer);
this.timer = null;
}
// 开始倒计时
this.countdown = 60;
this.timer = setInterval(() => {
if (this.countdown > 0) {
this.countdown--;
} else {
clearInterval(this.timer);
this.timer = null;
}
}, 1000);
// 调用发送验证码接口
Api.user.sendSmsVerification({
phone: this.form.areaCode + this.form.mobile,
captcha: this.form.captcha,
captchaId: this.form.captchaId
}, (res) => {
showSuccess('验证码发送成功');
}, (err) => {
showDanger(err.data.msg || '验证码发送失败');
this.countdown = 0;
this.fetchCaptcha();
});
},
// 修改逻辑
retrievePassword() {
// 验证逻辑
if (!validateMobile(this.form.mobile, this.form.areaCode)) {
showDanger('请输入正确的手机号码');
return;
}
if (!this.form.captcha) {
showDanger('请输入图形验证码');
return;
}
if (!this.form.mobileCaptcha) {
showDanger('请输入短信验证码');
return;
}
if (this.form.newPassword !== this.form.confirmPassword) {
showDanger('两次输入的密码不一致');
return;
}
Api.user.retrievePassword({
phone: this.form.areaCode + this.form.mobile,
password: this.form.newPassword,
code: this.form.mobileCaptcha
}, (res) => {
showSuccess('密码重置成功');
goToPage('/login');
}, (err) => {
showDanger(err.data.msg || '重置失败');
if (err.data != null && err.data.msg != null && err.data.msg.indexOf('图形验证码') > -1) {
this.fetchCaptcha()
}
});
},
goToLogin() {
goToPage('/login')
}
},
beforeDestroy() {
if (this.timer) {
clearInterval(this.timer);
}
}
}
</script>
<style lang="scss" scoped>
@import './auth.scss';
.send-captcha-btn {
margin-right: -5px;
min-width: 100px;
height: 40px;
line-height: 40px;
border-radius: 4px;
font-size: 14px;
background: rgb(87, 120, 255);
border: none;
padding: 0;
&:disabled {
background: #c0c4cc;
cursor: not-allowed;
}
}
</style>