diff --git a/schoolNewsServ/admin/src/main/resources/application.yml b/schoolNewsServ/admin/src/main/resources/application.yml index 5faa0cb..fc8aa2f 100644 --- a/schoolNewsServ/admin/src/main/resources/application.yml +++ b/schoolNewsServ/admin/src/main/resources/application.yml @@ -50,6 +50,39 @@ spring: # 关闭超时时间 shutdown-timeout: 100ms + # 邮件配置 + mail: + host: smtp.qq.com + port: 587 + username: 3223905473@qq.com + password: xmdmxvtjumxocicc + default-encoding: UTF-8 + properties: + mail: + smtp: + auth: true + starttls: + enable: true + required: true + +# 短信服务配置(支持多种服务商) +sms: + # 是否启用短信服务(true: 真实发送, false: 模拟模式) + # 开发环境建议设置为 false,避免浪费短信费用 + enabled: false + # 短信服务商(aliyun: 阿里云, tencent: 腾讯云) + provider: aliyun + # AccessKey ID(从服务商控制台获取) + access-key-id: LTAI5t68do3qVXx5Rufugt3X + # AccessKey Secret(从服务商控制台获取) + access-key-secret: 2vD9ToIff49Vph4JQXsn0Cy8nXQfzA + # 短信签名名称(需要在服务商平台申请) + sign-name: 星洋智慧 + # 验证码模板CODE(需要在服务商平台申请) + template-code: SMS_491985030 + # 区域ID(阿里云默认:cn-hangzhou,腾讯云默认:ap-guangzhou) + region-id: cn-hangzhou + endpoint: dysmsapi.aliyuncs.com # 认证配置 school-news: auth: @@ -65,7 +98,10 @@ school-news: white-list: - "/auth/login" - "/auth/logout" + - "/auth/register" - "/auth/captcha" + - "/auth/send-sms-code" + - "/auth/send-email-code" - "/auth/health" - "/actuator/**" - "/swagger-ui/**" diff --git a/schoolNewsServ/auth/src/main/java/org/xyzh/auth/controller/AuthController.java b/schoolNewsServ/auth/src/main/java/org/xyzh/auth/controller/AuthController.java index 03af5f0..3319936 100644 --- a/schoolNewsServ/auth/src/main/java/org/xyzh/auth/controller/AuthController.java +++ b/schoolNewsServ/auth/src/main/java/org/xyzh/auth/controller/AuthController.java @@ -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 发送结果 + * @author yslg + * @since 2025-11-03 + */ + @PostMapping("/send-email-code") + public ResultDomain> sendEmailCode(@RequestBody Map requestBody) { + ResultDomain> 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 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 发送结果 + * @author yslg + * @since 2025-11-03 + */ + @PostMapping("/send-sms-code") + public ResultDomain> sendSmsCode(@RequestBody Map requestBody) { + ResultDomain> 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 data = Map.of( + "sessionId", sessionId, + "message", "验证码已发送" + ); + + logger.info("短信验证码已发送,手机号: {}, sessionId: {}", phone, sessionId); + result.success("验证码已发送", data); + } else { + result.fail("验证码发送失败,请稍后重试"); + } + + return result; + } + + /** + * @description 用户注册 + * @param requestBody 注册参数 + * @return ResultDomain 注册结果 + * @author yslg + * @since 2025-11-03 + */ + @PostMapping("/register") + public ResultDomain register(@RequestBody Map requestBody, HttpServletRequest request) { + ResultDomain 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 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 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; + } } diff --git a/schoolNewsServ/auth/src/main/java/org/xyzh/auth/service/LoginServiceImpl.java b/schoolNewsServ/auth/src/main/java/org/xyzh/auth/service/LoginServiceImpl.java index fea8b61..b7c2faf 100644 --- a/schoolNewsServ/auth/src/main/java/org/xyzh/auth/service/LoginServiceImpl.java +++ b/schoolNewsServ/auth/src/main/java/org/xyzh/auth/service/LoginServiceImpl.java @@ -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()); diff --git a/schoolNewsServ/auth/src/main/java/org/xyzh/auth/strategy/LoginStrategy.java b/schoolNewsServ/auth/src/main/java/org/xyzh/auth/strategy/LoginStrategy.java index 9217a13..d135929 100644 --- a/schoolNewsServ/auth/src/main/java/org/xyzh/auth/strategy/LoginStrategy.java +++ b/schoolNewsServ/auth/src/main/java/org/xyzh/auth/strategy/LoginStrategy.java @@ -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; + } } \ No newline at end of file diff --git a/schoolNewsServ/auth/src/main/java/org/xyzh/auth/strategy/impl/EmailLoginStrategy.java b/schoolNewsServ/auth/src/main/java/org/xyzh/auth/strategy/impl/EmailLoginStrategy.java index 16ed2fa..a91f10d 100644 --- a/schoolNewsServ/auth/src/main/java/org/xyzh/auth/strategy/impl/EmailLoginStrategy.java +++ b/schoolNewsServ/auth/src/main/java/org/xyzh/auth/strategy/impl/EmailLoginStrategy.java @@ -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 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; + } } \ No newline at end of file diff --git a/schoolNewsServ/auth/src/main/java/org/xyzh/auth/strategy/impl/PasswordLoginStrategy.java b/schoolNewsServ/auth/src/main/java/org/xyzh/auth/strategy/impl/PasswordLoginStrategy.java index aa0f9df..ba059e6 100644 --- a/schoolNewsServ/auth/src/main/java/org/xyzh/auth/strategy/impl/PasswordLoginStrategy.java +++ b/schoolNewsServ/auth/src/main/java/org/xyzh/auth/strategy/impl/PasswordLoginStrategy.java @@ -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) { diff --git a/schoolNewsServ/auth/src/main/java/org/xyzh/auth/strategy/impl/PhoneLoginStrategy.java b/schoolNewsServ/auth/src/main/java/org/xyzh/auth/strategy/impl/PhoneLoginStrategy.java index 22abc74..da3502f 100644 --- a/schoolNewsServ/auth/src/main/java/org/xyzh/auth/strategy/impl/PhoneLoginStrategy.java +++ b/schoolNewsServ/auth/src/main/java/org/xyzh/auth/strategy/impl/PhoneLoginStrategy.java @@ -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 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; + } } \ No newline at end of file diff --git a/schoolNewsServ/auth/src/main/java/org/xyzh/auth/strategy/impl/UsernameLoginStrategy.java b/schoolNewsServ/auth/src/main/java/org/xyzh/auth/strategy/impl/UsernameLoginStrategy.java index 423fa72..fa71fce 100644 --- a/schoolNewsServ/auth/src/main/java/org/xyzh/auth/strategy/impl/UsernameLoginStrategy.java +++ b/schoolNewsServ/auth/src/main/java/org/xyzh/auth/strategy/impl/UsernameLoginStrategy.java @@ -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 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 diff --git a/schoolNewsServ/auth/src/main/java/org/xyzh/auth/strategy/impl/WechatLoginStrategy.java b/schoolNewsServ/auth/src/main/java/org/xyzh/auth/strategy/impl/WechatLoginStrategy.java index 910a4a8..a111f22 100644 --- a/schoolNewsServ/auth/src/main/java/org/xyzh/auth/strategy/impl/WechatLoginStrategy.java +++ b/schoolNewsServ/auth/src/main/java/org/xyzh/auth/strategy/impl/WechatLoginStrategy.java @@ -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 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 diff --git a/schoolNewsServ/auth/注册功能说明.md b/schoolNewsServ/auth/注册功能说明.md new file mode 100644 index 0000000..a6c2007 --- /dev/null +++ b/schoolNewsServ/auth/注册功能说明.md @@ -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. 记录注册日志用于分析 + diff --git a/schoolNewsServ/auth/登录功能说明.md b/schoolNewsServ/auth/登录功能说明.md new file mode 100644 index 0000000..e885198 --- /dev/null +++ b/schoolNewsServ/auth/登录功能说明.md @@ -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 +**作者**:开发团队 + diff --git a/schoolNewsServ/auth/短信服务架构说明.md b/schoolNewsServ/auth/短信服务架构说明.md new file mode 100644 index 0000000..f2f4572 --- /dev/null +++ b/schoolNewsServ/auth/短信服务架构说明.md @@ -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 sendSmsCode(...) { + // 直接调用统一接口,无需关心服务商 + boolean success = smsUtils.sendVerificationCode(phone, code); + ... +} +``` + +## 支持的服务商 + +### 当前已实现 +- ✅ **阿里云**(dysmsapi20170525) + +### 待实现 +- ⏳ **腾讯云**(预留接口) +- ⏳ **华为云**(预留接口) + +## 如何添加新服务商 + +### 步骤1:添加Maven依赖 + +在 `common-util/pom.xml` 中添加对应SDK: + +```xml + + + com.tencentcloudapi + tencentcloud-sdk-java + 3.x.x + +``` + +### 步骤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步 + +这样的设计既满足当前需求,又具备良好的扩展性,是一个**优雅、实用、易维护**的架构方案。 + diff --git a/schoolNewsServ/auth/邮件服务配置说明.md b/schoolNewsServ/auth/邮件服务配置说明.md new file mode 100644 index 0000000..8286b2c --- /dev/null +++ b/schoolNewsServ/auth/邮件服务配置说明.md @@ -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会在日志中输出验证码,不会实际发送 + +建议在开发环境中配置一个测试邮箱,以便完整测试邮件发送功能。 + diff --git a/schoolNewsServ/auth/阿里云短信配置示例.md b/schoolNewsServ/auth/阿里云短信配置示例.md new file mode 100644 index 0000000..544c295 --- /dev/null +++ b/schoolNewsServ/auth/阿里云短信配置示例.md @@ -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. 定期查看账户余额 + diff --git a/schoolNewsServ/auth/验证码安全机制说明.md b/schoolNewsServ/auth/验证码安全机制说明.md new file mode 100644 index 0000000..3484e54 --- /dev/null +++ b/schoolNewsServ/auth/验证码安全机制说明.md @@ -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 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机制,我们将验证码从"谁都可以用"变成了"只有请求者可以用",大大提高了系统的安全性。这是一个简单但有效的安全增强方案,推荐在所有需要验证码的场景中使用。 + diff --git a/schoolNewsServ/common/common-core/src/main/java/org/xyzh/common/core/domain/LoginParam.java b/schoolNewsServ/common/common-core/src/main/java/org/xyzh/common/core/domain/LoginParam.java index 4a8805a..5a2eee0 100644 --- a/schoolNewsServ/common/common-core/src/main/java/org/xyzh/common/core/domain/LoginParam.java +++ b/schoolNewsServ/common/common-core/src/main/java/org/xyzh/common/core/domain/LoginParam.java @@ -51,6 +51,13 @@ public class LoginParam { */ private String captcha; + /** + * @description 验证码SessionID(用于验证验证码) + * @author yslg + * @since 2025-11-03 + */ + private String captchaId; + /** * @description 登录类型 (email/username/phone/wechat) * @author yslg @@ -173,6 +180,24 @@ public class LoginParam { this.captcha = captcha; } + /** + * @description 获取验证码SessionID + * @author yslg + * @since 2025-11-03 + */ + public String getCaptchaId() { + return captchaId; + } + + /** + * @description 设置验证码SessionID + * @author yslg + * @since 2025-11-03 + */ + public void setCaptchaId(String captchaId) { + this.captchaId = captchaId; + } + /** * @description 获取登录类型 * @author yslg diff --git a/schoolNewsServ/common/common-util/pom.xml b/schoolNewsServ/common/common-util/pom.xml index 3724a5f..271d147 100644 --- a/schoolNewsServ/common/common-util/pom.xml +++ b/schoolNewsServ/common/common-util/pom.xml @@ -31,6 +31,19 @@ poi-ooxml ${poi.version} + + + + org.springframework.boot + spring-boot-starter-mail + + + + + com.aliyun + dysmsapi20170525 + 4.2.0 + \ No newline at end of file diff --git a/schoolNewsServ/common/common-util/src/main/java/org/xyzh/common/utils/EmailUtils.java b/schoolNewsServ/common/common-util/src/main/java/org/xyzh/common/utils/EmailUtils.java new file mode 100644 index 0000000..0fa6f3c --- /dev/null +++ b/schoolNewsServ/common/common-util/src/main/java/org/xyzh/common/utils/EmailUtils.java @@ -0,0 +1,152 @@ +package org.xyzh.common.utils; + +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.beans.factory.annotation.Value; +import org.springframework.mail.SimpleMailMessage; +import org.springframework.mail.javamail.JavaMailSender; +import org.springframework.mail.javamail.MimeMessageHelper; +import org.springframework.stereotype.Component; + +import jakarta.mail.MessagingException; +import jakarta.mail.internet.MimeMessage; + +/** + * @description 邮件发送工具类 + * @filename EmailUtils.java + * @author yslg + * @copyright xyzh + * @since 2025-11-03 + */ +@Component +public class EmailUtils { + + private static final Logger logger = LoggerFactory.getLogger(EmailUtils.class); + + @Autowired + private JavaMailSender mailSender; + + @Value("${spring.mail.username:}") + private String from; + + /** + * 发送简单文本邮件 + * @param to 收件人邮箱 + * @param subject 邮件主题 + * @param content 邮件内容 + * @return 是否发送成功 + */ + public boolean sendSimpleEmail(String to, String subject, String content) { + try { + SimpleMailMessage message = new SimpleMailMessage(); + message.setFrom(from); + message.setTo(to); + message.setSubject(subject); + message.setText(content); + + mailSender.send(message); + logger.info("简单邮件发送成功,收件人: {}", to); + return true; + } catch (Exception e) { + logger.error("简单邮件发送失败,收件人: {}, 错误: {}", to, e.getMessage(), e); + return false; + } + } + + /** + * 发送HTML格式邮件 + * @param to 收件人邮箱 + * @param subject 邮件主题 + * @param content HTML格式的邮件内容 + * @return 是否发送成功 + */ + public boolean sendHtmlEmail(String to, String subject, String content) { + try { + MimeMessage message = mailSender.createMimeMessage(); + MimeMessageHelper helper = new MimeMessageHelper(message, true, "UTF-8"); + + helper.setFrom(from); + helper.setTo(to); + helper.setSubject(subject); + helper.setText(content, true); // true表示HTML格式 + + mailSender.send(message); + logger.info("HTML邮件发送成功,收件人: {}", to); + return true; + } catch (MessagingException e) { + logger.error("HTML邮件发送失败,收件人: {}, 错误: {}", to, e.getMessage(), e); + return false; + } + } + + /** + * 发送验证码邮件 + * @param to 收件人邮箱 + * @param code 验证码 + * @return 是否发送成功 + */ + public boolean sendVerificationCode(String to, String code) { + String subject = "【红色思政学习平台】邮箱验证码"; + String content = buildVerificationCodeHtml(code); + return sendHtmlEmail(to, subject, content); + } + + /** + * 构建验证码邮件的HTML内容 + * @param code 验证码 + * @return HTML内容 + */ + private String buildVerificationCodeHtml(String code) { + return "" + + "" + + "" + + "" + + "" + + "" + + "" + + "
" + + "
" + + "

