feat(auth): 实现邮箱验证码发送与校验功能

- 新增邮箱格式校验方法 isValidEmail
- 添加发送邮箱验证码的邮件模板方法 sendCaptchaEmail
- 在用户信息更新时添加邮箱验证码校验逻辑,确保邮箱变更安全
- 新增 API 接口 /sendEmailCaptcha 用于发送邮箱验证码
- 添加 EmailCaptchaParam 参数类支持邮箱验证码请求数据
- 增加邮箱验证码的 Redis 缓存及过期时间常量定义
- 更新安全配置,允许 /api/sendEmailCaptcha 免认证访问
- User实体新增 emailCode 和 smsCode 字段用于验证码校验
This commit is contained in:
2026-07-20 22:38:23 +08:00
parent 7f53b39c56
commit e20503269d
7 changed files with 130 additions and 2 deletions

View File

@@ -5,6 +5,10 @@ public class RedisConstants {
public static final String SMS_CODE_KEY = "sms"; public static final String SMS_CODE_KEY = "sms";
// 验证码过期时间 // 验证码过期时间
public static final Long SMS_CODE_TTL = 5L; public static final Long SMS_CODE_TTL = 5L;
// 邮箱验证码Key
public static final String EMAIL_CODE_KEY = "emailCode";
// 邮箱验证码过期时间(分钟)
public static final Long EMAIL_CODE_TTL = 5L;
// 微信凭证access-token // 微信凭证access-token
public static final String ACCESS_TOKEN_KEY = "access-token"; public static final String ACCESS_TOKEN_KEY = "access-token";
// 空值防止击穿数据库 // 空值防止击穿数据库

View File

@@ -54,6 +54,7 @@ public class SecurityConfig extends WebSecurityConfigurerAdapter {
"/webjars/**", "/webjars/**",
"/hxz/v1/**", "/hxz/v1/**",
"/api/sendSmsCaptcha", "/api/sendSmsCaptcha",
"/api/sendEmailCaptcha",
"/api/loginBySms", "/api/loginBySms",
"/api/loginBySuperAdminSms", "/api/loginBySuperAdminSms",
"/api/loginByDeveloperSms", "/api/loginByDeveloperSms",

View File

@@ -290,4 +290,20 @@ public class CommonUtil {
return pattern.matcher(phoneNumber).matches(); return pattern.matcher(phoneNumber).matches();
} }
/**
* 校验邮箱格式是否有效
*
* @param email 要验证的邮箱字符串
* @return 如果字符串是有效的邮箱地址则返回true否则返回false
*/
public static boolean isValidEmail(String email) {
if (email == null) {
return false;
}
// 邮箱格式正则:本地部分@域名.顶级域
String regex = "^[A-Za-z0-9+_.-]+@[A-Za-z0-9.-]+\\.[A-Za-z]{2,}$";
Pattern pattern = Pattern.compile(regex);
return pattern.matcher(email).matches();
}
} }

View File

