登录注册、手机号、邮箱

This commit is contained in:
2025-11-03 13:37:55 +08:00
parent 16754b527e
commit 35aee59178
26 changed files with 4292 additions and 163 deletions

View File

@@ -1,14 +1,24 @@
package org.xyzh.auth.controller;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.security.crypto.password.PasswordEncoder;
import org.springframework.web.bind.annotation.*;
import org.xyzh.api.auth.login.LoginService;
import org.xyzh.api.system.user.UserService;
import org.xyzh.common.core.domain.LoginParam;
import org.xyzh.common.core.domain.LoginDomain;
import org.xyzh.common.core.domain.ResultDomain;
import org.xyzh.common.dto.user.TbSysUser;
import org.xyzh.common.utils.IDUtils;
import org.xyzh.common.utils.EmailUtils;
import org.xyzh.common.utils.SmsUtils;
import org.xyzh.common.redis.service.RedisService;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import jakarta.servlet.http.HttpServletRequest;
import java.util.concurrent.TimeUnit;
import java.util.Map;
/**
* @description AuthController.java文件描述 认证控制器
@@ -21,9 +31,25 @@ import jakarta.servlet.http.HttpServletRequest;
@RequestMapping("/auth")
public class AuthController {
private static final Logger logger = LoggerFactory.getLogger(AuthController.class);
@Autowired
private LoginService loginService;
@Autowired
private UserService userService;
@Autowired
private EmailUtils emailUtils;
@Autowired
private SmsUtils smsUtils;
@Autowired
private RedisService redisService;
@Autowired
private PasswordEncoder passwordEncoder;
/**
* @description 用户登录
* @param loginParam 登录参数
@@ -99,4 +125,360 @@ public class AuthController {
result.success("认证服务运行正常", "OK");
return result;
}
/**
* @description 发送邮箱验证码
* @param requestBody 包含email字段的请求体
* @return ResultDomain<Boolean> 发送结果
* @author yslg
* @since 2025-11-03
*/
@PostMapping("/send-email-code")
public ResultDomain<Map<String, String>> sendEmailCode(@RequestBody Map<String, String> requestBody) {
ResultDomain<Map<String, String>> result = new ResultDomain<>();
String email = requestBody.get("email");
// 验证邮箱格式
if (email == null || email.trim().isEmpty()) {
result.fail("邮箱不能为空");
return result;
}
String emailRegex = "^[A-Za-z0-9+_.-]+@[A-Za-z0-9.-]+\\.[A-Za-z]{2,}$";
if (!email.matches(emailRegex)) {
result.fail("邮箱格式不正确");
return result;
}
// 检查是否频繁发送60秒内只能发送一次
String rateLimitKey = "email:code:ratelimit:" + email;
if (redisService.hasKey(rateLimitKey)) {
result.fail("验证码已发送,请勿重复发送");
return result;
}
// 生成会话ID用于绑定验证码和用户
String sessionId = IDUtils.generateID();
// 生成6位数字验证码
String code = EmailUtils.generateVerificationCode();
// 发送邮件
boolean success = emailUtils.sendVerificationCode(email, code);
if (success) {
// 将验证码存储到Redis绑定sessionId有效期5分钟
String codeKey = "email:code:" + sessionId;
String codeValue = email + ":" + code; // 格式:邮箱:验证码
redisService.set(codeKey, codeValue, 5, TimeUnit.MINUTES);
// 设置5分钟的发送频率限制
redisService.set(rateLimitKey, "1", 5, TimeUnit.MINUTES);
// 返回sessionId给前端
Map<String, String> data = Map.of(
"sessionId", sessionId,
"message", "验证码已发送到邮箱"
);
logger.info("邮箱验证码已发送,邮箱: {}, sessionId: {}", email, sessionId);
result.success("验证码已发送到邮箱", data);
} else {
result.fail("验证码发送失败,请稍后重试");
}
return result;
}
/**
* @description 发送手机验证码
* @param requestBody 包含phone字段的请求体
* @return ResultDomain<Boolean> 发送结果
* @author yslg
* @since 2025-11-03
*/
@PostMapping("/send-sms-code")
public ResultDomain<Map<String, String>> sendSmsCode(@RequestBody Map<String, String> requestBody) {
ResultDomain<Map<String, String>> result = new ResultDomain<>();
String phone = requestBody.get("phone");
// 验证手机号格式
if (phone == null || phone.trim().isEmpty()) {
result.fail("手机号不能为空");
return result;
}
if (!SmsUtils.isValidPhone(phone)) {
result.fail("手机号格式不正确");
return result;
}
// 检查是否频繁发送60秒内只能发送一次
String rateLimitKey = "sms:code:ratelimit:" + phone;
if (redisService.hasKey(rateLimitKey)) {
result.fail("验证码已发送,请勿重复发送");
return result;
}
// 生成会话ID用于绑定验证码和用户
String sessionId = IDUtils.generateID();
// 生成6位数字验证码
String code = SmsUtils.generateVerificationCode();
// 发送短信
boolean success = smsUtils.sendVerificationCode(phone, code);
if (success) {
// 将验证码存储到Redis绑定sessionId有效期5分钟
String codeKey = "sms:code:" + sessionId;
String codeValue = phone + ":" + code; // 格式:手机号:验证码
redisService.set(codeKey, codeValue, 5, TimeUnit.MINUTES);
// 设置5分钟的发送频率限制
redisService.set(rateLimitKey, "1", 5, TimeUnit.MINUTES);
// 返回sessionId给前端
Map<String, String> data = Map.of(
"sessionId", sessionId,
"message", "验证码已发送"
);
logger.info("短信验证码已发送,手机号: {}, sessionId: {}", phone, sessionId);
result.success("验证码已发送", data);
} else {
result.fail("验证码发送失败,请稍后重试");
}
return result;
}
/**
* @description 用户注册
* @param requestBody 注册参数
* @return ResultDomain<LoginDomain> 注册结果
* @author yslg
* @since 2025-11-03
*/
@PostMapping("/register")
public ResultDomain<LoginDomain> register(@RequestBody Map<String, Object> requestBody, HttpServletRequest request) {
ResultDomain<LoginDomain> result = new ResultDomain<>();
try {
// 获取注册参数
String registerType = (String) requestBody.get("registerType");
String username = (String) requestBody.get("username");
String phone = (String) requestBody.get("phone");
String email = (String) requestBody.get("email");
String password = (String) requestBody.get("password");
String confirmPassword = (String) requestBody.get("confirmPassword");
String smsCode = (String) requestBody.get("smsCode");
String emailCode = (String) requestBody.get("emailCode");
String smsSessionId = (String) requestBody.get("smsSessionId");
String emailSessionId = (String) requestBody.get("emailSessionId");
String studentId = (String) requestBody.get("studentId");
// 1. 参数验证
if (password == null || password.trim().isEmpty()) {
result.fail("密码不能为空");
return result;
}
if (password.length() < 6) {
result.fail("密码至少6个字符");
return result;
}
if (!password.equals(confirmPassword)) {
result.fail("两次输入的密码不一致");
return result;
}
// 2. 根据注册类型进行不同的验证
TbSysUser user = new TbSysUser();
switch (registerType) {
case "username":
// 用户名注册
if (username == null || username.trim().isEmpty()) {
result.fail("用户名不能为空");
return result;
}
if (username.length() < 3 || username.length() > 20) {
result.fail("用户名长度为3-20个字符");
return result;
}
if (!username.matches("^[a-zA-Z0-9_]+$")) {
result.fail("用户名只能包含字母、数字和下划线");
return result;
}
user.setUsername(username);
logger.info("用户名注册: {}", username);
break;
case "phone":
// 手机号注册
if (phone == null || phone.trim().isEmpty()) {
result.fail("手机号不能为空");
return result;
}
if (!phone.matches("^1[3-9]\\d{9}$")) {
result.fail("手机号格式不正确");
return result;
}
if (smsCode == null || smsCode.trim().isEmpty()) {
result.fail("请输入手机验证码");
return result;
}
if (smsSessionId == null || smsSessionId.trim().isEmpty()) {
result.fail("会话已失效,请重新获取验证码");
return result;
}
// 通过sessionId验证手机验证码
String smsCodeKey = "sms:code:" + smsSessionId;
String storedSmsValue = (String) redisService.get(smsCodeKey);
if (storedSmsValue == null) {
result.fail("验证码已过期,请重新获取");
return result;
}
// 解析存储的值:手机号:验证码
String[] smsParts = storedSmsValue.split(":");
if (smsParts.length != 2) {
result.fail("验证码数据异常");
return result;
}
String storedPhone = smsParts[0];
String storedSmsCode = smsParts[1];
// 验证手机号和验证码是否匹配
if (!storedPhone.equals(phone)) {
result.fail("手机号与验证码不匹配");
logger.warn("手机号注册验证失败,提交手机号: {}, 验证码绑定手机号: {}", phone, storedPhone);
return result;
}
if (!storedSmsCode.equals(smsCode)) {
result.fail("验证码错误");
return result;
}
// 验证码使用后删除
redisService.delete(smsCodeKey);
user.setPhone(phone);
user.setUsername(phone); // 使用手机号作为用户名
logger.info("手机号注册: {}, sessionId: {}", phone, smsSessionId);
break;
case "email":
// 邮箱注册
if (email == null || email.trim().isEmpty()) {
result.fail("邮箱不能为空");
return result;
}
if (!email.matches("^[A-Za-z0-9+_.-]+@[A-Za-z0-9.-]+\\.[A-Za-z]{2,}$")) {
result.fail("邮箱格式不正确");
return result;
}
if (emailCode == null || emailCode.trim().isEmpty()) {
result.fail("请输入邮箱验证码");
return result;
}
if (emailSessionId == null || emailSessionId.trim().isEmpty()) {
result.fail("会话已失效,请重新获取验证码");
return result;
}
// 通过sessionId验证邮箱验证码
String emailCodeKey = "email:code:" + emailSessionId;
String storedEmailValue = (String) redisService.get(emailCodeKey);
if (storedEmailValue == null) {
result.fail("验证码已过期,请重新获取");
return result;
}
// 解析存储的值:邮箱:验证码
String[] emailParts = storedEmailValue.split(":");
if (emailParts.length != 2) {
result.fail("验证码数据异常");
return result;
}
String storedEmail = emailParts[0];
String storedEmailCode = emailParts[1];
// 验证邮箱和验证码是否匹配
if (!storedEmail.equals(email)) {
result.fail("邮箱与验证码不匹配");
logger.warn("邮箱注册验证失败,提交邮箱: {}, 验证码绑定邮箱: {}", email, storedEmail);
return result;
}
if (!storedEmailCode.equals(emailCode)) {
result.fail("验证码错误");
return result;
}
// 验证码使用后删除
redisService.delete(emailCodeKey);
user.setEmail(email);
user.setUsername(email.split("@")[0]); // 使用邮箱前缀作为用户名
logger.info("邮箱注册: {}, sessionId: {}", email, emailSessionId);
break;
default:
result.fail("未知的注册类型");
return result;
}
// 3. 密码加密
String encryptedPassword = passwordEncoder.encode(password);
user.setPassword(encryptedPassword);
// 4. 设置用户状态为正常
user.setStatus(0);
// 5. 调用UserService注册用户
ResultDomain<TbSysUser> registerResult = userService.registerUser(user);
if (!registerResult.isSuccess()) {
result.fail(registerResult.getMessage());
return result;
}
logger.info("用户注册成功: {}", user.getUsername());
// 6. 注册成功后自动登录
LoginParam loginParam = new LoginParam();
loginParam.setUsername(user.getUsername());
loginParam.setPassword(password);
loginParam.setLoginType("password");
if (phone != null && !phone.trim().isEmpty()) {
loginParam.setPhone(phone);
}
if (email != null && !email.trim().isEmpty()) {
loginParam.setEmail(email);
}
ResultDomain<LoginDomain> loginResult = loginService.login(loginParam, request);
if (loginResult.isSuccess()) {
result.success("注册成功", loginResult.getData());
} else {
// 注册成功但登录失败,返回注册成功信息
result.success("注册成功,请登录", loginResult.getData());
}
} catch (Exception e) {
logger.error("用户注册失败", e);
result.fail("注册失败: " + e.getMessage());
}
return result;
}
}

