diff --git a/backend/src/main/java/com/yfd/platform/system/controller/LoginController.java b/backend/src/main/java/com/yfd/platform/system/controller/LoginController.java index 689fa3c8..47f1c8fa 100644 --- a/backend/src/main/java/com/yfd/platform/system/controller/LoginController.java +++ b/backend/src/main/java/com/yfd/platform/system/controller/LoginController.java @@ -14,9 +14,12 @@ import com.yfd.platform.config.WebConfig; import com.yfd.platform.config.bean.LoginCodeEnum; import com.yfd.platform.config.bean.LoginProperties; import com.yfd.platform.constant.Constant; -import com.yfd.platform.system.domain.LoginUser; -import com.yfd.platform.system.domain.SysLog; -import com.yfd.platform.system.domain.SysUser; +import com.yfd.platform.system.domain.*; +import com.yfd.platform.system.mapper.SysMenuMapper; +import com.yfd.platform.system.mapper.SysUserMapper; +import com.yfd.platform.system.mapper.SysUserTenantMapper; +import com.yfd.platform.system.service.ISmsVerifyCodeService; +import com.yfd.platform.system.service.ISysAcctPasswordPolicyService; import com.yfd.platform.system.service.ISysLogService; import com.yfd.platform.system.service.IUserService; import com.yfd.platform.utils.RequestHolder; @@ -35,6 +38,7 @@ import org.springframework.web.bind.annotation.*; import jakarta.annotation.Resource; import jakarta.servlet.http.HttpServletRequest; + import java.sql.Timestamp; import java.util.HashMap; import java.util.List; @@ -66,10 +70,27 @@ public class LoginController { @Resource private LoginProperties loginProperties; + @Resource + private ISysAcctPasswordPolicyService policyService; + + @Resource + private SysUserTenantMapper sysUserTenantMapper; + + @Resource + private SysMenuMapper sysMenuMapper; + + @Resource + private SysUserMapper sysUserMapper; + + @Resource + private ISmsVerifyCodeService smsVerifyCodeService; + @PostMapping("/login") @Operation(summary = "登录用户") @ResponseBody - public ResponseResult login(SysUser user, @RequestHeader(value = "Tenant_id", required = false) String tenantId) throws Exception { + public ResponseResult login(SysUser user, + @RequestHeader(value = "Tenant_id", required = false) String tenantId, + @RequestParam(value = "clientType", required = false, defaultValue = "web") String clientType) throws Exception { // 密码解密 String password = RsaUtils.decryptByPrivateKey(privateKey, user.getPassword()); @@ -105,15 +126,15 @@ public class LoginController { if (!loginUser.isSuperAdmin() && StrUtil.isNotBlank(tenantId)) { List accessibleIds = loginUser.getAccessibleTenantIds(); if (accessibleIds == null || !accessibleIds.contains(tenantId)) { - return ResponseResult.error("账号不存在或密码错误"); + return ResponseResult.error("用户无权限访问该平台"); } } - if (StrUtil.isNotBlank(regStatus)&&"REJECTED".equals(regStatus)) { + if (StrUtil.isNotBlank(regStatus) && "REJECTED".equals(regStatus)) { return ResponseResult.error("账号审核未通过"); } - if (StrUtil.isNotBlank(regStatus)&&"PENDING".equals(regStatus)) { + if (StrUtil.isNotBlank(regStatus) && "PENDING".equals(regStatus)) { return ResponseResult.error("账号待审核,请联系管理员"); } @@ -121,6 +142,24 @@ public class LoginController { return ResponseResult.error("账号已停用"); } + // 多端登录控制:检查密码策略是否允许当前客户端类型多端登录 +// SysAcctPasswordPolicy policy = policyService.getCurrentPolicy(); +// if (policy != null) { +// boolean multiLoginEnabled; +// if ("app".equals(clientType)) { +// multiLoginEnabled = policy.getEnableAppMultipleLogin() != null && policy.getEnableAppMultipleLogin() == 1; +// } else { +// multiLoginEnabled = policy.getEnableWebMultipleLogin() != null && policy.getEnableWebMultipleLogin() == 1; +// } +// if (!multiLoginEnabled) { +// // 多端登录未开启,检查该用户是否已有同类型的活跃会话 +// String existingClientType = +// webConfig.loginuserCache().get("login:clientType:" + loginUser.getUsername()); +// if (StrUtil.isNotBlank(existingClientType) && existingClientType.equals(clientType)) { +// return ResponseResult.error("该账号已在其他设备登录,不允许同时登录"); +// } +// } +// } HttpServletRequest request = RequestHolder.getHttpServletRequest(); @@ -162,6 +201,8 @@ public class LoginController { String jsonStr = JSONUtil.toJsonStr(loginUser); webConfig.loginuserCache().put("login:" + userId, jsonStr); webConfig.loginuserCache().put("expire_time:" + userId, map.get("expire_time").toString()); + // 记录当前会话的客户端类型,用于多端登录控制 +// webConfig.loginuserCache().put("login:clientType:" + loginUser.getUsername(), clientType); return ResponseResult.successData(map); } @@ -201,6 +242,8 @@ public class LoginController { String userId = loginuser.getUser().getId(); //删除redis中的登陆用户信息 webConfig.loginuserCache().remove("login:" + userId); + // 清除客户端类型记录 +// webConfig.loginuserCache().remove("login:clientType:" + loginuser.getUsername()); //记录退出日志 HttpServletRequest request = RequestHolder.getHttpServletRequest(); SysLog sysLog = new SysLog(); @@ -266,4 +309,220 @@ public class LoginController { } + // ==================== 统一登录 ==================== + + /** + * 统一登录入口,支持两种方式: + * + *

均受密码策略中的多端登录控制(enableWebMultipleLogin / enableAppMultipleLogin)

+ */ + @PostMapping("/unifiedLogin") + @Operation(summary = "统一登录(支持密码/短信验证码)") + @ResponseBody + public ResponseResult unifiedLogin(@RequestHeader(value = "Tenant_id", required = false) String tenantId, + @RequestBody UnifiedLoginRequest req) throws Exception { + String clientType = StrUtil.isBlank(req.getClientType()) ? "web" : req.getClientType(); + LoginUser loginUser; + String loginDesc; + + // ========== 1. 密码登录 ========== + if ("password".equals(req.getLoginType())) { + if (StrUtil.isBlank(req.getUsername()) || StrUtil.isBlank(req.getPassword())) { + return ResponseResult.error("用户名和密码不能为空"); + } + + // 1.1 图形验证码校验 + if (StrUtil.isBlank(req.getCaptchaUuid()) || StrUtil.isBlank(req.getCaptchaCode())) { + return ResponseResult.error("验证码不能为空"); + } + String cachedCode = webConfig.loginuserCache().get(req.getCaptchaUuid()); + webConfig.loginuserCache().remove(req.getCaptchaUuid()); + if (StrUtil.isBlank(cachedCode)) { + return ResponseResult.error("验证码不存在或已过期"); + } + if (!req.getCaptchaCode().equalsIgnoreCase(cachedCode)) { + return ResponseResult.error("验证码错误"); + } + + // 1.2 密码解密 + Spring Security 认证 + String password = RsaUtils.decryptByPrivateKey(privateKey, req.getPassword()); + UsernamePasswordAuthenticationToken authToken = + new UsernamePasswordAuthenticationToken(req.getUsername(), password); + Authentication authenticate = authenticationManager.authenticate(authToken); + if (ObjectUtil.isNull(authenticate)) { + return ResponseResult.unlogin(); + } + loginUser = (LoginUser) authenticate.getPrincipal(); + loginDesc = loginUser.getUser().getNickname() + "登录系统!"; + } else if ("sms".equals(req.getLoginType())) { + // ========== 2. 短信验证码登录 ========== + if (StrUtil.isBlank(req.getPhone()) || StrUtil.isBlank(req.getSmsCode())) { + return ResponseResult.error("手机号和验证码不能为空"); + } + + // 2.1 验证短信验证码 + boolean verified = smsVerifyCodeService.verifyCode(req.getPhone(), req.getSmsCode(), SmsVerifyCode.TYPE_LOGIN, tenantId); + if (!verified) { + return ResponseResult.error("验证码错误或已过期"); + } + + // 2.2 通过手机号查找用户 + SysUser user = userService.getUserByPhone(req.getPhone(), null); + if (user == null) { + return ResponseResult.error("该手机号未注册"); + } + + // 2.3 状态校验 + String statusError = checkUserStatus(user); + if (statusError != null) { + return ResponseResult.error(statusError); + } + + // 2.4 判断超级管理员 + boolean isSuperAdmin = (checkIsSuperAdmin(user.getId()) || user.getUsertype() == 0); + + // 2.5 多门户权限校验 + List accessibleTenantIds = sysUserTenantMapper.getTenantIdsByUserId(user.getId()); + if (!isSuperAdmin) { + if (StrUtil.isBlank(tenantId)) { + return ResponseResult.error("用户无权限访问该平台"); + } + if (accessibleTenantIds == null || !accessibleTenantIds.contains(tenantId)) { + return ResponseResult.error("用户无权限访问该平台"); + } + } + + // 2.6 加载权限 + List permissions; + if (isSuperAdmin) { + permissions = sysMenuMapper.selectPermsByUserId(user.getId(), null); + } else { + permissions = sysMenuMapper.selectPermsByUserId(user.getId(), tenantId); + } + + loginUser = new LoginUser(user, permissions, tenantId, accessibleTenantIds, isSuperAdmin); + loginDesc = user.getNickname() + "使用短信验证码登录系统!"; + + // 短信登录:手动设置 SecurityContext + SecurityContextHolder.getContext().setAuthentication( + new UsernamePasswordAuthenticationToken(loginUser, null, loginUser.getAuthorities())); + } else { + return ResponseResult.error("登录类型错误,仅支持 password / sms"); + } + + // ========== 3. 密码登录的额外状态校验 ========== + if ("password".equals(req.getLoginType())) { + String statusError = checkUserStatus(loginUser.getUser()); + if (statusError != null) { + return ResponseResult.error(statusError); + } + // 多门户校验 + if (!loginUser.isSuperAdmin() && StrUtil.isNotBlank(tenantId)) { + List accessibleIds = loginUser.getAccessibleTenantIds(); + if (accessibleIds == null || !accessibleIds.contains(tenantId)) { + return ResponseResult.error("用户无权限访问该平台"); + } + } + } + + // ========== 4. 多端登录控制 ========== + String multiLoginError = checkMultiLogin(loginUser.getUsername(), clientType); + if (multiLoginError != null) { + return ResponseResult.error(multiLoginError); + } + + // ========== 5. 生成 Token + 缓存会话 ========== + return buildLoginResult(loginUser, tenantId, clientType, loginDesc); + } + + + /** + * 校验用户状态,返回错误信息,null 表示通过 + */ + private String checkUserStatus(SysUser user) { + if (user.getStatus() != null && user.getStatus() == 0) { + return "账号已停用"; + } + if (user.getRegStatus() != null && "PENDING".equals(user.getRegStatus())) { + return "账号待审核,请联系管理员"; + } + if (user.getRegStatus() != null && "REJECTED".equals(user.getRegStatus())) { + return "账号审核未通过"; + } + return null; + } + + /** + * 多端登录控制检查 + */ + private String checkMultiLogin(String username, String clientType) { + SysAcctPasswordPolicy policy = policyService.getCurrentPolicy(); + if (policy == null) { + return null; + } + boolean multiLoginEnabled = "app".equals(clientType) + ? (policy.getEnableAppMultipleLogin() != null && policy.getEnableAppMultipleLogin() == 1) + : (policy.getEnableWebMultipleLogin() != null && policy.getEnableWebMultipleLogin() == 1); + if (!multiLoginEnabled) { + String existingClientType = webConfig.loginuserCache().get("login:clientType:" + username); + if (StrUtil.isNotBlank(existingClientType) && existingClientType.equals(clientType)) { + return "该账号已在其他设备登录,不允许同时登录"; + } + } + return null; + } + + /** + * 判断是否为超级管理员 + */ + private boolean checkIsSuperAdmin(String userId) { + String maxLevel = sysUserMapper.getMaxLevel(userId); + return "1".equals(maxLevel); + } + + /** + * 生成 JWT Token、记录日志、缓存会话信息 + */ + private ResponseResult buildLoginResult(LoginUser loginUser, String tenantId, + String clientType, String loginDesc) { + // 记录登录日志 + HttpServletRequest request = RequestHolder.getHttpServletRequest(); + SysLog sysLog = new SysLog(); + sysLog.setUsercode(loginUser.getUsername()); + sysLog.setUsername(loginUser.getUser().getNickname()); + sysLog.setRequestip(StringUtils.getIp(request)); + sysLog.setBrowser(StringUtils.getBrowser(request)); + sysLog.setOpttype("登录(login)"); + sysLog.setModule("统一登录"); + sysLog.setMethod(this.getClass().getName() + ".unifiedLogin()"); + sysLog.setDescription(loginDesc); + sysLog.setLogtime(new Timestamp(System.currentTimeMillis())); + sysLogService.save(sysLog); + + // 生成 JWT Token + String userId = loginUser.getUser().getId(); + Map map = new HashMap<>(10) {{ + put("userid", userId); + put("username", loginUser.getUsername()); + long expireTime = System.currentTimeMillis() + (30L * 24L * 60L * 60L * 1000L); + put("expire_time", expireTime); + }}; + String token = JWTUtil.createToken(map, "12345678".getBytes()); + map.put("token", token); + map.put("accessibleTenantIds", loginUser.getAccessibleTenantIds()); + map.put("superAdmin", loginUser.isSuperAdmin()); + map.put("currentTenantId", tenantId); + + // 缓存登录会话 + String jsonStr = JSONUtil.toJsonStr(loginUser); + webConfig.loginuserCache().put("login:" + userId, jsonStr); + webConfig.loginuserCache().put("expire_time:" + userId, map.get("expire_time").toString()); + webConfig.loginuserCache().put("login:clientType:" + loginUser.getUsername(), clientType); + + return ResponseResult.successData(map); + } + } diff --git a/backend/src/main/java/com/yfd/platform/system/domain/UnifiedLoginRequest.java b/backend/src/main/java/com/yfd/platform/system/domain/UnifiedLoginRequest.java new file mode 100644 index 00000000..ba220e92 --- /dev/null +++ b/backend/src/main/java/com/yfd/platform/system/domain/UnifiedLoginRequest.java @@ -0,0 +1,56 @@ +package com.yfd.platform.system.domain; + +import lombok.Data; + +/** + *

+ * 统一登录请求体,支持密码登录和短信验证码登录 + *

+ * + *
+ * 密码登录示例:
+ * {
+ *     "loginType": "password",
+ *     "username": "admin",
+ *     "password": "rsa_encrypted_password",
+ *     "captchaUuid": "code_xxx",
+ *     "captchaCode": "abcd",
+ *     "clientType": "web"
+ * }
+ *
+ * 短信登录示例:
+ * {
+ *     "loginType": "sms",
+ *     "phone": "13800138000",
+ *     "smsCode": "123456",
+ *     "clientType": "app"
+ * }
+ * 
+ */ +@Data +public class UnifiedLoginRequest { + + /** 登录类型: "password"(用户名密码) / "sms"(短信验证码) */ + private String loginType; + + /** 用户名(密码登录必填) */ + private String username; + + /** 密码(密码登录必填,RSA公钥加密) */ + private String password; + + /** 手机号(短信登录必填) */ + private String phone; + + /** 短信验证码(短信登录必填) */ + private String smsCode; + + /** 图形验证码UUID(密码登录时用于从缓存中比对验证码) */ + private String captchaUuid; + + /** 图形验证码(密码登录时用户输入的验证码) */ + private String captchaCode; + + /** 客户端类型: "web"(浏览器)/ "app"(移动端),默认 "web" */ + private String clientType; +}