@@ -33,6 +33,8 @@ import com.gxwebsoft.common.system.entity.*;
import com.gxwebsoft.common.system.mapper.CompanyMapper; import com.gxwebsoft.common.system.mapper.CompanyMapper;
import com.gxwebsoft.common.system.param.LoginParam; import com.gxwebsoft.common.system.param.LoginParam;
import com.gxwebsoft.common.system.param.SmsCaptchaParam; import com.gxwebsoft.common.system.param.SmsCaptchaParam;
import com.gxwebsoft.common.system.param.EmailCaptchaParam;
import com.gxwebsoft.common.core.constants.RedisConstants;
import com.gxwebsoft.common.system.param.FindAccountByPhoneParam; import com.gxwebsoft.common.system.param.FindAccountByPhoneParam;
import com.gxwebsoft.common.system.param.ResetPasswordParam; import com.gxwebsoft.common.system.param.ResetPasswordParam;
import com.gxwebsoft.common.system.param.UpdatePasswordParam; import com.gxwebsoft.common.system.param.UpdatePasswordParam;
@@ -390,8 +392,33 @@ public class MainController extends BaseController {
update.setAvatar(user.getAvatar()); update.setAvatar(user.getAvatar());
update.setBgImage(user.getBgImage()); update.setBgImage(user.getBgImage());
update.setSex(user.getSex()); update.setSex(user.getSex());
update.setPhone(user.getPhone()); // 手机号变更:仅当提交短信验证码且校验通过时才更新,防止未经验证的手机号被绑定
update.setEmail(user.getEmail()); if (StrUtil.isNotBlank(user.getSmsCode())) {
String newPhone = user.getPhone();
if (StrUtil.isBlank(newPhone) || !CommonUtil.isValidPhoneNumber(newPhone)) {
return fail("手机号格式不正确", null);
}
String key = "code:" + newPhone;
String cached = redisUtil.get(key);
String devCode = redisUtil.get(CACHE_KEY_VERIFICATION_CODE_BY_DEV_SMS);
if (StrUtil.isBlank(cached) || (!cached.equals(user.getSmsCode()) && !user.getSmsCode().equals(devCode))) {
return fail("短信验证码不正确", null);
}
update.setPhone(newPhone);
redisUtil.delete(key);
cacheClient.delete(newPhone);
}
// 邮箱变更:仅当提交邮箱验证码且校验通过时才更新,防止未经验证的邮箱被绑定
if (StrUtil.isNotBlank(user.getEmailCode())) {
String key = RedisConstants.EMAIL_CODE_KEY + ":" + user.getEmail();
String cached = redisUtil.get(key);
if (StrUtil.isBlank(cached) || !cached.equals(user.getEmailCode())) {
return fail("邮箱验证码不正确", null);
}
update.setEmail(user.getEmail());
redisUtil.delete(key);
}
// 未提交邮箱验证码时不更新邮箱(保持原值,禁止绕过校验)
update.setProvince(user.getProvince()); update.setProvince(user.getProvince());
update.setCity(user.getCity()); update.setCity(user.getCity());
update.setRegion(user.getRegion()); update.setRegion(user.getRegion());
@@ -684,6 +711,32 @@ public class MainController extends BaseController {
} }
} }
@Operation(summary = "发送邮箱验证码")
@PostMapping("/sendEmailCaptcha")
public ApiResult<?> sendEmailCaptcha(@RequestBody EmailCaptchaParam param) {
if (param == null || StrUtil.isBlank(param.getEmail())) {
return fail("邮箱不能为空");
}
if (!CommonUtil.isValidEmail(param.getEmail())) {
return fail("请输入有效的邮箱地址");
}
// 生成6位邮箱验证码
String code = Integer.toString(ThreadLocalRandom.current().nextInt(100000, 1000000));
// 存储到Redis5分钟有效期key 与校验时保持一致)
String key = RedisConstants.EMAIL_CODE_KEY + ":" + param.getEmail();
redisUtil.set(key, code, RedisConstants.EMAIL_CODE_TTL, TimeUnit.MINUTES);
cacheClient.set(param.getEmail(), code, RedisConstants.EMAIL_CODE_TTL, TimeUnit.MINUTES);
Integer tenantId = getTenantId();
try {
emailTemplateUtil.sendCaptchaEmail(param.getEmail(), code, tenantId);
log.info("邮箱验证码发送成功 email={}", DesensitizedUtil.email(param.getEmail()));
return success("验证码已发送,请查收邮箱");
} catch (Exception e) {
log.error("邮箱验证码发送失败 email={}", param.getEmail(), e);
return fail("邮件发送失败,请稍后重试");
}
}
@OperationLog @OperationLog
@Operation(summary = "重置密码") @Operation(summary = "重置密码")
@PutMapping("/password") @PutMapping("/password")

View File

@@ -61,6 +61,14 @@ public class User implements UserDetails {
@Schema(description = "邮箱") @Schema(description = "邮箱")
private String email; private String email;
@Schema(description = "邮箱验证码(非数据库字段,仅用于绑定/修改邮箱时校验)")
@TableField(exist = false)
private String emailCode;
@Schema(description = "短信验证码(非数据库字段,仅用于绑定/修改手机号时校验)")
@TableField(exist = false)
private String smsCode;
@Schema(description = "资质") @Schema(description = "资质")
private String aptitude; private String aptitude;

View File

@@ -0,0 +1,30 @@
package com.gxwebsoft.common.system.param;
import com.fasterxml.jackson.annotation.JsonInclude;
import io.swagger.v3.oas.annotations.media.Schema;
import lombok.Data;
import java.io.Serializable;
/**
* 发送邮箱验证码参数
*
* @author WebSoft
* @since 2026-07-20
*/
@Data
@JsonInclude(JsonInclude.Include.NON_NULL)
@Schema(description = "发送邮箱验证码参数")
public class EmailCaptchaParam implements Serializable {
private static final long serialVersionUID = 1L;
@Schema(description = "邮箱")
private String email;
@Schema(description = "租户ID")
private String tenantId;
@Schema(description = "场景")
private String scene;
}

View File

@@ -196,4 +196,20 @@ public class EmailTemplateUtil {
sendNotificationEmailWithAction(title, content, email, tenantId, actionUrl, actionText); sendNotificationEmailWithAction(title, content, email, tenantId, actionUrl, actionText);
} }
/**
* 发送邮箱验证码邮件
*
* @param email 收件人邮箱
* @param code 验证码
* @param tenantId 租户ID
*/
public void sendCaptchaEmail(String email, String code, Integer tenantId) {
if (email == null || email.trim().isEmpty()) {
return;
}
String title = "邮箱验证码 - WebSoft";
String content = "您正在绑定或修改邮箱,验证码为:" + code + "5 分钟内有效,请勿泄露给他人。如非本人操作,请忽略此邮件。";
sendNotificationEmail(title, content, email, tenantId, "尊敬的用户", "验证码用于确认邮箱真实性,请勿转发给他人。", null, null);
}
} }