红色思政学习平台

" + + "
" + + "
" + + "

尊敬的用户,您好!

" + + "

您正在进行邮箱验证,您的验证码为:

" + + "
" + + "
" + code + "
" + + "
" + + "
" + + "

• 验证码有效期为10分钟,请尽快完成验证

" + + "

• 如果这不是您的操作,请忽略此邮件

" + + "

• 为了保护您的账号安全,请勿将验证码告知他人

" + + "
" + + "
" + + "
" + + "

此邮件由系统自动发送,请勿回复

" + + "

Copyright © 红色思政智能体平台

" + + "
" + + "
" + + "" + + ""; + } + + /** + * 生成6位数字验证码 + * @return 验证码 + */ + public static String generateVerificationCode() { + return String.valueOf((int)((Math.random() * 9 + 1) * 100000)); + } +} + diff --git a/schoolNewsServ/common/common-util/src/main/java/org/xyzh/common/utils/SmsUtils.java b/schoolNewsServ/common/common-util/src/main/java/org/xyzh/common/utils/SmsUtils.java new file mode 100644 index 0000000..9634981 --- /dev/null +++ b/schoolNewsServ/common/common-util/src/main/java/org/xyzh/common/utils/SmsUtils.java @@ -0,0 +1,259 @@ +package org.xyzh.common.utils; + +import com.aliyun.dysmsapi20170525.Client; +import com.aliyun.dysmsapi20170525.models.SendSmsRequest; +import com.aliyun.dysmsapi20170525.models.SendSmsResponse; +import com.aliyun.teaopenapi.models.Config; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; +import org.springframework.beans.factory.annotation.Value; +import org.springframework.stereotype.Component; +import org.springframework.util.StringUtils; + +/** + * @description 短信发送工具类 - 支持多种短信服务商 + * @filename SmsUtils.java + * @author yslg + * @copyright xyzh + * @since 2025-11-03 + */ +@Component +public class SmsUtils { + + private static final Logger logger = LoggerFactory.getLogger(SmsUtils.class); + + @Value("${sms.enabled:false}") + private boolean enabled; + + @Value("${sms.provider:aliyun}") + private String provider; + + @Value("${sms.access-key-id:}") + private String accessKeyId; + + @Value("${sms.access-key-secret:}") + private String accessKeySecret; + + @Value("${sms.sign-name:红色思政学习平台}") + private String signName; + + @Value("${sms.template-code:}") + private String templateCode; + + @Value("${sms.region-id:cn-hangzhou}") + private String regionId; + + @Value("${sms.endpoint:dysmsapi.aliyuncs.com}") + private String endpoint; + + /** + * 发送短信验证码 + * @param phone 手机号 + * @param code 验证码 + * @return 是否发送成功 + */ + public boolean sendVerificationCode(String phone, String code) { + // 如果未启用短信服务,使用模拟模式 + if (!enabled || !StringUtils.hasText(accessKeyId) || !StringUtils.hasText(accessKeySecret)) { + logger.warn("短信服务未配置或未启用,使用模拟模式"); + logger.info("【模拟发送】短信验证码,手机号: {}, 验证码: {}", phone, code); + return true; + } + + // 根据配置的服务商选择发送方式 + switch (provider.toLowerCase()) { + case "aliyun": + return sendByAliyun(phone, code); + case "tencent": + logger.warn("腾讯云短信服务暂未实现,使用模拟模式"); + logger.info("【模拟发送】短信验证码,手机号: {}, 验证码: {}", phone, code); + return true; + default: + logger.error("未知的短信服务商: {}", provider); + return false; + } + } + + /** + * 使用阿里云发送短信验证码 + * @param phone 手机号 + * @param code 验证码 + * @return 是否发送成功 + */ + private boolean sendByAliyun(String phone, String code) { + try { + Client client = createAliyunClient(); + + SendSmsRequest request = new SendSmsRequest() + .setPhoneNumbers(phone) + .setSignName(signName) + .setTemplateCode(templateCode) + .setTemplateParam("{\"code\":\"" + code + "\"}"); + + SendSmsResponse response = client.sendSms(request); + + if ("OK".equals(response.getBody().getCode())) { + logger.info("阿里云短信发送成功,手机号: {}, BizId: {}", phone, response.getBody().getBizId()); + return true; + } else { + logger.error("阿里云短信发送失败,手机号: {}, Code: {}, Message: {}", + phone, response.getBody().getCode(), response.getBody().getMessage()); + return false; + } + } catch (Exception e) { + logger.error("阿里云短信发送异常,手机号: {}, 错误: {}", phone, e.getMessage(), e); + return false; + } + } + + /** + * 创建阿里云短信客户端 + * @return 短信客户端 + */ + private Client createAliyunClient() throws Exception { + Config config = new Config() + .setAccessKeyId(accessKeyId) + .setAccessKeySecret(accessKeySecret) + .setEndpoint(endpoint); + return new Client(config); + } + + /** + * 发送通用短信(支持自定义模板) + * @param phone 手机号 + * @param templateCode 模板CODE + * @param templateParam 模板参数(JSON格式) + * @return 是否发送成功 + */ + public boolean sendSms(String phone, String templateCode, String templateParam) { + // 如果未启用短信服务,使用模拟模式 + if (!enabled || !StringUtils.hasText(accessKeyId) || !StringUtils.hasText(accessKeySecret)) { + logger.warn("短信服务未配置或未启用,使用模拟模式"); + logger.info("【模拟发送】短信,手机号: {}, 模板: {}, 参数: {}", phone, templateCode, templateParam); + return true; + } + + // 根据配置的服务商选择发送方式 + switch (provider.toLowerCase()) { + case "aliyun": + return sendSmsAliyun(phone, templateCode, templateParam); + case "tencent": + logger.warn("腾讯云短信服务暂未实现,使用模拟模式"); + logger.info("【模拟发送】短信,手机号: {}, 模板: {}, 参数: {}", phone, templateCode, templateParam); + return true; + default: + logger.error("未知的短信服务商: {}", provider); + return false; + } + } + + /** + * 使用阿里云发送通用短信 + */ + private boolean sendSmsAliyun(String phone, String templateCode, String templateParam) { + try { + Client client = createAliyunClient(); + + SendSmsRequest request = new SendSmsRequest() + .setPhoneNumbers(phone) + .setSignName(signName) + .setTemplateCode(templateCode) + .setTemplateParam(templateParam); + + SendSmsResponse response = client.sendSms(request); + + if ("OK".equals(response.getBody().getCode())) { + logger.info("阿里云短信发送成功,手机号: {}, BizId: {}", phone, response.getBody().getBizId()); + return true; + } else { + logger.error("阿里云短信发送失败,手机号: {}, Code: {}, Message: {}", + phone, response.getBody().getCode(), response.getBody().getMessage()); + return false; + } + } catch (Exception e) { + logger.error("阿里云短信发送异常,手机号: {}, 错误: {}", phone, e.getMessage(), e); + return false; + } + } + + /** + * 批量发送短信 + * @param phones 手机号列表,用逗号分隔 + * @param templateCode 模板CODE + * @param templateParam 模板参数(JSON格式) + * @return 是否发送成功 + */ + public boolean sendBatchSms(String phones, String templateCode, String templateParam) { + // 如果未启用短信服务,使用模拟模式 + if (!enabled || !StringUtils.hasText(accessKeyId) || !StringUtils.hasText(accessKeySecret)) { + logger.warn("短信服务未配置或未启用,使用模拟模式"); + logger.info("【模拟发送】批量短信,手机号: {}, 模板: {}, 参数: {}", phones, templateCode, templateParam); + return true; + } + + // 根据配置的服务商选择发送方式 + switch (provider.toLowerCase()) { + case "aliyun": + return sendBatchSmsAliyun(phones, templateCode, templateParam); + case "tencent": + logger.warn("腾讯云短信服务暂未实现,使用模拟模式"); + logger.info("【模拟发送】批量短信,手机号: {}, 模板: {}, 参数: {}", phones, templateCode, templateParam); + return true; + default: + logger.error("未知的短信服务商: {}", provider); + return false; + } + } + + /** + * 使用阿里云批量发送短信 + */ + private boolean sendBatchSmsAliyun(String phones, String templateCode, String templateParam) { + try { + Client client = createAliyunClient(); + + SendSmsRequest request = new SendSmsRequest() + .setPhoneNumbers(phones) + .setSignName(signName) + .setTemplateCode(templateCode) + .setTemplateParam(templateParam); + + SendSmsResponse response = client.sendSms(request); + + if ("OK".equals(response.getBody().getCode())) { + logger.info("阿里云批量短信发送成功,手机号: {}, BizId: {}", phones, response.getBody().getBizId()); + return true; + } else { + logger.error("阿里云批量短信发送失败,手机号: {}, Code: {}, Message: {}", + phones, response.getBody().getCode(), response.getBody().getMessage()); + return false; + } + } catch (Exception e) { + logger.error("阿里云批量短信发送异常,手机号: {}, 错误: {}", phones, e.getMessage(), e); + return false; + } + } + + /** + * 生成6位数字验证码 + * @return 验证码 + */ + public static String generateVerificationCode() { + return String.valueOf((int)((Math.random() * 9 + 1) * 100000)); + } + + /** + * 验证手机号格式 + * @param phone 手机号 + * @return 是否有效 + */ + public static boolean isValidPhone(String phone) { + if (phone == null || phone.trim().isEmpty()) { + return false; + } + // 中国大陆手机号验证 + String regex = "^1[3-9]\\d{9}$"; + return phone.matches(regex); + } +} + diff --git a/schoolNewsWeb/src/apis/system/auth.ts b/schoolNewsWeb/src/apis/system/auth.ts index 3d7066e..2e1eaf7 100644 --- a/schoolNewsWeb/src/apis/system/auth.ts +++ b/schoolNewsWeb/src/apis/system/auth.ts @@ -5,7 +5,7 @@ */ import { api } from '@/apis/index'; -import type { LoginParam, LoginDomain, ResultDomain } from '@/types'; +import type { LoginParam, RegisterParam, LoginDomain, ResultDomain } from '@/types'; /** * 认证API服务 @@ -21,6 +21,16 @@ export const authApi = { return response.data; }, + /** + * 用户注册 + * @param registerParam 注册参数 + * @returns Promise> + */ + async register(registerParam: RegisterParam): Promise> { + const response = await api.post('/auth/register', registerParam); + return response.data; + }, + /** * 用户登出 * @returns Promise> @@ -51,20 +61,20 @@ export const authApi = { /** * 发送手机验证码 * @param phone 手机号 - * @returns Promise> + * @returns Promise> */ - async sendSmsCode(phone: string): Promise> { - const response = await api.post('/auth/send-sms-code', { phone }); + async sendSmsCode(phone: string): Promise> { + const response = await api.post<{sessionId: string, message: string}>('/auth/send-sms-code', { phone }); return response.data; }, /** * 发送邮箱验证码 * @param email 邮箱 - * @returns Promise> + * @returns Promise> */ - async sendEmailCode(email: string): Promise> { - const response = await api.post('/auth/send-email-code', { email }); + async sendEmailCode(email: string): Promise> { + const response = await api.post<{sessionId: string, message: string}>('/auth/send-email-code', { email }); return response.data; } }; diff --git a/schoolNewsWeb/src/components/base/UserDropdown.vue b/schoolNewsWeb/src/components/base/UserDropdown.vue index cd146d5..908af9e 100644 --- a/schoolNewsWeb/src/components/base/UserDropdown.vue +++ b/schoolNewsWeb/src/components/base/UserDropdown.vue @@ -23,7 +23,7 @@ {{ avatarText }}
-
{{ user?.realName || user?.username }}
+
{{ user?.fullName || user?.username }}
{{ primaryRole }}
@@ -113,13 +113,13 @@ const userAvatar = computed(() => { }); const avatarText = computed(() => { - const name = props.user?.realName || props.user?.username || ''; + const name = props.user?.fullName || props.user?.username || ''; return name.charAt(0).toUpperCase(); }); const primaryRole = computed(() => { // 这里可以从store中获取用户角色信息 - return '管理员'; // 暂时硬编码 + return store.getters['auth/userRoles'][0].roleName || ''; }); // 获取用户下拉菜单(个人中心和账号中心) diff --git a/schoolNewsWeb/src/store/modules/auth.ts b/schoolNewsWeb/src/store/modules/auth.ts index 44e4d5b..3c463fa 100644 --- a/schoolNewsWeb/src/store/modules/auth.ts +++ b/schoolNewsWeb/src/store/modules/auth.ts @@ -24,13 +24,45 @@ export interface AuthState { routesLoaded: boolean; } -// 从localStorage恢复状态的辅助函数 +// 存储工具函数 +const StorageUtil = { + // 保存数据(根据rememberMe选择存储方式) + setItem(key: string, value: string, rememberMe = false) { + if (rememberMe) { + localStorage.setItem(key, value); + } else { + sessionStorage.setItem(key, value); + } + }, + + // 获取数据(优先从localStorage,其次sessionStorage) + getItem(key: string): string | null { + return localStorage.getItem(key) || sessionStorage.getItem(key); + }, + + // 删除数据(从两个存储中都删除) + removeItem(key: string) { + localStorage.removeItem(key); + sessionStorage.removeItem(key); + }, + + // 清除所有认证相关数据 + clearAuth() { + const keys = ['token', 'loginDomain', 'menus', 'permissions', 'rememberMe']; + keys.forEach(key => { + localStorage.removeItem(key); + sessionStorage.removeItem(key); + }); + } +}; + +// 从存储恢复状态的辅助函数 function getStoredState(): Partial { try { - const token = localStorage.getItem('token'); - const loginDomainStr = localStorage.getItem('loginDomain'); - const menusStr = localStorage.getItem('menus'); - const permissionsStr = localStorage.getItem('permissions'); + const token = StorageUtil.getItem('token'); + const loginDomainStr = StorageUtil.getItem('loginDomain'); + const menusStr = StorageUtil.getItem('menus'); + const permissionsStr = StorageUtil.getItem('permissions'); return { token: token || null, @@ -40,7 +72,7 @@ function getStoredState(): Partial { routesLoaded: false, // 路由始终需要重新加载 }; } catch (error) { - console.error('从localStorage恢复状态失败:', error); + console.error('从存储恢复状态失败:', error); return { token: null, loginDomain: null, @@ -111,50 +143,57 @@ const authModule: Module = { mutations: { // 设置登录信息 - SET_LOGIN_DOMAIN(state, loginDomain: LoginDomain) { + SET_LOGIN_DOMAIN(state, payload: { loginDomain: LoginDomain; rememberMe?: boolean }) { + const { loginDomain, rememberMe = false } = payload; + state.loginDomain = loginDomain; state.token = loginDomain.token || null; state.menus = loginDomain.menus || []; state.permissions = loginDomain.permissions || []; - // 持久化到localStorage + // 持久化到存储(根据rememberMe选择localStorage或sessionStorage) if (state.token) { - localStorage.setItem('token', state.token); + StorageUtil.setItem('token', state.token, rememberMe); } if (loginDomain) { - localStorage.setItem('loginDomain', JSON.stringify(loginDomain)); + StorageUtil.setItem('loginDomain', JSON.stringify(loginDomain), rememberMe); } if (state.menus.length > 0) { - localStorage.setItem('menus', JSON.stringify(state.menus)); + StorageUtil.setItem('menus', JSON.stringify(state.menus), rememberMe); } if (state.permissions.length > 0) { - localStorage.setItem('permissions', JSON.stringify(state.permissions)); + StorageUtil.setItem('permissions', JSON.stringify(state.permissions), rememberMe); } + // 保存rememberMe标记 + StorageUtil.setItem('rememberMe', String(rememberMe), rememberMe); }, // 设置Token SET_TOKEN(state, token: string | null) { state.token = token; + const rememberMe = StorageUtil.getItem('rememberMe') === 'true'; if (token) { - localStorage.setItem('token', token); + StorageUtil.setItem('token', token, rememberMe); } else { - localStorage.removeItem('token'); + StorageUtil.removeItem('token'); } }, // 设置菜单 SET_MENUS(state, menus: SysMenu[]) { state.menus = menus; + const rememberMe = StorageUtil.getItem('rememberMe') === 'true'; if (menus.length > 0) { - localStorage.setItem('menus', JSON.stringify(menus)); + StorageUtil.setItem('menus', JSON.stringify(menus), rememberMe); } }, // 设置权限 SET_PERMISSIONS(state, permissions: SysPermission[]) { state.permissions = permissions; + const rememberMe = StorageUtil.getItem('rememberMe') === 'true'; if (permissions.length > 0) { - localStorage.setItem('permissions', JSON.stringify(permissions)); + StorageUtil.setItem('permissions', JSON.stringify(permissions), rememberMe); } }, @@ -171,11 +210,8 @@ const authModule: Module = { state.permissions = []; state.routesLoaded = false; - // 清除localStorage - localStorage.removeItem('token'); - localStorage.removeItem('loginDomain'); - localStorage.removeItem('menus'); - localStorage.removeItem('permissions'); + // 清除所有认证相关存储(localStorage和sessionStorage) + StorageUtil.clearAuth(); } }, @@ -188,8 +224,12 @@ const authModule: Module = { return Promise.reject(result.message); } const loginDomain = result.data; - // 保存登录信息 - commit('SET_LOGIN_DOMAIN', loginDomain); + + // 保存登录信息(传递rememberMe参数) + commit('SET_LOGIN_DOMAIN', { + loginDomain, + rememberMe: loginParam.rememberMe || false + }); // 生成动态路由 await dispatch('generateRoutes'); diff --git a/schoolNewsWeb/src/types/auth/index.ts b/schoolNewsWeb/src/types/auth/index.ts index d7e0498..4be66c8 100644 --- a/schoolNewsWeb/src/types/auth/index.ts +++ b/schoolNewsWeb/src/types/auth/index.ts @@ -5,7 +5,7 @@ */ import { SysUser, SysUserInfo } from '../user'; -import { DeptRoleVO } from '../dept'; +import { UserDeptRoleVO } from '../dept'; import { SysPermission } from '../permission'; import { SysMenu } from '../menu'; import { LoginType } from '../enums'; @@ -32,6 +32,52 @@ export interface LoginParam { captchaId?: string; /** 记住我 */ rememberMe?: boolean; + /** 同意用户协议 */ + agree?: boolean; +} + +/** + * 注册方式枚举 + */ +export enum RegisterType { + /** 用户名注册 */ + USERNAME = 'username', + /** 手机号注册 */ + PHONE = 'phone', + /** 邮箱注册 */ + EMAIL = 'email' +} + +/** + * 注册参数 + */ +export interface RegisterParam { + /** 注册方式 */ + registerType: RegisterType; + /** 用户名 */ + username?: string; + /** 真实姓名 */ + realName?: string; + /** 手机号 */ + phone?: string; + /** 邮箱 */ + email?: string; + /** 密码 */ + password: string; + /** 确认密码 */ + confirmPassword: string; + /** 学号 */ + studentId?: string; + /** 手机验证码 */ + smsCode?: string; + /** 邮箱验证码 */ + emailCode?: string; + /** 短信会话ID */ + smsSessionId?: string; + /** 邮箱会话ID */ + emailSessionId?: string; + /** 同意用户协议 */ + agree: boolean; } /** @@ -43,7 +89,7 @@ export interface LoginDomain { /** 用户详细信息 */ userInfo?: SysUserInfo; /** 用户角色列表 */ - roles?: DeptRoleVO[]; + roles?: UserDeptRoleVO[]; /** 用户权限列表 */ permissions?: SysPermission[]; /** 用户菜单列表 */ diff --git a/schoolNewsWeb/src/views/admin/manage/system/UserManageView.vue b/schoolNewsWeb/src/views/admin/manage/system/UserManageView.vue index 748e034..0fb1f92 100644 --- a/schoolNewsWeb/src/views/admin/manage/system/UserManageView.vue +++ b/schoolNewsWeb/src/views/admin/manage/system/UserManageView.vue @@ -109,10 +109,10 @@ - + - + diff --git a/schoolNewsWeb/src/views/public/login/Login.vue b/schoolNewsWeb/src/views/public/login/Login.vue index 10f9bb7..549c35c 100644 --- a/schoolNewsWeb/src/views/public/login/Login.vue +++ b/schoolNewsWeb/src/views/public/login/Login.vue @@ -24,7 +24,23 @@

红色思政学习平台

- + + + + + @@ -35,23 +51,104 @@ class="login-form" @submit.prevent="handleLogin" > - - - + + - - - + +