View File

@@ -118,23 +118,17 @@ public class LoginServiceImpl implements LoginService {
}
if (loginType.equals("password")) {
// 验证凭据(密码或验证码)
if (!strategy.verifyCredential(loginParam.getPassword(), user.getPassword())) {
result.fail("密码错误");
logLoginAttempt(loginParam, user, false, loginAttempt, "密码错误");
return result;
}
}
else {
// 验证凭据(验证码)
String storedCaptcha = (String) redisService.get("captcha:" + loginParam.getPhone());
if (!strategy.verifyCredential(loginParam.getCaptcha(), storedCaptcha)) {
result.fail("验证码错误");
logLoginAttempt(loginParam, user, false, loginAttempt, "验证码错误");
} else if (loginType.equals("email") || loginType.equals("phone")) {
if (!strategy.verifyCaptchaWithSession(loginParam)) {
result.fail("验证码错误或已过期");
logLoginAttempt(loginParam, user, false, loginAttempt, "验证码验证失败");
return result;
}
// 验证码使用后删除
redisService.delete("captcha:" + loginParam.getPhone());
}
// 构建登录域对象
@@ -142,9 +136,12 @@ public class LoginServiceImpl implements LoginService {
// 生成JWT令牌
loginDomain.setToken(jwtTokenUtil.generateToken(loginDomain));
// 将LoginDomain存储到Redis中
// 将LoginDomain存储到Redis中根据rememberMe设置不同的过期时间
String redisKey = "login:token:" + user.getID();
redisService.set(redisKey, loginDomain, 24 * 60 * 60, TimeUnit.SECONDS);
long expireTime = loginParam.isRememberMe()
? 7 * 24 * 60 * 60 // rememberMe: 7天
: 24 * 60 * 60; // 不rememberMe: 1天
redisService.set(redisKey, loginDomain, expireTime, TimeUnit.SECONDS);
// 登录成功后清除失败次数并记录成功日志
redisService.delete(attemptKey);
@@ -280,6 +277,16 @@ public class LoginServiceImpl implements LoginService {
if (user != null) {
loginLog.setUserID(user.getID());
loginLog.setUsername(user.getUsername());
}else{
if (loginParam.getLoginType().equals("password")) {
loginLog.setUsername(loginParam.getUsername());
}else if (loginParam.getLoginType().equals("email")) {
loginLog.setUsername(loginParam.getEmail());
}else if (loginParam.getLoginType().equals("phone")) {
loginLog.setUsername(loginParam.getPhone());
}else if (loginParam.getLoginType().equals("wechat")) {
loginLog.setUsername(loginParam.getWechatID());
}
}
// 注意:实际生产中不应记录密码
// loginLog.setPassword(loginParam.getPassword());

View File

@@ -47,4 +47,16 @@ public interface LoginStrategy {
* @since 2025-09-28
*/
boolean verifyCredential(String inputCredential, String storedCredential);
/**
* @description 验证验证码从Redis获取并验证SessionID
* @param loginParam 登录参数
* @return boolean 是否验证成功
* @author yslg
* @since 2025-11-03
*/
default boolean verifyCaptchaWithSession(LoginParam loginParam) {
// 默认实现:不支持验证码登录
return false;
}
}

View File

@@ -2,14 +2,12 @@ package org.xyzh.auth.strategy.impl;
import org.springframework.stereotype.Component;
import org.springframework.security.crypto.password.PasswordEncoder;
import java.util.List;
import org.springframework.beans.factory.annotation.Autowired;
import org.xyzh.auth.strategy.LoginStrategy;
import org.xyzh.common.core.domain.LoginParam;
import org.xyzh.common.dto.user.TbSysUser;
import org.xyzh.api.system.user.UserService;
import org.xyzh.common.redis.service.RedisService;
/**
* @description EmailLoginStrategy.java文件描述 邮箱登录策略
@@ -34,23 +32,72 @@ public class EmailLoginStrategy implements LoginStrategy {
@Override
public boolean validate(LoginParam loginParam) {
return loginParam.getEmail() != null && !loginParam.getEmail().trim().isEmpty()
&& loginParam.getPassword() != null && !loginParam.getPassword().trim().isEmpty();
if (loginParam.getEmail() == null || loginParam.getEmail().trim().isEmpty()) {
return false;
}
// 密码登录或验证码登录都可以
return (loginParam.getPassword() != null && !loginParam.getPassword().trim().isEmpty())
|| (loginParam.getCaptcha() != null && !loginParam.getCaptcha().trim().isEmpty());
}
@Override
public TbSysUser findUser(LoginParam loginParam) {
TbSysUser filter = new TbSysUser();
filter.setEmail(loginParam.getEmail());
List<TbSysUser> users = userService.getUserByFilter(filter).getDataList();
if(users.isEmpty()) {
TbSysUser user = userService.getLoginUser(filter).getData();
if(user == null) {
return null;
}
return users.get(0);
return user;
}
@Autowired
private RedisService redisService;
@Override
public boolean verifyCredential(String inputCredential, String storedCredential) {
// 密码验证
return passwordEncoder.matches(inputCredential, storedCredential);
}
@Override
public boolean verifyCaptchaWithSession(LoginParam loginParam) {
String captchaId = loginParam.getCaptchaId();
String inputCaptcha = loginParam.getCaptcha();
String email = loginParam.getEmail();
// 验证参数
if (captchaId == null || captchaId.trim().isEmpty()) {
return false;
}
if (inputCaptcha == null || inputCaptcha.trim().isEmpty()) {
return false;
}
// 从Redis获取验证码
String codeKey = "email:code:" + captchaId;
String storedValue = (String) redisService.get(codeKey);
if (storedValue == null) {
return false;
}
// 解析存储的值:邮箱:验证码
String[] parts = storedValue.split(":");
if (parts.length != 2) {
return false;
}
String storedEmail = parts[0];
String storedCaptcha = parts[1];
// 验证邮箱和验证码是否匹配
if (!storedEmail.equals(email) || !storedCaptcha.equals(inputCaptcha)) {
return false;
}
// 验证码使用后删除
redisService.delete(codeKey);
return true;
}
}

View File

@@ -7,6 +7,9 @@ import org.xyzh.auth.strategy.LoginStrategy;
import org.xyzh.common.core.domain.LoginParam;
import org.xyzh.common.dto.user.TbSysUser;
import org.xyzh.common.utils.NonUtils;
import org.xyzh.common.utils.validation.ValidationUtils;
import org.xyzh.common.utils.validation.method.EmailValidateMethod;
import org.xyzh.common.utils.validation.method.PhoneValidateMethod;
import org.xyzh.api.system.user.UserService;
import java.util.List;
@@ -53,15 +56,15 @@ public class PasswordLoginStrategy implements LoginStrategy {
@Override
public TbSysUser findUser(LoginParam loginParam) {
TbSysUser filter = new TbSysUser();
if (NonUtils.isNotEmpty(loginParam.getUsername())) {
EmailValidateMethod emailValidateMethod = new EmailValidateMethod();
PhoneValidateMethod phoneValidateMethod = new PhoneValidateMethod();
if(emailValidateMethod.validate(loginParam.getUsername())){
filter.setEmail(loginParam.getUsername());
}else if (phoneValidateMethod.validate(loginParam.getUsername())){
filter.setPhone(loginParam.getUsername());
}else{
filter.setUsername(loginParam.getUsername());
}
if (NonUtils.isNotEmpty(loginParam.getEmail())) {
filter.setEmail(loginParam.getEmail());
}
if (NonUtils.isNotEmpty(loginParam.getPhone())) {
filter.setPhone(loginParam.getPhone());
}
filter.setPassword(passwordEncoder.encode(loginParam.getPassword()));
TbSysUser user = userService.getLoginUser(filter).getData();
if(user == null) {

View File

@@ -7,8 +7,7 @@ import org.xyzh.auth.strategy.LoginStrategy;
import org.xyzh.common.core.domain.LoginParam;
import org.xyzh.common.dto.user.TbSysUser;
import org.xyzh.api.system.user.UserService;
import java.util.List;
import org.xyzh.common.redis.service.RedisService;
/**
* @description PhoneLoginStrategy.java文件描述 手机号登录策略
@@ -33,27 +32,72 @@ public class PhoneLoginStrategy implements LoginStrategy {
@Override
public boolean validate(LoginParam loginParam) {
return loginParam.getPhone() != null && !loginParam.getPhone().trim().isEmpty()
&& (loginParam.getPassword() != null || loginParam.getCaptcha() != null);
if (loginParam.getPhone() == null || loginParam.getPhone().trim().isEmpty()) {
return false;
}
// 密码登录或验证码登录都可以
return (loginParam.getPassword() != null && !loginParam.getPassword().trim().isEmpty())
|| (loginParam.getCaptcha() != null && !loginParam.getCaptcha().trim().isEmpty());
}
@Override
public TbSysUser findUser(LoginParam loginParam) {
TbSysUser filter = new TbSysUser();
filter.setPhone(loginParam.getPhone());
List<TbSysUser> users = userService.getUserByFilter(filter).getDataList();
if(users.isEmpty()) {
TbSysUser user = userService.getLoginUser(filter).getData();
if(user == null) {
return null;
}
return users.get(0);
return user;
}
@Autowired
private RedisService redisService;
@Override
public boolean verifyCredential(String inputCredential, String storedCredential) {
// 手机号登录可能使用验证码,如果有验证码则跳过密码验证
if (inputCredential == null) {
return true; // 假设验证码已经在其他地方验证过了
}
// 密码验证
return passwordEncoder.matches(inputCredential, storedCredential);
}
@Override
public boolean verifyCaptchaWithSession(LoginParam loginParam) {
String captchaId = loginParam.getCaptchaId();
String inputCaptcha = loginParam.getCaptcha();
String phone = loginParam.getPhone();
// 验证参数
if (captchaId == null || captchaId.trim().isEmpty()) {
return false;
}
if (inputCaptcha == null || inputCaptcha.trim().isEmpty()) {
return false;
}
// 从Redis获取验证码
String codeKey = "sms:code:" + captchaId;
String storedValue = (String) redisService.get(codeKey);
if (storedValue == null) {
return false;
}
// 解析存储的值:手机号:验证码
String[] parts = storedValue.split(":");
if (parts.length != 2) {
return false;
}
String storedPhone = parts[0];
String storedCaptcha = parts[1];
// 验证手机号和验证码是否匹配
if (!storedPhone.equals(phone) || !storedCaptcha.equals(inputCaptcha)) {
return false;
}
// 验证码使用后删除
redisService.delete(codeKey);
return true;
}
}

View File

@@ -8,8 +8,6 @@ import org.xyzh.common.core.domain.LoginParam;
import org.xyzh.common.dto.user.TbSysUser;
import org.xyzh.api.system.user.UserService;
import java.util.List;
/**
* @description UsernameLoginStrategy.java文件描述 用户名登录策略
* @filename UsernameLoginStrategy.java
@@ -41,11 +39,11 @@ public class UsernameLoginStrategy implements LoginStrategy {
public TbSysUser findUser(LoginParam loginParam) {
TbSysUser filter = new TbSysUser();
filter.setUsername(loginParam.getUsername());
List<TbSysUser> users = userService.getUserByFilter(filter).getDataList();
if(users.isEmpty()) {
TbSysUser user = userService.getLoginUser(filter).getData();
if(user == null) {
return null;
}
return users.get(0);
return user;
}
@Override

View File

@@ -7,8 +7,6 @@ import org.xyzh.common.core.domain.LoginParam;
import org.xyzh.common.dto.user.TbSysUser;
import org.xyzh.api.system.user.UserService;
import java.util.List;
/**
* @description WechatLoginStrategy.java文件描述 微信登录策略
* @filename WechatLoginStrategy.java
@@ -37,11 +35,11 @@ public class WechatLoginStrategy implements LoginStrategy {
public TbSysUser findUser(LoginParam loginParam) {
TbSysUser filter = new TbSysUser();
filter.setWechatID(loginParam.getWechatID());
List<TbSysUser> users = userService.getUserByFilter(filter).getDataList();
if(users.isEmpty()) {
TbSysUser user = userService.getLoginUser(filter).getData();
if(user == null) {
return null;
}
return users.get(0);
return user;
}
@Override

View File

@@ -0,0 +1,430 @@
# 注册功能说明文档
## 功能概述
系统支持三种注册方式:
1. **用户名注册** - 直接使用用户名+密码注册
2. **手机号注册** - 使用手机号+验证码+密码注册
3. **邮箱注册** - 使用邮箱+验证码+密码注册
## API接口
### 接口地址
```
POST /auth/register
```
### 请求参数
#### 通用参数
| 参数名 | 类型 | 必填 | 说明 |
|--------|------|------|------|
| registerType | String | 是 | 注册类型username/phone/email |
| password | String | 是 | 密码至少6个字符 |
| confirmPassword | String | 是 | 确认密码 |
| studentId | String | 否 | 学号 |
#### 用户名注册 (registerType=username)
| 参数名 | 类型 | 必填 | 说明 |
|--------|------|------|------|
| username | String | 是 | 用户名3-20字符仅字母数字下划线 |
#### 手机号注册 (registerType=phone)
| 参数名 | 类型 | 必填 | 说明 |
|--------|------|------|------|
| phone | String | 是 | 手机号11位中国手机号 |
| smsCode | String | 是 | 手机验证码6位数字 |
#### 邮箱注册 (registerType=email)
| 参数名 | 类型 | 必填 | 说明 |
|--------|------|------|------|
| email | String | 是 | 邮箱地址 |
| emailCode | String | 是 | 邮箱验证码6位数字 |
### 请求示例
#### 1. 用户名注册
```json
{
"registerType": "username",
"username": "zhangsan",
"password": "123456",
"confirmPassword": "123456",
"studentId": "2021001"
}
```
#### 2. 手机号注册
```json
{
"registerType": "phone",
"phone": "13800138000",
"smsCode": "123456",
"password": "123456",
"confirmPassword": "123456",
"studentId": "2021001"
}
```
#### 3. 邮箱注册
```json
{
"registerType": "email",
"email": "user@example.com",
"emailCode": "123456",
"password": "123456",
"confirmPassword": "123456",
"studentId": "2021001"
}
```
### 响应结果
#### 注册成功
```json
{
"code": 200,
"message": "注册成功",
"data": {
"user": {
"ID": "xxx",
"username": "zhangsan",
"email": "user@example.com",
"phone": "13800138000",
"status": 0
},
"token": "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9...",
"loginTime": "2025-11-03T10:30:00",
"ipAddress": "127.0.0.1"
}
}
```
#### 注册失败
```json
{
"code": 500,
"message": "用户已存在",
"data": null
}
```
## 业务流程
### 1. 用户名注册流程
```
用户输入用户名和密码
验证用户名格式3-20字符字母数字下划线
验证密码至少6字符两次输入一致
检查用户名是否已存在
密码加密BCrypt
创建用户账号
自动登录并返回token
```
### 2. 手机号注册流程
```
用户输入手机号
点击"获取验证码"按钮
系统发送短信验证码60秒限制
用户输入验证码和密码
验证手机号格式
验证验证码10分钟有效期
检查手机号是否已注册
密码加密BCrypt
创建用户账号(用户名=手机号)
删除验证码(防止重复使用)
自动登录并返回token
```
### 3. 邮箱注册流程
```
用户输入邮箱
点击"获取验证码"按钮
系统发送邮件验证码60秒限制
用户输入验证码和密码
验证邮箱格式
验证验证码10分钟有效期
检查邮箱是否已注册
密码加密BCrypt
创建用户账号(用户名=邮箱前缀)
删除验证码(防止重复使用)
自动登录并返回token
```
## 安全机制
### 1. 密码加密
- 使用 BCryptPasswordEncoder 进行密码加密
- 不可逆加密,安全性高
- 每次加密结果不同内置salt
### 2. 验证码机制
- **有效期**10分钟
- **发送频率**60秒只能发送一次
- **一次性使用**:验证后立即删除
- **存储位置**Redis
- 手机验证码:`sms:code:手机号`
- 邮箱验证码:`email:code:邮箱`
### 3. 用户唯一性校验
通过UserService.registerUser内部实现
- 检查用户名是否重复
- 检查手机号是否重复
- 检查邮箱是否重复
### 4. 注册后自动登录
- 注册成功后自动调用登录接口
- 返回完整的登录信息token、用户信息等
- 用户无需再次输入密码
## 数据库设计
### 用户表 (tb_sys_user)
| 字段 | 类型 | 说明 |
|------|------|------|
| ID | VARCHAR | 用户ID主键 |
| username | VARCHAR | 用户名 |
| password | VARCHAR | 加密后的密码 |
| email | VARCHAR | 邮箱 |
| phone | VARCHAR | 手机号 |
| wechatID | VARCHAR | 微信号 |
| status | INT | 用户状态0-正常1-禁用2-锁定) |
| createTime | DATETIME | 创建时间 |
| updateTime | DATETIME | 更新时间 |
| deleted | BOOLEAN | 是否删除 |
### 用户信息表 (tb_sys_user_info)
- 与用户表一对一关系
- 存储用户详细信息(头像、性别、姓名等)
- 注册时自动创建默认记录
## 错误码说明
| 错误码 | 错误信息 | 说明 |
|--------|----------|------|
| 500 | 密码不能为空 | 密码字段为空 |
| 500 | 密码至少6个字符 | 密码长度不足 |
| 500 | 两次输入的密码不一致 | 密码和确认密码不匹配 |
| 500 | 用户名不能为空 | 用户名注册时用户名为空 |
| 500 | 用户名长度为3-20个字符 | 用户名长度不符合要求 |
| 500 | 用户名只能包含字母、数字和下划线 | 用户名包含非法字符 |
| 500 | 手机号不能为空 | 手机号注册时手机号为空 |
| 500 | 手机号格式不正确 | 手机号格式错误 |
| 500 | 请输入手机验证码 | 手机验证码为空 |
| 500 | 验证码已过期,请重新获取 | 验证码不存在或已过期 |
| 500 | 验证码错误 | 验证码不正确 |
| 500 | 邮箱不能为空 | 邮箱注册时邮箱为空 |
| 500 | 邮箱格式不正确 | 邮箱格式错误 |
| 500 | 请输入邮箱验证码 | 邮箱验证码为空 |
| 500 | 用户已存在 | 用户名/手机号/邮箱已被注册 |
| 500 | 未知的注册类型 | registerType参数错误 |
## 前端集成示例
### 1. 用户名注册
```typescript
import { authApi } from '@/apis/system/auth';
const handleRegister = async () => {
const result = await authApi.register({
registerType: 'username',
username: 'zhangsan',
password: '123456',
confirmPassword: '123456',
agree: true
});
if (result.code === 200) {
// 注册成功保存token
localStorage.setItem('token', result.data.token);
// 跳转到首页
router.push('/');
}
};
```
### 2. 手机号注册
```typescript
// 发送验证码
const sendSmsCode = async () => {
const result = await authApi.sendSmsCode('13800138000');
if (result.code === 200) {
ElMessage.success('验证码已发送');
// 开始倒计时
startCountdown();
}
};
// 注册
const handleRegister = async () => {
const result = await authApi.register({
registerType: 'phone',
phone: '13800138000',
smsCode: '123456',
password: '123456',
confirmPassword: '123456',
agree: true
});
if (result.code === 200) {
ElMessage.success('注册成功');
// 保存token并跳转
}
};
```
### 3. 邮箱注册
```typescript
// 发送验证码
const sendEmailCode = async () => {
const result = await authApi.sendEmailCode('user@example.com');
if (result.code === 200) {
ElMessage.success('验证码已发送到邮箱');
// 开始倒计时
startCountdown();
}
};
// 注册
const handleRegister = async () => {
const result = await authApi.register({
registerType: 'email',
email: 'user@example.com',
emailCode: '123456',
password: '123456',
confirmPassword: '123456',
agree: true
});
if (result.code === 200) {
ElMessage.success('注册成功');
// 保存token并跳转
}
};
```
## 测试用例
### 1. 用户名注册测试
```bash
# 正常注册
curl -X POST http://localhost:8081/schoolNewsServ/auth/register \
-H "Content-Type: application/json" \
-d '{
"registerType": "username",
"username": "testuser",
"password": "123456",
"confirmPassword": "123456"
}'
# 用户名重复
curl -X POST http://localhost:8081/schoolNewsServ/auth/register \
-H "Content-Type: application/json" \
-d '{
"registerType": "username",
"username": "testuser",
"password": "123456",
"confirmPassword": "123456"
}'
# 预期:返回"用户已存在"
# 用户名过短
curl -X POST http://localhost:8081/schoolNewsServ/auth/register \
-H "Content-Type: application/json" \
-d '{
"registerType": "username",
"username": "ab",
"password": "123456",
"confirmPassword": "123456"
}'
# 预期:返回"用户名长度为3-20个字符"
```
### 2. 手机号注册测试
```bash
# 先发送验证码
curl -X POST http://localhost:8081/schoolNewsServ/auth/send-sms-code \
-H "Content-Type: application/json" \
-d '{"phone": "13800138000"}'
# 然后注册(使用日志中的验证码)
curl -X POST http://localhost:8081/schoolNewsServ/auth/register \
-H "Content-Type: application/json" \
-d '{
"registerType": "phone",
"phone": "13800138000",
"smsCode": "123456",
"password": "123456",
"confirmPassword": "123456"
}'
```
### 3. 邮箱注册测试
```bash
# 先发送验证码
curl -X POST http://localhost:8081/schoolNewsServ/auth/send-email-code \
-H "Content-Type: application/json" \
-d '{"email": "test@example.com"}'
# 然后注册(检查邮箱获取验证码)
curl -X POST http://localhost:8081/schoolNewsServ/auth/register \
-H "Content-Type: application/json" \
-d '{
"registerType": "email",
"email": "test@example.com",
"emailCode": "123456",
"password": "123456",
"confirmPassword": "123456"
}'
```
## 注意事项
1. **验证码有效期**验证码10分钟后失效需要重新获取
2. **发送频率限制**:同一手机号/邮箱60秒内只能发送一次验证码
3. **密码安全**密码使用BCrypt加密存储不可逆
4. **自动登录**注册成功后自动登录前端需要保存返回的token
5. **用户名规则**
- 用户名注册:用户自定义
- 手机号注册:用户名=手机号
- 邮箱注册:用户名=邮箱前缀
6. **开发测试**:短信服务使用模拟模式时,验证码会在日志中输出
## 后续优化建议
1. 添加图形验证码,防止恶意发送验证码
2. 添加IP限流防止批量注册
3. 添加邀请码机制
4. 实现手机号和邮箱的绑定功能
5. 添加密码强度验证
6. 实现找回密码功能
7. 添加注册协议弹窗确认
8. 记录注册日志用于分析

View File

@@ -0,0 +1,454 @@
# 登录功能说明文档
## 功能概述
本系统实现了多种登录方式,包括:
1. **密码登录**:用户名/手机号/邮箱 + 密码
2. **验证码登录**:手机号 + 短信验证码 / 邮箱 + 邮箱验证码
## 架构设计
### 策略模式架构
登录功能采用**策略模式Strategy Pattern**设计,具有以下优势:
- **高扩展性**:新增登录方式只需实现策略接口
- **低耦合**:登录逻辑与业务逻辑分离
- **易维护**:每种登录方式独立实现,互不干扰
### 核心组件
#### 1. 策略接口:`LoginStrategy`
```java
public interface LoginStrategy {
String getLoginType(); // 获取登录类型
boolean validate(LoginParam loginParam); // 验证登录参数
TbSysUser findUser(LoginParam loginParam); // 查找用户
boolean verifyCredential(String input, String stored); // 验证密码
boolean verifyCaptchaWithSession(LoginParam loginParam); // 验证验证码带SessionID
}
```
#### 2. 策略实现
- **PhoneLoginStrategy**:手机号登录策略
- 支持手机号 + 密码登录
- 支持手机号 + 短信验证码登录
- **EmailLoginStrategy**:邮箱登录策略
- 支持邮箱 + 密码登录
- 支持邮箱 + 邮箱验证码登录
- **UsernameLoginStrategy**:用户名登录策略
- 支持用户名 + 密码登录
- **PasswordLoginStrategy**:通用密码登录策略
- 自动识别用户名/手机号/邮箱
#### 3. 策略工厂:`LoginStrategyFactory`
根据登录类型loginType返回对应的登录策略实例。
#### 4. 登录服务:`LoginServiceImpl`
统一的登录入口,调用策略模式处理不同的登录方式。
## 前后端交互
### 前端登录请求
#### 密码登录示例
```javascript
{
"loginType": "phone", // 登录类型username/phone/email/password
"phone": "13800138000", // 手机号phone登录时必填
"password": "123456", // 密码
"rememberMe": true, // 记住我
"agree": true // 同意协议
}
```
#### 验证码登录示例
```javascript
{
"loginType": "phone", // 登录类型
"phone": "13800138000", // 手机号
"captcha": "123456", // 验证码
"captchaId": "uuid-xxx", // 验证码会话ID
"rememberMe": true,
"agree": true
}
```
### 后端登录流程
```
1. 接收登录请求 (POST /auth/login)
2. 自动检测登录类型detectLoginType
3. 获取对应的登录策略LoginStrategyFactory
4. 验证登录参数strategy.validate
5. 查找用户strategy.findUser
6. 检查用户状态
7. 验证凭据:
- 密码登录 → strategy.verifyCredential(password, storedPassword)
- 验证码登录 → strategy.verifyCaptchaWithSession(loginParam)
8. 生成JWT令牌
9. 返回登录结果
```
## 验证码登录实现
### 验证码发送
#### 发送短信验证码
**接口**`POST /auth/send-sms-code`
**请求**
```json
{
"phone": "13800138000"
}
```
**响应**
```json
{
"code": 200,
"message": "验证码已发送",
"data": {
"sessionId": "uuid-xxx",
"message": "验证码已发送"
}
}
```
#### 发送邮箱验证码
**接口**`POST /auth/send-email-code`
**请求**
```json
{
"email": "user@example.com"
}
```
**响应**
```json
{
"code": 200,
"message": "验证码已发送到邮箱",
"data": {
"sessionId": "uuid-xxx",
"message": "验证码已发送到邮箱"
}
}
```
### 验证码验证流程(策略模式)
以手机号验证码登录为例:
```java
@Override
public boolean verifyCaptchaWithSession(LoginParam loginParam) {
// 1. 获取参数
String captchaId = loginParam.getCaptchaId(); // 会话ID
String inputCaptcha = loginParam.getCaptcha(); // 用户输入的验证码
String phone = loginParam.getPhone(); // 手机号
// 2. 参数验证
if (captchaId == null || inputCaptcha == null) {
return false;
}
// 3. 从Redis获取验证码使用SessionID
String codeKey = "sms:code:" + captchaId;
String storedValue = redisService.get(codeKey); // 格式:"手机号:验证码"
if (storedValue == null) {
return false; // 验证码已过期
}
// 4. 解析存储的值
String[] parts = storedValue.split(":");
String storedPhone = parts[0];
String storedCaptcha = parts[1];
// 5. 验证手机号和验证码是否匹配
if (!storedPhone.equals(phone) || !storedCaptcha.equals(inputCaptcha)) {
return false;
}
// 6. 验证码使用后删除
redisService.delete(codeKey);
return true;
}
```
## 安全机制
### 1. SessionID绑定
- 验证码发送时生成唯一的`sessionId`
- Redis存储格式`key="sms:code:{sessionId}", value="phone:code"`
- 验证时必须提供正确的`sessionId`才能获取验证码
- 防止验证码被其他用户使用
### 2. 双重验证
验证码登录时需要同时验证:
- 手机号/邮箱是否匹配
- 验证码是否正确
### 3. 一次性验证
验证码验证成功后立即从Redis删除防止重复使用。
### 4. 有效期限制
- 验证码有效期5分钟
- 发送频率限制60秒内只能发送一次
### 5. 登录会话时长
- **勾选RememberMe**Redis缓存7天前端LocalStorage长期保存
- **未勾选RememberMe**Redis缓存1天前端SessionStorage会话保存
### 6. 登录失败限制
- 最大尝试次数3次
- 超过限制后10分钟内无法登录
## Redis数据结构
### 验证码存储
```
# 短信验证码
key: sms:code:{sessionId}
value: {phone}:{code}
ttl: 300秒5分钟
# 邮箱验证码
key: email:code:{sessionId}
value: {email}:{code}
ttl: 300秒5分钟
```
### 发送频率限制
```
key: send:sms:{phone}
value: 1
ttl: 60秒
key: send:email:{email}
value: 1
ttl: 60秒
```
### 登录失败次数
```
key: login:attempt:{username}
value: {attemptCount}
ttl: 600秒10分钟
```
### 登录会话缓存
```
key: login:token:{userId}
value: LoginDomain对象
ttl:
- rememberMe=true: 7天604800秒
- rememberMe=false: 1天86400秒
```
## 前端实现要点
### 1. 登录模式切换
```javascript
const loginMode = ref<'password' | 'captcha'>('password'); // 密码登录/验证码登录
// 切换登录模式
const switchLoginMode = (mode) => {
loginMode.value = mode;
// 清空表单
loginForm.username = '';
loginForm.password = '';
loginForm.captcha = '';
loginForm.captchaId = '';
};
```
### 2. 验证码类型切换
```javascript
const captchaType = ref<'phone' | 'email'>('phone'); // 手机号/邮箱
// 切换验证码类型
const switchCaptchaType = (type) => {
captchaType.value = type;
// 清空相关字段
};
```
### 3. 发送验证码
```javascript
const handleSendSmsCode = async () => {
// 1. 验证手机号
if (!loginForm.phone || !phonePattern.test(loginForm.phone)) {
ElMessage.warning('请输入正确的手机号');
return;
}
// 2. 调用API发送验证码
const result = await authApi.sendSmsCode(loginForm.phone);
if (result.code === 200 && result.data) {
// 3. 保存sessionId重要
loginForm.captchaId = result.data.sessionId;
// 4. 开始倒计时
smsCountdown.value = 60;
const timer = setInterval(() => {
smsCountdown.value--;
if (smsCountdown.value <= 0) {
clearInterval(timer);
}
}, 1000);
ElMessage.success('验证码已发送');
}
};
```
### 4. 提交登录
```javascript
const handleLogin = async () => {
// 1. 表单验证
await loginFormRef.value.validate();
// 2. 调用登录API前端会自动填充loginType
const result = await store.dispatch('auth/login', loginForm);
// 3. 登录成功处理
if (result.code === 200) {
ElMessage.success('登录成功!');
router.push('/home');
}
};
```
## API端点
| 方法 | 路径 | 说明 | 是否需要认证 |
|------|------|------|-------------|
| POST | /auth/login | 用户登录 | 否 |
| POST | /auth/send-sms-code | 发送短信验证码 | 否 |
| POST | /auth/send-email-code | 发送邮箱验证码 | 否 |
| POST | /auth/logout | 用户登出 | 是 |
## 配置说明
### application.yml
```yaml
# 邮件服务配置
spring:
mail:
host: smtp.qq.com
port: 587
username: your-email@qq.com
password: your-authorization-code
properties:
mail:
smtp:
auth: true
starttls:
enable: true
# 短信服务配置
sms:
enabled: true
provider: aliyun
access-key-id: your-access-key-id
access-key-secret: your-access-key-secret
sign-name: 红色思政学习平台
template-code: SMS_xxxxx
region-id: cn-hangzhou
# 安全白名单
school-news:
auth:
white-list:
- /auth/login
- /auth/send-sms-code
- /auth/send-email-code
```
## 测试建议
### 1. 密码登录测试
- 用户名 + 密码
- 手机号 + 密码
- 邮箱 + 密码
### 2. 验证码登录测试
- 手机号 + 短信验证码
- 邮箱 + 邮箱验证码
- 验证码过期测试
- SessionID不匹配测试
- 验证码错误测试
### 3. 安全测试
- 登录失败次数限制
- 验证码发送频率限制
- 验证码一次性使用
- SessionID绑定验证
## 扩展说明
如需新增登录方式(如微信登录),只需:
1. 创建新的策略实现类
```java
@Component
public class WechatLoginStrategy implements LoginStrategy {
@Override
public String getLoginType() {
return "wechat";
}
// 实现其他方法...
}
```
2. 前端添加对应的UI和逻辑
策略工厂会自动识别并加载新的登录策略。
---
**文档版本**v1.0
**更新日期**2025-11-03
**作者**:开发团队

View File

@@ -0,0 +1,264 @@
# 短信服务架构说明
## 设计理念
短信服务采用**通用化、可扩展**的设计:
- ✅ 配置保持通用,不绑定特定服务商
- ✅ 支持多种短信服务商切换
- ✅ 易于扩展新的服务商
- ✅ 统一的对外接口
## 架构设计
### 1. 配置层(通用化)
```yaml
sms:
enabled: false # 是否启用
provider: aliyun # 服务商选择
access-key-id: xxx # 通用配置
access-key-secret: xxx
sign-name: xxx
template-code: xxx
region-id: xxx
```
**设计优势**
- 配置不带服务商前缀,保持通用性
- 通过 `provider` 灵活切换服务商
- 未来增加服务商无需修改配置结构
### 2. 工具类层SmsUtils
```java
@Component
public class SmsUtils {
// 通用配置
@Value("${sms.provider:aliyun}")
private String provider;
// 对外统一接口
public boolean sendVerificationCode(String phone, String code) {
switch (provider) {
case "aliyun":
return sendByAliyun(phone, code);
case "tencent":
return sendByTencent(phone, code);
default:
return false;
}
}
// 各服务商的私有实现
private boolean sendByAliyun(String phone, String code) { ... }
private boolean sendByTencent(String phone, String code) { ... }
}
```
**设计优势**
- 对外统一接口:`sendVerificationCode()`
- 各服务商只是内部的私有方法
- 调用方无需关心使用哪个服务商
- 新增服务商只需添加一个私有方法
### 3. 调用层AuthController
```java
@Autowired
private SmsUtils smsUtils;
public ResultDomain<Boolean> sendSmsCode(...) {
// 直接调用统一接口,无需关心服务商
boolean success = smsUtils.sendVerificationCode(phone, code);
...
}
```
## 支持的服务商
### 当前已实现
-**阿里云**dysmsapi20170525
### 待实现
-**腾讯云**(预留接口)
-**华为云**(预留接口)
## 如何添加新服务商
### 步骤1添加Maven依赖
`common-util/pom.xml` 中添加对应SDK
```xml
<!-- 例如:腾讯云短信 -->
<dependency>
<groupId>com.tencentcloudapi</groupId>
<artifactId>tencentcloud-sdk-java</artifactId>
<version>3.x.x</version>
</dependency>
```
### 步骤2实现私有方法
`SmsUtils.java` 中添加私有实现方法:
```java
/**
* 使用腾讯云发送短信验证码
*/
private boolean sendByTencent(String phone, String code) {
try {
// 腾讯云SDK调用逻辑
...
return true;
} catch (Exception e) {
logger.error("腾讯云短信发送失败", e);
return false;
}
}
```
### 步骤3添加到分发逻辑
`sendVerificationCode()` 方法中添加case
```java
switch (provider.toLowerCase()) {
case "aliyun":
return sendByAliyun(phone, code);
case "tencent":
return sendByTencent(phone, code); // 新增
default:
logger.error("未知的短信服务商: {}", provider);
return false;
}
```
### 步骤4配置切换
修改 `application.yml`
```yaml
sms:
provider: tencent # 切换到腾讯云
```
完成!无需修改调用方代码。
## 开发模式
### 模拟模式(推荐用于开发)
```yaml
sms:
enabled: false # 关闭真实发送
```
**特点**
- 不会实际发送短信
- 验证码输出到日志
- 不产生任何费用
- 便于本地调试
### 真实发送模式
```yaml
sms:
enabled: true
provider: aliyun
access-key-id: xxx
access-key-secret: xxx
```
## 最佳实践
### 1. 环境隔离
**开发环境**
```yaml
sms:
enabled: false # 模拟模式
```
**生产环境**
```yaml
sms:
enabled: true
provider: aliyun
access-key-id: ${SMS_ACCESS_KEY_ID} # 从环境变量读取
access-key-secret: ${SMS_ACCESS_KEY_SECRET}
```
### 2. 安全性
- ❌ 不要将 AccessKey 写在代码中
- ❌ 不要将 AccessKey 提交到 Git
- ✅ 使用环境变量或配置中心
- ✅ 使用 RAM 子账号而非主账号
- ✅ 定期更换密钥
### 3. 可靠性
```java
// 系统已实现:
- 60秒发送频率限制防刷
- 10分钟验证码有效期
- Redis存储验证码
- 手机号格式验证
// 建议增加:
- 图形验证码前置
- IP限流
- 黑名单机制
- 发送失败重试
```
### 4. 监控告警
建议监控指标:
- 短信发送成功率
- 短信发送量(防异常消耗)
- 验证码验证成功率
- 单个手机号发送频率
## 优势总结
### 对比旧方案
**旧方案**(绑定服务商):
```yaml
aliyun:
sms:
enabled: true
```
- ❌ 配置绑定服务商
- ❌ 切换服务商需要大量修改
- ❌ 扩展性差
**新方案**(通用化):
```yaml
sms:
provider: aliyun
enabled: true
```
- ✅ 配置保持通用
- ✅ 切换服务商只需修改 `provider`
- ✅ 扩展性强,易于维护
### 符合设计原则
- **开闭原则**:对扩展开放,对修改关闭
- **单一职责**:每个服务商实现独立
- **依赖倒置**:调用方依赖抽象接口,不依赖具体实现
- **里氏替换**:各服务商实现可以互相替换
## 总结
这个架构设计的核心理念是:
1. **配置通用化**:不绑定特定服务商
2. **实现私有化**:服务商只是一个方法
3. **接口统一化**:对外提供统一接口
4. **扩展简单化**新增服务商仅需3步
这样的设计既满足当前需求,又具备良好的扩展性,是一个**优雅、实用、易维护**的架构方案。

View File

@@ -0,0 +1,242 @@
# 邮件和短信服务配置说明
## 一、邮件服务配置
### 1. 在 application.yml 中添加邮件配置
```yaml
spring:
mail:
# 邮件服务器地址以QQ邮箱为例
host: smtp.qq.com
# 端口号
port: 587
# 发件人邮箱
username: your-email@qq.com
# 授权码(不是邮箱密码)
password: your-authorization-code
# 编码
default-encoding: UTF-8
properties:
mail:
smtp:
auth: true
starttls:
enable: true
required: true
```
### 2. 获取QQ邮箱授权码步骤
1. 登录QQ邮箱
2. 点击【设置】->【账户】
3. 找到【POP3/IMAP/SMTP/Exchange/CardDAV/CalDAV服务】
4. 开启【POP3/SMTP服务】或【IMAP/SMTP服务】
5. 点击【生成授权码】
6. 按照提示发送短信获取16位授权码
7. 将授权码填入配置文件的 `password` 字段
### 3. 其他常用邮箱配置
#### 163邮箱
```yaml
spring:
mail:
host: smtp.163.com
port: 465
username: your-email@163.com
password: your-authorization-code
```
#### Gmail
```yaml
spring:
mail:
host: smtp.gmail.com
port: 587
username: your-email@gmail.com
password: your-app-password
```
## 二、短信服务配置(支持多服务商)
### 1. 通用配置说明
系统支持多种短信服务商,通过 `provider` 配置切换:
- **aliyun**: 阿里云短信服务(已实现)
- **tencent**: 腾讯云短信服务(待实现)
### 2. 配置文件设置
`application.yml` 中添加以下配置:
```yaml
sms:
# 是否启用短信服务true: 真实发送, false: 模拟模式)
enabled: false
# 短信服务商aliyun: 阿里云, tencent: 腾讯云)
provider: aliyun
# AccessKey ID从服务商控制台获取
access-key-id: LTAI5txxxxxxxxxxxxxxxx
# AccessKey Secret从服务商控制台获取
access-key-secret: xxxxxxxxxxxxxxxxxxxxxxxxxxxxxx
# 短信签名名称(需要在服务商平台申请)
sign-name: 红色思政学习平台
# 验证码模板CODE需要在服务商平台申请
template-code: SMS_123456789
# 区域ID阿里云cn-hangzhou腾讯云ap-guangzhou
region-id: cn-hangzhou
```
### 3. 阿里云短信服务配置
#### 3.1 申请阿里云短信服务
1. 登录 [阿里云控制台](https://www.aliyun.com/)
2. 开通【短信服务】产品
3. 创建签名(需要企业资质或个人认证)
4. 创建短信模板(验证码类型)
- 模板示例:`您的验证码为:${code}该验证码10分钟内有效请勿泄漏于他人`
5. 获取 AccessKey ID 和 AccessKey Secret
- 进入【访问控制】-> 【用户管理】
- 创建RAM用户并授予 AliyunDysmsFullAccess 权限
- 创建 AccessKey
#### 3.2 阿里云配置示例
```yaml
sms:
enabled: true
provider: aliyun
access-key-id: LTAI5txxxxxxxxxxxxxxxx
access-key-secret: xxxxxxxxxxxxxxxxxxxxxxxxxxxxxx
sign-name: 红色思政学习平台
template-code: SMS_123456789
region-id: cn-hangzhou
```
#### 3.3 费用说明
- **验证码短信**:约 ¥0.045/条
- **通知短信**:约 ¥0.045/条
详细配置步骤请查看:`阿里云短信配置示例.md`
### 4. 腾讯云短信服务配置(待实现)
```yaml
sms:
enabled: true
provider: tencent
access-key-id: your-secret-id
access-key-secret: your-secret-key
sign-name: 红色思政学习平台
template-code: 123456
region-id: ap-guangzhou
```
### 5. 开发模式说明
如果 `enabled` 设置为 `false` 或未配置 AccessKey系统会自动使用**模拟模式**
- ✅ 不会实际发送短信
- ✅ 在日志中输出验证码内容
- ✅ 适合开发和测试环境
- ✅ 不产生任何费用
**推荐**:开发环境始终使用 `enabled: false`
### 6. 模板参数说明
验证码模板需要包含 `${code}` 变量系统会自动替换为6位数字验证码。
模板示例:
```
【红色思政学习平台】您的验证码为:${code}该验证码10分钟内有效请勿泄漏于他人
```
### 7. 注意事项
- **AccessKey 安全**:不要将 AccessKey 提交到代码仓库
- **短信限流**:各服务商都有频率限制,注意防刷
- **验证码有效期**:建议在模板中明确说明
- **测试建议**:开发环境使用 `enabled: false` 避免浪费
## 三、Redis配置
验证码存储在Redis中确保Redis服务已启动并配置正确
```yaml
spring:
redis:
host: localhost
port: 6379
password: your-redis-password
database: 0
```
## 四、API接口说明
### 1. 发送邮箱验证码
- **接口**: POST /auth/send-email-code
- **参数**:
```json
{
"email": "user@example.com"
}
```
- **返回**:
```json
{
"code": 200,
"message": "验证码已发送到邮箱",
"data": true
}
```
### 2. 发送手机验证码
- **接口**: POST /auth/send-sms-code
- **参数**:
```json
{
"phone": "13800138000"
}
```
- **返回**:
```json
{
"code": 200,
"message": "验证码已发送",
"data": true
}
```
### 3. 验证码特性
- 验证码有效期10分钟
- 发送频率限制60秒内只能发送一次
- 验证码长度6位数字
- 验证码存储在Redis中格式为
- 邮箱验证码:`email:code:邮箱地址`
- 手机验证码:`sms:code:手机号`
## 五、前端注册方式
前端支持三种注册方式:
1. **用户名注册**:输入用户名、学号、密码
2. **手机号注册**:输入手机号、验证码、学号、密码
3. **邮箱注册**:输入邮箱、验证码、学号、密码
用户可以通过标签页切换注册方式。
## 六、开发环境测试
在开发环境中:
- 邮件服务如果未配置邮箱EmailUtils会记录错误日志
- 短信服务SmsUtils会在日志中输出验证码不会实际发送
建议在开发环境中配置一个测试邮箱,以便完整测试邮件发送功能。

View File

@@ -0,0 +1,253 @@
# 阿里云短信服务配置完整指南
## 一、前期准备
### 1. 注册阿里云账号
访问 https://www.aliyun.com/ 注册账号并完成实名认证
### 2. 开通短信服务
1. 登录阿里云控制台
2. 搜索"短信服务"并开通
3. 进入短信服务控制台https://dysms.console.aliyun.com/
## 二、创建短信签名
### 1. 申请签名
- 进入【国内消息】->【签名管理】
- 点击【添加签名】
- 填写签名信息:
- **签名名称**:红色思政学习平台
- **签名来源**:网站(需要提供网站备案信息)
- **签名场景**:验证码
- **签名说明**:用于用户注册和登录验证
- **资质证明**:上传相关资质文件
### 2. 审核时间
通常1-2个工作日审核完成
## 三、创建短信模板
### 1. 申请模板
- 进入【国内消息】->【模板管理】
- 点击【添加模板】
- 填写模板信息:
- **模板类型**:验证码
- **模板名称**:注册验证码
- **模板内容**:您的验证码为:${code}该验证码10分钟内有效请勿泄漏于他人
### 2. 注意事项
- 模板必须包含变量:`${code}`
- 验证码模板必须说明有效期
- 必须包含防骗提示语
### 3. 审核时间
通常1-2个工作日审核完成
### 4. 获取模板CODE
审核通过后会生成模板CODE格式如`SMS_123456789`
## 四、创建RAM用户推荐
### 1. 为什么使用RAM用户
- 主账号权限过大,不安全
- RAM用户可以精细化权限控制
- 方便管理多个应用的访问权限
### 2. 创建步骤
#### 步骤1进入访问控制
访问https://ram.console.aliyun.com/users
#### 步骤2创建用户
1. 点击【创建用户】
2. 填写用户信息:
- **登录名称**school-news-sms
- **显示名称**:红色思政短信服务
- **访问方式**勾选【OpenAPI调用访问】
#### 步骤3保存AccessKey
创建成功后,**立即下载CSV文件保存AccessKey信息**(只显示一次!)
- AccessKey IDLTAI5txxxxxxxxxxxxxxxxxx
- AccessKey Secretxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx
#### 步骤4授权
1. 找到刚创建的用户
2. 点击【添加权限】
3. 搜索并添加权限:`AliyunDysmsFullAccess`(短信服务完全访问权限)
## 五、配置项目
### 1. 在 application.yml 中配置
```yaml
aliyun:
sms:
# 是否启用阿里云短信服务true: 真实发送, false: 模拟模式)
enabled: true
# AccessKey ID从RAM用户获取
access-key-id: LTAI5txxxxxxxxxxxxxxxxxx
# AccessKey Secret从RAM用户获取
access-key-secret: xxxxxxxxxxxxxxxxxxxxxxxxxxxxxx
# 短信签名名称(审核通过后的签名名称)
sign-name: 红色思政学习平台
# 验证码模板CODE审核通过后的模板CODE
template-code: SMS_123456789
# 区域ID
region-id: cn-hangzhou
```
### 2. 开发环境配置
开发环境建议使用模拟模式:
```yaml
aliyun:
sms:
enabled: false # 关闭真实发送
access-key-id:
access-key-secret:
sign-name: 红色思政学习平台
template-code:
region-id: cn-hangzhou
```
**模拟模式特点**
- 不会实际发送短信(节省费用)
- 验证码会在控制台日志中输出
- 适合开发和测试
### 3. 生产环境配置
生产环境使用真实发送:
```yaml
aliyun:
sms:
enabled: true # 开启真实发送
access-key-id: ${ALIYUN_SMS_ACCESS_KEY_ID} # 从环境变量读取
access-key-secret: ${ALIYUN_SMS_ACCESS_KEY_SECRET} # 从环境变量读取
sign-name: 红色思政学习平台
template-code: SMS_123456789
region-id: cn-hangzhou
```
## 六、测试验证
### 1. 启动项目
```bash
cd schoolNewsServ/admin
mvn spring-boot:run
```
### 2. 测试发送验证码
使用 Postman 或 curl 测试:
```bash
curl -X POST http://localhost:8081/schoolNewsServ/auth/send-sms-code \
-H "Content-Type: application/json" \
-d '{"phone":"13800138000"}'
```
### 3. 查看日志
**模拟模式**
```
【模拟发送】短信验证码,手机号: 13800138000, 验证码: 123456
```
**真实发送**
```
阿里云短信发送成功,手机号: 13800138000, BizId: 123456789^0
```
## 七、费用说明
### 1. 收费标准
- **验证码短信**:约 ¥0.045/条
- **通知短信**:约 ¥0.045/条
- **推广短信**:约 ¥0.055/条
### 2. 充值方式
1. 进入短信服务控制台
2. 点击【充值】
3. 选择充值金额建议先充值100元测试
### 3. 余额查询
控制台首页可查看账户余额和使用情况
## 八、常见问题
### 1. 签名审核不通过
- **原因**:资质不全、信息不符
- **解决**:按审核意见补充资料,重新提交
### 2. 模板审核不通过
- **原因**:内容不规范、缺少必要信息
- **解决**:修改模板内容,确保包含:
- 验证码变量 `${code}`
- 有效期说明
- 防骗提示
### 3. 发送失败isv.BUSINESS_LIMIT_CONTROL
- **原因**:短信发送频率过高
- **解决**
- 同一手机号1分钟内只能发送1条
- 同一手机号1小时内最多发送5条
- 同一手机号1天内最多发送10条
### 4. 发送失败isv.AMOUNT_NOT_ENOUGH
- **原因**:账户余额不足
- **解决**:充值
### 5. 发送失败isv.MOBILE_NUMBER_ILLEGAL
- **原因**:手机号格式错误
- **解决**:检查手机号是否正确
### 6. 代码中看不到验证码
- **原因**enabled=true 时真实发送,不会在日志输出
- **解决**
- 开发环境设置 `enabled: false`
- 或查看手机短信
## 九、安全建议
### 1. AccessKey 安全
- ❌ 不要将 AccessKey 直接写在代码中
- ❌ 不要将 AccessKey 提交到 Git 仓库
- ✅ 使用环境变量或配置中心
- ✅ 定期更换 AccessKey
- ✅ 使用 RAM 用户而非主账号
### 2. 防刷机制
系统已实现:
- ✅ 60秒发送频率限制
- ✅ 验证码10分钟有效期
- ✅ Redis存储验证码
- ✅ 手机号格式验证
建议增加:
- 图形验证码
- IP限流
- 黑名单机制
### 3. 监控告警
- 设置短信发送量告警
- 监控异常发送行为
- 定期查看短信发送报表
## 十、技术支持
- 阿里云短信服务文档https://help.aliyun.com/product/44282.html
- 工单咨询https://selfservice.console.aliyun.com/ticket/createIndex
- 电话客服95187
---
**最后提醒**
1. 开发环境务必使用 `enabled: false` 避免浪费
2. AccessKey 信息要妥善保管
3. 注意短信发送频率限制
4. 定期查看账户余额

View File

@@ -0,0 +1,327 @@
# 验证码安全机制说明
## 安全问题
### 旧方案的漏洞
```
Redis存储sms:code:手机号 => 验证码
```
**问题**:任何人只要知道手机号和验证码,就可以注册该手机号的账号。
**攻击场景**
1. 攻击者获取目标用户手机号
2. 攻击者触发验证码发送
3. 如果攻击者通过其他方式获取到验证码(如社会工程学、短信拦截等)
4. 攻击者可以用这个手机号+验证码注册账号
## 新方案SessionID绑定
### 核心思想
验证码不直接绑定手机号/邮箱而是绑定一个临时会话IDSessionID确保只有发起验证码请求的用户才能使用。
### 实现机制
#### 1. 发送验证码流程
```
用户请求发送验证码(手机号/邮箱)
后端生成 SessionIDUUID
发送验证码到用户手机/邮箱
Redis存储
key: sms:code:SessionID
value: 手机号:验证码
expire: 5分钟
返回 SessionID 给前端
```
**关键点**
- SessionID 是随机生成的UUID无法预测
- 验证码存储时绑定SessionID不直接绑定手机号
- 前端必须保存并传递SessionID
#### 2. 验证验证码流程
```
用户提交注册表单
- 手机号/邮箱
- 验证码
- SessionID从发送验证码时获取
后端验证流程:
1. 通过SessionID从Redis获取手机号:验证码
2. 验证提交的手机号是否匹配存储的手机号
3. 验证提交的验证码是否匹配存储的验证码
4. 两者都匹配才允许注册
注册成功后删除验证码
```
### 数据结构
#### Redis存储格式
**短信验证码**
```
Key: sms:code:550e8400-e29b-41d4-a716-446655440000
Value: 13800138000:123456
TTL: 300秒5分钟
```
**邮箱验证码**
```
Key: email:code:650e8400-e29b-41d4-a716-446655440001
Value: user@example.com:654321
TTL: 300秒5分钟
```
**频率限制**
```
Key: sms:code:ratelimit:13800138000
Value: 1
TTL: 300秒5分钟
```
### API接口变化
#### 发送验证码接口
**旧版本**
```json
POST /auth/send-sms-code
Request: { "phone": "13800138000" }
Response: {
"code": 200,
"message": "验证码已发送",
"data": true
}
```
**新版本**
```json
POST /auth/send-sms-code
Request: { "phone": "13800138000" }
Response: {
"code": 200,
"message": "验证码已发送",
"data": {
"sessionId": "550e8400-e29b-41d4-a716-446655440000",
"message": "验证码已发送"
}
}
```
#### 注册接口
**旧版本**
```json
POST /auth/register
{
"registerType": "phone",
"phone": "13800138000",
"smsCode": "123456",
"password": "123456",
"confirmPassword": "123456"
}
```
**新版本**
```json
POST /auth/register
{
"registerType": "phone",
"phone": "13800138000",
"smsCode": "123456",
"smsSessionId": "550e8400-e29b-41d4-a716-446655440000", // 新增
"password": "123456",
"confirmPassword": "123456"
}
```
### 安全优势
#### 1. 防止验证码盗用
- **旧方案**:知道手机号+验证码 = 可以注册
- **新方案**:需要手机号+验证码+SessionID = 更安全
攻击者即使获取到验证码没有SessionID也无法注册。
#### 2. 会话绑定
SessionID由发起验证码请求的用户持有其他人无法获取。
#### 3. 防止批量注册
每次发送验证码都会生成新的SessionID无法重复使用。
#### 4. 时效性
SessionID和验证码都有5分钟有效期过期自动失效。
### 攻击场景分析
#### 场景1短信拦截攻击
**攻击流程**
1. 攻击者获取目标手机号
2. 攻击者拦截短信获取验证码
**旧方案**:✗ 攻击者可以注册
**新方案**:✓ 攻击者没有SessionID无法注册
#### 场景2社会工程学
**攻击流程**
1. 攻击者诱骗用户说出验证码
**旧方案**:✗ 攻击者可以注册
**新方案**:✓ 攻击者没有SessionID无法注册
#### 场景3中间人攻击
**攻击流程**
1. 攻击者拦截网络请求获取验证码
**旧方案**:✗ 攻击者可以注册
**新方案**:✗ 攻击者可以拦截SessionID需要HTTPS
**防御**必须使用HTTPS加密传输
### 前端实现
#### 1. 保存SessionID
```typescript
// 发送验证码
const sendSmsCode = async () => {
const result = await authApi.sendSmsCode(phone);
if (result.code === 200 && result.data) {
// 保存sessionId到表单
registerForm.smsSessionId = result.data.sessionId;
}
};
```
#### 2. 提交注册
```typescript
// 注册
const register = async () => {
const result = await authApi.register({
registerType: 'phone',
phone: '13800138000',
smsCode: '123456',
smsSessionId: registerForm.smsSessionId, // 传递sessionId
password: '123456',
confirmPassword: '123456'
});
};
```
### 后端实现
#### 1. 生成SessionID
```java
// 生成会话ID
String sessionId = IDUtils.generateID();
// 存储验证码绑定sessionId
String codeKey = "sms:code:" + sessionId;
String codeValue = phone + ":" + code;
redisService.set(codeKey, codeValue, 5, TimeUnit.MINUTES);
// 返回sessionId
Map<String, String> data = Map.of(
"sessionId", sessionId,
"message", "验证码已发送"
);
```
#### 2. 验证SessionID
```java
// 获取存储的值
String codeKey = "sms:code:" + smsSessionId;
String storedValue = redisService.get(codeKey);
// 解析:手机号:验证码
String[] parts = storedValue.split(":");
String storedPhone = parts[0];
String storedCode = parts[1];
// 验证
if (!storedPhone.equals(phone)) {
return "手机号与验证码不匹配";
}
if (!storedCode.equals(smsCode)) {
return "验证码错误";
}
// 验证通过后删除
redisService.delete(codeKey);
```
### 验证日志
```
[INFO] 短信验证码已发送,手机号: 138****8000, sessionId: 550e8400-e29b-41d4-a716-446655440000
[INFO] 手机号注册: 13800138000, sessionId: 550e8400-e29b-41d4-a716-446655440000
[WARN] 手机号注册验证失败,提交手机号: 13800138001, 验证码绑定手机号: 13800138000
```
### 注意事项
1. **HTTPS必须**
- SessionID通过HTTP传输必须使用HTTPS加密
- 防止中间人攻击窃取SessionID
2. **SessionID保密**
- 不要在日志中完整输出SessionID
- 不要在URL参数中传递SessionID
3. **有效期设置**
- 验证码有效期5分钟
- 频率限制5分钟内只能发送一次
- SessionID随验证码一起失效
4. **错误提示**
- 不要暴露具体的验证失败原因
- 统一返回"验证码错误"或"验证码已过期"
5. **前端存储**
- SessionID存储在组件状态中不持久化
- 页面刷新后SessionID丢失需要重新获取验证码
## 对比总结
| 方面 | 旧方案 | 新方案 | 优势 |
|------|--------|--------|------|
| 存储键 | 手机号 | SessionID | 无法通过手机号直接访问 |
| 验证要素 | 手机号+验证码 | 手机号+验证码+SessionID | 多一层安全保障 |
| 防止盗用 | ✗ | ✓ | SessionID由请求方持有 |
| 批量攻击 | 易受攻击 | 难以攻击 | 每次请求生成新SessionID |
| 实现复杂度 | 简单 | 中等 | 增加SessionID管理 |
| 前端改动 | 无 | 需要保存SessionID | 增加一个字段 |
## 推荐实践
1.**使用HTTPS**强制要求HTTPS访问
2.**SessionID加密**可以考虑对SessionID进行签名
3.**监控异常**:记录验证失败的日志,监控异常行为
4.**IP限流**限制单个IP的发送频率
5.**图形验证码**:发送验证码前增加图形验证码
6.**行为分析**:分析用户行为,识别机器人
7.**黑名单机制**:记录恶意手机号/IP加入黑名单
## 总结
通过引入SessionID机制我们将验证码从"谁都可以用"变成了"只有请求者可以用",大大提高了系统的安全性。这是一个简单但有效的安全增强方案,推荐在所有需要验证码的场景中使用。