登录注册、手机号、邮箱
This commit is contained in:
@@ -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;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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());
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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) {
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
430
schoolNewsServ/auth/注册功能说明.md
Normal file
430
schoolNewsServ/auth/注册功能说明.md
Normal 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. 记录注册日志用于分析
|
||||
|
||||
454
schoolNewsServ/auth/登录功能说明.md
Normal file
454
schoolNewsServ/auth/登录功能说明.md
Normal 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
|
||||
**作者**:开发团队
|
||||
|
||||
264
schoolNewsServ/auth/短信服务架构说明.md
Normal file
264
schoolNewsServ/auth/短信服务架构说明.md
Normal 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步
|
||||
|
||||
这样的设计既满足当前需求,又具备良好的扩展性,是一个**优雅、实用、易维护**的架构方案。
|
||||
|
||||
242
schoolNewsServ/auth/邮件服务配置说明.md
Normal file
242
schoolNewsServ/auth/邮件服务配置说明.md
Normal 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会在日志中输出验证码,不会实际发送
|
||||
|
||||
建议在开发环境中配置一个测试邮箱,以便完整测试邮件发送功能。
|
||||
|
||||
253
schoolNewsServ/auth/阿里云短信配置示例.md
Normal file
253
schoolNewsServ/auth/阿里云短信配置示例.md
Normal 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 ID:LTAI5txxxxxxxxxxxxxxxxxx
|
||||
- AccessKey Secret:xxxxxxxxxxxxxxxxxxxxxxxxxxxxxx
|
||||
|
||||
#### 步骤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. 定期查看账户余额
|
||||
|
||||
327
schoolNewsServ/auth/验证码安全机制说明.md
Normal file
327
schoolNewsServ/auth/验证码安全机制说明.md
Normal file
@@ -0,0 +1,327 @@
|
||||
# 验证码安全机制说明
|
||||
|
||||
## 安全问题
|
||||
|
||||
### 旧方案的漏洞
|
||||
|
||||
```
|
||||
Redis存储:sms:code:手机号 => 验证码
|
||||
```
|
||||
|
||||
**问题**:任何人只要知道手机号和验证码,就可以注册该手机号的账号。
|
||||
|
||||
**攻击场景**:
|
||||
1. 攻击者获取目标用户手机号
|
||||
2. 攻击者触发验证码发送
|
||||
3. 如果攻击者通过其他方式获取到验证码(如社会工程学、短信拦截等)
|
||||
4. 攻击者可以用这个手机号+验证码注册账号
|
||||
|
||||
## 新方案:SessionID绑定
|
||||
|
||||
### 核心思想
|
||||
|
||||
验证码不直接绑定手机号/邮箱,而是绑定一个临时会话ID(SessionID),确保只有发起验证码请求的用户才能使用。
|
||||
|
||||
### 实现机制
|
||||
|
||||
#### 1. 发送验证码流程
|
||||
|
||||
```
|
||||
用户请求发送验证码(手机号/邮箱)
|
||||
↓
|
||||
后端生成 SessionID(UUID)
|
||||
↓
|
||||
发送验证码到用户手机/邮箱
|
||||
↓
|
||||
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机制,我们将验证码从"谁都可以用"变成了"只有请求者可以用",大大提高了系统的安全性。这是一个简单但有效的安全增强方案,推荐在所有需要验证码的场景中使用。
|
||||
|
||||
Reference in New Issue
Block a user