From 2441efa9bacbd8a4c21048e18c2723bf2d0b35ba Mon Sep 17 00:00:00 2001 From: "Ray.Hao" <1490493387@qq.com> Date: Sun, 5 Jul 2026 16:53:46 +0800 Subject: [PATCH] =?UTF-8?q?refactor(security):=20Ports=20&=20Adapters=20?= =?UTF-8?q?=E6=9E=B6=E6=9E=84=E9=87=8D=E6=9E=84=EF=BD=9C=E8=A7=A3=E9=99=A4?= =?UTF-8?q?=E5=8F=8D=E5=90=91=E4=BE=9D=E8=B5=96=EF=BD=9C=E6=8A=BD=E7=A6=BB?= =?UTF-8?q?=20Security=20Starter=20=E5=87=86=E5=A4=87?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 新增 2 个端口接口(UserAuthenticationPort / PermissionPort)+ 1 个适配器(PermissionAdapter) - SecurityUserDetails 用 roles(Set) 替代 authorities,消除 Jackson 序列化问题 - 删除 UserSession,Redis / JWT 直存 SecurityUserDetails - SecurityUserDetailsService / PermissionService 注入 Port 替代 system Service --- docs/deep-research-report.md | 452 ++++ docs/security-refactor-plan.md | 1953 +++++++++++++++++ .../security/config/SecurityConfig.java | 88 +- .../exception/MobileNotBoundException.java} | 10 +- .../exception/SmsCaptchaException.java | 3 +- .../filter/CaptchaValidationFilter.java | 14 +- .../handler/JsonAccessDeniedHandler.java} | 13 +- .../JsonAuthenticationEntryPoint.java} | 27 +- .../model/SmsAuthenticationToken.java | 44 +- .../model/WxMaAuthenticationToken.java | 31 +- .../provider/SmsAuthenticationProvider.java | 61 +- .../provider/WxMaAuthenticationProvider.java | 45 +- .../auth/service/impl/AuthServiceImpl.java | 6 +- .../service/impl/WxMaAuthServiceImpl.java | 12 +- .../common/constant/JwtClaimConstants.java | 4 +- .../enums/SocialPlatformEnum.java | 2 +- .../interceptor/MyDataPermissionHandler.java | 4 +- .../filter/TokenAuthenticationFilter.java | 62 +- .../{UserAuthInfo.java => SecurityUser.java} | 10 +- ...rDetails.java => SecurityUserDetails.java} | 44 +- .../framework/security/model/UserSession.java | 49 - .../security/model/WxMaBindInfo.java | 0 .../security/port/PermissionPort.java | 22 + .../security/port/UserAuthenticationPort.java | 40 + .../security/service/PermissionService.java | 24 +- .../service/RolePermissionService.java | 0 .../service/SecurityUserDetailsService.java | 41 + .../service/SysUserDetailsService.java | 82 - .../service/UserAuthQueryService.java | 0 .../service/WxMaUserAuthQueryService.java | 0 .../security/token/JwtTokenManager.java | 246 +-- .../security/token/RedisTokenManager.java | 222 +- .../security/util/SecurityUtils.java | 69 +- .../message/controller/SseController.java | 4 +- .../youlai/boot/system/mapper/UserMapper.java | 10 +- .../boot/system/mapper/UserSocialMapper.java | 4 +- .../boot/system/model/entity/UserSocial.java | 2 +- .../security/adapter/PermissionAdapter.java | 27 + .../adapter/UserAuthenticationAdapter.java | 39 + .../boot/system/service/UserService.java | 14 +- .../system/service/UserSocialService.java | 6 +- .../system/service/impl/UserServiceImpl.java | 33 +- .../service/impl/UserSocialServiceImpl.java | 6 +- .../resources/mapper/system/UserMapper.xml | 2 +- .../mapper/system/UserSocialMapper.xml | 2 +- .../security/token/JwtTokenManagerTest.java | 26 +- 46 files changed, 2917 insertions(+), 938 deletions(-) create mode 100644 docs/deep-research-report.md create mode 100644 docs/security-refactor-plan.md rename src/main/java/com/youlai/boot/{framework => auth}/security/config/SecurityConfig.java (59%) rename src/main/java/com/youlai/boot/{framework/security/exception/NeedBindMobileException.java => auth/security/exception/MobileNotBoundException.java} (60%) rename src/main/java/com/youlai/boot/{framework => auth}/security/exception/SmsCaptchaException.java (83%) rename src/main/java/com/youlai/boot/{framework => auth}/security/filter/CaptchaValidationFilter.java (93%) rename src/main/java/com/youlai/boot/{framework/security/handler/MyAccessDeniedHandler.java => auth/security/handler/JsonAccessDeniedHandler.java} (64%) rename src/main/java/com/youlai/boot/{framework/security/handler/MyAuthenticationEntryPoint.java => auth/security/handler/JsonAuthenticationEntryPoint.java} (54%) rename src/main/java/com/youlai/boot/{framework => auth}/security/model/SmsAuthenticationToken.java (54%) rename src/main/java/com/youlai/boot/{framework => auth}/security/model/WxMaAuthenticationToken.java (68%) rename src/main/java/com/youlai/boot/{framework => auth}/security/provider/SmsAuthenticationProvider.java (61%) rename src/main/java/com/youlai/boot/{framework => auth}/security/provider/WxMaAuthenticationProvider.java (64%) rename src/main/java/com/youlai/boot/{system => common}/enums/SocialPlatformEnum.java (94%) rename src/main/java/com/youlai/boot/framework/security/model/{UserAuthInfo.java => SecurityUser.java} (77%) rename src/main/java/com/youlai/boot/framework/security/model/{SysUserDetails.java => SecurityUserDetails.java} (65%) delete mode 100644 src/main/java/com/youlai/boot/framework/security/model/UserSession.java delete mode 100644 src/main/java/com/youlai/boot/framework/security/model/WxMaBindInfo.java create mode 100644 src/main/java/com/youlai/boot/framework/security/port/PermissionPort.java create mode 100644 src/main/java/com/youlai/boot/framework/security/port/UserAuthenticationPort.java delete mode 100644 src/main/java/com/youlai/boot/framework/security/service/RolePermissionService.java create mode 100644 src/main/java/com/youlai/boot/framework/security/service/SecurityUserDetailsService.java delete mode 100644 src/main/java/com/youlai/boot/framework/security/service/SysUserDetailsService.java delete mode 100644 src/main/java/com/youlai/boot/framework/security/service/UserAuthQueryService.java delete mode 100644 src/main/java/com/youlai/boot/framework/security/service/WxMaUserAuthQueryService.java create mode 100644 src/main/java/com/youlai/boot/system/security/adapter/PermissionAdapter.java create mode 100644 src/main/java/com/youlai/boot/system/security/adapter/UserAuthenticationAdapter.java diff --git a/docs/deep-research-report.md b/docs/deep-research-report.md new file mode 100644 index 00000000..842edae6 --- /dev/null +++ b/docs/deep-research-report.md @@ -0,0 +1,452 @@ + + +# 概要(已归档) + +> **本文档已于 2026-07-04 完整合并至 `security-refactor-plan.md`(v2.1)。** +> +> 合并时进行的去重处理: +> - 虚构模型 `SecurityUser` → 统一使用实际存在的 `UserAuthInfo` +> - 4 端口方案 → 精简为 2 核心端口(`UserAuthenticationPort`、`PermissionPort`)+ 1 可选扩展(`OnlineUserPort`,移至附录) +> - `RolePort` 删除(roles 已包含在 `UserAuthInfo` 中,单独端口冗余) +> - 重复的 Ports & Adapters 模式介绍 → 精简为第二节 2.1 +> - 重复的迁移步骤/代码审查清单 → 合并至统一章节 +> +> 以下为原始内容,仅供历史参考。 + +--- + +当前 `framework/security` 模块直接依赖 `system` 模块的用户、角色等服务,实现登录认证和权限校验。比如 `SecurityUserDetailsService` 中通过 `UserService.getAuthInfoByUsername(...)` 获取用户认证信息,并使用 `SecurityUserDetails` 包装返回;还引入了 `UserSocialService` 等系统服务。这种实现导致安全模块与系统模块耦合度极高,不利于后续模块化或替换。按照 Ports & Adapters(六边形架构)原则,**应将安全模块对系统模块的调用抽象为接口(Port)**,在系统模块提供对应的实现(Adapter)。这样安全模块只依赖接口,而不直接引用系统实体或服务,实现内核业务与外部实现的解耦。下文给出全面的重构设计:定义清晰的包结构、接口(Port)命名规范、适配器实现示例、事件设计、自动装配等,并通过对比表、迁移步骤和测试/审查清单详细说明改动原因与风险。 + +**主要改进**:将原来直接使用 `UserService`/`UserSocialService` 等系统服务的逻辑,迁移到新的接口调用上。以 `UserAuthenticationPort`、`PermissionPort`、`RolePort`、`OnlineUserPort` 等抽象端口替代 `SecurityUserDetailsService` 中的调用;在系统模块中提供适配器实现;修改原认证/权限代码使用这些端口接口,从而使安全模块与系统模块仅通过抽象接口交互,实现松耦合和更高的可测试性。 + +# 现状 vs 建议(关键点比较) + +| 对比维度 | **当前实现(耦合严重)** | **建议实现(使用 Ports & Adapters)** | +|-----------|-------------------------|---------------------------------------| +| **依赖关系** | `framework/security` 直接依赖 `system` 模块的实体和服务。
代码中大量 `import com.youlai.boot.system.*`。 | 安全模块仅引用自身定义的接口(Port),不再直接 `import system`。系统模块实现这些接口并注入。
安全模块不直接依赖系统实体,只通过抽象调用。 | +| **用户认证** | 在 `SecurityUserDetailsService` 中通过 `UserService` 查询用户认证数据。 | 定义 `UserAuthenticationPort` 接口(`loadUserByUsername`),由系统侧适配器实现。`SecurityUserDetailsService` 调用该接口获得用户信息。 | +| **角色权限** | `SecurityUserDetails` 构造时直接使用 `UserAuthInfo.getRoles()`等;权限校验使用 `PermissionService`(注入 `RoleMenuService`)动态获取权限集合。 | 定义 `RolePort`、`PermissionPort` 接口,分别提供用户角色、用户权限查询方法。系统模块通过查询角色表、角色-权限表等实现。安全模块使用端口获取角色/权限。 | +| **在线用户** | 无专门接口;若记录在线用户,需要手动在登录逻辑中调用系统服务。 | 定义 `OnlineUserPort` 接口,在登录成功/退出时由安全模块发布事件调用该接口(示例:`online(SecurityUser user)`,`offline(Long userId)`)。系统侧实现可记录用户登录状态或统计。 | +| **命名规范** | 普通 Service 命名(`XxxService`、`XxxServiceImpl`);未区分 Ports/Adapters 概念。 | 端口接口统一以 `Port` 结尾,如 `UserAuthenticationPort`、`PermissionPort` 等;适配器类可以 `Adapter` 或 `ServiceImpl` 后缀。符合常见 *hexagonal* 实践。 | +| **包结构** | 安全模块代码散落在 `security.service`、`security.model` 等包中,与业务服务混合。 | 建议在安全模块新增 `security.port` 包放置接口,或置于 `security.service` 目录下;系统模块新增 `adapter` 包放置实现。逻辑清晰分层。 | +| **测试可控** | 安全代码测试需要引入系统服务(耦合高)。 | 安全逻辑可针对 Port 接口单元测试(使用模拟实现),系统实现也可单独测试。解耦后更易覆盖测试。 | +| **演进灵活** | 更换系统或提取模块成本高。 | 通过实现不同的 Port 适配器,可灵活替换系统实现;后续拆分为 Starter 模式时,只需发布 Port 接口和依赖注入方案。 | + +# Ports & Adapters 模式简介 + +**六边形架构(Ports & Adapters)**模式提出应用核心业务逻辑通过抽象的"端口(Port)"与外部通信,实现对外界依赖的隔离。外部实现(如数据库、Web 服务、其他模块)通过具体的适配器(Adapter)连入端口。通俗地说,将原本直接调用的外部组件封装为接口(Port),核心业务只依赖接口而非具体实现;在外围提供实现类(Adapter)注入接口。此模式不是 GOF 中的"23 种设计模式"之一,而是一种架构模式,常被称为**六边形架构**或**Hexagonal Architecture**。 + +在 Spring Boot 项目中,可将安全模块核心逻辑看作"内部六边形",定义各类服务所需的接口(Ports),而系统模块提供对应实现。例如: + +- **端口(Port)接口**:定义安全模块需要的功能,如加载用户信息、查询权限、角色等。接口可使用 `Port` 后缀命名以示区分(`UserAuthenticationPort`、`PermissionPort`、`RolePort`、`OnlineUserPort`)。 +- **适配器(Adapter)实现**:在系统模块中实现上述接口,内部调用 `UserService`、`RoleService`、`RoleMenuService` 等具体服务,将数据转换成安全模块期望的格式。使用 `@Component` 等注解注入 Spring 容器。 +- **解耦效果**:安全模块只在编译期看到接口,不持有系统包的任何引用,大幅降低耦合。系统模块可自行修改实现细节或拆分发布,只要契合接口契约即可。 + +例如,下述接口定义了登录认证所需的端口: + +```java +package com.youlai.boot.security.port; + +/** + * 用户认证接口 (Port). + * 由系统模块提供实现,用于根据用户名加载用户安全信息。 + */ +public interface UserAuthenticationPort { + + /** + * 根据用户名加载用户认证信息(包含用户名、密码、启用状态、角色列表等)。 + * + * @param username 用户名 + * @return 包含用户认证信息的SecurityUser对象,如果用户不存在可返回null或抛出异常 + * @throws UsernameNotFoundException 如果未找到用户 + */ + SecurityUser loadUserByUsername(String username) throws UsernameNotFoundException; +} +``` + +同理,可定义获取权限和角色的接口(示例): + +```java +package com.youlai.boot.security.port; + +/** + * 权限查询接口 (Port). + * 提供获取指定用户权限集合的方法。 + */ +public interface PermissionPort { + /** + * 获取指定用户 ID 的权限字符串集合。 + * + * @param userId 用户ID + * @return 权限字符串集合,如"user:list", "order:detail"等 + */ + Set getPermissions(Long userId); +} + +/** + * 角色查询接口 (Port). + * 提供获取指定用户角色集合的方法。 + */ +public interface RolePort { + /** + * 获取指定用户 ID 的角色标识集合。 + * + * @param userId 用户ID + * @return 角色编码集合,如"ADMIN", "USER"等 + */ + Set getRoles(Long userId); +} + +/** + * 在线用户通知接口 (Port). + * 用户登录登出时用于通知系统模块记录在线状态。 + */ +public interface OnlineUserPort { + /** + * 用户上线通知,登录认证成功后调用。 + * @param user 登录成功的安全用户信息 + */ + void online(SecurityUser user); + + /** + * 用户下线通知,可在登出或 token 失效时调用。 + * @param userId 用户ID + */ + void offline(Long userId); +} +``` + +以上接口(Ports)只属于安全模块,无系统包引用。接口命名使用了 `Port` 后缀,这是一种常见约定,用于区分业务接口与基础设施实现。**命名规范**上,接口名应精确描述业务功能,遵循驼峰式命名,端口名称以英文名词加上"Port"后缀,如 `UserAuthenticationPort`;实现类可名为 `SystemUserAuthenticationAdapter` 或 `UserAuthenticationAdapterImpl` 等,并标记为 Spring 的组件(`@Component`)。 + +# 包结构与端口定义 + +重构后的建议包结构示例如下(省略部分不相关包): + +``` +com.youlai.boot.security +├── config/ # 安全模块配置(如 WebSecurity 配置等) +├── port/ # 定义端口接口(Ports) +│ ├── UserAuthenticationPort.java +│ ├── PermissionPort.java +│ ├── RolePort.java +│ └── OnlineUserPort.java +├── service/ # 安全模块内部服务实现(仅业务逻辑,无系统依赖) +│ ├── SecurityUserDetailsService.java # 实现 UserDetailsService,调用 Port 接口 +│ └── ... # 其他认证服务 +├── handler/ # 异常、结果处理器等 +├── filter/ # 安全过滤器,如 JWT 过滤等 +├── model/ # 安全模块领域模型,如 SecurityUser、UserSession 等 +└── ... # 其他安全相关包 +com.youlai.boot.system +└── adapter/ # 系统模块中的适配器实现 + ├── UserAuthenticationAdapter.java # 实现 UserAuthenticationPort + ├── PermissionAdapter.java # 实现 PermissionPort + ├── RoleAdapter.java # 实现 RolePort + ├── OnlineUserAdapter.java # 实现 OnlineUserPort + └── ... +``` + +其中,`com.youlai.boot.security.model.SecurityUser` 是我们自定义的安全用户模型,包含用户ID、用户名、密码、启用状态、角色、权限等字段,用于端口接口返回(可参考 `UserAuthInfo`)。安全核心逻辑不直接使用 `com.youlai.boot.system.model.entity.*`,而是使用自己的 `SecurityUser` 或相似 DTO。SecurityUserDetails 的构造可改为接受 `SecurityUser`。 + +### 接口(Port)定义 + +以登录认证为例,`UserAuthenticationPort` 接口定义如下: + +```java +package com.youlai.boot.security.port; + +import org.springframework.security.core.userdetails.UsernameNotFoundException; + +/** + * 用户认证端口(Port)。 + * Security 模块通过此接口加载用户认证信息,由系统模块提供实现。 + */ +public interface UserAuthenticationPort { + + /** + * 根据用户名加载用户安全信息,包括用户名、密码、启用状态、角色列表等。 + * + * @param username 用户名 + * @return 包含用户认证数据的 SecurityUser 对象 + * @throws UsernameNotFoundException 用户不存在时抛出 + */ + SecurityUser loadUserByUsername(String username) throws UsernameNotFoundException; +} +``` + +类似地,`PermissionPort`、`RolePort`、`OnlineUserPort` 接口如前所示。所有接口方法应带有清晰的 Javadoc 注释,说明参数和返回值(或异常)。方法命名使用动词开头,如 `getPermissions`、`getRoles`、`online`、`offline`,与团队命名规范一致。返回类型避免使用系统实体类,而是安全模块自己的模型或基础类型。 + +# 适配器实现示例 + +在系统模块中实现上述接口。适配器需标注为 Spring 组件(如 `@Component`),以便自动注入。示例: + +```java +package com.youlai.boot.system.adapter; + +import com.youlai.boot.security.model.SecurityUser; +import com.youlai.boot.security.port.UserAuthenticationPort; +import com.youlai.boot.system.service.UserService; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.security.core.userdetails.UsernameNotFoundException; +import org.springframework.stereotype.Component; + +/** + * 系统用户适配器,实现安全模块的 UserAuthenticationPort。 + */ +@Component +public class UserAuthenticationAdapter implements UserAuthenticationPort { + + @Autowired + private UserService userService; + + @Override + public SecurityUser loadUserByUsername(String username) throws UsernameNotFoundException { + // 调用系统服务获取用户认证信息(可能包括用户实体、角色列表等) + var userAuthInfo = userService.getAuthInfoByUsername(username); + if (userAuthInfo == null) { + throw new UsernameNotFoundException("用户名不存在: " + username); + } + // 将系统返回的数据转换为安全模块的 SecurityUser + SecurityUser user = new SecurityUser(); + user.setUserId(userAuthInfo.getUserId()); + user.setUsername(userAuthInfo.getUsername()); + user.setPassword(userAuthInfo.getPassword()); + user.setEnabled(userAuthInfo.getStatus() == 1); + user.setDeptId(userAuthInfo.getDeptId()); + user.setRoles(userAuthInfo.getRoles()); + // 如果需要,也可以在此填充用户权限 + return user; + } +} +``` + +在上例中,**`UserAuthenticationAdapter` 实现了 `UserAuthenticationPort`**,在调用时系统模块可直接注入此类。它内部调用原来的 `UserService.getAuthInfoByUsername`,然后将数据封装为安全模块领域的 `SecurityUser`。如此,安全模块再也无需直接引用 `com.youlai.boot.system` 包,只关心接口和 `SecurityUser` 类型。 + +权限、角色适配器示例(系统模块): + +```java +package com.youlai.boot.system.adapter; + +import com.youlai.boot.security.port.PermissionPort; +import com.youlai.boot.security.port.RolePort; +import com.youlai.boot.system.service.RoleMenuService; +import com.youlai.boot.system.service.UserService; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.stereotype.Component; + +import java.util.Set; + +/** + * 系统权限/角色适配器。 + */ +@Component +public class PermissionAndRoleAdapter implements PermissionPort, RolePort { + + @Autowired + private RoleMenuService roleMenuService; // 系统服务,用于获取角色权限 + + @Autowired + private UserService userService; // 系统服务,用于获取用户角色 + + @Override + public Set getRoles(Long userId) { + // 调用系统服务查询用户角色标识列表(需实现此方法或自行查询用户角色关联表) + return userService.getUserRoleCodes(userId); + } + + @Override + public Set getPermissions(Long userId) { + // 根据用户角色获取权限列表(类似原有 PermissionService 逻辑) + Set roles = getRoles(userId); + if (roles == null || roles.isEmpty()) { + return Set.of(); + } + // roleMenuService.getRolePermsByRoleCodes 返回角色对应的菜单/权限标识 + return roleMenuService.getRolePermsByRoleCodes(roles); + } +} +``` + +注意:上述代码假定 `UserService` 提供 `getUserRoleCodes(userId)` 方法来返回用户拥有的角色编码集合;如果没有,需自行查询 `sys_user_role` 表并结合 `SysRole` 表获取。`RoleMenuService.getRolePermsByRoleCodes(roles)` 方法则返回对应角色的所有权限字符串。这些实现细节可根据系统模块已有功能调整,但**接口契约应保证返回安全模块期望的格式**。 + +`OnlineUserPort` 的实现示例:当用户登录或退出时,安全模块可发布事件(详见下一节)由适配器捕获并执行,比如记录在线用户或更新登录时间: + +```java +package com.youlai.boot.system.adapter; + +import com.youlai.boot.security.model.SecurityUser; +import com.youlai.boot.security.port.OnlineUserPort; +import com.youlai.boot.system.service.UserService; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.stereotype.Component; + +/** + * 在线用户适配器,实现上线/下线通知。 + */ +@Component +public class OnlineUserAdapter implements OnlineUserPort { + + @Autowired + private UserService userService; + + @Override + public void online(SecurityUser user) { + // 系统记录用户上线事件(例如保存用户最后登录时间、标记在线状态) + userService.markUserOnline(user.getUserId()); + } + + @Override + public void offline(Long userId) { + // 系统记录用户下线事件 + userService.markUserOffline(userId); + } +} +``` + +其中 `markUserOnline/markUserOffline` 需在系统模块实现,可简单更新用户实体字段或写入登录日志。 + +# 事件设计 + +为了在用户登录成功或退出时自动调用 `OnlineUserPort`,可使用 Spring 事件机制或 Spring Security 提供的监听器。例如,定义监听 Spring Security 的 `AuthenticationSuccessEvent` 和自定义登出事件: + +```java +@Component +public class AuthenticationEventListener { + + @Autowired + private OnlineUserPort onlineUserPort; + + // 登录成功事件 + @EventListener + public void onAuthenticationSuccess(AuthenticationSuccessEvent event) { + Object principal = event.getAuthentication().getPrincipal(); + if (principal instanceof SecurityUser) { + SecurityUser user = (SecurityUser) principal; + onlineUserPort.online(user); + } + } + + // 注销事件(可用 ServletRequestListener 或自定义 Token 过期事件触发) + @EventListener + public void onLogoutSuccess(CustomLogoutEvent event) { + Long userId = event.getUserId(); + onlineUserPort.offline(userId); + } +} +``` + +在上例中,当用户认证成功时,将触发 `online(SecurityUser)` 调用;当用户登出或 token 过期时,可触发 `offline` 调用。这样无须在业务逻辑手工嵌入标记在线/离线逻辑,符合观察者模式设计。 + +**事件时序示例**(Mermaid 时序图): + +```mermaid +sequenceDiagram + participant Client 客户端 + participant Security 安全模块 + participant Port 用户认证端口 + participant System 系统模块 + Client->>Security: 发起登录请求 + Security->>Port: loadUserByUsername(username) + Port->>System: 调用 UserService.getAuthInfoByUsername(...) + System-->>Port: 返回用户数据 + Port-->>Security: 返回 SecurityUser + Security->>Port: online(SecurityUser) // 通过事件或直接调用 + Port->>System: 调用 UserService.markUserOnline + System-->>Port: 确认 +``` + +# 自动装配与配置 + +由于还未拆分为独立 Starter,可借助 Spring Boot 自动扫描和条件注入来保证端口与适配器正确关联: + +- 在安全模块中无需特殊配置,只要扫描到 `com.youlai.boot.security.port` 下的接口即可(Spring 会自动注入匹配类型的 Bean)。接口注解可以省略。 +- 在系统模块适配器类上加 `@Component`(或其他 stereotype),保证其 Bean 名称默认即类名首字母小写,可用 `@Qualifier` 指定(非必需)。Spring 容器会自动将实现注入到安全模块需要的接口引用处。 +- 如果未来拆分为独立的 `security-starter`,可编写一个自动配置类,`@ConditionalOnMissingBean(UserAuthenticationPort.class)` 等方式提供默认实现或抛出警告。但当前单体工程中可省略。 + +# 单元/集成测试要点 + +1. **安全模块单元测试**:对 `SecurityUserDetailsService` 等类进行单元测试时,模拟 `UserAuthenticationPort` 等依赖接口(使用 Mock 对象)。验证当接口返回合法的 `SecurityUser` 时,`loadUserByUsername` 正确返回 `UserDetails`;当返回 null 或抛异常时,抛出 `UsernameNotFoundException`。 +2. **适配器单元测试**:对系统模块的适配器类进行测试。可以使用内存数据库或模拟 `UserService`、`RoleMenuService` 以测试 `UserAuthenticationAdapter`、`PermissionAdapter` 等正确调用系统服务并转换结果。 +3. **集成测试**:启动 Spring 容器,加载真实的 `UserService` 与端口实现,测试完整的登录认证流程。例如插入一个测试用户,模拟登录请求,验证能通过认证并获得正确角色权限。还可测试登录事件触发 `OnlineUserPort` 的调用。 +4. **兼容性测试**:迁移期间同时保留旧有和新实现路径,在新的接口实现加入时,可通过配置文件开关或条件注册方式,确保临时并存(如果需要灰度)。测试现有系统接口不受影响。 + +# 迁移步骤(逐步变更清单) + +以下是重构安全模块的推荐步骤,可按次序逐步实施,每步均编写并运行相关测试,确认无误后继续下一步。示例以 git 操作为准,可调整为团队实际流程。 + +1. **新建端口接口**:在 `security` 模块新建 `com.youlai.boot.security.port` 包,添加 `UserAuthenticationPort`, `PermissionPort`, `RolePort`, `OnlineUserPort` 接口(如上定义)。 + ```shell + # 新建包和文件(示例路径): + mkdir -p src/main/java/com/youlai/boot/security/port + ``` + 在 `UserAuthenticationPort.java` 中写入接口定义和注释。其余接口同理。 +2. **系统模块实现适配器**:在 `system` 模块中新建 `adapter` 包,实现上述接口。例如: + - `UserAuthenticationAdapter implements UserAuthenticationPort`(`@Component`),内部调用原有 `UserService.getAuthInfoByUsername(...)` 并转换为 `SecurityUser`。 + - `PermissionAdapter implements PermissionPort`,使用 `RoleMenuService` 获取权限集合。 + - `RoleAdapter implements RolePort`,使用 `UserService` 或 `SysUserRoleMapper` 获取角色集合。 + - `OnlineUserAdapter implements OnlineUserPort`,调用系统服务记录在线状态。 + 编写完成后,启动 Spring,检查这些 Bean 是否被扫描注入。 +3. **修改认证服务使用 Port**:修改 `security/service/SecurityUserDetailsService.java`: + - 去掉对 `UserService`、`UserSocialService` 的注入。 + - 增加对 `UserAuthenticationPort` 的注入。例如: + ```diff + @RequiredArgsConstructor + @Service +- public class SecurityUserDetailsService implements UserDetailsService { ++ public class SecurityUserDetailsService implements UserDetailsService { + // 删除:private final UserService userService; + // 删除:private final UserSocialService userSocialService; + private final UserAuthenticationPort userAuthPort; + ``` + - 修改 `loadUserByUsername` 方法,调用 `userAuthPort.loadUserByUsername(username)` 取代原有的 `userService.getAuthInfoByUsername`。如: + ```diff + public UserDetails loadUserByUsername(String username) throws UsernameNotFoundException { +- UserAuthInfo userAuthInfo = userService.getAuthInfoByUsername(username); ++ SecurityUser user = userAuthPort.loadUserByUsername(username); ++ if (user == null) { throw new UsernameNotFoundException(username); } +- return new SecurityUserDetails(userAuthInfo); ++ return new SecurityUserDetails(user); + } + ``` + - 由于 `SecurityUserDetails` 构造函数接收的类型可能变更为 `SecurityUser`(或兼容的DTO),需要相应调整 `SecurityUserDetails`。例如新增一个构造函数或修改现有构造逻辑。 +4. **移除系统引用**:清理安全模块中对 `com.youlai.boot.system.*` 的直接引用。确认没有遗留的导入。例如删除 `import com.youlai.boot.system.service.UserService;` 等。如果项目中还存在其他安全相关的系统依赖,同样替换为调用端口。 +5. **调整其他安全逻辑**:如果有自定义 `PermissionService`、`SecurityUtils` 等,也可考虑改用新 Port。否则只需确保系统角色/权限从端口获取。例如将某些静态调用改为注入 `PermissionPort` 使用。 +6. **更新配置或常量**:如果 `SecurityUser` 类中新加入了字段,应更新相关配置(如 Jackson 序列化时忽略密码)和常量。保证新旧字段兼容。 +7. **回归测试**:执行所有单元测试和集成测试,验证登录、权限校验、在线用户事件等功能正常。特别测试无系统用户调用失败情况。 +8. **代码审查、文档更新**:按照开发规范检查命名、注释完整性等(参见下文清单)。完善 Javadoc、异常处理后提交合并。 + +每步变更完成后,可用临时配置(如 Spring 配置文件)切换旧实现(如保留一个旧的 `UserDetailsService` Bean)与新实现进行对比测试,确保新代码无功能回归。如发现问题可回滚到上一步完成修复后再继续。 + +# 回归测试用例清单 + +- **用户认证**:正确用户名/密码登录成功;错误用户名或密码登录失败并抛 `UsernameNotFoundException` 或认证错误。 +- **用户禁用**:用户状态为禁用时,登录应被拒绝(`isEnabled()` 返回 `false`)。 +- **角色加载**:登录成功后,在 `SecurityUserDetails` 中可通过 `getAuthorities()` 获得正确的角色列表(前缀为 `ROLE_`)。 +- **权限校验**:使用带有 `@PreAuthorize` 或 `ss.hasPerm` 表达式的接口,用户具备权限时允许访问,不具备时禁止访问。 +- **微信登录(如适用)**:若支持小程序或其他社交登录,测试通过 `UserSocialService` 注入的相关功能是否正常(可暂按旧逻辑保留)。 +- **在线用户事件**:模拟登录成功后,验证系统模块已执行 `online(userId)` 操作(如在线表中插入记录、更新最后登录时间);登出后执行 `offline(userId)` 清理。 +- **边界情况**:极限测试用户角色集合为空、权限集合为空、端口实现抛异常等,确保安全模块正确处理并记录日志(不漏调用)。 +- **事务和并发**:在用户登录、权限检查时模拟高并发,确保端口实现线程安全(无共享可变状态)。 +- **版本兼容**:如有旧前端调用 API,确保接口行为未因内部重构发生变化。 + +# 代码审查清单 + +- **接口与实现匹配**:所有 Port 接口的实现类均以 `implements` 关键词明确标注,并使用 `@Component`(或注解使之纳入 Spring 容器)。接口方法签名与实现一致(参数、异常声明)。 +- **命名规范**:接口名以 Port 结尾,使用混合大小写词首大写;实现类名可以 "Adapter" 或 "Impl" 结尾。包名统一小写(参见开发规范)。例如 `UserAuthenticationPort` 对应 `UserAuthenticationAdapter`。 +- **注释完善**:所有公共接口、实现类、接口方法应有清晰的 Javadoc(中文)。描述业务功能、参数、返回及异常。举例:`UserAuthenticationPort.loadUserByUsername` 应说明抛出 `UsernameNotFoundException` 的情况。 +- **依赖注入**:使用构造器注入或 `@Autowired` 注入端口和系统服务;避免在业务代码中手动 `new` 实现类。优先使用 `final` 属性和构造函数注入保证必需依赖。 +- **日志与异常**:捕获系统服务可能抛出的异常时,应记录日志。使用 `log.error(...)` 并传递参数而非拼字符串(参见改前日志写法)。用户找不到时抛出 `UsernameNotFoundException`;权限不足时抛出 `AccessDeniedException` 或让 Spring Security 处理。 +- **安全性**:不要在日志中明文输出密码、密钥等敏感信息。对外接口返回的用户信息只包含必要字段(可设置 JSON 忽略字段)。遵循《阿里巴巴 Java 开发手册》对异常和日志的要求。 +- **测试覆盖**:对于新增 Port 接口和实现类,应编写单元测试;关键业务逻辑(如角色-权限映射)也要测试。请确认 CI 报告无遗漏。 +- **依赖关系正确**:检查修改后安全模块的 `pom.xml` 中对 system 模块的依赖是否为编译时(compile)且非传递;无多余依赖。逆向检查系统模块应添加对 security 模块的依赖,以访问接口定义(分包引用)。 +- **兼容性**:若需要在过渡期同时支持旧逻辑,确保原有 `UserService` 功能在适配器中仍被调用。避免出现切换时功能丢失。 +- **文档更新**:更新系统架构文档(如有)以反映新的接口和依赖关系。尤其对外部接入的配置点(如果有变)做说明。 + +--- + +**参考资料:** 以上方案参考了当前有来开源 `youlai-boot` 项目中 `framework/security` 与 `system` 模块的实现。具体关键代码位置如 `SecurityUserDetailsService`(参见)展示了现有耦合;AWS 官方文档对 Ports & Adapters 模式的介绍说明了设计思想。方案设计兼顾中文社区通用命名规范和企业级开发规范,并提供示例代码片段、迁移步骤及测试清单,以确保方案可执行性和安全稳定性。 diff --git a/docs/security-refactor-plan.md b/docs/security-refactor-plan.md new file mode 100644 index 00000000..e82031d9 --- /dev/null +++ b/docs/security-refactor-plan.md @@ -0,0 +1,1953 @@ +# Security 框架重构与 Starter 抽离方案 + +> **状态**:待实施 +> **范围**:`framework/security` 模块 +> **目标**:消除模型冗余 → 端口/适配器解耦 → 抽离为独立 Spring Boot Starter +> **适用**:youlai-boot(单模块)和 youlai-boot-multi(多模块) +> **最后更新**:2026-07-04 +> **合并自**:`deep-research-report.md`(Ports & Adapters 研究)+ 原 `security-refactor-plan.md`(模型消除教程) +> **实施策略**:不保留历史登录态;重构发布后所有旧 Token 失效,用户重新登录 + +--- + +## 目录 + +- [〇、文档使用指南(AI 协作必读)](#〇文档使用指南ai-协作必读) +- [一、问题诊断](#一问题诊断) +- [二、架构原则与设计决策](#二架构原则与设计决策) +- [三、方案设计](#三方案设计) +- [四、单模块重构(youlai-boot)](#四单模块重构youlai-boot) +- [五、多模块重构(youlai-boot-multi)](#五多模块重构youlai-boot-multi) +- [六、Security Starter 抽离与发布方案](#六security-starter-抽离与发布方案) +- [七、测试策略](#七测试策略) +- [八、实施阶段](#八实施阶段) +- [九、AI 协作指南](#九ai-协作指南) +- [十、代码审查清单](#十代码审查清单) +- [附录 A:术语表](#附录-a术语表) +- [附录 B:变更日志](#附录-b变更日志) + +--- + +## 〇、文档使用指南(AI 协作必读) + +> **本节专为 AI 协作设计。任何 AI 在修改 security 相关代码前,必须先阅读本节。** + +### 0.1 文档定位 + +本文档是 youlai-boot security 模块的**唯一权威重构方案**,合并了早期的架构研究(`deep-research-report.md`)和实施计划。所有 security 相关的架构变更、代码修改、命名约定均以本文档为准。 + +### 0.2 如何使用本文档 + +| 你的目标 | 阅读顺序 | +|----------|----------| +| **理解为什么要改** | 第一节 → 第二节 | +| **执行单模块重构** | 第三节 → 第四节 → 第八节(P0→P1→P2→P3)| +| **执行多模块重构** | 第三节 → 第五节 → 第八节(P0→P1→P2→P3) | +| **抽离为 Starter(实操)** | 先完成第四/五节 → 第六节 → 6.11(实操步骤) | +| **发布到 Maven Central** | 6.12(GPG + Sonatype + deploy) | +| **其他项目接入 Starter** | 6.13(引入依赖 + 实现适配器 + 验证) | +| **修改 security 代码** | 第九节(AI 协作指南)→ 第十节(审查清单)| +| **新增端口/适配器** | 第二节 2.3 → 第九节 9.3 | + +### 0.3 关键决策摘要(不可违背) + +| 编号 | 决策 | 原因 | +|------|------|------| +| D1 | 端口数量固定为 **2 个**:`UserAuthenticationPort`、`PermissionPort` | `RolePort` 冗余——roles 已包含在 `SecurityUser` 中;在线用户不纳入本次重构 | +| D2 | 将 `UserAuthInfo` **重命名为 `SecurityUser`** 作为端口返回模型 | `SecurityUser` 语义更中性,作为 Starter 公开 API 更规范;原 `UserAuthInfo` 偏向"认证信息"但实际承载用户安全数据;重命名而非新建,改动最小 | +| D3 | `SecurityUserDetails.authorities` 字段类型 `Collection` → `Set roles` | 消除序列化问题,`getAuthorities()` 改为实时计算 | +| D4 | **删除 `UserSession`**,发布后旧 Token 全部失效 | 其 5 个字段 100% 是 `SecurityUserDetails` 的子集,中间层无意义;不保留历史登录态 | +| D5 | 端口接口放在 `framework.security.port` 包,适配器放在 `system.security.adapter` 包 | 物理隔离端口定义与实现,符合六边形架构 | +| D6 | Starter 模块命名为 **`youlai-security-spring-boot-starter`** | 遵循 Spring Boot 官方 starter 命名规范 | +| D7 | `SocialPlatformEnum` 下沉到 `youlai-common` 后再定义端口 | 端口和 Starter 不允许出现任何 `com.youlai.boot.system.*` 引用 | +| D8 | Starter 不内置业务 JSON 响应写出逻辑,不依赖 `ResponseWriter` | 认证失败和鉴权失败走 Spring Security 标准异常与使用方 `AuthenticationEntryPoint` / `AccessDeniedHandler` | + +### 0.4 代码中的空占位文件说明 + +> ⚠️ **重要发现**:当前 `framework/security/service/` 下已存在 4 个空文件,表明端口模式**已被原开发者规划但未实施**: + +| 空文件 | 推测用途 | 重构后处理 | +|--------|----------|------------| +| `UserAuthQueryService.java` | 用户认证查询接口占位 | **删除**,由 `port/UserAuthenticationPort.java` 替代 | +| `RolePermissionService.java` | 角色权限查询接口占位 | **删除**,由 `port/PermissionPort.java` 替代 | +| `WxMaUserAuthQueryService.java` | 微信小程序认证查询占位 | **删除**,合并入 `UserAuthenticationPort.getAuthInfoByOpenid()` | +| `WxMaBindInfo.java` | 微信绑定信息模型占位 | **删除**,微信绑定流程留在使用方 Provider 内实现 | + +--- + +## 一、问题诊断 + +### 1.1 模型冗余(3 → 2) + +当前三个模型字段高度重叠(`SecurityUser` 为 `UserAuthInfo` 重命名后的名称,见 D2): + +| 字段 | `SecurityUser`
(原 `UserAuthInfo`) | `SecurityUserDetails` | `UserSession` | +|------|:---:|:---:|:---:| +| userId | ✓ | ✓ | ✓ | +| username | ✓ | ✓ | ✓ | +| deptId | ✓ | ✓ | ✓ | +| dataScopes | ✓ | ✓ | ✓ | +| roles | ✓ | — | ✓ | +| authorities | — | ✓ | — | +| password | ✓ | ✓ | — | +| enabled | — | ✓ | — | +| nickname | ✓ | — | — | +| status | ✓ | — | — | + +**数据流**:`SecurityUser → SecurityUserDetails → UserSession → SecurityUserDetails`,中间经历两次无意义字段拷贝。`UserSession` 的 5 个字段 100% 是 `SecurityUserDetails` 的子集,存在的唯一原因是 `SimpleGrantedAuthority` 不便序列化。 + +### 1.2 框架 ↔ 业务循环依赖 + +``` +framework.security ──→ system(SecurityUserDetailsService 直接注入 UserService、UserSocialService) +framework.security ──→ system(PermissionService 直接注入 RoleMenuService) +system ──→ framework.security(SecurityUtils、SecurityUser、SecurityUserDetails) +``` + +`SecurityUserDetailsService` 和 `PermissionService` 中大量 `import com.youlai.boot.system.*`,违反了依赖方向。在多模块项目中,这导致 `youlai-framework` 无法独立于 `youlai-system` 发布。 + +### 1.3 序列化问题(已临时修复) + +`JacksonJsonRedisSerializer` 反序列化时丢失类型信息,`UserSession` 被还原为 `LinkedHashMap`。 + +**当前修复方案**:提取 `JsonMapper` 为共享 Bean,使用 `convertValue` 显式转换。✅ 已生效,但 `UserSession` 中间层本身是不必要的。 + +### 1.4 现状 vs 目标对比 + +| 维度 | 当前实现(耦合严重) | 目标实现(Ports & Adapters) | +|------|---------------------|------------------------------| +| **依赖关系** | `framework/security` 直接 `import com.youlai.boot.system.*` | 安全模块仅引用自身端口接口,不持有 system 引用 | +| **用户认证** | `SecurityUserDetailsService` 注入 `UserService` 查询 | 注入 `UserAuthenticationPort`,由 system 侧适配器实现 | +| **权限校验** | `PermissionService` 注入 `RoleMenuService` | 注入 `PermissionPort`,由 system 侧适配器实现 | +| **角色获取** | `SecurityUserDetails` 构造时转换 `authorities` | `roles` 字段直接存储 `Set`,`getAuthorities()` 实时计算 | +| **在线用户** | 无专门接口 | 不纳入本次 Security Starter 重构 | +| **序列化** | `UserSession` 中间层 + `convertValue` 修补 | 直接存取 `SecurityUserDetails`(纯 JDK 类型) | +| **可测试性** | 安全代码测试需引入 system 服务 | 针对端口接口 Mock 测试,解耦 | +| **模块化** | 无法独立发布 security 模块 | 端口定义可随 Starter 独立发布 | + +--- + +## 二、架构原则与设计决策 + +### 2.1 Ports & Adapters 模式(精简) + +**六边形架构**核心思想:应用核心业务通过抽象的**端口(Port)**与外部通信,外部实现通过**适配器(Adapter)**连入端口。 + +- **端口(Port)**:定义安全模块需要的功能接口,放在 `framework.security.port` 包。命名以 `Port` 后缀结尾。 +- **适配器(Adapter)**:在 `system` 模块实现端口接口,内部调用具体 Service。标注 `@Component`。 +- **解耦效果**:安全模块编译期不持有任何 `com.youlai.boot.system.*` 引用。 + +### 2.2 端口定义决策 + +| 端口 | 方法 | 职责 | 是否核心 | +|------|------|------|:---:| +| `UserAuthenticationPort` | `getAuthInfoByUsername(String)`
`getAuthInfoByMobile(String)`
`getAuthInfoByOpenid(SocialPlatformEnum, String)` | 用户认证信息查询 | ✅ | +| `PermissionPort` | `getRolePerms(Set roleCodes)` | 角色权限集合查询 | ✅ | + +> **为什么不设 `RolePort`?** +> 角色编码(roles)已作为 `Set` 包含在 `SecurityUser` 中,登录时一次性获取,无需单独端口查询。`SecurityUtils.getRoles()` 直接从 `SecurityUserDetails.roles` 字段取值即可。 + +> **为什么不做在线用户端口?** +> 在线用户管理属于业务运营能力,不属于认证鉴权最小内核。本次重构不提供 `OnlineUserPort`,后续如需在线用户功能,由业务模块基于登录/登出事件自行实现,不进入 Security Starter。 + +### 2.3 命名规范 + +| 类型 | 命名规则 | 示例 | +|------|----------|------| +| 端口接口 | `<领域意图>Port` | `UserAuthenticationPort`、`PermissionPort` | +| 适配器实现 | `<领域意图>Adapter` | `UserAuthenticationAdapter`、`PermissionAdapter` | +| 端口包路径 | `framework.security.port` | — | +| 适配器包路径 | `system.security.adapter`(单模块)
`youlai-system/.../security/adapter`(多模块) | — | +| 端口返回模型 | `SecurityUser`(由 `UserAuthInfo` 重命名,纯 POJO,无 system 依赖) | — | + +> **禁止**:使用 `Service`/`Query` 后缀命名端口(如 `UserAuthQueryService`),这与业务 Service 混淆。当前代码中的空文件 `UserAuthQueryService.java`、`RolePermissionService.java` 就是错误命名的占位,应删除并用 `Port` 后缀替代。 + +--- + +## 三、方案设计 + +### 3.1 消除 UserSession + +**核心改动**:`SecurityUserDetails` 将 `Collection authorities` 替换为 `Set roles`,`getAuthorities()` 改为实时计算。 + +```java +@Data +@NoArgsConstructor +public class SecurityUserDetails implements UserDetails { + + private Long userId; + private String username; + private String password; + private Boolean enabled; + private Long deptId; + private List dataScopes; + private Set roles; // ← 替换 authorities + + public SecurityUserDetails(SecurityUser user) { + this.userId = user.getUserId(); + this.username = user.getUsername(); + this.password = user.getPassword(); + this.enabled = ObjectUtil.equal(user.getStatus(), 1); + this.deptId = user.getDeptId(); + this.dataScopes = user.getDataScopes(); + this.roles = user.getRoles(); + } + + @Override + public Collection getAuthorities() { + if (CollectionUtil.isEmpty(roles)) { + return Collections.emptySet(); + } + return roles.stream() + .map(role -> new SimpleGrantedAuthority(SecurityConstants.ROLE_PREFIX + role)) + .collect(Collectors.toSet()); + } + + // 其他 UserDetails 方法不变... +} +``` + +**效果**:所有字段均为纯 JDK 类型,`SecurityUserDetails` 可直接序列化存储,`UserSession` 文件删除。 + +### 3.2 端口/适配器解耦 + +``` +┌─────────────────────────────────┐ ┌──────────────────────────────────┐ +│ framework/security │ │ system │ +│ ┌───────────────────────────┐ │ │ ┌────────────────────────────┐ │ +│ │ port/ │ │ │ │ security/adapter/ │ │ +│ │ ├ UserAuthenticationPort │◄─┼─────┼──│ ├ UserAuthenticationAdapter│ │ +│ │ └ PermissionPort │◄─┼─────┼──│ └ PermissionAdapter │ │ +│ └───────────────────────────┘ │ │ └────────────────────────────┘ │ +│ ┌───────────────────────────┐ │ │ ┌────────────────────────────┐ │ +│ │ service/ │ │ │ │ service/ │ │ +│ │ ├ SecurityUserDetailsService │──┼─────┼──│ (注入 Port,不注入 system)│ │ +│ │ └ PermissionService │──┼─────┼──│ │ │ +│ └───────────────────────────┘ │ │ └────────────────────────────┘ │ +└─────────────────────────────────┘ └──────────────────────────────────┘ + ↑ 编译期零 system 依赖 ↑ 运行期注入 Port 实现 +``` + +**端口定义**(framework 层): + +| 端口 | 方法 | 职责 | +|------|------|------| +| `UserAuthenticationPort` | `getAuthInfoByUsername(String)`
`getAuthInfoByMobile(String)`
`getAuthInfoByOpenid(platform, openid)` | 用户认证信息查询 | +| `PermissionPort` | `getRolePerms(Set roleCodes)` | 角色权限集合查询 | + +**适配器实现**(system 层): + +| 适配器 | 实现端口 | 委托目标 | +|--------|----------|----------| +| `UserAuthenticationAdapter` | `UserAuthenticationPort` | `UserService` + `UserSocialService` | +| `PermissionAdapter` | `PermissionPort` | `RoleMenuService` | + +### 3.3 SecurityUtils 简化 + +```java +// 之前:5 步链式(getAuthorities → filter ROLE_ → strip) +// 之后:直接取 roles 字段 +public static Set getRoles() { + return getUser().map(SecurityUserDetails::getRoles).orElse(Set.of()); +} +``` + +### 3.4 Token 管理器适配 + +- **RedisTokenManager**:直接存储 `SecurityUserDetails`(password 置 null),删除 `UserSession` 中间层和 `buildUserDetails` 重建逻辑 +- **JwtTokenManager**:JWT claims 使用 `roles` 字段保存不带 `ROLE_` 前缀的角色编码;`parseToken` 只解析 `roles`,`getAuthorities()` 实时补 `ROLE_` 前缀 +- **旧 Token 策略**:不接受历史 JWT / Redis Token。发布时清理 Redis 中 `auth:*` 登录态 key,并轮换 `security.session.jwt.secret-key`,所有用户重新登录 + +--- + +## 四、单模块重构(youlai-boot) + +### 4.1 端口接口定义 + +先将 `SocialPlatformEnum` 下沉到 `youlai-common`,再新建包 `framework.security.port`: + +``` +framework/security/ +├── port/ +│ ├── UserAuthenticationPort.java # 新增 +│ └── PermissionPort.java # 新增 +├── model/ +│ ├── SecurityUserDetails.java # 改造 +│ ├── SecurityUser.java # 重命名(原 UserAuthInfo) +│ └── UserSession.java # 删除 +├── service/ +│ ├── SecurityUserDetailsService.java # 改造(注入 Port) +│ ├── PermissionService.java # 改造(注入 Port) +│ ├── UserAuthQueryService.java # 删除(空占位) +│ ├── RolePermissionService.java # 删除(空占位) +│ ├── WxMaUserAuthQueryService.java # 删除(空占位) +│ └── WxMaBindInfo.java # 删除(空占位,如在 model 包则一并清理) +└── ... +``` + +```java +// framework/security/port/UserAuthenticationPort.java +package com.youlai.boot.framework.security.port; + +import com.youlai.boot.framework.security.model.SecurityUser; +import com.youlai.boot.common.enums.SocialPlatformEnum; + +/** + * 用户认证信息查询端口。 + *

+ * 由 system 模块提供适配器实现,framework 层通过此接口获取认证数据。 + * 框架层不直接依赖 system 模块的 Service。 + * + * @see com.youlai.boot.system.security.adapter.UserAuthenticationAdapter + */ +public interface UserAuthenticationPort { + + /** + * 根据用户名查询认证信息。 + * + * @param username 用户名 + * @return 认证信息,不存在返回 null + */ + SecurityUser getAuthInfoByUsername(String username); + + /** + * 根据手机号查询认证信息。 + * + * @param mobile 手机号 + * @return 认证信息,不存在返回 null + */ + SecurityUser getAuthInfoByMobile(String mobile); + + /** + * 根据第三方 openid 查询认证信息。 + * + * @param platform 第三方平台 + * @param openid openid + * @return 认证信息,未绑定返回 null + */ + SecurityUser getAuthInfoByOpenid(SocialPlatformEnum platform, String openid); +} +``` + +```java +// framework/security/port/PermissionPort.java +package com.youlai.boot.framework.security.port; + +import java.util.Set; + +/** + * 权限查询端口。 + *

+ * 由 system 模块提供适配器实现,framework 层通过此接口获取角色权限集合。 + * + * @see com.youlai.boot.system.security.adapter.PermissionAdapter + */ +public interface PermissionPort { + + /** + * 根据角色编码集合查询权限标识集合。 + * + * @param roleCodes 角色编码集合 + * @return 权限标识集合,如 "sys:user:create" + */ + Set getRolePerms(Set roleCodes); +} +``` + +> **硬性要求**:`UserAuthenticationPort` 必须引用 `com.youlai.boot.common.enums.SocialPlatformEnum`。端口接口中禁止出现任何 `com.youlai.boot.system.*` 引用。 + +### 4.2 SecurityUserDetails 改造 + +```java +// framework/security/model/SecurityUserDetails.java +// 改动点: +// 1. authorities 字段类型 Collection → Set roles +// 2. 构造函数直接赋值 roles +// 3. getAuthorities() 改为实时计算 + +@Data +@NoArgsConstructor +public class SecurityUserDetails implements UserDetails { + + private Long userId; + private String username; + private String password; + private Boolean enabled; + private Long deptId; + private List dataScopes; + private Set roles; // ← 替换 authorities + + public SecurityUserDetails(SecurityUser user) { + this.userId = user.getUserId(); + this.username = user.getUsername(); + this.password = user.getPassword(); + this.enabled = ObjectUtil.equal(user.getStatus(), 1); + this.deptId = user.getDeptId(); + this.dataScopes = user.getDataScopes(); + this.roles = user.getRoles(); // ← 直接赋值,无需转换 + } + + @Override + public Collection getAuthorities() { + if (CollectionUtil.isEmpty(roles)) { + return Collections.emptySet(); + } + return roles.stream() + .map(role -> new SimpleGrantedAuthority(SecurityConstants.ROLE_PREFIX + role)) + .collect(Collectors.toSet()); + } +} +``` + +### 4.3 适配器实现 + +在 system 模块新建 `system.security.adapter` 包: + +```java +// system/security/adapter/UserAuthenticationAdapter.java +package com.youlai.boot.system.security.adapter; + +import com.youlai.boot.framework.security.model.SecurityUser; +import com.youlai.boot.framework.security.port.UserAuthenticationPort; +import com.youlai.boot.common.enums.SocialPlatformEnum; +import com.youlai.boot.system.service.UserService; +import com.youlai.boot.system.service.UserSocialService; +import lombok.RequiredArgsConstructor; +import org.springframework.stereotype.Component; + +/** + * 用户认证信息查询适配器。 + *

+ * 实现 framework 层的 UserAuthenticationPort,委托 system 层服务完成查询。 + * 此类是端口与系统服务的唯一耦合点。 + */ +@Component +@RequiredArgsConstructor +public class UserAuthenticationAdapter implements UserAuthenticationPort { + + private final UserService userService; + private final UserSocialService userSocialService; + + @Override + public SecurityUser getAuthInfoByUsername(String username) { + return userService.getAuthInfoByUsername(username); + } + + @Override + public SecurityUser getAuthInfoByMobile(String mobile) { + return userService.getAuthInfoByMobile(mobile); + } + + @Override + public SecurityUser getAuthInfoByOpenid(SocialPlatformEnum platform, String openid) { + return userSocialService.getAuthInfoByOpenid(platform, openid); + } +} +``` + +```java +// system/security/adapter/PermissionAdapter.java +package com.youlai.boot.system.security.adapter; + +import com.youlai.boot.framework.security.port.PermissionPort; +import com.youlai.boot.system.service.RoleMenuService; +import lombok.RequiredArgsConstructor; +import org.springframework.stereotype.Component; +import java.util.Set; + +/** + * 权限查询适配器。 + *

+ * 实现 framework 层的 PermissionPort,委托 RoleMenuService 查询权限。 + */ +@Component +@RequiredArgsConstructor +public class PermissionAdapter implements PermissionPort { + + private final RoleMenuService roleMenuService; + + @Override + public Set getRolePerms(Set roleCodes) { + return roleMenuService.getRolePermsByRoleCodes(roleCodes); + } +} +``` + +### 4.4 服务层改造 + +```java +// framework/security/service/SecurityUserDetailsService.java(改造后) +package com.youlai.boot.framework.security.service; + +import com.youlai.boot.framework.security.model.SecurityUserDetails; +import com.youlai.boot.framework.security.model.SecurityUser; +import com.youlai.boot.framework.security.port.UserAuthenticationPort; +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.springframework.security.core.userdetails.UserDetails; +import org.springframework.security.core.userdetails.UserDetailsService; +import org.springframework.security.core.userdetails.UsernameNotFoundException; +import org.springframework.stereotype.Service; + +/** + * 系统用户认证 DetailsService。 + *

+ * 通过 UserAuthenticationPort 获取认证信息,不直接依赖 system 模块。 + */ +@Service +@RequiredArgsConstructor +@Slf4j +public class SecurityUserDetailsService implements UserDetailsService { + + private final UserAuthenticationPort userAuthPort; // ← 替换 UserService + + @Override + public UserDetails loadUserByUsername(String username) throws UsernameNotFoundException { + try { + SecurityUser SecurityUser = userAuthPort.getAuthInfoByUsername(username); + if (SecurityUser == null) { + throw new UsernameNotFoundException(username); + } + return new SecurityUserDetails(SecurityUser); + } catch (Exception e) { + log.error("认证异常:{}", e.getMessage()); + throw e; + } + } +} +``` + +```java +// framework/security/service/PermissionService.java(改造后) +package com.youlai.boot.framework.security.service; + +import com.youlai.boot.framework.security.port.PermissionPort; +import com.youlai.boot.framework.security.util.SecurityUtils; +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.springframework.stereotype.Component; +import org.springframework.util.PatternMatchUtils; +import java.util.Set; + +@Component("ss") +@RequiredArgsConstructor +@Slf4j +public class PermissionService { + + private final PermissionPort permissionPort; // ← 替换 RoleMenuService + + public boolean hasPerm(String requiredPerm) { + // ... 前置校验不变(超管放行等)... + Set roleCodes = SecurityUtils.getRoles(); + Set rolePerms = permissionPort.getRolePerms(roleCodes); // ← 通过 Port 调用 + // ... 后续逻辑不变(通配符匹配等)... + } +} +``` + +### 4.5 Token 管理器适配 + +**RedisTokenManager** 改动: + +```java +// 之前:UserSession session = new UserSession(user); +// redisTemplate.opsForValue().set(key, session); +// 之后:直接存 SecurityUserDetails(password 置 null) +SecurityUserDetails sessionUser = new SecurityUserDetails(); +sessionUser.setUserId(user.getUserId()); +sessionUser.setUsername(user.getUsername()); +sessionUser.setDeptId(user.getDeptId()); +sessionUser.setDataScopes(user.getDataScopes()); +sessionUser.setRoles(user.getRoles()); +sessionUser.setEnabled(user.isEnabled()); +sessionUser.setPassword(null); // 不存密码 +redisTemplate.opsForValue().set(key, sessionUser); +``` + +```java +// 之前:UserSession raw = jsonMapper.convertValue(obj, UserSession.class); +// return buildUserDetails(raw); +// 之后:直接还原 +SecurityUserDetails raw = jsonMapper.convertValue(obj, SecurityUserDetails.class); +return raw; +``` + +**JwtTokenManager** 改动: + +```java +// parseToken 中: +// 之前:从 claims 取 authorities,构造 Set +// 之后:从 claims 取 roles(Set),直接 setRoles() +Set roles = claims.get(JwtClaimConstants.ROLES); +securityUserDetails.setRoles(roles); +// getAuthorities() 会在运行时自动 add ROLE_ 前缀 +``` + +同步修改 `JwtClaimConstants`: + +```java +// 删除 AUTHORITIES,新增 ROLES +String ROLES = "roles"; +``` + +上线发布时执行登录态失效: + +```text +1. 清理 Redis 中 auth:* 登录态 key +2. 轮换 security.session.jwt.secret-key +3. 通知前端收到 401 后跳转登录页 +``` + +### 4.6 单模块改动清单 + +| 文件 | 操作 | 改动 | +|------|------|------| +| `framework/security/port/UserAuthenticationPort.java` | **新增** | 端口接口 | +| `framework/security/port/PermissionPort.java` | **新增** | 端口接口 | +| `framework/security/model/SecurityUserDetails.java` | 改造 | `authorities` → `roles`,`getAuthorities()` 改为计算 | +| `framework/security/model/UserSession.java` | **删除** | 不再需要 | +| `framework/security/service/SecurityUserDetailsService.java` | 改造 | 注入 `UserAuthenticationPort` 替代 `UserService` | +| `framework/security/service/PermissionService.java` | 改造 | 注入 `PermissionPort` 替代 `RoleMenuService` | +| `framework/security/config/SecurityConfig.java` | 改造 | 移除 `UserService` 直接注入,Provider 改走端口或留在使用方 | +| `framework/security/service/UserAuthQueryService.java` | **删除** | 空占位文件 | +| `framework/security/service/RolePermissionService.java` | **删除** | 空占位文件 | +| `framework/security/service/WxMaUserAuthQueryService.java` | **删除** | 空占位文件 | +| `framework/security/model/WxMaBindInfo.java` | **删除** | 空占位文件 | +| `framework/security/util/SecurityUtils.java` | 改造 | `getRoles()` 简化为直接取 `roles` 字段 | +| `framework/security/token/RedisTokenManager.java` | 改造 | 存 `SecurityUserDetails`,删除 `UserSession` 引用和 `buildUserDetails` | +| `framework/security/token/JwtTokenManager.java` | 改造 | `parseToken` 设 `roles` 替代 `authorities` | +| `common/constant/JwtClaimConstants.java` | 改造 | 删除 `AUTHORITIES`,新增 `ROLES = "roles"` | +| `common/enums/SocialPlatformEnum.java` | **新增/迁移** | 从 `system.enums` 下沉到 common | +| `system/security/adapter/UserAuthenticationAdapter.java` | **新增** | 适配器实现 | +| `system/security/adapter/PermissionAdapter.java` | **新增** | 适配器实现 | + +--- + +## 五、多模块重构(youlai-boot-multi) + +多模块项目中,`youlai-framework` 是独立 Maven 模块,`youlai-system` 是另一个模块。核心区别:端口定义在 `youlai-framework`,适配器实现在 `youlai-system`。 + +### 5.1 模块依赖关系 + +``` +youlai-framework(端口定义 + 基础设施) ←──依赖── youlai-system(适配器实现 + 业务) + ↑ ↑ + └──依赖── youlai-auth、youlai-application 等 +``` + +> **关键**:`youlai-framework` 的 `pom.xml` **不依赖** `youlai-system`。`youlai-system` 依赖 `youlai-framework`,实现端口接口。 + +### 5.2 当前多模块状态 + +多模块项目已将 `SecurityUserDetailsService` 和 `PermissionService` 移至 `youlai-system/security/service/`,但代码仍直接 `import` system 服务,循环依赖未解决。`youlai-framework/security/service/` 目录为空。 + +> ⚠️ **版本不一致提醒**:根 POM 声明 `4.3.1`,而 `youlai-framework` 和 `youlai-system` 的 parent 版本为 `4.3.0`。重构时应一并修正。 + +### 5.3 端口接口定义(youlai-framework) + +在 `youlai-framework` 模块新建 `port` 包: + +``` +youlai-framework/src/main/java/com/youlai/boot/framework/security/ +├── port/ +│ ├── UserAuthenticationPort.java # 新增 +│ └── PermissionPort.java # 新增 +├── model/ +│ ├── SecurityUserDetails.java # 改造(同单模块 4.2) +│ ├── SecurityUser.java # 重命名(原 UserAuthInfo) +│ └── UserSession.java # 删除 +├── token/ +│ ├── TokenManager.java # 不变 +│ ├── JwtTokenManager.java # 改造(同单模块 4.5) +│ └── RedisTokenManager.java # 改造(同单模块 4.5) +├── util/ +│ └── SecurityUtils.java # 改造(同单模块 3.3) +└── service/ # 保持为空(服务在 system 模块) +``` + +> 端口接口代码与单模块完全相同(见 4.1),仅包名一致。 + +### 5.4 适配器实现(youlai-system) + +在 `youlai-system` 模块新建 `adapter` 包(当前**不存在** adapter 目录,需创建): + +``` +youlai-system/src/main/java/com/youlai/boot/system/security/ +├── adapter/ # ← 新建目录 +│ ├── UserAuthenticationAdapter.java # 新增 +│ └── PermissionAdapter.java # 新增 +├── service/ +│ ├── SecurityUserDetailsService.java # 改造(注入 Port) +│ └── PermissionService.java # 改造(注入 Port) +├── provider/ +│ ├── SmsAuthenticationProvider.java # 使用方业务 Provider,留在 system +│ └── WxMaAuthenticationProvider.java # 使用方业务 Provider,留在 system +└── config/ + └── SecurityConfig.java # 检查注入对象 +``` + +> 适配器代码与单模块完全相同(见 4.3),仅位于 `youlai-system` 模块。 + +### 5.5 服务层改造(youlai-system) + +`SecurityUserDetailsService` 和 `PermissionService` 已在 `youlai-system` 模块中,改造方式与单模块相同(见 4.4),注入端口接口替代直接服务引用。 + +`SmsAuthenticationProvider` 和 `WxMaAuthenticationProvider` 属于使用方业务认证流程,留在 `youlai-system`。它们可以直接调用 `UserService` / `UserSocialService` 完成验证码、微信绑定、session_key 更新等业务动作,但不得迁入 Starter。 + +### 5.6 POM 依赖检查 + +```xml + + + + com.youlai + youlai-common + + + + + + + + com.youlai + youlai-framework + + +``` + +### 5.7 多模块改动清单 + +| 模块 | 文件 | 操作 | +|------|------|------| +| `youlai-framework` | `security/port/UserAuthenticationPort.java` | 新增 | +| `youlai-framework` | `security/port/PermissionPort.java` | 新增 | +| `youlai-framework` | `security/model/SecurityUserDetails.java` | 改造 | +| `youlai-framework` | `security/model/UserSession.java` | **删除** | +| `youlai-framework` | `security/util/SecurityUtils.java` | 改造 | +| `youlai-framework` | `security/token/JwtTokenManager.java` | 改造 | +| `youlai-framework` | `security/token/RedisTokenManager.java` | 改造 | +| `youlai-framework` | `pom.xml` | 确认不含 youlai-system 依赖 | +| `youlai-system` | `security/adapter/UserAuthenticationAdapter.java` | 新增 | +| `youlai-system` | `security/adapter/PermissionAdapter.java` | 新增 | +| `youlai-system` | `security/service/SecurityUserDetailsService.java` | 改造 | +| `youlai-system` | `security/service/PermissionService.java` | 改造 | +| `youlai-system` | `security/provider/SmsAuthenticationProvider.java` | 保留在使用方,不迁入 Starter | +| `youlai-system` | `security/provider/WxMaAuthenticationProvider.java` | 保留在使用方,不迁入 Starter | +| `youlai-system` | `pom.xml` | parent 版本修正为 4.3.1 | + +--- + +## 六、Security Starter 抽离与发布方案 + +> **前提**:完成第四节(单模块)或第五节(多模块)的端口/适配器解耦后,方可执行本节。 + +### 6.1 可行性分析 + +| 维度 | 当前状态 | Starter 要求 | 差距 | +|------|----------|--------------|------| +| 端口定义 | 散落在 `framework.security` | 需独立模块 | 需新建 Maven 模块 | +| 自动装配 | 无 `spring.factories` / `AutoConfiguration.imports` | 需 SPI 注册 | 需新建自动配置类 | +| 配置属性 | `SecurityProperties` 已有 `@ConfigurationProperties` | 需 `@EnableConfigurationProperties` | 改造 | +| Token 实现 | 固定保留 `JwtTokenManager` 与 `RedisTokenManager` 两个实现 | 按 `security.session.type` 条件装配 | 改造 | +| 业务耦合 | `SocialPlatformEnum` 引用 system 包 | 需下沉到 common | 改造 | +| 依赖管理 | security 与 cache/mybatis/captcha 混在 framework | 需独立依赖树 | 新建 pom.xml | + +**结论**:可行。端口/适配器解耦是前置条件,解耦后 security 模块编译期零 system 依赖,符合 Starter 抽离要求。 + +### 6.2 Starter 模块设计 + +``` +youlai-boot-multi/(多模块项目根) +├── youlai-common/ # 常量、枚举(SocialPlatformEnum 下沉至此) +├── youlai-security-spring-boot-starter/ # ← 新增:Security Starter +│ ├── src/main/java/com/youlai/boot/framework/security/ +│ │ ├── autoconfigure/ # 自动配置类 +│ │ │ ├── SecurityAutoConfiguration.java +│ │ │ ├── JwtTokenAutoConfiguration.java +│ │ │ └── RedisTokenAutoConfiguration.java +│ │ ├── port/ # 端口接口(对外契约) +│ │ │ ├── UserAuthenticationPort.java +│ │ │ └── PermissionPort.java +│ │ ├── model/ # 安全模型 +│ │ │ ├── SecurityUserDetails.java +│ │ │ ├── SecurityUser.java +│ │ │ ├── RoleDataScope.java +│ │ │ └── ...(Token 模型等) +│ │ ├── service/ # 框架层服务 +│ │ │ ├── SecurityUserDetailsService.java +│ │ │ └── PermissionService.java +│ │ ├── token/ # Token 管理器 +│ │ │ ├── TokenManager.java +│ │ │ ├── JwtTokenManager.java +│ │ │ └── RedisTokenManager.java +│ │ ├── filter/ # Token 认证过滤器 +│ │ │ └── TokenAuthenticationFilter.java +│ │ ├── exception/ # 异常 +│ │ ├── util/ # SecurityUtils +│ │ └── config/ # SecurityProperties +│ ├── src/main/resources/ +│ │ └── META-INF/ +│ │ └── spring/ +│ │ └── org.springframework.boot.autoconfigure.AutoConfiguration.imports +│ └── pom.xml +├── youlai-system/ # 提供 Adapter 实现 +├── youlai-framework/ # 剥离 security 后,保留 cache/mybatis 等 +└── youlai-application/ # 引用 starter +``` + +### 6.3 pom.xml + +```xml + + + 4.0.0 + + + com.youlai + youlai-boot + 4.3.1 + + + youlai-security-spring-boot-starter + youlai Security Spring Boot Starter - 认证鉴权自动装配 + + + + + com.youlai + youlai-common + + + + + org.springframework.boot + spring-boot-starter-security + + + org.springframework.boot + spring-boot-starter-web + + + + + org.springframework.boot + spring-boot-starter-data-redis + + + + + cn.hutool + hutool-all + + + + + org.springframework.boot + spring-boot-autoconfigure + + + +``` + +> **关键设计**:Starter 固定携带 Security、Web、Redis、Hutool JWT 所需依赖。使用方不再拼装 JWT/Redis 依赖,降低接入复杂度。 + +### 6.4 自动配置类 + +```java +// autoconfigure/SecurityAutoConfiguration.java +package com.youlai.boot.framework.security.autoconfigure; + +import com.youlai.boot.framework.security.config.SecurityProperties; +import com.youlai.boot.framework.security.filter.TokenAuthenticationFilter; +import com.youlai.boot.framework.security.port.PermissionPort; +import com.youlai.boot.framework.security.port.UserAuthenticationPort; +import com.youlai.boot.framework.security.service.PermissionService; +import com.youlai.boot.framework.security.service.SecurityUserDetailsService; +import com.youlai.boot.framework.security.token.TokenManager; +import org.springframework.boot.autoconfigure.AutoConfiguration; +import org.springframework.boot.autoconfigure.condition.ConditionalOnMissingBean; +import org.springframework.boot.autoconfigure.condition.ConditionalOnProperty; +import org.springframework.boot.context.properties.EnableConfigurationProperties; +import org.springframework.context.annotation.Bean; +import org.springframework.security.crypto.bcrypt.BCryptPasswordEncoder; +import org.springframework.security.crypto.password.PasswordEncoder; +import org.springframework.security.core.userdetails.UserDetailsService; +import tools.jackson.databind.json.JsonMapper; + +/** + * Security 自动配置入口。 + *

+ * 装配条件:classpath 存在 SecurityProperties 类。 + * 使用方可通过 spring.autoconfigure.exclude 排除整体自动配置。 + */ +@AutoConfiguration +@EnableConfigurationProperties(SecurityProperties.class) +public class SecurityAutoConfiguration { + + /** + * 密码编码器,默认 BCrypt。 + */ + @Bean + @ConditionalOnMissingBean(PasswordEncoder.class) + public PasswordEncoder passwordEncoder() { + return new BCryptPasswordEncoder(); + } + + /** + * JSON 映射器,供 RedisTokenManager 反序列化使用。 + */ + @Bean + @ConditionalOnMissingBean(JsonMapper.class) + public JsonMapper jsonMapper() { + return JsonMapper.builder().findAndAddModules().build(); + } + + /** + * 用户认证服务。 + *

+ * 需要使用方提供 UserAuthenticationPort 实现。 + */ + @Bean + @ConditionalOnMissingBean(UserDetailsService.class) + public UserDetailsService userDetailsService(UserAuthenticationPort userAuthenticationPort) { + return new SecurityUserDetailsService(userAuthenticationPort); + } + + /** + * 权限服务 Bean(SpEL @ss.hasPerm(...) 使用)。 + *

+ * 需要 PermissionPort 实现,否则启动失败—— + * 使用方必须提供 PermissionAdapter。 + */ + @Bean("ss") + @ConditionalOnMissingBean(name = "ss") + @ConditionalOnProperty(prefix = "security", name = "enabled", havingValue = "true", matchIfMissing = true) + public PermissionService permissionService(PermissionPort permissionPort) { + return new PermissionService(permissionPort); + } + + /** + * Token 认证过滤器。 + *

+ * 过滤器不写业务 JSON 响应;无效 Token 抛出 Spring Security AuthenticationException, + * 由使用方 SecurityConfig 中的 AuthenticationEntryPoint 统一处理。 + */ + @Bean + @ConditionalOnMissingBean(TokenAuthenticationFilter.class) + public TokenAuthenticationFilter tokenAuthenticationFilter(TokenManager tokenManager) { + return new TokenAuthenticationFilter(tokenManager); + } +} +``` + +```java +// autoconfigure/JwtTokenAutoConfiguration.java +package com.youlai.boot.framework.security.autoconfigure; + +import com.youlai.boot.framework.security.token.JwtTokenManager; +import com.youlai.boot.framework.security.token.TokenManager; +import com.youlai.boot.framework.security.config.SecurityProperties; +import org.springframework.boot.autoconfigure.AutoConfiguration; +import org.springframework.boot.autoconfigure.condition.ConditionalOnClass; +import org.springframework.boot.autoconfigure.condition.ConditionalOnMissingBean; +import org.springframework.boot.autoconfigure.condition.ConditionalOnProperty; +import org.springframework.context.annotation.Bean; +import org.springframework.data.redis.core.RedisTemplate; + +/** + * JWT Token 管理器自动配置。 + *

+ * 装配条件: + * 1. classpath 存在 cn.hutool.jwt.JWTUtil + * 2. security.session.type = jwt + */ +@AutoConfiguration +@ConditionalOnClass(name = "cn.hutool.jwt.JWTUtil") +@ConditionalOnProperty(prefix = "security.session", name = "type", havingValue = "jwt", matchIfMissing = true) +public class JwtTokenAutoConfiguration { + + @Bean + @ConditionalOnMissingBean(TokenManager.class) + public TokenManager jwtTokenManager(SecurityProperties properties, + RedisTemplate redisTemplate) { + return new JwtTokenManager(properties, redisTemplate); + } +} +``` + +```java +// autoconfigure/RedisTokenAutoConfiguration.java +package com.youlai.boot.framework.security.autoconfigure; + +import com.youlai.boot.framework.security.token.RedisTokenManager; +import com.youlai.boot.framework.security.token.TokenManager; +import com.youlai.boot.framework.security.config.SecurityProperties; +import org.springframework.boot.autoconfigure.AutoConfiguration; +import org.springframework.boot.autoconfigure.condition.ConditionalOnClass; +import org.springframework.boot.autoconfigure.condition.ConditionalOnMissingBean; +import org.springframework.boot.autoconfigure.condition.ConditionalOnProperty; +import org.springframework.data.redis.core.RedisTemplate; +import org.springframework.context.annotation.Bean; +import tools.jackson.databind.json.JsonMapper; + +/** + * Redis Token 管理器自动配置。 + *

+ * 装配条件: + * 1. classpath 存在 RedisTemplate + * 2. security.session.type = redis-token + */ +@AutoConfiguration +@ConditionalOnClass(RedisTemplate.class) +@ConditionalOnProperty(prefix = "security.session", name = "type", havingValue = "redis-token") +public class RedisTokenAutoConfiguration { + + @Bean + @ConditionalOnMissingBean(TokenManager.class) + public TokenManager redisTokenManager(SecurityProperties properties, + RedisTemplate redisTemplate, + JsonMapper jsonMapper) { + return new RedisTokenManager(properties, redisTemplate, jsonMapper); + } +} +``` + +### 6.5 SPI 注册文件 + +``` +src/main/resources/META-INF/spring/org.springframework.boot.autoconfigure.AutoConfiguration.imports +``` + +``` +com.youlai.boot.framework.security.autoconfigure.SecurityAutoConfiguration +com.youlai.boot.framework.security.autoconfigure.JwtTokenAutoConfiguration +com.youlai.boot.framework.security.autoconfigure.RedisTokenAutoConfiguration +``` + +> **Spring Boot 3.x+** 使用 `AutoConfiguration.imports`(非 `spring.factories`)。本项目 Spring Boot 4.0,必须使用此方式。 + +### 6.6 配置属性 + +```yaml +# application.yml(使用方配置示例) +security: + enabled: true + session: + type: jwt # jwt | redis-token + access-token-time-to-live: 7200 + refresh-token-time-to-live: 604800 + jwt: + secret-key: ${JWT_SECRET:replace-with-strong-secret} + redis-token: + allow-multi-login: true + ignore-urls: + - /api/v1/auth/login/** + - /api/v1/auth/refresh-token + unsecured-urls: + - /doc.html + - /swagger-ui/** + - /v3/api-docs/** +``` + +```java +// config/SecurityProperties.java(Starter 内) +@Data +@ConfigurationProperties(prefix = "security") +public class SecurityProperties { + private boolean enabled = true; + private SessionConfig session = new SessionConfig(); + private String[] ignoreUrls = new String[0]; + private String[] unsecuredUrls = new String[0]; + + @Data + public static class SessionConfig { + private String type = "jwt"; + private Integer accessTokenTimeToLive = 7200; + private Integer refreshTokenTimeToLive = 604800; + private JwtConfig jwt = new JwtConfig(); + private RedisTokenConfig redisToken = new RedisTokenConfig(); + } + + @Data + public static class JwtConfig { + private String secretKey; + } + + @Data + public static class RedisTokenConfig { + private Boolean allowMultiLogin = true; + } +} +``` + +### 6.7 枚举下沉处理 + +`UserAuthenticationPort.getAuthInfoByOpenid(SocialPlatformEnum, String)` 必须引用公共枚举。定义端口前先处理: + +**方案**:将 `SocialPlatformEnum` 从 `youlai-system` 下沉到 `youlai-common`: + +``` +# 之前 +youlai-system/src/main/java/com/youlai/boot/system/enums/SocialPlatformEnum.java + +# 之后 +youlai-common/src/main/java/com/youlai/boot/common/enums/SocialPlatformEnum.java +``` + +同步修改所有引用方的 import 路径。Starter 的 `UserAuthenticationPort` 只引用 `com.youlai.boot.common.enums.SocialPlatformEnum`。 + +### 6.8 接入契约摘要 + +**使用方 pom.xml**: + +```xml + + com.youlai + youlai-security-spring-boot-starter + 4.3.1 + +``` + +**使用方实现 Adapter**(必须): + +```java +// 使用方的 system 模块必须提供两个适配器 +@Component +public class MyUserAuthenticationAdapter implements UserAuthenticationPort { ... } + +@Component +public class MyPermissionAdapter implements PermissionPort { ... } +``` + +> 如果使用方未提供 `UserAuthenticationPort` 或 `PermissionPort` 的 Bean,Starter 启动时会因构造器注入失败而报错——这是**预期行为**,强制使用方实现契约。 + +### 6.9 迁移路径 + +``` +阶段 1:端口/适配器解耦(第四节/第五节) + ↓ +阶段 2:枚举下沉(SocialPlatformEnum → youlai-common) + ↓ +阶段 3:创建 youlai-security-spring-boot-starter 模块 + ↓ +阶段 4:将 framework/security 代码迁移至 Starter 模块 + ↓ +阶段 5:编写自动配置类 + SPI 文件 + ↓ +阶段 6:youlai-framework 移除 security 子包,依赖改为引用 Starter + ↓ +阶段 7:youlai-system 的 Adapter 确认无变化(实现同一端口接口) + ↓ +阶段 8:集成测试 → 发布 +``` + +### 6.10 Starter 边界界定 + +| 归属 Starter | 归属使用方(system 模块) | 归属 youlai-common | +|:---:|:---:|:---:| +| `port/` 端口接口 | `adapter/` 适配器实现 | `SecurityConstants` 常量 | +| `model/` 安全模型 | `SecurityConfig` Web 安全配置 | `SocialPlatformEnum` 枚举 | +| `service/` SecurityUserDetailsService、PermissionService | `provider/` 认证 Provider | `Result` 统一响应 | +| `token/` Token 管理器 | | `enums/` 公共枚举 | +| `filter/TokenAuthenticationFilter` | `filter/CaptchaValidationFilter` | | +| `exception/` 安全异常 | `AuthenticationEntryPoint`、`AccessDeniedHandler` | | +| `util/` SecurityUtils | | | +| `autoconfigure/` 自动配置 | | | + +> **注意**:`SecurityConfig`(`@EnableWebSecurity` 配置类)归使用方,因为不同项目的安全规则(放行路径、CORS 等)不同,不应由 Starter 强制装配。Starter 仅提供 `SecurityProperties` 供使用方读取配置。 + +> **异常响应边界**:Starter 不提供 `MyAuthenticationEntryPoint`、`MyAccessDeniedHandler`、`ResponseWriter`。`TokenAuthenticationFilter` 只负责解析 Token 和填充 `SecurityContext`;无效 Token 抛出 Spring Security `AuthenticationException`。最终 HTTP 状态码、JSON 结构、错误码由使用方的 `AuthenticationEntryPoint` 和 `AccessDeniedHandler` 决定。 + +### 6.11 实操步骤(命令行操作) + +> 以下以多模块项目 `youlai-boot-multi` 为例,演示从零创建 Starter 模块到代码迁移的完整命令行操作。单模块项目参考第四节先完成端口解耦,再按 6.11.3 起的步骤操作。 + +#### 6.11.1 重命名 UserAuthInfo → SecurityUser + +```powershell +# Windows PowerShell +cd d:\Project\youlai-admin\youlai-boot-multi\youlai-framework + +# 重命名文件 +Rename-Item -Path "src\main\java\com\youlai\boot\framework\security\model\UserAuthInfo.java" ` + -NewName "SecurityUser.java" +``` + +```bash +# Linux/macOS +cd d:/Project/youlai-admin/youlai-boot-multi/youlai-framework +mv src/main/java/com/youlai/boot/framework/security/model/UserAuthInfo.java \ + src/main/java/com/youlai/boot/framework/security/model/SecurityUser.java +``` + +然后用 IDE 全局替换(Ctrl+Shift+R): +- 类名 `UserAuthInfo` → `SecurityUser` +- 检查所有 `import ...UserAuthInfo` → `import ...SecurityUser` +- 更新 Javadoc 中的 `@author` 等引用 + +#### 6.11.2 创建 Starter Maven 模块 + +```powershell +# 在多模块项目根目录下创建新模块目录 +cd d:\Project\youlai-admin\youlai-boot-multi + +mkdir youlai-security-spring-boot-starter +mkdir youlai-security-spring-boot-starter\src\main\java\com\youlai\boot\framework\security +mkdir youlai-security-spring-boot-starter\src\main\resources\META-INF\spring +``` + +创建 `youlai-security-spring-boot-starter/pom.xml`(内容见 6.3 节)。 + +#### 6.11.3 在根 POM 注册新模块 + +```xml + +youlai-security-spring-boot-starter +``` + +#### 6.11.4 迁移代码到 Starter 模块 + +```powershell +# 将 framework/security 下的代码移动到 Starter 模块 +$src = "youlai-framework\src\main\java\com\youlai\boot\framework\security" +$dst = "youlai-security-spring-boot-starter\src\main\java\com\youlai\boot\framework\security" + +# 移动 Starter 内核子包 +Move-Item -Path "$src\port" -Destination "$dst\port" -Force +Move-Item -Path "$src\model" -Destination "$dst\model" -Force +Move-Item -Path "$src\service" -Destination "$dst\service" -Force +Move-Item -Path "$src\token" -Destination "$dst\token" -Force +Move-Item -Path "$src\exception" -Destination "$dst\exception" -Force +Move-Item -Path "$src\util" -Destination "$dst\util" -Force + +# 只迁移 TokenAuthenticationFilter,CaptchaValidationFilter 留在使用方 +mkdir "$dst\filter" +Move-Item -Path "$src\filter\TokenAuthenticationFilter.java" -Destination "$dst\filter\TokenAuthenticationFilter.java" -Force + +# 只迁移 SecurityProperties,PasswordEncoder 由 SecurityAutoConfiguration 提供 +mkdir "$dst\config" +Move-Item -Path "$src\config\SecurityProperties.java" -Destination "$dst\config\SecurityProperties.java" -Force + +# 新建 autoconfigure 包 +mkdir "$dst\autoconfigure" + +# 移动 resources +Move-Item -Path "youlai-framework\src\main\resources\..." ` + -Destination "youlai-security-spring-boot-starter\src\main\resources\..." -Force +``` + +> **关键**:`provider/`、`handler/`、`CaptchaValidationFilter`、`PasswordEncoderConfig` **不迁移**。Provider 和验证码属于使用方业务流程;handler 和 `ResponseWriter` 属于使用方响应格式;PasswordEncoder Bean 由 `SecurityAutoConfiguration` 统一提供。 + +#### 6.11.5 创建自动配置类和 SPI 文件 + +```powershell +# 创建 3 个自动配置类(内容见 6.4 节) +# SecurityAutoConfiguration.java +# JwtTokenAutoConfiguration.java +# RedisTokenAutoConfiguration.java + +# 创建 SPI 注册文件 +$spiPath = "youlai-security-spring-boot-starter\src\main\resources\META-INF\spring\org.springframework.boot.autoconfigure.AutoConfiguration.imports" +Set-Content -Path $spiPath -Encoding UTF8 -Value @" +com.youlai.boot.framework.security.autoconfigure.SecurityAutoConfiguration +com.youlai.boot.framework.security.autoconfigure.JwtTokenAutoConfiguration +com.youlai.boot.framework.security.autoconfigure.RedisTokenAutoConfiguration +"@ +``` + +#### 6.11.6 修改 youlai-framework 的 pom.xml + +```xml + + + + com.youlai + youlai-common + + + + + com.youlai + youlai-security-spring-boot-starter + + + + + +``` + +#### 6.11.7 枚举下沉 + +```powershell +# 将 SocialPlatformEnum 从 youlai-system 移到 youlai-common +$src = "youlai-system\src\main\java\com\youlai\boot\system\enums\SocialPlatformEnum.java" +$dst = "youlai-common\src\main\java\com\youlai\boot\common\enums\SocialPlatformEnum.java" + +# 确保目标目录存在 +mkdir "youlai-common\src\main\java\com\youlai\boot\common\enums" -Force +Move-Item -Path $src -Destination $dst -Force +``` + +然后用 IDE 全局替换包名: +- `com.youlai.boot.system.enums.SocialPlatformEnum` → `com.youlai.boot.common.enums.SocialPlatformEnum` + +#### 6.11.8 验证编译 + +```powershell +cd d:\Project\youlai-admin\youlai-boot-multi + +# 编译全部模块 +mvn clean compile -pl youlai-security-spring-boot-starter,youlai-common,youlai-system,youlai-framework -am + +# 确认 Starter 模块零 system 依赖(搜索结果应为空) +Select-String -Path "youlai-security-spring-boot-starter\src\**\*.java" ` + -Pattern "import com\.youlai\.boot\.system\." -SimpleMatch +``` + +> 如果最后一条命令有输出,说明 Starter 仍存在 system 依赖,需排查并消除。 + +#### 6.11.9 验证自动装配 + +```powershell +# 启动应用,观察日志中是否出现自动配置加载信息 +mvn spring-boot:run -pl youlai-application + +# 或在测试中验证 +mvn test -pl youlai-application -Dtest=SecurityAutoConfigurationTest +``` + +--- + +### 6.12 发布到 Maven Central + +> 以下流程基于 Sonatype Central Publisher Portal。发布凭据使用 Portal User Token,不使用旧发布流程。 + +#### 6.12.1 前置准备 + +| 准备项 | 说明 | 获取方式 | +|--------|------|----------| +| **Sonatype 账号** | 发布构件的账号 | 注册 https://central.sonatype.com | +| **GPG 密钥** | 签名构件 | `gpg --gen-key` 生成 | +| **Namespace 所有权验证** | 证明你拥有该 groupId | 在 Central Portal 添加 namespace 并按提示完成 DNS 或 GitHub 验证 | +| **Maven settings.xml** | 配置 Central Portal User Token | `~/.m2/settings.xml` | + +#### 6.12.2 生成并上传 GPG 密钥 + +```bash +# 生成 GPG 密钥 +gpg --gen-key +# 按提示输入姓名、邮箱、密码 + +# 查看密钥 ID +gpg --list-keys +# 输出示例:rsa3072/ABCDEF1234567890 + +# 上传公钥到密钥服务器(Maven Central 要求) +gpg --keyserver keyserver.ubuntu.com --send-keys ABCDEF1234567890 +# 备用服务器 +gpg --keyserver keys.openpgp.org --send-keys ABCDEF1234567890 +gpg --keyserver pgp.mit.edu --send-keys ABCDEF1234567890 +``` + +#### 6.12.3 配置 settings.xml + +```xml + + + + + central + 你的 Central Portal token username + 你的 Central Portal token password + + + + + + central + + gpg + 你的GPG密码 + + + + + + central + + +``` + +#### 6.12.4 配置 Starter 的 pom.xml(发布相关) + +在 `youlai-security-spring-boot-starter/pom.xml` 的 `` 下追加: + +```xml + +youlai Security Spring Boot Starter +认证鉴权 Spring Boot Starter,支持 JWT / Redis Token 双模式 +https://github.com/youlaitech/youlai-starter + + + + The Apache Software License, Version 2.0 + http://www.apache.org/licenses/LICENSE-2.0.txt + + + + + + youlai + youlai + youlai@example.com + https://github.com/youlaitech + + + + + https://github.com/youlaitech/youlai-starter + scm:git:git://github.com/youlaitech/youlai-starter.git + scm:git:ssh://github.com/youlaitech/youlai-starter.git + + + + + + + + org.apache.maven.plugins + maven-source-plugin + 3.3.1 + + + attach-sources + jar-no-fork + + + + + + + org.apache.maven.plugins + maven-javadoc-plugin + 3.6.3 + + + attach-javadocs + jar + + + + + + + org.apache.maven.plugins + maven-gpg-plugin + 3.1.0 + + + sign-artifacts + verify + sign + + + + + + + org.sonatype.central + central-publishing-maven-plugin + 0.11.0 + true + + central + true + + + + +``` + +> **注意**:Central Portal 要求发布包包含 sources、javadocs、GPG 签名和完整 POM 元信息。`central-publishing-maven-plugin` 负责上传与发布,不负责自动补齐这些元信息。 + +#### 6.12.5 执行发布 + +```bash +cd d:/Project/youlai-admin/youlai-boot-multi/youlai-security-spring-boot-starter + +# 1. 编译 + 测试 + 签名 + 发布(一条命令) +mvn clean deploy -P central + +# 2. 发布后登录 https://central.sonatype.com 检查状态 +# 状态变为 "Published" 后,约 15-30 分钟同步到 Maven Central +``` + +```bash +# 验证已发布(等待同步后) +# 浏览器访问: +# https://repo1.maven.org/maven2/com/youlai/youlai-security-spring-boot-starter/ +``` + +#### 6.12.6 快照版本(SNAPSHOT)发布 + +开发阶段可发布 SNAPSHOT 版本供其他项目测试: + +```xml + +4.3.2-SNAPSHOT +``` + +```bash +# SNAPSHOT 发布到 Central Portal Snapshots +mvn clean deploy -P central + +# 使用方添加快照仓库 +``` + +```xml + + + + central-snapshots + https://central.sonatype.com/repository/maven-snapshots/ + true + + +``` + +--- + +### 6.13 使用方式(其他项目接入) + +#### 6.13.1 引入依赖 + +```xml + + + + + com.youlai + youlai-security-spring-boot-starter + 1.0.0 + + +``` + +#### 6.13.2 配置 application.yml + +```yaml +security: + enabled: true + session: + type: jwt # jwt | redis-token + access-token-time-to-live: 7200 + refresh-token-time-to-live: 604800 + jwt: + secret-key: ${JWT_SECRET:your-strong-secret-key-here} + redis-token: + allow-multi-login: true + ignore-urls: + - /api/v1/auth/login/** + - /api/v1/auth/refresh-token + unsecured-urls: + - /doc.html + - /swagger-ui/** + - /v3/api-docs/** +``` + +#### 6.13.3 实现适配器(必须) + +```java +// 使用方必须提供两个适配器 Bean,否则启动失败 +@Component +public class MyUserAuthenticationAdapter implements UserAuthenticationPort { + + @Autowired + private MyUserService myUserService; // 使用方自己的用户服务 + + @Override + public SecurityUser getAuthInfoByUsername(String username) { + // 调用使用方自己的用户查询逻辑 + MyUserEntity user = myUserService.findByUsername(username); + if (user == null) return null; + + SecurityUser securityUser = new SecurityUser(); + securityUser.setUserId(user.getId()); + securityUser.setUsername(user.getUsername()); + securityUser.setPassword(user.getPassword()); + securityUser.setStatus(user.getStatus()); + securityUser.setDeptId(user.getDeptId()); + securityUser.setRoles(user.getRoleCodes()); + securityUser.setDataScopes(user.getDataScopes()); + return securityUser; + } + + @Override + public SecurityUser getAuthInfoByMobile(String mobile) { + // ... 实现手机号查询 + } + + @Override + public SecurityUser getAuthInfoByOpenid(SocialPlatformEnum platform, String openid) { + // ... 实现第三方登录查询(如不支持可 return null) + } +} + +@Component +public class MyPermissionAdapter implements PermissionPort { + + @Autowired + private MyRoleMenuService roleMenuService; + + @Override + public Set getRolePerms(Set roleCodes) { + return roleMenuService.findPermsByRoleCodes(roleCodes); + } +} +``` + +#### 6.13.4 编写 SecurityConfig(使用方自定义安全规则) + +```java +@Configuration +@EnableWebSecurity +@EnableMethodSecurity +public class SecurityConfig { + + @Autowired + private TokenAuthenticationFilter tokenAuthenticationFilter; // Starter 提供 + + @Autowired + private AuthenticationEntryPoint authenticationEntryPoint; // 使用方提供 + + @Autowired + private AccessDeniedHandler accessDeniedHandler; // 使用方提供 + + @Bean + public SecurityFilterChain filterChain(HttpSecurity http) throws Exception { + http + .csrf(csrf -> csrf.disable()) + .sessionManagement(session -> session.sessionCreationPolicy(SessionCreationPolicy.STATELESS)) + .authorizeHttpRequests(auth -> auth + .requestMatchers("/api/v1/auth/login").permitAll() + .requestMatchers("/api/v1/auth/captcha").permitAll() + .anyRequest().authenticated() + ) + .exceptionHandling(ex -> ex + .authenticationEntryPoint(authenticationEntryPoint) + .accessDeniedHandler(accessDeniedHandler) + ) + // 必须位于 ExceptionTranslationFilter 之后、AuthorizationFilter 之前, + // 这样 TokenAuthenticationFilter 抛出的 AuthenticationException 会由 + // AuthenticationEntryPoint 统一处理。 + .addFilterBefore(tokenAuthenticationFilter, AuthorizationFilter.class); + return http.build(); + } +} +``` + +使用方需要提供 `AuthenticationEntryPoint` 和 `AccessDeniedHandler` Bean。youlai 项目可以在这两个 Bean 内继续使用自己的 `ResponseWriter` 输出统一 `Result`;其他项目可以返回空 401/403、Problem Details 或自有 JSON。Starter 不关心响应格式。 + +#### 6.13.5 验证接入成功 + +```java +// 启动应用,日志应显示自动配置已加载: +// ... SecurityAutoConfiguration matched +// ... JwtTokenAutoConfiguration matched(或 RedisTokenAutoConfiguration) + +// 调用登录接口测试 +// POST /api/v1/auth/login → 返回 token +// GET /api/v1/sys/users(带 token + @PreAuthorize("@ss.hasPerm('sys:user:list')"))→ 200 或 403 +``` + +--- + +### 6.14 迁移路径总览 + +``` +阶段 1:P1-P3 端口/适配器解耦(第四节/第五节) + │ + ├── UserAuthInfo 重命名为 SecurityUser + ├── 枚举下沉 SocialPlatformEnum → youlai-common + └── 删除空占位文件、UserSession + │ + ↓ +阶段 2:创建 Starter 模块(6.11 实操步骤) + │ + ├── 6.11.1 重命名 UserAuthInfo → SecurityUser + ├── 6.11.2 创建 Starter 模块 + pom.xml + ├── 6.11.3 根 POM 注册模块 + ├── 6.11.4 迁移代码到 Starter + ├── 6.11.5 创建自动配置类 + SPI 文件 + ├── 6.11.6 youlai-framework pom.xml 改为引用 Starter + ├── 6.11.7 枚举下沉 + ├── 6.11.8 验证编译 + 零 system 依赖 + └── 6.11.9 验证自动装配 + │ + ↓ +阶段 3:发布到 Maven Central(6.12) + │ + ├── 6.12.1 Sonatype 账号 + GPG 密钥 + ├── 6.12.2 上传 GPG 公钥 + ├── 6.12.3 配置 settings.xml + ├── 6.12.4 配置 pom.xml 发布插件 + ├── 6.12.5 mvn clean deploy + └── 6.12.6 验证 Maven Central + │ + ↓ +阶段 4:其他项目接入(6.13) + │ + ├── 6.13.1 引入依赖 + ├── 6.13.2 配置 application.yml + ├── 6.13.3 实现适配器 + ├── 6.13.4 编写 SecurityConfig + └── 6.13.5 验证接入 +``` + +--- + +## 七、测试策略 + +### 7.1 单元测试 + +| 测试目标 | Mock 对象 | 验证点 | +|----------|----------|--------| +| `SecurityUserDetailsService` | `UserAuthenticationPort` | 返回正确 `SecurityUserDetails`;用户不存在抛 `UsernameNotFoundException` | +| `PermissionService` | `PermissionPort` | 有权限返回 true;无权限返回 false;超管放行 | +| `UserAuthenticationAdapter` | `UserService`、`UserSocialService` | 委托调用正确;返回值转换正确 | +| `PermissionAdapter` | `RoleMenuService` | 委托调用正确 | +| `RedisTokenManager` | `RedisTemplate`、`JsonMapper` | 存取 `SecurityUserDetails` 正确;`password` 为 null | +| `JwtTokenManager` | — | JWT 签发/解析 `roles` 正确;`getAuthorities()` 带 `ROLE_` 前缀 | + +### 7.2 集成测试 + +- 登录流程:用户名密码 → 生成 token → 解析 token → 权限校验 +- Redis-Token 模式:登录 → Redis 存储 → 解析还原 → 踢人 +- JWT 模式:登录 → JWT 签发 → 解析 → tokenVersion 失效 +- SpEL 权限:`@PreAuthorize("@ss.hasPerm('sys:user:create')")` 通过/拒绝 + +### 7.3 回归测试用例 + +- **用户认证**:正确用户名/密码登录成功;错误用户名或密码登录失败 +- **用户禁用**:用户状态为禁用时,登录应被拒绝(`isEnabled()` 返回 `false`) +- **角色加载**:登录成功后,`SecurityUserDetails.getAuthorities()` 获得正确的角色列表(前缀 `ROLE_`) +- **权限校验**:`@PreAuthorize` 或 `ss.hasPerm` 表达式,有权限放行、无权限拒绝 +- **微信登录**:通过 `UserAuthenticationPort.getAuthInfoByOpenid` 测试社交登录 +- **边界情况**:角色集合为空、权限集合为空、端口实现抛异常 +- **事务和并发**:高并发登录、权限检查,确保端口实现线程安全 +- **登录态失效**:发布后旧 JWT / Redis Token 均返回 401,前端跳转登录页 + +--- + +## 八、实施阶段 + +| 阶段 | 内容 | 风险 | 可独立发布 | +|------|------|------|:---:| +| **P0** | 多模块 parent 版本统一;确认 `SocialPlatformEnum` 下沉;确认旧 Token 发布失效策略 | 低(前置整理) | ✓ | +| **P1** | `SecurityUserDetails` 改 `roles` + 删 `UserSession` + `RedisTokenManager` 简化 + `SecurityUtils` 简化 + 删除 4 个空占位文件 | 低(内部改造,接口不变) | ✓ | +| **P2** | `JwtTokenManager` 适配 `roles` | 中(需回归 JWT 登录流程) | ✓ | +| **P3** | 端口/适配器解耦 + 枚举下沉 | 中(影响面大,需全量回归) | ✓ | +| **P4** | Security Starter 抽离(6.11 实操) | 高(模块拆分,需完整回归) | ✓ | +| **P5** | 发布到 Maven Central(6.12) | 中(发布流程,不影响代码) | ✓ | + +> P0 必须先完成。P1、P2 可合并发布(模型改造一起做),P3 单独发布,P4 在 P3 稳定后执行,P5 在 P4 验证通过后执行。 + +--- + +## 九、AI 协作指南 + +> **本节为 AI 修改 security 代码的操作规范。任何 AI 在修改前必须阅读。** + +### 9.1 修改前必读检查 + +``` +□ 已阅读第〇节「文档使用指南」 +□ 已阅读第二节「设计决策」,确认不违背 D1-D8 +□ 已确认当前代码处于哪个实施阶段(P0/P1/P2/P3/P4/P5) +□ 已确认是单模块还是多模块项目 +``` + +### 9.2 代码修改检查清单 + +``` +□ framework/security 下的代码不含 import com.youlai.boot.system.* +□ 端口接口在 framework.security.port 包,无 system 依赖 +□ 适配器在 system.security.adapter 包,标注 @Component +□ 端口返回模型使用 SecurityUser(由 UserAuthInfo 重命名) +□ SecurityUserDetails.roles 为 Set,getAuthorities() 实时计算 +□ UserSession.java 已删除,无残留引用 +□ RedisTokenManager 存取 SecurityUserDetails,password 置 null +□ JwtTokenManager parseToken 设 roles,JWT claims 格式不变 +□ SecurityUtils.getRoles() 直接取 roles 字段,无 ROLE_ strip +□ 所有新增接口和类有 Javadoc 注释 +□ 端口接口 @see 注释引用适配器类(方便定位实现) +□ 多模块:youlai-framework/pom.xml 不依赖 youlai-system +``` + +### 9.3 新增端口/适配器决策树 + +``` +需要安全模块调用 system 模块的新功能? +│ +├─ 是 → 是否能通过现有端口完成? +│ │ +│ ├─ 能 → 在现有端口接口添加方法,适配器实现 +│ └─ 不能 → 需要新端口 +│ │ +│ ├─ 功能是否属于认证鉴权最小内核? +│ │ ├─ 是 → 新增核心端口(framework.security.port) +│ │ └─ 否 → 不加入 Security Starter,由业务模块自行实现 +│ │ +│ └─ 命名:<领域意图>Port,如 AuditLogPort +│ +└─ 否 → 不需要端口,直接在 framework 内实现 +``` + +### 9.4 常见问题 + +**Q: 为什么不用 `@Autowired` 而用构造器注入?** +A: 构造器注入(`@RequiredArgsConstructor` + `final`)保证依赖不可变、非空,符合 Spring 官方推荐。 + +**Q: 端口接口需要标注 `@Component` 吗?** +A: 不需要。端口是接口,由适配器实现类标注 `@Component`,Spring 自动按类型注入。 + +**Q: `SocialPlatformEnum` 为什么必须下沉到 common?** +A: 端口接口 `UserAuthenticationPort` 引用了它。如果端口在 Starter 模块而枚举在 system 模块,Starter 编译期就会依赖 system——违背解耦目标。 + +**Q: 可以在端口接口返回 system 实体类吗?** +A: **禁止**。端口返回类型必须为 `SecurityUser`(纯 POJO)或 JDK 基础类型(`Set` 等)。适配器负责实体 → POJO 转换。 + +**Q: 为什么 `UserAuthInfo` 要重命名为 `SecurityUser`?** +A: 两点原因:① 语义——`UserAuthInfo` 偏向"认证信息",但该模型实际承载用户安全数据(角色、权限范围、部门等),`SecurityUser` 更准确;② 作为 Starter 公开 API,`SecurityUser` 比 `UserAuthInfo` 更中性、更规范,其他项目接入时更直观。重命名而非新建,改动最小。 + +**Q: `SecurityUser` 和 `SecurityUserDetails` 有什么区别?** +A: `SecurityUser` 是端口返回的安全数据 POJO(含 status、nickname 等原始字段);`SecurityUserDetails` 是 Spring Security `UserDetails` 实现(含 enabled、roles 等认证字段)。`SecurityUserDetails` 由 `SecurityUser` 构造,数据流:`Adapter 查 DB → SecurityUser → SecurityUserDetails`。 + +**Q: 新增了一个安全过滤器,放在哪?** +A: 放在 `framework/security/filter/`(单模块)或 Starter 的 `filter/` 包。过滤器属于框架基础设施,不依赖 system。 + +**Q: `SecurityConfig` 应该放在哪?** +A: 放在使用方(system 模块或 application 模块)。不同项目安全规则不同,不应由 Starter 强制装配。Starter 仅提供 `SecurityProperties`。 + +**Q: 如何确认重构后 framework 层零 system 依赖?** +A: 在 `framework/security` 目录执行全局搜索 `import com.youlai.boot.system`,结果应为空(`SocialPlatformEnum` 下沉后)。 + +### 9.5 文档修改规范 + +1. **修改本文档时**:更新顶部「最后更新」日期,并在附录 B 变更日志追加记录 +2. **新增设计决策时**:在第〇节 0.3 关键决策表追加 `D7`、`D8`... 编号,不得修改已有决策编号 +3. **新增端口时**:更新第二节 2.2 端口定义表、第六节 6.10 边界界定表 +4. **完成实施阶段时**:在第八节对应阶段标注「✅ 已完成」及日期 +5. **代码示例更新时**:保持与实际代码同步,注释标注 `// 改动点:` 说明差异 + +### 9.6 AI 提示词模板 + +当需要让 AI 执行 security 重构时,可使用以下提示词: + +``` +请按照 docs/security-refactor-plan.md 执行 security 模块重构。 + +当前阶段:P3(端口/适配器解耦) +项目类型:单模块(youlai-boot) + +要求: +1. 先阅读第〇节「文档使用指南」和第二节「设计决策」 +2. 按第四节「单模块重构」执行 +3. 完成后对照第十节「代码审查清单」自查 +4. 不违背 D1-D8 任何决策 +5. 删除 4 个空占位文件(见 0.4 节) +``` + +--- + +## 十、代码审查清单 + +### 10.1 模型改造(P1/P2) + +- [ ] `SecurityUserDetails` 的 `roles` 字段为 `Set`,`getAuthorities()` 实时计算 +- [ ] `UserSession.java` 已删除,无残留引用 +- [ ] `RedisTokenManager` 存取 `SecurityUserDetails`,`password` 置 null +- [ ] `JwtTokenManager` 的 `parseToken` 设 `roles`,JWT claims 格式不变 +- [ ] `SecurityUtils.getRoles()` 直接取 `roles` 字段,无 `ROLE_` strip 逻辑 +- [ ] 4 个空占位文件已删除(`UserAuthQueryService`、`RolePermissionService`、`WxMaUserAuthQueryService`、`WxMaBindInfo`) + +### 10.2 端口/适配器解耦(P3) + +- [ ] `SecurityUserDetailsService` 不含 `import com.youlai.boot.system.*` +- [ ] `PermissionService` 不含 `import com.youlai.boot.system.*` +- [ ] 端口接口在 `framework.security.port` 包下,无 system 依赖 +- [ ] 适配器在 `system.security.adapter` 包下,标注 `@Component` +- [ ] 端口接口有 `@see` 注释引用适配器类 +- [ ] `SocialPlatformEnum` 已下沉到 `youlai-common`(P3/Starter 阶段) +- [ ] `SmsAuthenticationProvider`、`WxMaAuthenticationProvider` 留在使用方,未迁入 Starter + +### 10.3 Starter 抽离(P4) + +- [ ] `UserAuthInfo` 已重命名为 `SecurityUser`,所有引用已更新 +- [ ] `youlai-security-spring-boot-starter` 模块已创建 +- [ ] `pom.xml` 固定包含 Security、Web、Redis、Hutool JWT 依赖 +- [ ] `AutoConfiguration.imports` 文件已创建,注册 3 个自动配置类 +- [ ] `JwtTokenAutoConfiguration` 使用 `@ConditionalOnClass` + `@ConditionalOnProperty` +- [ ] `RedisTokenAutoConfiguration` 使用 `@ConditionalOnClass` + `@ConditionalOnProperty` +- [ ] `SecurityConfig` 留在使用方,不在 Starter 中强制装配 +- [ ] `youlai-framework` 移除 security 子包,pom.xml 改为引用 Starter +- [ ] `SocialPlatformEnum` 已下沉到 `youlai-common` +- [ ] Starter 模块零 `import com.youlai.boot.system.*`(已验证) + +### 10.4 Starter 发布 + +- [ ] Sonatype Central Portal 账号已注册,namespace/GroupId 已验证 +- [ ] GPG 密钥已生成并上传到 3 个密钥服务器 +- [ ] `settings.xml` 已配置 `central` server 凭据 +- [ ] `pom.xml` 已配置 `maven-source-plugin`、`maven-javadoc-plugin`、`maven-gpg-plugin` +- [ ] `pom.xml` 已配置 `central-publishing-maven-plugin`(新方式) +- [ ] `pom.xml` 已配置 ``、``、`` 元信息 +- [ ] `mvn clean deploy -P central` 执行成功 +- [ ] Maven Central 可搜索到构件(https://central.sonatype.com) + +### 10.5 通用规范 + +- [ ] 所有新增接口和类有 Javadoc 注释(中文) +- [ ] 端口方法签名包含 `@param`、`@return`、`@throws` 注释 +- [ ] 使用构造器注入(`@RequiredArgsConstructor` + `final`) +- [ ] 日志不输出密码、密钥等敏感信息 +- [ ] 单元测试覆盖端口调用和适配器委托 +- [ ] 多模块:`youlai-framework/pom.xml` 不依赖 `youlai-system` +- [ ] 多模块:parent 版本与根 POM 一致(4.3.1) + +--- + +## 附录 A:术语表 + +| 术语 | 含义 | +|------|------| +| **Port(端口)** | 安全模块定义的抽象接口,描述需要外部提供的功能。放在 `framework.security.port` 包。 | +| **Adapter(适配器)** | system 模块中对 Port 接口的具体实现,委托 system Service 完成实际操作。放在 `system.security.adapter` 包。 | +| **Ports & Adapters** | 六边形架构模式,核心业务通过端口与外部通信,外部实现通过适配器连入。 | +| **SecurityUser** | 安全模块的用户安全数据 POJO,纯 JDK 类型,无 system 依赖。端口接口的返回类型。由原 `UserAuthInfo` 重命名而来(D2 决策)。 | +| **SecurityUserDetails** | Spring Security `UserDetails` 实现,封装认证后的用户信息。`roles` 字段为 `Set`。 | +| **UserSession** | (待删除)Redis 存储的会话中间模型,字段是 `SecurityUserDetails` 的子集。 | +| **TokenManager** | Token 管理器接口,有 `JwtTokenManager` 和 `RedisTokenManager` 两个实现。 | +| **SecurityProperties** | 安全配置属性类,`@ConfigurationProperties(prefix = "security")`。 | +| **Starter** | Spring Boot 自动装配模块,引入依赖即自动配置。本项目命名为 `youlai-security-spring-boot-starter`。 | +| **AutoConfiguration.imports** | Spring Boot 3.x+ 的自动配置注册文件,位于 `META-INF/spring/` 目录。 | +| **D1-D8** | 本文档第〇节定义的 8 条关键设计决策,不可违背。 | + +--- + +## 附录 B:变更日志 + +| 日期 | 版本 | 变更内容 | +|------|------|----------| +| 2026-06-29 | v1.0 | 初版 security-refactor-plan.md(模型消除 + 单/多模块教程) | +| 2026-06-29 | v1.0 | deep-research-report.md 归档(Ports & Adapters 研究) | +| 2026-07-04 | v2.0 | **合并两文档**:消除重复逻辑(统一模型为 SecurityUser、端口收敛、重复的模式介绍);新增第六节 Security Starter 抽离方案;新增第九节 AI 协作指南;补充空占位文件说明(0.4 节);补充枚举下沉方案(6.7 节);补充多模块版本不一致提醒(5.2 节) | +| 2026-07-04 | v2.1 | **SecurityUser 重命名 + Starter 实操发布指南**:D2 决策更新(`UserAuthInfo` → `SecurityUser` 重命名,非新建);全文 `UserAuthInfo` 替换为 `SecurityUser`;新增第六节 6.11 实操步骤(命令行);新增 Starter 发布与使用方式 | +| 2026-07-04 | v2.2 | **方案收敛为唯一施工路线**:删除 OnlineUserPort 与额外仓库分支;明确不接受旧 Token;JWT claims 统一为 `roles`;Starter 不依赖 `ResponseWriter`,异常响应交给使用方 `AuthenticationEntryPoint` / `AccessDeniedHandler`;配置项统一使用 `security.session.*` | + +--- + +> **参考资料**:本方案基于 youlai-boot 项目 `framework/security` 与 `system` 模块的实际代码分析,结合 Ports & Adapters(六边形架构)模式和 Spring Boot Starter 自动装配规范设计。所有代码示例中的包名、类名、方法签名均与实际代码库对齐。 diff --git a/src/main/java/com/youlai/boot/framework/security/config/SecurityConfig.java b/src/main/java/com/youlai/boot/auth/security/config/SecurityConfig.java similarity index 59% rename from src/main/java/com/youlai/boot/framework/security/config/SecurityConfig.java rename to src/main/java/com/youlai/boot/auth/security/config/SecurityConfig.java index fa541460..597afd12 100644 --- a/src/main/java/com/youlai/boot/framework/security/config/SecurityConfig.java +++ b/src/main/java/com/youlai/boot/auth/security/config/SecurityConfig.java @@ -1,17 +1,19 @@ -package com.youlai.boot.framework.security.config; +package com.youlai.boot.auth.security.config; import cn.binarywang.wx.miniapp.api.WxMaService; import cn.hutool.core.util.ArrayUtil; import com.youlai.boot.framework.captcha.service.CaptchaService; -import com.youlai.boot.framework.security.filter.CaptchaValidationFilter; +import com.youlai.boot.framework.security.config.SecurityProperties; import com.youlai.boot.framework.security.filter.TokenAuthenticationFilter; -import com.youlai.boot.framework.security.handler.MyAccessDeniedHandler; -import com.youlai.boot.framework.security.handler.MyAuthenticationEntryPoint; -import com.youlai.boot.framework.security.provider.SmsAuthenticationProvider; -import com.youlai.boot.framework.security.provider.WxMaAuthenticationProvider; +import com.youlai.boot.framework.security.port.UserAuthenticationPort; +import com.youlai.boot.framework.security.service.SecurityUserDetailsService; import com.youlai.boot.framework.security.token.TokenManager; -import com.youlai.boot.framework.security.service.SysUserDetailsService; -import com.youlai.boot.system.service.UserService; +import com.youlai.boot.auth.security.filter.CaptchaValidationFilter; +import com.youlai.boot.auth.security.handler.JsonAccessDeniedHandler; +import com.youlai.boot.auth.security.handler.JsonAuthenticationEntryPoint; +import com.youlai.boot.auth.security.provider.SmsAuthenticationProvider; +import com.youlai.boot.auth.security.provider.WxMaAuthenticationProvider; +import com.youlai.boot.system.service.UserSocialService; import lombok.RequiredArgsConstructor; import org.springframework.context.annotation.Bean; import org.springframework.context.annotation.Configuration; @@ -29,9 +31,13 @@ import org.springframework.security.config.http.SessionCreationPolicy; import org.springframework.security.crypto.password.PasswordEncoder; import org.springframework.security.web.SecurityFilterChain; import org.springframework.security.web.authentication.UsernamePasswordAuthenticationFilter; +import org.springframework.security.web.access.intercept.AuthorizationFilter; /** - * Spring Security 配置类 + * Spring Security 配置类。 + *

+ * 归使用方(auth 模块),安全规则(放行路径、CORS、Provider 装配、响应格式) + * 因项目而异,不应由框架层强制装配。 * * @author Ray.Hao * @since 4.3.1 @@ -44,57 +50,41 @@ public class SecurityConfig { private final RedisTemplate redisTemplate; private final PasswordEncoder passwordEncoder; - private final TokenManager tokenManager; - private final UserService userService; - private final SysUserDetailsService userDetailsService; - + private final SecurityUserDetailsService userDetailsService; private final CaptchaService captchaService; private final SecurityProperties securityProperties; - /** - * 配置安全过滤链 SecurityFilterChain - */ @Bean public SecurityFilterChain securityFilterChain(HttpSecurity http) throws Exception { return http .authorizeHttpRequests(requestMatcherRegistry -> { - // 配置无需登录即可访问的公开接口(配置文件方式) String[] ignoreUrls = securityProperties.getIgnoreUrls(); if (ArrayUtil.isNotEmpty(ignoreUrls)) { requestMatcherRegistry.requestMatchers(ignoreUrls).permitAll(); } - // 其他所有请求需登录后访问 requestMatcherRegistry.anyRequest().authenticated(); } ) .exceptionHandling(configurer -> configurer - .authenticationEntryPoint(new MyAuthenticationEntryPoint()) // 未认证异常处理器 - .accessDeniedHandler(new MyAccessDeniedHandler()) // 无权限访问异常处理器 + .authenticationEntryPoint(new JsonAuthenticationEntryPoint()) + .accessDeniedHandler(new JsonAccessDeniedHandler()) ) - - // 禁用默认的 Spring Security 特性,适用于前后端分离架构 .sessionManagement(configurer -> - configurer.sessionCreationPolicy(SessionCreationPolicy.STATELESS) // 无状态认证,不使用 Session + configurer.sessionCreationPolicy(SessionCreationPolicy.STATELESS) ) - .csrf(AbstractHttpConfigurer::disable) // 禁用 CSRF 防护,前后端分离无需此防护机制 - .formLogin(AbstractHttpConfigurer::disable) // 禁用默认的表单登录功能,前后端分离采用 Token 认证方式 - .httpBasic(AbstractHttpConfigurer::disable) // 禁用 HTTP Basic 认证,避免弹窗式登录 - // 禁用 X-Frame-Options 响应头,允许页面被嵌套到 iframe 中 + .csrf(AbstractHttpConfigurer::disable) + .formLogin(AbstractHttpConfigurer::disable) + .httpBasic(AbstractHttpConfigurer::disable) .headers(headers -> headers.frameOptions(HeadersConfigurer.FrameOptionsConfig::disable)) - // 验证码校验过滤器 + // 验证码校验(使用方过滤器,直接写 JSON 响应) .addFilterBefore(new CaptchaValidationFilter(captchaService), UsernamePasswordAuthenticationFilter.class) - // 验证和解析过滤器 - .addFilterBefore(new TokenAuthenticationFilter(tokenManager), UsernamePasswordAuthenticationFilter.class) + // Token 认证(Starter 过滤器,抛 AuthenticationException 交给 ExceptionTranslationFilter 处理) + .addFilterBefore(new TokenAuthenticationFilter(tokenManager), AuthorizationFilter.class) .build(); } - /** - * 配置Web安全自定义器,以忽略特定请求路径的安全性检查。 - *

- * 该配置用于指定哪些请求路径不经过Spring Security过滤器链。通常用于静态资源文件。 - */ @Bean public WebSecurityCustomizer webSecurityCustomizer() { return (web) -> { @@ -105,38 +95,27 @@ public class SecurityConfig { }; } - /** - * 默认密码认证的 Provider - */ @Bean public DaoAuthenticationProvider daoAuthenticationProvider() { - DaoAuthenticationProvider daoAuthenticationProvider = new DaoAuthenticationProvider(userDetailsService); - daoAuthenticationProvider.setPasswordEncoder(passwordEncoder); - return daoAuthenticationProvider; + DaoAuthenticationProvider provider = new DaoAuthenticationProvider(userDetailsService); + provider.setPasswordEncoder(passwordEncoder); + return provider; } - /** - * 短信验证码认证 Provider - */ @Bean - public SmsAuthenticationProvider smsAuthenticationProvider() { - return new SmsAuthenticationProvider(userService, redisTemplate); + public SmsAuthenticationProvider smsAuthenticationProvider(UserAuthenticationPort userAuthPort) { + return new SmsAuthenticationProvider(userAuthPort, redisTemplate); } - /** - * 微信小程序认证 Provider - */ @Bean public WxMaAuthenticationProvider wechatMiniAuthenticationProvider( WxMaService wxMaService, - SysUserDetailsService sysUserDetailsService + UserAuthenticationPort userAuthenticationPort, + UserSocialService userSocialService ) { - return new WxMaAuthenticationProvider(wxMaService, sysUserDetailsService); + return new WxMaAuthenticationProvider(wxMaService, userAuthenticationPort, userSocialService); } - /** - * 认证管理器 - */ @Bean public AuthenticationManager authenticationManager( DaoAuthenticationProvider daoAuthenticationProvider, @@ -149,5 +128,4 @@ public class SecurityConfig { wxMaAuthenticationProvider ); } - } diff --git a/src/main/java/com/youlai/boot/framework/security/exception/NeedBindMobileException.java b/src/main/java/com/youlai/boot/auth/security/exception/MobileNotBoundException.java similarity index 60% rename from src/main/java/com/youlai/boot/framework/security/exception/NeedBindMobileException.java rename to src/main/java/com/youlai/boot/auth/security/exception/MobileNotBoundException.java index 706b6179..099b9ee3 100644 --- a/src/main/java/com/youlai/boot/framework/security/exception/NeedBindMobileException.java +++ b/src/main/java/com/youlai/boot/auth/security/exception/MobileNotBoundException.java @@ -1,17 +1,16 @@ -package com.youlai.boot.framework.security.exception; +package com.youlai.boot.auth.security.exception; import org.springframework.security.core.AuthenticationException; /** - * 需要绑定手机号异常 + * 需要绑定手机号异常(微信小程序登录未绑定手机号时抛出)。 */ -public class NeedBindMobileException extends AuthenticationException { +public class MobileNotBoundException extends AuthenticationException { private final String openid; - private final String sessionKey; - public NeedBindMobileException(String openid, String sessionKey) { + public MobileNotBoundException(String openid, String sessionKey) { super("需要绑定手机号"); this.openid = openid; this.sessionKey = sessionKey; @@ -24,5 +23,4 @@ public class NeedBindMobileException extends AuthenticationException { public String getSessionKey() { return sessionKey; } - } diff --git a/src/main/java/com/youlai/boot/framework/security/exception/SmsCaptchaException.java b/src/main/java/com/youlai/boot/auth/security/exception/SmsCaptchaException.java similarity index 83% rename from src/main/java/com/youlai/boot/framework/security/exception/SmsCaptchaException.java rename to src/main/java/com/youlai/boot/auth/security/exception/SmsCaptchaException.java index 8fca2cd9..d5bfd0ea 100644 --- a/src/main/java/com/youlai/boot/framework/security/exception/SmsCaptchaException.java +++ b/src/main/java/com/youlai/boot/auth/security/exception/SmsCaptchaException.java @@ -1,4 +1,4 @@ -package com.youlai.boot.framework.security.exception; +package com.youlai.boot.auth.security.exception; import org.springframework.security.core.AuthenticationException; @@ -9,6 +9,7 @@ import org.springframework.security.core.AuthenticationException; * @since 2025/3/1 */ public class SmsCaptchaException extends AuthenticationException { + public SmsCaptchaException(String msg) { super(msg); } diff --git a/src/main/java/com/youlai/boot/framework/security/filter/CaptchaValidationFilter.java b/src/main/java/com/youlai/boot/auth/security/filter/CaptchaValidationFilter.java similarity index 93% rename from src/main/java/com/youlai/boot/framework/security/filter/CaptchaValidationFilter.java rename to src/main/java/com/youlai/boot/auth/security/filter/CaptchaValidationFilter.java index 30504bc2..db2b4973 100644 --- a/src/main/java/com/youlai/boot/framework/security/filter/CaptchaValidationFilter.java +++ b/src/main/java/com/youlai/boot/auth/security/filter/CaptchaValidationFilter.java @@ -1,4 +1,4 @@ -package com.youlai.boot.framework.security.filter; +package com.youlai.boot.auth.security.filter; import cn.hutool.core.util.StrUtil; import cn.hutool.json.JSONObject; @@ -29,7 +29,9 @@ import java.io.InputStreamReader; import java.nio.charset.StandardCharsets; /** - * 图形验证码校验过滤器 + * 图形验证码校验过滤器。 + *

+ * 归使用方,因为验证码规则(哪些接口需要验证码、验证码类型)因项目而异。 */ public class CaptchaValidationFilter extends OncePerRequestFilter { @@ -49,20 +51,17 @@ public class CaptchaValidationFilter extends OncePerRequestFilter { public void doFilterInternal(HttpServletRequest request, HttpServletResponse response, FilterChain chain) throws ServletException, IOException { - // 非登录接口直接放行 if (!LOGIN_PATH_REQUEST_MATCHER.matches(request)) { chain.doFilter(request, response); return; } - // 仅支持 JSON 登录 String contentType = request.getContentType(); if (contentType == null || !contentType.contains(MediaType.APPLICATION_JSON_VALUE)) { ResponseWriter.writeError(response, ResultCode.USER_VERIFICATION_CODE_ERROR); return; } - // 包装请求,确保下游还能读取 body ContentCachingRequestWrapper requestWrapper = new ContentCachingRequestWrapper(request, -1); byte[] bodyBytes = StreamUtils.copyToByteArray(requestWrapper.getInputStream()); @@ -85,9 +84,6 @@ public class CaptchaValidationFilter extends OncePerRequestFilter { } } - /** - * Simple wrapper to allow repeated reads of the request body after we've parsed it here. - */ private static class RepeatableReadRequestWrapper extends HttpServletRequestWrapper { private final byte[] cachedBody; @@ -139,5 +135,3 @@ public class CaptchaValidationFilter extends OncePerRequestFilter { } } } - - diff --git a/src/main/java/com/youlai/boot/framework/security/handler/MyAccessDeniedHandler.java b/src/main/java/com/youlai/boot/auth/security/handler/JsonAccessDeniedHandler.java similarity index 64% rename from src/main/java/com/youlai/boot/framework/security/handler/MyAccessDeniedHandler.java rename to src/main/java/com/youlai/boot/auth/security/handler/JsonAccessDeniedHandler.java index b4b28ab4..3124829f 100644 --- a/src/main/java/com/youlai/boot/framework/security/handler/MyAccessDeniedHandler.java +++ b/src/main/java/com/youlai/boot/auth/security/handler/JsonAccessDeniedHandler.java @@ -1,4 +1,4 @@ -package com.youlai.boot.framework.security.handler; +package com.youlai.boot.auth.security.handler; import com.youlai.boot.common.result.ResultCode; import com.youlai.boot.framework.web.util.ResponseWriter; @@ -9,17 +9,18 @@ import jakarta.servlet.http.HttpServletRequest; import jakarta.servlet.http.HttpServletResponse; /** - * 无权限访问处理器 + * 无权限访问处理器。 + *

+ * 归使用方,因为 JSON 响应格式因项目而异。 * * @author Ray.Hao * @since 2.0.0 */ -public class MyAccessDeniedHandler implements AccessDeniedHandler { +public class JsonAccessDeniedHandler implements AccessDeniedHandler { @Override - public void handle(HttpServletRequest request, HttpServletResponse response, AccessDeniedException accessDeniedException) { - // 权限不足返回 403 Forbidden + public void handle(HttpServletRequest request, HttpServletResponse response, + AccessDeniedException accessDeniedException) { ResponseWriter.writeError(response, ResultCode.ACCESS_PERMISSION_EXCEPTION); } - } diff --git a/src/main/java/com/youlai/boot/framework/security/handler/MyAuthenticationEntryPoint.java b/src/main/java/com/youlai/boot/auth/security/handler/JsonAuthenticationEntryPoint.java similarity index 54% rename from src/main/java/com/youlai/boot/framework/security/handler/MyAuthenticationEntryPoint.java rename to src/main/java/com/youlai/boot/auth/security/handler/JsonAuthenticationEntryPoint.java index 4f4257c1..0a265125 100644 --- a/src/main/java/com/youlai/boot/framework/security/handler/MyAuthenticationEntryPoint.java +++ b/src/main/java/com/youlai/boot/auth/security/handler/JsonAuthenticationEntryPoint.java @@ -1,4 +1,4 @@ -package com.youlai.boot.framework.security.handler; +package com.youlai.boot.auth.security.handler; import com.youlai.boot.common.result.ResultCode; import com.youlai.boot.framework.web.util.ResponseWriter; @@ -14,35 +14,24 @@ import jakarta.servlet.http.HttpServletResponse; import java.io.IOException; /** - * 统一处理 Spring Security 认证失败响应 + * 统一处理 Spring Security 认证失败响应。 + *

+ * 归使用方,因为 JSON 响应格式(Result 结构、错误码)因项目而异。 * * @author Ray.Hao * @since 2.0.0 */ -public class MyAuthenticationEntryPoint implements AuthenticationEntryPoint { +public class JsonAuthenticationEntryPoint implements AuthenticationEntryPoint { - /** - * 认证失败处理入口方法 - * - * @param request 触发异常的请求对象(可用于获取请求头、参数等) - * @param response 响应对象(用于写入错误信息) - * @param authException 认证异常对象(包含具体失败原因) - */ @Override - public void commence(HttpServletRequest request, HttpServletResponse response, AuthenticationException authException) throws IOException, ServletException { + public void commence(HttpServletRequest request, HttpServletResponse response, + AuthenticationException authException) throws IOException, ServletException { if (authException instanceof BadCredentialsException) { - // 用户名或密码错误 ResponseWriter.writeError(response, ResultCode.USER_PASSWORD_ERROR); - } else if(authException instanceof InsufficientAuthenticationException){ - // 请求头缺失Authorization、Token格式错误、Token过期、签名验证失败 + } else if (authException instanceof InsufficientAuthenticationException) { ResponseWriter.writeError(response, ResultCode.ACCESS_TOKEN_INVALID); } else { - // 其他未明确处理的认证异常(如账户被锁定、账户禁用等) ResponseWriter.writeError(response, ResultCode.USER_LOGIN_EXCEPTION, authException.getMessage()); } } } - - - - diff --git a/src/main/java/com/youlai/boot/framework/security/model/SmsAuthenticationToken.java b/src/main/java/com/youlai/boot/auth/security/model/SmsAuthenticationToken.java similarity index 54% rename from src/main/java/com/youlai/boot/framework/security/model/SmsAuthenticationToken.java rename to src/main/java/com/youlai/boot/auth/security/model/SmsAuthenticationToken.java index bdcb60fe..064e277b 100644 --- a/src/main/java/com/youlai/boot/framework/security/model/SmsAuthenticationToken.java +++ b/src/main/java/com/youlai/boot/auth/security/model/SmsAuthenticationToken.java @@ -1,4 +1,4 @@ -package com.youlai.boot.framework.security.model; +package com.youlai.boot.auth.security.model; import org.springframework.security.authentication.AbstractAuthenticationToken; import org.springframework.security.core.GrantedAuthority; @@ -8,13 +8,9 @@ import java.io.Serial; import java.util.Collection; /** - * 短信验证码认证 Token + * 短信验证码认证 Token。 *

- * 用于短信验证码登录场景,遵循 Spring Security 认证模型: - *

    - *
  • 未认证状态:principal 为手机号,credentials 为验证码
  • - *
  • 已认证状态:principal 为用户详情,credentials 为 null
  • - *
+ * 未认证:principal=手机号,credentials=验证码;已认证:principal=SecurityUserDetails,credentials=null。 * * @author Ray.Hao * @since 2.20.0 @@ -24,30 +20,9 @@ public class SmsAuthenticationToken extends AbstractAuthenticationToken { @Serial private static final long serialVersionUID = 621L; - /** - * 认证信息 - *
    - *
  • 未认证时:手机号
  • - *
  • 已认证时:SysUserDetails 用户详情
  • - *
- */ private final Object principal; - - /** - * 凭证信息 - *
    - *
  • 未认证时:短信验证码
  • - *
  • 已认证时:null
  • - *
- */ private final Object credentials; - /** - * 创建未认证的 Token - * - * @param mobile 手机号 - * @param verifyCode 短信验证码 - */ public SmsAuthenticationToken(String mobile, String verifyCode) { super(AuthorityUtils.NO_AUTHORITIES); this.principal = mobile; @@ -55,12 +30,6 @@ public class SmsAuthenticationToken extends AbstractAuthenticationToken { setAuthenticated(false); } - /** - * 创建已认证的 Token - * - * @param principal 用户详情(SysUserDetails) - * @param authorities 授权信息 - */ public SmsAuthenticationToken(Object principal, Collection authorities) { super(authorities); this.principal = principal; @@ -68,13 +37,6 @@ public class SmsAuthenticationToken extends AbstractAuthenticationToken { super.setAuthenticated(true); } - /** - * 创建已认证的 Token(静态工厂方法) - * - * @param principal 用户详情(SysUserDetails) - * @param authorities 授权信息 - * @return 已认证的 SmsAuthenticationToken - */ public static SmsAuthenticationToken authenticated(Object principal, Collection authorities) { return new SmsAuthenticationToken(principal, authorities); } diff --git a/src/main/java/com/youlai/boot/framework/security/model/WxMaAuthenticationToken.java b/src/main/java/com/youlai/boot/auth/security/model/WxMaAuthenticationToken.java similarity index 68% rename from src/main/java/com/youlai/boot/framework/security/model/WxMaAuthenticationToken.java rename to src/main/java/com/youlai/boot/auth/security/model/WxMaAuthenticationToken.java index 584929ef..3f012894 100644 --- a/src/main/java/com/youlai/boot/framework/security/model/WxMaAuthenticationToken.java +++ b/src/main/java/com/youlai/boot/auth/security/model/WxMaAuthenticationToken.java @@ -1,4 +1,4 @@ -package com.youlai.boot.framework.security.model; +package com.youlai.boot.auth.security.model; import org.springframework.security.authentication.AbstractAuthenticationToken; import org.springframework.security.core.GrantedAuthority; @@ -8,7 +8,9 @@ import java.io.Serial; import java.util.Collection; /** - * 微信小程序认证 Token + * 微信小程序认证 Token。 + *

+ * 未认证:principal=微信code;已认证:principal=SecurityUserDetails。 * * @author Ray.Hao * @since 4.0.0 @@ -18,25 +20,9 @@ public class WxMaAuthenticationToken extends AbstractAuthenticationToken { @Serial private static final long serialVersionUID = 622L; - /** - * 认证信息 - * 未认证时:微信code - * 已认证时:SysUserDetails 用户详情 - */ private final Object principal; - - /** - * 凭证信息 - * 未认证时:null - * 已认证时:null - */ private final Object credentials; - /** - * 创建未认证的 Token - * - * @param code 微信小程序code - */ public WxMaAuthenticationToken(String code) { super(AuthorityUtils.NO_AUTHORITIES); this.principal = code; @@ -44,12 +30,6 @@ public class WxMaAuthenticationToken extends AbstractAuthenticationToken { setAuthenticated(false); } - /** - * 创建已认证的 Token - * - * @param principal 用户详情(SysUserDetails) - * @param authorities 授权信息 - */ public WxMaAuthenticationToken(Object principal, Collection authorities) { super(authorities); this.principal = principal; @@ -57,9 +37,6 @@ public class WxMaAuthenticationToken extends AbstractAuthenticationToken { super.setAuthenticated(true); } - /** - * 创建已认证的 Token(静态工厂方法) - */ public static WxMaAuthenticationToken authenticated(Object principal, Collection authorities) { return new WxMaAuthenticationToken(principal, authorities); } diff --git a/src/main/java/com/youlai/boot/framework/security/provider/SmsAuthenticationProvider.java b/src/main/java/com/youlai/boot/auth/security/provider/SmsAuthenticationProvider.java similarity index 61% rename from src/main/java/com/youlai/boot/framework/security/provider/SmsAuthenticationProvider.java rename to src/main/java/com/youlai/boot/auth/security/provider/SmsAuthenticationProvider.java index 3450bd7a..68626416 100644 --- a/src/main/java/com/youlai/boot/framework/security/provider/SmsAuthenticationProvider.java +++ b/src/main/java/com/youlai/boot/auth/security/provider/SmsAuthenticationProvider.java @@ -1,13 +1,13 @@ -package com.youlai.boot.framework.security.provider; +package com.youlai.boot.auth.security.provider; import cn.hutool.core.util.ObjectUtil; import cn.hutool.core.util.StrUtil; import com.youlai.boot.common.constant.RedisConstants; -import com.youlai.boot.framework.security.exception.SmsCaptchaException; -import com.youlai.boot.framework.security.model.SmsAuthenticationToken; -import com.youlai.boot.framework.security.model.SysUserDetails; -import com.youlai.boot.framework.security.model.UserAuthInfo; -import com.youlai.boot.system.service.UserService; +import com.youlai.boot.framework.security.model.SecurityUser; +import com.youlai.boot.framework.security.model.SecurityUserDetails; +import com.youlai.boot.framework.security.port.UserAuthenticationPort; +import com.youlai.boot.auth.security.exception.SmsCaptchaException; +import com.youlai.boot.auth.security.model.SmsAuthenticationToken; import lombok.extern.slf4j.Slf4j; import org.springframework.data.redis.core.RedisTemplate; import org.springframework.security.authentication.AuthenticationProvider; @@ -19,47 +19,34 @@ import org.springframework.security.core.userdetails.UsernameNotFoundException; /** * 短信验证码认证 Provider *

- * 实现 Spring Security 的 {@link AuthenticationProvider} 接口,处理短信验证码登录认证。 - *

* 认证流程: *

    *
  1. 根据手机号查询用户信息
  2. - *
  3. 校验用户状态(是否禁用)
  4. + *
  5. 校验用户状态
  6. *
  7. 校验短信验证码(与 Redis 缓存比对)
  8. - *
  9. 验证成功后删除验证码,防止重复使用
  10. + *
  11. 验证成功后删除验证码
  12. *
  13. 返回已认证的 Authentication
  14. *
* * @author Ray.Hao * @since 2.17.0 - * @see SmsAuthenticationToken - * @see AuthenticationProvider */ @Slf4j public class SmsAuthenticationProvider implements AuthenticationProvider { - private final UserService userService; - + private final UserAuthenticationPort userAuthPort; private final RedisTemplate redisTemplate; - public SmsAuthenticationProvider(UserService userService, RedisTemplate redisTemplate) { - this.userService = userService; + public SmsAuthenticationProvider(UserAuthenticationPort userAuthPort, RedisTemplate redisTemplate) { + this.userAuthPort = userAuthPort; this.redisTemplate = redisTemplate; } - /** - * 执行短信验证码认证 - * - * @param authentication 未认证的 {@link SmsAuthenticationToken} - * @return 已认证的 {@link SmsAuthenticationToken} - * @throws AuthenticationException 认证失败异常 - */ @Override public Authentication authenticate(Authentication authentication) throws AuthenticationException { String mobile = (String) authentication.getPrincipal(); String inputVerifyCode = (String) authentication.getCredentials(); - // 参数校验 if (StrUtil.isBlank(mobile)) { log.warn("短信验证码登录失败:手机号为空"); throw new SmsCaptchaException("手机号不能为空"); @@ -69,21 +56,18 @@ public class SmsAuthenticationProvider implements AuthenticationProvider { throw new SmsCaptchaException("验证码不能为空"); } - // 根据手机号获取用户信息 - UserAuthInfo userAuthInfo = userService.getAuthInfoByMobile(mobile); + SecurityUser securityUser = userAuthPort.getAuthInfoByMobile(mobile); - if (userAuthInfo == null) { + if (securityUser == null) { log.warn("短信验证码登录失败:用户不存在,手机号={}", mobile); throw new UsernameNotFoundException("用户不存在"); } - // 检查用户状态是否有效 - if (ObjectUtil.notEqual(userAuthInfo.getStatus(), 1)) { - log.warn("短信验证码登录失败:用户已禁用,用户名={}", userAuthInfo.getUsername()); + if (ObjectUtil.notEqual(securityUser.getStatus(), 1)) { + log.warn("短信验证码登录失败:用户已禁用,用户名={}", securityUser.getUsername()); throw new DisabledException("用户已被禁用"); } - // 校验短信验证码 String cacheKey = StrUtil.format(RedisConstants.Captcha.SMS_LOGIN_CODE, mobile); String cachedVerifyCode = (String) redisTemplate.opsForValue().get(cacheKey); @@ -97,24 +81,13 @@ public class SmsAuthenticationProvider implements AuthenticationProvider { throw new SmsCaptchaException("验证码错误"); } - // 验证成功后删除验证码,防止重复使用 redisTemplate.delete(cacheKey); - // 构建认证后的用户详情信息 - SysUserDetails userDetails = new SysUserDetails(userAuthInfo); - - log.info("短信验证码登录成功:用户名={},手机号={}", userAuthInfo.getUsername(), mobile); - - // 创建已认证的 SmsAuthenticationToken + SecurityUserDetails userDetails = new SecurityUserDetails(securityUser); + log.info("短信验证码登录成功:用户名={},手机号={}", securityUser.getUsername(), mobile); return SmsAuthenticationToken.authenticated(userDetails, userDetails.getAuthorities()); } - /** - * 支持的认证类型 - * - * @param authentication 认证类型 - * @return 是否支持该认证类型 - */ @Override public boolean supports(Class authentication) { return SmsAuthenticationToken.class.isAssignableFrom(authentication); diff --git a/src/main/java/com/youlai/boot/framework/security/provider/WxMaAuthenticationProvider.java b/src/main/java/com/youlai/boot/auth/security/provider/WxMaAuthenticationProvider.java similarity index 64% rename from src/main/java/com/youlai/boot/framework/security/provider/WxMaAuthenticationProvider.java rename to src/main/java/com/youlai/boot/auth/security/provider/WxMaAuthenticationProvider.java index d30bfd60..3b40f2f5 100644 --- a/src/main/java/com/youlai/boot/framework/security/provider/WxMaAuthenticationProvider.java +++ b/src/main/java/com/youlai/boot/auth/security/provider/WxMaAuthenticationProvider.java @@ -1,14 +1,16 @@ -package com.youlai.boot.framework.security.provider; +package com.youlai.boot.auth.security.provider; import cn.binarywang.wx.miniapp.api.WxMaService; import cn.binarywang.wx.miniapp.bean.WxMaJscode2SessionResult; import cn.hutool.core.util.ObjectUtil; -import com.youlai.boot.framework.security.exception.NeedBindMobileException; -import com.youlai.boot.framework.security.model.SysUserDetails; -import com.youlai.boot.framework.security.model.UserAuthInfo; -import com.youlai.boot.framework.security.model.WxMaAuthenticationToken; -import com.youlai.boot.framework.security.service.SysUserDetailsService; +import com.youlai.boot.common.enums.SocialPlatformEnum; +import com.youlai.boot.framework.security.model.SecurityUser; +import com.youlai.boot.framework.security.model.SecurityUserDetails; +import com.youlai.boot.framework.security.port.UserAuthenticationPort; +import com.youlai.boot.auth.security.exception.MobileNotBoundException; +import com.youlai.boot.auth.security.model.WxMaAuthenticationToken; import com.youlai.boot.system.model.entity.UserSocial; +import com.youlai.boot.system.service.UserSocialService; import lombok.RequiredArgsConstructor; import lombok.extern.slf4j.Slf4j; import me.chanjar.weixin.common.error.WxErrorException; @@ -26,7 +28,8 @@ import org.springframework.security.core.userdetails.UsernameNotFoundException; public class WxMaAuthenticationProvider implements AuthenticationProvider { private final WxMaService wxMaService; - private final SysUserDetailsService sysUserDetailsService; + private final UserAuthenticationPort userAuthPort; + private final UserSocialService userSocialService; @Override public Authentication authenticate(Authentication authentication) throws AuthenticationException { @@ -38,44 +41,35 @@ public class WxMaAuthenticationProvider implements AuthenticationProvider { } try { - // 1. 用 code 换取 openid WxMaJscode2SessionResult session = wxMaService.jsCode2SessionInfo(code); String openid = session.getOpenid(); String sessionKey = session.getSessionKey(); log.info("微信小程序登录:openid={}", openid); - // 2. 根据 openid 查询绑定信息 - UserSocial userSocial = sysUserDetailsService.getWechatMiniBindInfo(openid); + UserSocial userSocial = userSocialService.getByPlatformAndOpenid(SocialPlatformEnum.WECHAT_MINI, openid); if (userSocial == null) { - // 未绑定,抛出异常提示需要绑定手机号 log.info("微信小程序登录:用户未绑定手机号,openid={}", openid); - throw new NeedBindMobileException(openid, sessionKey); + throw new MobileNotBoundException(openid, sessionKey); } - // 3. 获取用户认证信息 - UserAuthInfo userAuthInfo = sysUserDetailsService.getAuthInfoByWechatOpenid(openid); + SecurityUser securityUser = userAuthPort.getAuthInfoByOpenid(SocialPlatformEnum.WECHAT_MINI, openid); - if (userAuthInfo == null) { + if (securityUser == null) { log.warn("微信小程序登录失败:用户不存在,openid={}", openid); throw new UsernameNotFoundException("用户不存在"); } - // 4. 检查用户状态 - if (ObjectUtil.notEqual(userAuthInfo.getStatus(), 1)) { - log.warn("微信小程序登录失败:用户已禁用,username={}", userAuthInfo.getUsername()); + if (ObjectUtil.notEqual(securityUser.getStatus(), 1)) { + log.warn("微信小程序登录失败:用户已禁用,username={}", securityUser.getUsername()); throw new DisabledException("用户已被禁用"); } - // 5. 更新 session_key - sysUserDetailsService.updateWechatSessionKey(userSocial.getId(), sessionKey); - - // 6. 构建已认证 Token - SysUserDetails userDetails = new SysUserDetails(userAuthInfo); - - log.info("微信小程序登录成功:username={}, openid={}", userAuthInfo.getUsername(), openid); + userSocialService.updateSessionKey(userSocial.getId(), sessionKey); + SecurityUserDetails userDetails = new SecurityUserDetails(securityUser); + log.info("微信小程序登录成功:username={}, openid={}", securityUser.getUsername(), openid); return WxMaAuthenticationToken.authenticated(userDetails, userDetails.getAuthorities()); } catch (WxErrorException e) { @@ -88,5 +82,4 @@ public class WxMaAuthenticationProvider implements AuthenticationProvider { public boolean supports(Class authentication) { return WxMaAuthenticationToken.class.isAssignableFrom(authentication); } - } diff --git a/src/main/java/com/youlai/boot/auth/service/impl/AuthServiceImpl.java b/src/main/java/com/youlai/boot/auth/service/impl/AuthServiceImpl.java index 086caa17..a2bc3c44 100644 --- a/src/main/java/com/youlai/boot/auth/service/impl/AuthServiceImpl.java +++ b/src/main/java/com/youlai/boot/auth/service/impl/AuthServiceImpl.java @@ -6,8 +6,8 @@ import com.youlai.boot.common.constant.RedisConstants; import com.youlai.boot.framework.captcha.model.CaptchaInfo; import com.youlai.boot.framework.captcha.service.CaptchaService; import com.youlai.boot.framework.security.model.AuthenticationToken; -import com.youlai.boot.framework.security.model.SmsAuthenticationToken; import com.youlai.boot.framework.security.token.TokenManager; +import com.youlai.boot.auth.security.model.SmsAuthenticationToken; import com.youlai.boot.framework.security.util.SecurityUtils; import com.youlai.boot.framework.integration.sms.enums.SmsTypeEnum; import com.youlai.boot.framework.integration.sms.service.SmsService; @@ -58,9 +58,9 @@ public class AuthServiceImpl implements AuthService { // 2. 执行认证(认证中) // 说明:这里的认证流程由 Spring Security 提供的 AuthenticationManager 执行。 // 默认情况下会委托给 DaoAuthenticationProvider: - // 1) retrieveUser(...):内部通过 UserDetailsService.loadUserByUsername(...) 获取用户信息(本项目为 SysUserDetailsService 实现) + // 1) retrieveUser(...):内部通过 UserDetailsService.loadUserByUsername(...) 获取用户信息(本项目为 SecurityUserDetailsService 实现) // 2) additionalAuthenticationChecks(...):对比请求密码与用户存储密码(由 PasswordEncoder 完成匹配) - // 认证通过后返回已认证的 Authentication(principal 为 SysUserDetails,authorities 为角色/权限集合)。 + // 认证通过后返回已认证的 Authentication(principal 为 SecurityUserDetails,authorities 为角色/权限集合)。 Authentication authentication = authenticationManager.authenticate(authenticationToken); // 3. 认证成功后生成 JWT 令牌,并存入 Security 上下文,供登录日志 AOP 使用(已认证) diff --git a/src/main/java/com/youlai/boot/auth/service/impl/WxMaAuthServiceImpl.java b/src/main/java/com/youlai/boot/auth/service/impl/WxMaAuthServiceImpl.java index 9a6e0aa6..439cb329 100644 --- a/src/main/java/com/youlai/boot/auth/service/impl/WxMaAuthServiceImpl.java +++ b/src/main/java/com/youlai/boot/auth/service/impl/WxMaAuthServiceImpl.java @@ -8,12 +8,12 @@ import cn.hutool.core.util.StrUtil; import com.youlai.boot.auth.model.vo.WxMaLoginVO; import com.youlai.boot.auth.service.WxMaAuthService; import com.youlai.boot.common.constant.RedisConstants; -import com.youlai.boot.framework.security.exception.NeedBindMobileException; import com.youlai.boot.framework.security.model.AuthenticationToken; -import com.youlai.boot.framework.security.model.SysUserDetails; -import com.youlai.boot.framework.security.model.WxMaAuthenticationToken; +import com.youlai.boot.framework.security.model.SecurityUserDetails; import com.youlai.boot.framework.security.token.TokenManager; -import com.youlai.boot.system.enums.SocialPlatformEnum; +import com.youlai.boot.auth.security.exception.MobileNotBoundException; +import com.youlai.boot.auth.security.model.WxMaAuthenticationToken; +import com.youlai.boot.common.enums.SocialPlatformEnum; import com.youlai.boot.system.model.entity.SysUser; import com.youlai.boot.system.service.UserSocialService; import com.youlai.boot.system.service.UserService; @@ -69,7 +69,7 @@ public class WxMaAuthServiceImpl implements WxMaAuthService { .tokenType(authToken.getTokenType()) .expiresIn(authToken.getExpiresIn()) .build(); - } catch (NeedBindMobileException e) { + } catch (MobileNotBoundException e) { return WxMaLoginVO.builder() .isNewUser(true) .needBindMobile(true) @@ -234,7 +234,7 @@ public class WxMaAuthServiceImpl implements WxMaAuthService { * 生成认证令牌 */ private AuthenticationToken generateAuthToken(String mobile) { - SysUserDetails userDetails = new SysUserDetails(userService.getAuthInfoByMobile(mobile)); + SecurityUserDetails userDetails = new SecurityUserDetails(userService.getAuthInfoByMobile(mobile)); Authentication authentication = new UsernamePasswordAuthenticationToken( userDetails, null, userDetails.getAuthorities() ); diff --git a/src/main/java/com/youlai/boot/common/constant/JwtClaimConstants.java b/src/main/java/com/youlai/boot/common/constant/JwtClaimConstants.java index 9aaadcda..61be1d98 100644 --- a/src/main/java/com/youlai/boot/common/constant/JwtClaimConstants.java +++ b/src/main/java/com/youlai/boot/common/constant/JwtClaimConstants.java @@ -33,9 +33,9 @@ public interface JwtClaimConstants { String DATA_SCOPES = "dataScopes"; /** - * 权限(角色Code)集合 + * 角色编码集合(不带 ROLE_ 前缀) */ - String AUTHORITIES = "authorities"; + String ROLES = "roles"; /** * Token 版本号 diff --git a/src/main/java/com/youlai/boot/system/enums/SocialPlatformEnum.java b/src/main/java/com/youlai/boot/common/enums/SocialPlatformEnum.java similarity index 94% rename from src/main/java/com/youlai/boot/system/enums/SocialPlatformEnum.java rename to src/main/java/com/youlai/boot/common/enums/SocialPlatformEnum.java index 0779f594..c6e85c13 100644 --- a/src/main/java/com/youlai/boot/system/enums/SocialPlatformEnum.java +++ b/src/main/java/com/youlai/boot/common/enums/SocialPlatformEnum.java @@ -1,4 +1,4 @@ -package com.youlai.boot.system.enums; +package com.youlai.boot.common.enums; import com.baomidou.mybatisplus.annotation.EnumValue; import com.youlai.boot.common.base.IBaseEnum; diff --git a/src/main/java/com/youlai/boot/framework/mybatis/interceptor/MyDataPermissionHandler.java b/src/main/java/com/youlai/boot/framework/mybatis/interceptor/MyDataPermissionHandler.java index 7dd4e003..bc2a48b9 100644 --- a/src/main/java/com/youlai/boot/framework/mybatis/interceptor/MyDataPermissionHandler.java +++ b/src/main/java/com/youlai/boot/framework/mybatis/interceptor/MyDataPermissionHandler.java @@ -7,7 +7,7 @@ import com.baomidou.mybatisplus.extension.plugins.handler.DataPermissionHandler; import com.youlai.boot.common.annotation.DataPermission; import com.youlai.boot.common.enums.DataScopeEnum; import com.youlai.boot.framework.security.model.RoleDataScope; -import com.youlai.boot.framework.security.model.SysUserDetails; +import com.youlai.boot.framework.security.model.SecurityUserDetails; import com.youlai.boot.framework.security.util.SecurityUtils; import lombok.SneakyThrows; import lombok.extern.slf4j.Slf4j; @@ -58,7 +58,7 @@ public class MyDataPermissionHandler implements DataPermissionHandler { // 获取当前用户的数据权限列表 List dataScopes = SecurityUtils.getUser() - .map(SysUserDetails::getDataScopes) + .map(SecurityUserDetails::getDataScopes) .orElse(List.of()); // 如果任一角色是 ALL,则跳过数据权限过滤(并集策略) diff --git a/src/main/java/com/youlai/boot/framework/security/filter/TokenAuthenticationFilter.java b/src/main/java/com/youlai/boot/framework/security/filter/TokenAuthenticationFilter.java index 4aad3db0..c4298f35 100644 --- a/src/main/java/com/youlai/boot/framework/security/filter/TokenAuthenticationFilter.java +++ b/src/main/java/com/youlai/boot/framework/security/filter/TokenAuthenticationFilter.java @@ -2,78 +2,72 @@ package com.youlai.boot.framework.security.filter; import cn.hutool.core.util.StrUtil; import com.youlai.boot.common.constant.SecurityConstants; -import com.youlai.boot.common.result.ResultCode; -import com.youlai.boot.framework.web.util.ResponseWriter; import com.youlai.boot.framework.security.token.TokenManager; import jakarta.servlet.FilterChain; import jakarta.servlet.ServletException; import jakarta.servlet.http.HttpServletRequest; import jakarta.servlet.http.HttpServletResponse; import org.springframework.http.HttpHeaders; +import org.springframework.security.authentication.InsufficientAuthenticationException; import org.springframework.security.core.Authentication; +import org.springframework.security.core.AuthenticationException; import org.springframework.security.core.context.SecurityContextHolder; import org.springframework.web.filter.OncePerRequestFilter; import java.io.IOException; /** - * Token 认证校验过滤器 + * Token 认证过滤器。 + *

+ * 仅负责解析 Token 和填充 {@link SecurityContextHolder}。 + * 无效 Token 抛出 {@link AuthenticationException},由使用方的 + * {@code AuthenticationEntryPoint} 统一处理响应格式。 + *

+ * 必须注册在 {@code ExceptionTranslationFilter} 之后(即 {@code AuthorizationFilter} 之前), + * 这样抛出的异常才能被 {@code ExceptionTranslationFilter} 捕获。 * * @author wangtao - * @since 2025/3/6 16:50 + * @since 2025/3/6 */ public class TokenAuthenticationFilter extends OncePerRequestFilter { - /** - * Token 管理器 - */ private final TokenManager tokenManager; public TokenAuthenticationFilter(TokenManager tokenManager) { this.tokenManager = tokenManager; } - /** - * 校验 Token ,包括验签和是否过期 - * 如果 Token 有效,将 Token 解析为 Authentication 对象,并设置到 Spring Security 上下文中 - */ @Override - protected void doFilterInternal(HttpServletRequest request, HttpServletResponse response, FilterChain filterChain) throws ServletException, IOException { + protected void doFilterInternal(HttpServletRequest request, HttpServletResponse response, + FilterChain filterChain) throws ServletException, IOException { String rawToken = resolveToken(request); - try { - if (StrUtil.isNotBlank(rawToken)) { - // 执行令牌有效性检查(包含密码学验签和过期时间验证) - boolean isValidToken = tokenManager.validateToken(rawToken); - if (!isValidToken) { - ResponseWriter.writeError(response, ResultCode.ACCESS_TOKEN_INVALID); - return; + if (StrUtil.isNotBlank(rawToken)) { + try { + boolean isValid = tokenManager.validateToken(rawToken); + if (!isValid) { + SecurityContextHolder.clearContext(); + throw new InsufficientAuthenticationException("Token 无效或已过期"); } - - // 将令牌解析为 Spring Security 上下文认证对象 Authentication authentication = tokenManager.parseToken(rawToken); SecurityContextHolder.getContext().setAuthentication(authentication); + } catch (AuthenticationException ex) { + SecurityContextHolder.clearContext(); + throw ex; + } catch (Exception ex) { + SecurityContextHolder.clearContext(); + throw new InsufficientAuthenticationException("Token 认证失败", ex); } - } catch (Exception ex) { - // 安全上下文清除保障(防止上下文残留) - SecurityContextHolder.clearContext(); - ResponseWriter.writeError(response, ResultCode.ACCESS_TOKEN_INVALID); - return; } - // 继续后续过滤器链执行 filterChain.doFilter(request, response); } - /** - * 从请求中解析 Token(仅支持 Authorization Header) - */ private String resolveToken(HttpServletRequest request) { - String authorizationHeader = request.getHeader(HttpHeaders.AUTHORIZATION); - if (StrUtil.isNotBlank(authorizationHeader) - && authorizationHeader.startsWith(SecurityConstants.BEARER_TOKEN_PREFIX)) { - return authorizationHeader.substring(SecurityConstants.BEARER_TOKEN_PREFIX.length()); + String header = request.getHeader(HttpHeaders.AUTHORIZATION); + if (StrUtil.isNotBlank(header) && header.startsWith(SecurityConstants.BEARER_TOKEN_PREFIX)) { + return header.substring(SecurityConstants.BEARER_TOKEN_PREFIX.length()); } return null; } diff --git a/src/main/java/com/youlai/boot/framework/security/model/UserAuthInfo.java b/src/main/java/com/youlai/boot/framework/security/model/SecurityUser.java similarity index 77% rename from src/main/java/com/youlai/boot/framework/security/model/UserAuthInfo.java rename to src/main/java/com/youlai/boot/framework/security/model/SecurityUser.java index e6b17d21..d9673e7f 100644 --- a/src/main/java/com/youlai/boot/framework/security/model/UserAuthInfo.java +++ b/src/main/java/com/youlai/boot/framework/security/model/SecurityUser.java @@ -6,16 +6,16 @@ import java.util.List; import java.util.Set; /** - * 用户认证信息 + * 安全模块用户数据 POJO。 *

- * 用于登录认证过程中的用户信息承载,包含用户名、密码、状态、角色等与认证/授权相关的数据。 - *

+ * 作为端口接口的返回类型,承载用户认证所需的全部数据。 + * 纯 JDK 类型,无 system 模块依赖,可直接序列化。 * * @author Ray.Hao * @since 2025/12/16 */ @Data -public class UserAuthInfo { +public class SecurityUser { /** * 用户ID @@ -48,7 +48,7 @@ public class UserAuthInfo { private Integer status; /** - * 角色集合 + * 角色编码集合 */ private Set roles; diff --git a/src/main/java/com/youlai/boot/framework/security/model/SysUserDetails.java b/src/main/java/com/youlai/boot/framework/security/model/SecurityUserDetails.java similarity index 65% rename from src/main/java/com/youlai/boot/framework/security/model/SysUserDetails.java rename to src/main/java/com/youlai/boot/framework/security/model/SecurityUserDetails.java index 2e733b11..eeecd5f3 100644 --- a/src/main/java/com/youlai/boot/framework/security/model/SysUserDetails.java +++ b/src/main/java/com/youlai/boot/framework/security/model/SecurityUserDetails.java @@ -13,17 +13,18 @@ import java.util.*; import java.util.stream.Collectors; /** - * Spring Security 用户认证对象 + * Spring Security 用户认证对象。 *

- * 封装了用户的基本信息和权限信息,供 Spring Security 进行用户认证与授权。 - * 实现了 {@link UserDetails} 接口,提供用户的核心信息。 + * 实现 {@link UserDetails},封装用户标识、角色、数据权限等认证信息。 + * {@link #roles} 字段存储角色编码(不带 ROLE_ 前缀), + * {@link #getAuthorities()} 运行时补前缀并转为 {@link SimpleGrantedAuthority}。 * * @author Ray.Hao * @version 3.0.0 */ @Data @NoArgsConstructor -public class SysUserDetails implements UserDetails { +public class SecurityUserDetails implements UserDetails { /** * 用户ID @@ -52,42 +53,35 @@ public class SysUserDetails implements UserDetails { /** * 数据权限列表 - *

- * 存储用户所有角色的数据权限范围,用于实现多角色权限合并(并集策略) */ private List dataScopes; /** - * 用户角色权限集合 + * 角色编码集合(不带 ROLE_ 前缀) */ - private Collection authorities; + private Set roles; /** - * 构造函数:根据用户认证信息初始化用户详情对象 - * - * @param user 用户认证信息对象 {@link UserAuthInfo} + * 构造函数:根据 {@link SecurityUser} 初始化。 */ - public SysUserDetails(UserAuthInfo user) { + public SecurityUserDetails(SecurityUser user) { this.userId = user.getUserId(); this.username = user.getUsername(); this.password = user.getPassword(); this.enabled = ObjectUtil.equal(user.getStatus(), 1); this.deptId = user.getDeptId(); this.dataScopes = user.getDataScopes(); - - // 初始化角色权限集合 - this.authorities = CollectionUtil.isNotEmpty(user.getRoles()) - ? user.getRoles().stream() - // 角色名加上前缀 "ROLE_",用于区分角色 (ROLE_ADMIN) 和权限 (user:add) - .map(role -> new SimpleGrantedAuthority(SecurityConstants.ROLE_PREFIX + role)) - .collect(Collectors.toSet()) - : Collections.emptySet(); + this.roles = user.getRoles(); } - @Override public Collection getAuthorities() { - return this.authorities; + if (CollectionUtil.isEmpty(roles)) { + return Collections.emptySet(); + } + return roles.stream() + .map(role -> new SimpleGrantedAuthority(SecurityConstants.ROLE_PREFIX + role)) + .collect(Collectors.toSet()); } @Override @@ -107,8 +101,6 @@ public class SysUserDetails implements UserDetails { /** * 判断是否包含"全部数据"权限 - * - * @return 是否有全部数据权限 */ public boolean hasAllDataScope() { if (CollectionUtil.isEmpty(dataScopes)) { @@ -119,9 +111,7 @@ public class SysUserDetails implements UserDetails { } /** - * 获取数据权限列表 - * - * @return 数据权限列表,永不为null + * 获取数据权限列表,永不为 null */ public List getDataScopes() { return dataScopes != null ? dataScopes : Collections.emptyList(); diff --git a/src/main/java/com/youlai/boot/framework/security/model/UserSession.java b/src/main/java/com/youlai/boot/framework/security/model/UserSession.java deleted file mode 100644 index a798a4c5..00000000 --- a/src/main/java/com/youlai/boot/framework/security/model/UserSession.java +++ /dev/null @@ -1,49 +0,0 @@ -package com.youlai.boot.framework.security.model; - -import lombok.AllArgsConstructor; -import lombok.Data; -import lombok.NoArgsConstructor; - -import java.util.List; -import java.util.Set; - -/** - * 用户会话信息 - *

- * 存储在Token中的用户会话快照,包含用户身份、数据权限和角色权限信息。 - * 用于Redis-Token模式下的会话管理,支持在线用户查询和会话控制。 - * - * @author wangtao - * @since 2025/2/27 10:31 - */ -@Data -@NoArgsConstructor -@AllArgsConstructor -public class UserSession { - - /** - * 用户ID - */ - private Long userId; - - /** - * 用户名 - */ - private String username; - - /** - * 部门ID - */ - private Long deptId; - - /** - * 数据权限列表 - */ - private List dataScopes; - - /** - * 角色权限集合 - */ - private Set roles; - -} diff --git a/src/main/java/com/youlai/boot/framework/security/model/WxMaBindInfo.java b/src/main/java/com/youlai/boot/framework/security/model/WxMaBindInfo.java deleted file mode 100644 index e69de29b..00000000 diff --git a/src/main/java/com/youlai/boot/framework/security/port/PermissionPort.java b/src/main/java/com/youlai/boot/framework/security/port/PermissionPort.java new file mode 100644 index 00000000..9417eded --- /dev/null +++ b/src/main/java/com/youlai/boot/framework/security/port/PermissionPort.java @@ -0,0 +1,22 @@ +package com.youlai.boot.framework.security.port; + +import java.util.Set; + +/** + * 权限查询端口。 + *

+ * 由 system 模块提供适配器实现,framework 层通过此接口获取角色权限集合, + * 不直接依赖 system 模块的 {@code RoleMenuService}。 + * + * @see com.youlai.boot.system.security.adapter.PermissionAdapter + */ +public interface PermissionPort { + + /** + * 根据角色编码集合查询权限标识集合。 + * + * @param roleCodes 角色编码集合 + * @return 权限标识集合,如 "sys:user:create" + */ + Set getRolePerms(Set roleCodes); +} diff --git a/src/main/java/com/youlai/boot/framework/security/port/UserAuthenticationPort.java b/src/main/java/com/youlai/boot/framework/security/port/UserAuthenticationPort.java new file mode 100644 index 00000000..82acea2c --- /dev/null +++ b/src/main/java/com/youlai/boot/framework/security/port/UserAuthenticationPort.java @@ -0,0 +1,40 @@ +package com.youlai.boot.framework.security.port; + +import com.youlai.boot.common.enums.SocialPlatformEnum; +import com.youlai.boot.framework.security.model.SecurityUser; + +/** + * 用户认证信息查询端口。 + *

+ * 由 system 模块提供适配器实现,framework 层通过此接口获取认证数据, + * 不直接依赖 system 模块的 {@code UserService} / {@code UserSocialService}。 + * + * @see com.youlai.boot.system.security.adapter.UserAuthenticationAdapter + */ +public interface UserAuthenticationPort { + + /** + * 根据用户名查询认证信息。 + * + * @param username 用户名 + * @return 认证信息,不存在返回 null + */ + SecurityUser getAuthInfoByUsername(String username); + + /** + * 根据手机号查询认证信息。 + * + * @param mobile 手机号 + * @return 认证信息,不存在返回 null + */ + SecurityUser getAuthInfoByMobile(String mobile); + + /** + * 根据第三方平台 openid 查询认证信息。 + * + * @param platform 第三方平台 + * @param openid openid + * @return 认证信息,未绑定返回 null + */ + SecurityUser getAuthInfoByOpenid(SocialPlatformEnum platform, String openid); +} diff --git a/src/main/java/com/youlai/boot/framework/security/service/PermissionService.java b/src/main/java/com/youlai/boot/framework/security/service/PermissionService.java index 46516f3c..ef7cc446 100644 --- a/src/main/java/com/youlai/boot/framework/security/service/PermissionService.java +++ b/src/main/java/com/youlai/boot/framework/security/service/PermissionService.java @@ -2,8 +2,8 @@ package com.youlai.boot.framework.security.service; import cn.hutool.core.collection.CollectionUtil; import cn.hutool.core.util.StrUtil; +import com.youlai.boot.framework.security.port.PermissionPort; import com.youlai.boot.framework.security.util.SecurityUtils; -import com.youlai.boot.system.service.RoleMenuService; import lombok.RequiredArgsConstructor; import lombok.extern.slf4j.Slf4j; import org.springframework.stereotype.Component; @@ -12,11 +12,10 @@ import org.springframework.util.PatternMatchUtils; import java.util.Set; /** - * Spring Security 权限校验组件 + * Spring Security 权限校验组件。 *

- * 用于 SpEL 表达式权限校验,如:@PreAuthorize("@ss.hasPerm('sys:user:create')") - *

- * 权限数据来源:{@link RoleMenuService#getRolePermsByRoleCodes}(带 Redis 缓存) + * 用于 SpEL 表达式:{@code @PreAuthorize("@ss.hasPerm('sys:user:create')")}。 + * 通过 {@link PermissionPort} 查询角色权限,不直接依赖 system 模块。 * * @author Ray.Hao * @since 0.0.1 @@ -26,39 +25,30 @@ import java.util.Set; @Slf4j public class PermissionService { - private final RoleMenuService roleMenuService; + private final PermissionPort permissionPort; /** - * 判断当前登录用户是否拥有操作权限 - *

- * 支持通配符匹配,如:权限码 "sys:user:*" 可匹配 "sys:user:create"、"sys:user:delete" 等 - * - * @param requiredPerm 所需权限 - * @return 是否有权限 + * 判断当前用户是否拥有操作权限,支持通配符匹配。 */ public boolean hasPerm(String requiredPerm) { if (StrUtil.isBlank(requiredPerm)) { return false; } - // 超级管理员放行 if (SecurityUtils.isRoot()) { return true; } - // 获取当前登录用户的角色编码集合 Set roleCodes = SecurityUtils.getRoles(); if (CollectionUtil.isEmpty(roleCodes)) { return false; } - // 获取当前登录用户的所有角色的权限列表(从缓存读取) - Set rolePerms = roleMenuService.getRolePermsByRoleCodes(roleCodes); + Set rolePerms = permissionPort.getRolePerms(roleCodes); if (CollectionUtil.isEmpty(rolePerms)) { return false; } - // 判断权限列表中是否包含所需权限(支持通配符) boolean hasPermission = rolePerms.stream() .anyMatch(rolePerm -> PatternMatchUtils.simpleMatch(rolePerm, requiredPerm)); diff --git a/src/main/java/com/youlai/boot/framework/security/service/RolePermissionService.java b/src/main/java/com/youlai/boot/framework/security/service/RolePermissionService.java deleted file mode 100644 index e69de29b..00000000 diff --git a/src/main/java/com/youlai/boot/framework/security/service/SecurityUserDetailsService.java b/src/main/java/com/youlai/boot/framework/security/service/SecurityUserDetailsService.java new file mode 100644 index 00000000..9b998c22 --- /dev/null +++ b/src/main/java/com/youlai/boot/framework/security/service/SecurityUserDetailsService.java @@ -0,0 +1,41 @@ +package com.youlai.boot.framework.security.service; + +import com.youlai.boot.framework.security.model.SecurityUser; +import com.youlai.boot.framework.security.model.SecurityUserDetails; +import com.youlai.boot.framework.security.port.UserAuthenticationPort; +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.springframework.security.core.userdetails.UserDetails; +import org.springframework.security.core.userdetails.UserDetailsService; +import org.springframework.security.core.userdetails.UsernameNotFoundException; +import org.springframework.stereotype.Service; + +/** + * 系统用户认证 DetailsService。 + *

+ * 通过 {@link UserAuthenticationPort} 获取认证信息,不直接依赖 system 模块。 + * + * @author Ray.Hao + * @since 2021/10/19 + */ +@Service +@RequiredArgsConstructor +@Slf4j +public class SecurityUserDetailsService implements UserDetailsService { + + private final UserAuthenticationPort userAuthPort; + + @Override + public UserDetails loadUserByUsername(String username) throws UsernameNotFoundException { + try { + SecurityUser securityUser = userAuthPort.getAuthInfoByUsername(username); + if (securityUser == null) { + throw new UsernameNotFoundException(username); + } + return new SecurityUserDetails(securityUser); + } catch (Exception e) { + log.error("认证异常:{}", e.getMessage()); + throw e; + } + } +} diff --git a/src/main/java/com/youlai/boot/framework/security/service/SysUserDetailsService.java b/src/main/java/com/youlai/boot/framework/security/service/SysUserDetailsService.java deleted file mode 100644 index 2bbe035b..00000000 --- a/src/main/java/com/youlai/boot/framework/security/service/SysUserDetailsService.java +++ /dev/null @@ -1,82 +0,0 @@ -package com.youlai.boot.framework.security.service; - -import com.youlai.boot.framework.security.model.SysUserDetails; -import com.youlai.boot.framework.security.model.UserAuthInfo; -import com.youlai.boot.system.enums.SocialPlatformEnum; -import com.youlai.boot.system.model.entity.UserSocial; -import com.youlai.boot.system.service.UserSocialService; -import com.youlai.boot.system.service.UserService; -import lombok.RequiredArgsConstructor; -import lombok.extern.slf4j.Slf4j; -import org.springframework.security.core.userdetails.UserDetails; -import org.springframework.security.core.userdetails.UserDetailsService; -import org.springframework.security.core.userdetails.UsernameNotFoundException; -import org.springframework.stereotype.Service; - -/** - * 系统用户认证 DetailsService - * - * @author Ray.Hao - * @since 2021/10/19 - */ -@Service -@RequiredArgsConstructor -@Slf4j -public class SysUserDetailsService implements UserDetailsService { - - private final UserService userService; - private final UserSocialService userSocialService; - - /** - * 根据用户名获取用户信息 - * - * @param username 用户名 - * @return 用户信息 - * @throws UsernameNotFoundException 用户名未找到异常 - */ - @Override - public UserDetails loadUserByUsername(String username) throws UsernameNotFoundException { - try { - UserAuthInfo userAuthInfo = userService.getAuthInfoByUsername(username); - if (userAuthInfo == null) { - throw new UsernameNotFoundException(username); - } - return new SysUserDetails(userAuthInfo); - } catch (Exception e) { - // 记录异常日志 - log.error("认证异常:{}", e.getMessage()); - // 抛出异常 - throw e; - } - } - - /** - * 根据微信小程序openid查询绑定信息 - * - * @param openid 微信小程序openid - * @return 绑定信息,未绑定返回null - */ - public UserSocial getWechatMiniBindInfo(String openid) { - return userSocialService.getByPlatformAndOpenid(SocialPlatformEnum.WECHAT_MINI, openid); - } - - /** - * 根据微信小程序openid获取用户认证信息 - * - * @param openid 微信小程序openid - * @return 用户认证信息,用户不存在返回null - */ - public UserAuthInfo getAuthInfoByWechatOpenid(String openid) { - return userSocialService.getAuthInfoByOpenid(SocialPlatformEnum.WECHAT_MINI, openid); - } - - /** - * 更新微信小程序session_key - * - * @param bindId 绑定记录ID - * @param sessionKey session_key - */ - public void updateWechatSessionKey(Long bindId, String sessionKey) { - userSocialService.updateSessionKey(bindId, sessionKey); - } -} diff --git a/src/main/java/com/youlai/boot/framework/security/service/UserAuthQueryService.java b/src/main/java/com/youlai/boot/framework/security/service/UserAuthQueryService.java deleted file mode 100644 index e69de29b..00000000 diff --git a/src/main/java/com/youlai/boot/framework/security/service/WxMaUserAuthQueryService.java b/src/main/java/com/youlai/boot/framework/security/service/WxMaUserAuthQueryService.java deleted file mode 100644 index e69de29b..00000000 diff --git a/src/main/java/com/youlai/boot/framework/security/token/JwtTokenManager.java b/src/main/java/com/youlai/boot/framework/security/token/JwtTokenManager.java index 1b153f64..bdd44dab 100644 --- a/src/main/java/com/youlai/boot/framework/security/token/JwtTokenManager.java +++ b/src/main/java/com/youlai/boot/framework/security/token/JwtTokenManager.java @@ -17,30 +17,25 @@ import com.youlai.boot.framework.security.config.SecurityProperties; import com.youlai.boot.framework.security.exception.TokenInvalidException; import com.youlai.boot.framework.security.model.AuthenticationToken; import com.youlai.boot.framework.security.model.RoleDataScope; +import com.youlai.boot.framework.security.model.SecurityUserDetails; import org.apache.commons.lang3.StringUtils; -import com.youlai.boot.framework.security.model.SysUserDetails; import org.springframework.boot.autoconfigure.condition.ConditionalOnProperty; import org.springframework.data.redis.core.RedisTemplate; import org.springframework.security.authentication.UsernamePasswordAuthenticationToken; import org.springframework.security.core.Authentication; -import org.springframework.security.core.GrantedAuthority; -import org.springframework.security.core.authority.SimpleGrantedAuthority; import org.springframework.stereotype.Service; import java.util.*; -import java.util.concurrent.TimeUnit; // Import TimeUnit +import java.util.concurrent.TimeUnit; import java.util.stream.Collectors; /** - * JWT Token 管理器 + * JWT Token 管理器。 *

- * 实现基于JWT的无状态认证,支持: - *

    - *
  • Access Token + Refresh Token 双令牌机制
  • - *
  • Token 撤销(jti黑名单)
  • - *
  • 用户级会话失效(tokenVersion)
  • - *
  • 多角色数据权限存储
  • - *
+ * 基于 JWT 的无状态认证,支持 Access + Refresh 双令牌、Token 撤销(jti 黑名单)、 + * 用户级会话失效(tokenVersion)、多角色数据权限存储。 + *

+ * JWT claims 中存储角色编码(不带 ROLE_ 前缀),解析后由 {@link SecurityUserDetails#getAuthorities()} 运行时补前缀。 * * @author Ray.Hao * @since 2024/11/15 @@ -59,12 +54,6 @@ public class JwtTokenManager implements TokenManager { this.secretKey = securityProperties.getSession().getJwt().getSecretKey().getBytes(); } - /** - * 生成令牌 - * - * @param authentication 认证信息 - * @return 令牌响应对象 - */ @Override public AuthenticationToken generateToken(Authentication authentication) { int accessTokenTimeToLive = securityProperties.getSession().getAccessTokenTimeToLive(); @@ -81,22 +70,15 @@ public class JwtTokenManager implements TokenManager { .build(); } - /** - * 解析令牌 - * - * @param token JWT Token - * @return Authentication 对象 - */ @Override public Authentication parseToken(String token) { - JWT jwt = JWTUtil.parseToken(token); JSONObject payloads = jwt.getPayloads(); - SysUserDetails userDetails = new SysUserDetails(); - userDetails.setUserId(payloads.getLong(JwtClaimConstants.USER_ID)); // 用户ID - userDetails.setDeptId(payloads.getLong(JwtClaimConstants.DEPT_ID)); // 部门ID + SecurityUserDetails userDetails = new SecurityUserDetails(); + userDetails.setUserId(payloads.getLong(JwtClaimConstants.USER_ID)); + userDetails.setDeptId(payloads.getLong(JwtClaimConstants.DEPT_ID)); - // 解析数据权限列表 + // 数据权限 JSONArray dataScopesArray = payloads.getJSONArray(JwtClaimConstants.DATA_SCOPES); if (dataScopesArray != null && !dataScopesArray.isEmpty()) { List dataScopes = dataScopesArray.stream() @@ -115,88 +97,60 @@ public class JwtTokenManager implements TokenManager { userDetails.setDataScopes(dataScopes); } - userDetails.setUsername(payloads.getStr(JWTPayload.SUBJECT)); // 用户名 - // 角色集合 - Set authorities = payloads.getJSONArray(JwtClaimConstants.AUTHORITIES) - .stream() - .map(authority -> new SimpleGrantedAuthority(Convert.toStr(authority))) - .collect(Collectors.toSet()); + userDetails.setUsername(payloads.getStr(JWTPayload.SUBJECT)); - return new UsernamePasswordAuthenticationToken(userDetails, "", authorities); + // 角色编码(不带 ROLE_ 前缀) + JSONArray rolesArray = payloads.getJSONArray(JwtClaimConstants.ROLES); + if (rolesArray != null && !rolesArray.isEmpty()) { + Set roles = rolesArray.stream() + .map(Convert::toStr) + .collect(Collectors.toSet()); + userDetails.setRoles(roles); + } + + return new UsernamePasswordAuthenticationToken(userDetails, "", userDetails.getAuthorities()); } - /** - * 校验令牌 - * - * @param token JWT Token - * @return 是否有效 - */ @Override public boolean validateToken(String token) { return validateToken(token, false); } - /** - * 校验刷新令牌 - * - * @param refreshToken JWT Token - * @return 验证结果 - */ @Override public boolean validateRefreshToken(String refreshToken) { return validateToken(refreshToken, true); } - /** - * 校验令牌 - *

- * 校验流程(按顺序执行): - *

    - *
  1. 签名验证 + 过期时间检查
  2. - *
  3. 刷新令牌类型校验(仅刷新场景)
  4. - *
  5. tokenVersion 校验(用户级会话失效)
  6. - *
  7. jti 黑名单校验(单Token撤销)
  8. - *
- * - * @param token JWT Token - * @param validateRefreshToken 是否校验刷新令牌类型 - * @return 是否有效 - */ private boolean validateToken(String token, boolean validateRefreshToken) { JWT jwt = JWTUtil.parseToken(token); - // 检查 Token 是否有效(验签 + 是否过期) boolean isValid = jwt.setKey(secretKey).validate(0); if (isValid) { JSONObject payloads = jwt.getPayloads(); - // 1. 校验刷新令牌类型(仅在校验刷新令牌场景启用) + // 刷新令牌类型校验 String jti = payloads.getStr(JWTPayload.JWT_ID); if (validateRefreshToken) { - //刷新token需要校验token类别 boolean isRefreshToken = payloads.getBool(JwtClaimConstants.TOKEN_TYPE); if (!isRefreshToken) { return false; } } - // 2. 校验 tokenVersion(用于按用户维度失效历史 Token) - // 场景示例:用户修改密码、被管理员强制下线、手动"踢所有端"后,递增 tokenVersion, - // 之前签发的 Token 因版本号不匹配而失效 + + // tokenVersion 校验(用户维度 Token 失效) Long userId = payloads.getLong(JwtClaimConstants.USER_ID); if (userId != null) { Integer tokenVersion = payloads.getInt(JwtClaimConstants.TOKEN_VERSION); - + String versionKey = StrUtil.format(RedisConstants.Auth.USER_TOKEN_VERSION, userId); Object currentVersionObj = redisTemplate.opsForValue().get(versionKey); int currentVersion = currentVersionObj != null ? Convert.toInt(currentVersionObj) : 0; - // 版本号不匹配则 Token 无效(新签发的 Token 版本号必须 >= Redis 中的版本号) if (tokenVersion == null || tokenVersion < currentVersion) { return false; } } - // 3. 判断 Token 是否已被撤销(单端退出/会话注销) - // 场景示例:单点退出登录、后台手动注销某个会话、封禁账号后立即阻断当前 Token 等 + // jti 黑名单校验 if (isTokenRevoked(jti)) { return false; } @@ -204,17 +158,11 @@ public class JwtTokenManager implements TokenManager { return isValid; } - /** - * 将令牌加入黑名单 - * - * @param token JWT Token - */ @Override public void invalidateToken(String token) { if (StringUtils.isBlank(token)) { return; } - if (token.startsWith(SecurityConstants.BEARER_TOKEN_PREFIX)) { token = token.substring(SecurityConstants.BEARER_TOKEN_PREFIX.length()); } @@ -225,77 +173,15 @@ public class JwtTokenManager implements TokenManager { revokeTokenByJti(jti, expirationAt); } - /** - * 检查Token是否已被撤销 - * - * @param jti Token唯一标识 - * @return true-已撤销,false-未撤销 - */ - private boolean isTokenRevoked(String jti) { - if (StringUtils.isBlank(jti)) { - return false; - } - return Boolean.TRUE.equals(redisTemplate.hasKey(StrUtil.format(RedisConstants.Auth.REVOKED_JTI, jti))); - } - - /** - * 将Token加入撤销黑名单 - *

- * 黑名单有效期与Token剩余有效期一致,避免永久存储 - * - * @param jti Token唯一标识 - * @param expirationAt Token过期时间戳 - */ - private void revokeTokenByJti(String jti, Integer expirationAt) { - if (StringUtils.isBlank(jti)) { - return; - } - - String revokedJtiKey = StrUtil.format(RedisConstants.Auth.REVOKED_JTI, jti); - if (expirationAt != null) { - int currentTimeSeconds = Convert.toInt(System.currentTimeMillis() / 1000); - if (expirationAt < currentTimeSeconds) { - return; - } - int expirationIn = expirationAt - currentTimeSeconds; - redisTemplate.opsForValue().set(revokedJtiKey, Boolean.TRUE, expirationIn, TimeUnit.SECONDS); - } else { - redisTemplate.opsForValue().set(revokedJtiKey, Boolean.TRUE); - } - } - - /** - * 失效指定用户的所有会话 - *

- * 通过递增用户 tokenVersion,使该用户之前签发的所有 Token 因版本号不匹配而失效。 - *

- * 适用场景: - *

    - *
  • 用户修改密码
  • - *
  • 管理员强制下线用户
  • - *
  • 用户主动踢出所有设备
  • - *
  • 用户被禁用
  • - *
- * - * @param userId 用户ID - */ @Override public void invalidateUserSessions(Long userId) { if (userId == null) { return; } - String versionKey = StrUtil.format(RedisConstants.Auth.USER_TOKEN_VERSION, userId); - // 递增版本号,无需设置 TTL(版本号永久有效,避免 TTL 过期导致的安全问题) redisTemplate.opsForValue().increment(versionKey); } - /** - * 刷新令牌 - * - * @param refreshToken 刷新令牌 - * @return 令牌响应对象 - */ @Override public AuthenticationToken refreshToken(String refreshToken) { boolean isValid = validateRefreshToken(refreshToken); @@ -313,45 +199,19 @@ public class JwtTokenManager implements TokenManager { .build(); } - /** - * 生成 JWT Token - * - * @param authentication 认证信息 - * @param ttl 过期时间(秒),-1表示永不过期 - * @return JWT Token字符串 - */ + // ======================== private ======================== + private String generateToken(Authentication authentication, int ttl) { return generateToken(authentication, ttl, false); } - - /** - * 生成 JWT Token - *

- * Payload包含: - *

    - *
  • userId - 用户ID
  • - *
  • deptId - 部门ID
  • - *
  • dataScopes - 数据权限列表
  • - *
  • authorities - 角色权限集合
  • - *
  • tokenType - 是否为刷新令牌
  • - *
  • tokenVersion - Token版本号(用于会话失效控制)
  • - *
  • iat/exp - 签发/过期时间
  • - *
  • jti - Token唯一标识(用于撤销)
  • - *
- * - * @param authentication 认证信息 - * @param ttl 过期时间(秒) - * @param isRefreshToken 是否为刷新令牌 - * @return JWT Token字符串 - */ private String generateToken(Authentication authentication, int ttl, boolean isRefreshToken) { - SysUserDetails userDetails = (SysUserDetails) authentication.getPrincipal(); + SecurityUserDetails userDetails = (SecurityUserDetails) authentication.getPrincipal(); Map payload = new HashMap<>(); - payload.put(JwtClaimConstants.USER_ID, userDetails.getUserId()); // 用户ID - payload.put(JwtClaimConstants.DEPT_ID, userDetails.getDeptId()); // 部门ID + payload.put(JwtClaimConstants.USER_ID, userDetails.getUserId()); + payload.put(JwtClaimConstants.DEPT_ID, userDetails.getDeptId()); - // 存储数据权限列表 + // 数据权限 List dataScopes = userDetails.getDataScopes(); if (dataScopes != null && !dataScopes.isEmpty()) { List> scopesList = dataScopes.stream() @@ -366,13 +226,12 @@ public class JwtTokenManager implements TokenManager { payload.put(JwtClaimConstants.DATA_SCOPES, scopesList); } - // claims 中添加角色信息 - Set roles = authentication.getAuthorities().stream() - .map(GrantedAuthority::getAuthority) - .collect(Collectors.toSet()); - payload.put(JwtClaimConstants.AUTHORITIES, roles); + // 角色编码(不带 ROLE_ 前缀) + Set roles = userDetails.getRoles(); + if (roles != null && !roles.isEmpty()) { + payload.put(JwtClaimConstants.ROLES, roles); + } - // 获取当前用户的 Token 版本号,用于会话失效控制 Long userId = userDetails.getUserId(); int tokenVersion = 0; if (userId != null) { @@ -384,12 +243,8 @@ public class JwtTokenManager implements TokenManager { Date now = new Date(); payload.put(JWTPayload.ISSUED_AT, now); - payload.put(JwtClaimConstants.TOKEN_TYPE, false); - if (isRefreshToken) { - payload.put(JwtClaimConstants.TOKEN_TYPE, true); - } + payload.put(JwtClaimConstants.TOKEN_TYPE, isRefreshToken); - // 设置过期时间 -1 表示永不过期 if (ttl != -1) { Date expiresAt = DateUtil.offsetSecond(now, ttl); payload.put(JWTPayload.EXPIRES_AT, expiresAt); @@ -400,4 +255,27 @@ public class JwtTokenManager implements TokenManager { return JWTUtil.createToken(payload, secretKey); } + private boolean isTokenRevoked(String jti) { + if (StringUtils.isBlank(jti)) { + return false; + } + return Boolean.TRUE.equals(redisTemplate.hasKey(StrUtil.format(RedisConstants.Auth.REVOKED_JTI, jti))); + } + + private void revokeTokenByJti(String jti, Integer expirationAt) { + if (StringUtils.isBlank(jti)) { + return; + } + String revokedJtiKey = StrUtil.format(RedisConstants.Auth.REVOKED_JTI, jti); + if (expirationAt != null) { + int currentTimeSeconds = Convert.toInt(System.currentTimeMillis() / 1000); + if (expirationAt < currentTimeSeconds) { + return; + } + int expirationIn = expirationAt - currentTimeSeconds; + redisTemplate.opsForValue().set(revokedJtiKey, Boolean.TRUE, expirationIn, TimeUnit.SECONDS); + } else { + redisTemplate.opsForValue().set(revokedJtiKey, Boolean.TRUE); + } + } } diff --git a/src/main/java/com/youlai/boot/framework/security/token/RedisTokenManager.java b/src/main/java/com/youlai/boot/framework/security/token/RedisTokenManager.java index 52481ad4..661a8b46 100644 --- a/src/main/java/com/youlai/boot/framework/security/token/RedisTokenManager.java +++ b/src/main/java/com/youlai/boot/framework/security/token/RedisTokenManager.java @@ -1,6 +1,5 @@ package com.youlai.boot.framework.security.token; -import cn.hutool.core.collection.CollectionUtil; import cn.hutool.core.util.IdUtil; import cn.hutool.core.util.StrUtil; import com.youlai.boot.common.constant.RedisConstants; @@ -9,35 +8,25 @@ import com.youlai.boot.common.result.ResultCode; import com.youlai.boot.framework.security.config.SecurityProperties; import com.youlai.boot.framework.security.exception.TokenInvalidException; import com.youlai.boot.framework.security.model.AuthenticationToken; -import com.youlai.boot.framework.security.model.UserSession; -import com.youlai.boot.framework.security.model.SysUserDetails; +import com.youlai.boot.framework.security.model.SecurityUserDetails; import org.springframework.boot.autoconfigure.condition.ConditionalOnProperty; import org.springframework.data.redis.core.RedisTemplate; import org.springframework.security.authentication.UsernamePasswordAuthenticationToken; import org.springframework.security.core.Authentication; -import org.springframework.security.core.GrantedAuthority; -import org.springframework.security.core.authority.SimpleGrantedAuthority; import org.springframework.stereotype.Service; import tools.jackson.databind.json.JsonMapper; import java.util.Optional; -import java.util.Set; import java.util.concurrent.TimeUnit; -import java.util.stream.Collectors; /** - * Redis Token 管理器 + * Redis Token 管理器。 *

- * 实现基于Redis的有状态认证,支持: - *

    - *
  • Access Token + Refresh Token 双令牌机制
  • - *
  • 单设备/多设备登录控制
  • - *
  • 用户级会话失效
  • - *
  • 在线用户管理
  • - *
+ * 基于 Redis 的有状态 Token 认证,支持 Access + Refresh 双令牌、 + * 单/多设备登录控制、用户级会话失效。 *

- * 与JWT模式相比,Redis模式支持主动踢人、在线用户查询等功能 + * 直接存取 {@link SecurityUserDetails}(password 置 null),无需 UserSession 中间层。 * * @author Ray.Hao * @since 2024/11/15 @@ -58,33 +47,16 @@ public class RedisTokenManager implements TokenManager { this.jsonMapper = jsonMapper; } - /** - * 生成 Token - * - * @param authentication 用户认证信息 - * @return 生成的 AuthenticationToken 对象 - */ @Override public AuthenticationToken generateToken(Authentication authentication) { - SysUserDetails user = (SysUserDetails) authentication.getPrincipal(); + SecurityUserDetails user = (SecurityUserDetails) authentication.getPrincipal(); String accessToken = IdUtil.fastSimpleUUID(); String refreshToken = IdUtil.fastSimpleUUID(); - // 构建用户会话信息 - UserSession userSession = new UserSession( - user.getUserId(), - user.getUsername(), - user.getDeptId(), - user.getDataScopes(), - user.getAuthorities().stream() - .map(GrantedAuthority::getAuthority) - .collect(Collectors.toSet()) - ); + // 构建会话快照(不存密码) + SecurityUserDetails sessionUser = buildSessionUser(user); - // 存储访问令牌、刷新令牌和刷新令牌映射 - storeTokensInRedis(accessToken, refreshToken, userSession); - - // 单设备登录控制 + storeTokensInRedis(accessToken, refreshToken, sessionUser); handleSingleDeviceLogin(user.getUserId(), accessToken); return AuthenticationToken.builder() @@ -94,61 +66,24 @@ public class RedisTokenManager implements TokenManager { .build(); } - /** - * 根据 token 解析用户信息 - * - * @param token Redis Token - * @return 构建的 Authentication 对象 - */ @Override public Authentication parseToken(String token) { Object raw = redisTemplate.opsForValue().get(formatTokenKey(token)); if (raw == null) return null; - UserSession userSession = jsonMapper.convertValue(raw, UserSession.class); - - // 构建用户权限集合 - Set authorities = null; - - Set roles = userSession.getRoles(); - if (CollectionUtil.isNotEmpty(roles)) { - authorities = roles.stream() - .map(SimpleGrantedAuthority::new) - .collect(Collectors.toSet()); - } - - // 构建用户详情对象 - SysUserDetails userDetails = buildUserDetails(userSession, authorities); - return new UsernamePasswordAuthenticationToken(userDetails, null, authorities); + SecurityUserDetails userDetails = jsonMapper.convertValue(raw, SecurityUserDetails.class); + return new UsernamePasswordAuthenticationToken(userDetails, null, userDetails.getAuthorities()); } - /** - * 校验 Token 是否有效 - * - * @param token 访问令牌 - * @return 是否有效 - */ @Override public boolean validateToken(String token) { return redisTemplate.hasKey(formatTokenKey(token)); } - /** - * 校验 RefreshToken 是否有效 - * - * @param refreshToken 访问令牌 - * @return 是否有效 - */ @Override public boolean validateRefreshToken(String refreshToken) { return redisTemplate.hasKey(formatRefreshTokenKey(refreshToken)); } - /** - * 刷新令牌 - * - * @param refreshToken 刷新令牌 - * @return 新生成的 AuthenticationToken 对象 - */ @Override public AuthenticationToken refreshToken(String refreshToken) { Object raw = redisTemplate.opsForValue() @@ -156,16 +91,16 @@ public class RedisTokenManager implements TokenManager { if (raw == null) { throw new TokenInvalidException(ResultCode.REFRESH_TOKEN_INVALID); } - UserSession userSession = jsonMapper.convertValue(raw, UserSession.class); - Object oldAccessTokenValue = redisTemplate.opsForValue().get(StrUtil.format(RedisConstants.Auth.USER_ACCESS_TOKEN, userSession.getUserId())); - // 删除旧的访问令牌记录 + SecurityUserDetails sessionUser = jsonMapper.convertValue(raw, SecurityUserDetails.class); + + Object oldAccessTokenValue = redisTemplate.opsForValue() + .get(StrUtil.format(RedisConstants.Auth.USER_ACCESS_TOKEN, sessionUser.getUserId())); Optional.of(oldAccessTokenValue) .map(String.class::cast) .ifPresent(oldAccessToken -> redisTemplate.delete(formatTokenKey(oldAccessToken))); - // 生成新访问令牌并存储 String newAccessToken = IdUtil.fastSimpleUUID(); - storeAccessToken(newAccessToken, userSession); + storeAccessToken(newAccessToken, sessionUser); int accessTtl = securityProperties.getSession().getAccessTokenTimeToLive(); return AuthenticationToken.builder() @@ -175,164 +110,91 @@ public class RedisTokenManager implements TokenManager { .build(); } - /** - * Make access token invalid - *

- * Only deletes the current token, not all sessions for the user. - * This ensures single-device logout doesn't affect other devices when allowMultiLogin=true. - * - * @param token Access token - */ @Override public void invalidateToken(String token) { String cleanToken = cleanBearerPrefix(token); - // Only delete the current token, not all user sessions redisTemplate.delete(formatTokenKey(cleanToken)); } - /** - * 使指定用户的所有会话失效 - *

- * 适用场景:用户修改密码、管理员强制下线、账号封禁等 - * - * @param userId 用户ID - */ @Override public void invalidateUserSessions(Long userId) { if (userId == null) { return; } - - // 1. 删除访问令牌相关 String userAccessKey = StrUtil.format(RedisConstants.Auth.USER_ACCESS_TOKEN, userId); Object accessTokenValue = redisTemplate.opsForValue().get(userAccessKey); if (accessTokenValue instanceof String accessToken) { redisTemplate.delete(formatTokenKey(accessToken)); } - // 无论是否存在访问令牌映射,都尝试删除 userAccessKey redisTemplate.delete(userAccessKey); - // 2. 删除刷新令牌相关 String userRefreshKey = StrUtil.format(RedisConstants.Auth.USER_REFRESH_TOKEN, userId); Object refreshTokenValue = redisTemplate.opsForValue().get(userRefreshKey); if (refreshTokenValue instanceof String refreshToken) { redisTemplate.delete(StrUtil.format(RedisConstants.Auth.REFRESH_TOKEN_USER, refreshToken)); } - // 同样清理 userRefreshKey 本身 redisTemplate.delete(userRefreshKey); } - /** - * 将访问令牌和刷新令牌存储至 Redis - * - * @param accessToken 访问令牌 - * @param refreshToken 刷新令牌 - * @param userSession 用户会话信息 - */ - private void storeTokensInRedis(String accessToken, String refreshToken, UserSession userSession) { - // 访问令牌 -> 用户信息 - setRedisValue(formatTokenKey(accessToken), userSession, securityProperties.getSession().getAccessTokenTimeToLive()); + // ======================== private ======================== - // 刷新令牌 -> 用户信息 - String refreshTokenKey = StrUtil.format(RedisConstants.Auth.REFRESH_TOKEN_USER, refreshToken); - setRedisValue(refreshTokenKey, userSession, securityProperties.getSession().getRefreshTokenTimeToLive()); - - // 用户ID -> 刷新令牌 - setRedisValue(StrUtil.format(RedisConstants.Auth.USER_REFRESH_TOKEN, userSession.getUserId()), - refreshToken, - securityProperties.getSession().getRefreshTokenTimeToLive()); + private SecurityUserDetails buildSessionUser(SecurityUserDetails user) { + SecurityUserDetails sessionUser = new SecurityUserDetails(); + sessionUser.setUserId(user.getUserId()); + sessionUser.setUsername(user.getUsername()); + sessionUser.setDeptId(user.getDeptId()); + sessionUser.setDataScopes(user.getDataScopes()); + sessionUser.setRoles(user.getRoles()); + sessionUser.setEnabled(user.isEnabled()); + sessionUser.setPassword(null); + return sessionUser; + } + + private void storeTokensInRedis(String accessToken, String refreshToken, SecurityUserDetails sessionUser) { + setRedisValue(formatTokenKey(accessToken), sessionUser, + securityProperties.getSession().getAccessTokenTimeToLive()); + String refreshTokenKey = StrUtil.format(RedisConstants.Auth.REFRESH_TOKEN_USER, refreshToken); + setRedisValue(refreshTokenKey, sessionUser, + securityProperties.getSession().getRefreshTokenTimeToLive()); + setRedisValue(StrUtil.format(RedisConstants.Auth.USER_REFRESH_TOKEN, sessionUser.getUserId()), + refreshToken, securityProperties.getSession().getRefreshTokenTimeToLive()); } - /** - * 处理单设备登录控制 - *

- * 当配置不允许多设备登录时,新登录会使旧Token失效 - * - * @param userId 用户ID - * @param accessToken 新生成的访问令牌 - */ private void handleSingleDeviceLogin(Long userId, String accessToken) { Boolean allowMultiLogin = securityProperties.getSession().getRedisToken().getAllowMultiLogin(); String userAccessKey = StrUtil.format(RedisConstants.Auth.USER_ACCESS_TOKEN, userId); - // 单设备登录控制,删除旧的访问令牌 if (!allowMultiLogin) { Object oldAccessTokenValue = redisTemplate.opsForValue().get(userAccessKey); if (oldAccessTokenValue instanceof String oldAccessToken) { redisTemplate.delete(formatTokenKey(oldAccessToken)); } } - // 存储访问令牌映射(用户ID -> 访问令牌),用于单设备登录控制删除旧的访问令牌和刷新令牌时删除旧令牌 setRedisValue(userAccessKey, accessToken, securityProperties.getSession().getAccessTokenTimeToLive()); } - /** - * 存储新的访问令牌 - * - * @param newAccessToken 新访问令牌 - * @param userSession 用户会话信息 - */ - private void storeAccessToken(String newAccessToken, UserSession userSession) { - setRedisValue(StrUtil.format(RedisConstants.Auth.ACCESS_TOKEN_USER, newAccessToken), userSession, securityProperties.getSession().getAccessTokenTimeToLive()); - String userAccessKey = StrUtil.format(RedisConstants.Auth.USER_ACCESS_TOKEN, userSession.getUserId()); + private void storeAccessToken(String newAccessToken, SecurityUserDetails sessionUser) { + setRedisValue(StrUtil.format(RedisConstants.Auth.ACCESS_TOKEN_USER, newAccessToken), sessionUser, + securityProperties.getSession().getAccessTokenTimeToLive()); + String userAccessKey = StrUtil.format(RedisConstants.Auth.USER_ACCESS_TOKEN, sessionUser.getUserId()); setRedisValue(userAccessKey, newAccessToken, securityProperties.getSession().getAccessTokenTimeToLive()); } - /** - * 构建用户详情对象 - * - * @param userSession 用户会话信息 - * @param authorities 权限集合 - * @return SysUserDetails 用户详情 - */ - private SysUserDetails buildUserDetails(UserSession userSession, Set authorities) { - SysUserDetails userDetails = new SysUserDetails(); - userDetails.setUserId(userSession.getUserId()); - userDetails.setUsername(userSession.getUsername()); - userDetails.setDeptId(userSession.getDeptId()); - userDetails.setDataScopes(userSession.getDataScopes()); - userDetails.setAuthorities(authorities); - return userDetails; - } - - /** - * 格式化访问令牌的 Redis 键 - * - * @param token 访问令牌 - * @return 格式化后的 Redis 键 - */ private String formatTokenKey(String token) { return StrUtil.format(RedisConstants.Auth.ACCESS_TOKEN_USER, token); } - /** - * 格式化刷新令牌的 Redis 键 - * - * @param refreshToken 访问令牌 - * @return 格式化后的 Redis 键 - */ private String formatRefreshTokenKey(String refreshToken) { return StrUtil.format(RedisConstants.Auth.REFRESH_TOKEN_USER, refreshToken); } - /** - * 将值存储到 Redis - * - * @param key 键 - * @param value 值 - * @param ttl 过期时间(秒),-1表示永不过期 - */ private void setRedisValue(String key, Object value, int ttl) { if (ttl != -1) { redisTemplate.opsForValue().set(key, value, ttl, TimeUnit.SECONDS); } else { - redisTemplate.opsForValue().set(key, value); // ttl=-1时永不过期 + redisTemplate.opsForValue().set(key, value); } } - - /** - * 清理 Bearer 前缀 - */ private String cleanBearerPrefix(String token) { if (token.startsWith(SecurityConstants.BEARER_TOKEN_PREFIX)) { return token.substring(SecurityConstants.BEARER_TOKEN_PREFIX.length()).trim(); diff --git a/src/main/java/com/youlai/boot/framework/security/util/SecurityUtils.java b/src/main/java/com/youlai/boot/framework/security/util/SecurityUtils.java index 3fed56b1..d4f28d3c 100644 --- a/src/main/java/com/youlai/boot/framework/security/util/SecurityUtils.java +++ b/src/main/java/com/youlai/boot/framework/security/util/SecurityUtils.java @@ -1,21 +1,16 @@ package com.youlai.boot.framework.security.util; -import cn.hutool.core.collection.CollectionUtil; -import cn.hutool.core.util.StrUtil; -import com.youlai.boot.common.constant.SecurityConstants; import com.youlai.boot.common.constant.SystemConstants; import com.youlai.boot.framework.security.model.RoleDataScope; -import com.youlai.boot.framework.security.model.SysUserDetails; +import com.youlai.boot.framework.security.model.SecurityUserDetails; import jakarta.servlet.http.HttpServletRequest; import org.springframework.http.HttpHeaders; import org.springframework.security.core.Authentication; -import org.springframework.security.core.GrantedAuthority; import org.springframework.security.core.context.SecurityContextHolder; import org.springframework.web.context.request.RequestContextHolder; import org.springframework.web.context.request.ServletRequestAttributes; import java.util.*; -import java.util.stream.Collectors; /** * Spring Security 工具类 @@ -26,101 +21,73 @@ import java.util.stream.Collectors; public class SecurityUtils { /** - * 获取当前登录人信息 - * - * @return Optional + * 获取当前登录用户 */ - public static Optional getUser() { + public static Optional getUser() { Authentication authentication = SecurityContextHolder.getContext().getAuthentication(); if (authentication != null) { Object principal = authentication.getPrincipal(); - if (principal instanceof SysUserDetails) { - return Optional.of((SysUserDetails) principal); + if (principal instanceof SecurityUserDetails) { + return Optional.of((SecurityUserDetails) principal); } } return Optional.empty(); } - /** * 获取用户ID - * - * @return Long */ public static Long getUserId() { - return getUser().map(SysUserDetails::getUserId).orElse(null); + return getUser().map(SecurityUserDetails::getUserId).orElse(null); } - /** * 获取用户账号 - * - * @return String 用户账号 */ public static String getUsername() { - return getUser().map(SysUserDetails::getUsername).orElse(null); + return getUser().map(SecurityUserDetails::getUsername).orElse(null); } - /** * 获取部门ID - * - * @return Long */ public static Long getDeptId() { - return getUser().map(SysUserDetails::getDeptId).orElse(null); + return getUser().map(SecurityUserDetails::getDeptId).orElse(null); } /** * 获取数据权限列表 - * - * @return 数据权限列表 */ public static List getDataScopes() { - return getUser().map(SysUserDetails::getDataScopes).orElse(List.of()); + return getUser().map(SecurityUserDetails::getDataScopes).orElse(List.of()); } /** - * 获取角色集合 - * - * @return 角色集合 + * 获取角色编码集合,不带 ROLE_ 前缀。 + *

+ * 直接从 {@link SecurityUserDetails#getRoles()} 取值,不做 stripping 和过滤。 */ public static Set getRoles() { - return Optional.ofNullable(SecurityContextHolder.getContext().getAuthentication()) - .map(Authentication::getAuthorities) - .filter(CollectionUtil::isNotEmpty) - .stream() - .flatMap(Collection::stream) - .map(GrantedAuthority::getAuthority) - // 筛选角色,authorities 中的角色都是以 ROLE_ 开头 - .filter(authority -> authority.startsWith(SecurityConstants.ROLE_PREFIX)) - .map(authority -> StrUtil.removePrefix(authority, SecurityConstants.ROLE_PREFIX)) - .collect(Collectors.toSet()); + return getUser().map(SecurityUserDetails::getRoles).orElse(Set.of()); } /** * 是否超级管理员 - *

- * 超级管理员忽视任何权限判断 */ public static boolean isRoot() { - Set roles = getRoles(); - return roles.contains(SystemConstants.ROOT_ROLE_CODE); + return getRoles().contains(SystemConstants.ROOT_ROLE_CODE); } /** - * 获取请求中的 Token - * - * @return Token 字符串 + * 从请求头获取 Token */ public static String getAccessToken() { - ServletRequestAttributes servletRequestAttributes = ((ServletRequestAttributes) RequestContextHolder.getRequestAttributes()); - if(Objects.isNull(servletRequestAttributes)) { + ServletRequestAttributes attributes = (ServletRequestAttributes) RequestContextHolder.getRequestAttributes(); + if (attributes == null) { return null; } - HttpServletRequest request = servletRequestAttributes.getRequest(); + HttpServletRequest request = attributes.getRequest(); return request.getHeader(HttpHeaders.AUTHORIZATION); } - } diff --git a/src/main/java/com/youlai/boot/message/controller/SseController.java b/src/main/java/com/youlai/boot/message/controller/SseController.java index f663c96b..3135a842 100644 --- a/src/main/java/com/youlai/boot/message/controller/SseController.java +++ b/src/main/java/com/youlai/boot/message/controller/SseController.java @@ -1,7 +1,7 @@ package com.youlai.boot.message.controller; import com.youlai.boot.common.result.Result; -import com.youlai.boot.framework.security.model.SysUserDetails; +import com.youlai.boot.framework.security.model.SecurityUserDetails; import com.youlai.boot.framework.security.util.SecurityUtils; import com.youlai.boot.message.service.SseService; import io.swagger.v3.oas.annotations.Operation; @@ -29,7 +29,7 @@ public class SseController { @Operation(summary = "建立SSE连接") @GetMapping(value = "/connect", produces = MediaType.TEXT_EVENT_STREAM_VALUE) public SseEmitter connect() { - SysUserDetails user = SecurityUtils.getUser().orElse(null); + SecurityUserDetails user = SecurityUtils.getUser().orElse(null); if (user == null) { log.warn("SSE连接失败:未获取到当前用户"); return null; diff --git a/src/main/java/com/youlai/boot/system/mapper/UserMapper.java b/src/main/java/com/youlai/boot/system/mapper/UserMapper.java index d07fdcad..dd6a3d20 100644 --- a/src/main/java/com/youlai/boot/system/mapper/UserMapper.java +++ b/src/main/java/com/youlai/boot/system/mapper/UserMapper.java @@ -6,7 +6,7 @@ import com.youlai.boot.system.model.entity.SysUser; import com.youlai.boot.system.model.query.UserQuery; import com.youlai.boot.system.model.form.UserForm; import com.youlai.boot.common.annotation.DataPermission; -import com.youlai.boot.framework.security.model.UserAuthInfo; +import com.youlai.boot.framework.security.model.SecurityUser; import com.youlai.boot.system.model.vo.UserExportVO; import com.youlai.boot.system.model.vo.UserPageVO; import com.youlai.boot.system.model.vo.UserProfileVO; @@ -48,9 +48,9 @@ public interface UserMapper extends BaseMapper { * @param username 用户名 * @return 认证信息 */ - UserAuthInfo getAuthInfoByUsername(String username); + SecurityUser getAuthInfoByUsername(String username); - default UserAuthInfo getAuthCredentialsByUsername(String username) { + default SecurityUser getAuthCredentialsByUsername(String username) { return getAuthInfoByUsername(username); } @@ -60,9 +60,9 @@ public interface UserMapper extends BaseMapper { * @param mobile 手机号 * @return 认证信息 */ - UserAuthInfo getAuthInfoByMobile(String mobile); + SecurityUser getAuthInfoByMobile(String mobile); - default UserAuthInfo getAuthCredentialsByMobile(String mobile) { + default SecurityUser getAuthCredentialsByMobile(String mobile) { return getAuthInfoByMobile(mobile); } diff --git a/src/main/java/com/youlai/boot/system/mapper/UserSocialMapper.java b/src/main/java/com/youlai/boot/system/mapper/UserSocialMapper.java index 430075d3..0f50ccbb 100644 --- a/src/main/java/com/youlai/boot/system/mapper/UserSocialMapper.java +++ b/src/main/java/com/youlai/boot/system/mapper/UserSocialMapper.java @@ -1,7 +1,7 @@ package com.youlai.boot.system.mapper; import com.baomidou.mybatisplus.core.mapper.BaseMapper; -import com.youlai.boot.framework.security.model.UserAuthInfo; +import com.youlai.boot.framework.security.model.SecurityUser; import com.youlai.boot.system.model.entity.UserSocial; import org.apache.ibatis.annotations.Mapper; @@ -17,6 +17,6 @@ public interface UserSocialMapper extends BaseMapper { * @param userId 用户ID * @return 认证信息 */ - UserAuthInfo getAuthInfoByUserId(Long userId); + SecurityUser getAuthInfoByUserId(Long userId); } diff --git a/src/main/java/com/youlai/boot/system/model/entity/UserSocial.java b/src/main/java/com/youlai/boot/system/model/entity/UserSocial.java index f8ddff8c..c5bbd7fc 100644 --- a/src/main/java/com/youlai/boot/system/model/entity/UserSocial.java +++ b/src/main/java/com/youlai/boot/system/model/entity/UserSocial.java @@ -2,7 +2,7 @@ package com.youlai.boot.system.model.entity; import com.baomidou.mybatisplus.annotation.TableName; import com.youlai.boot.common.base.BaseEntity; -import com.youlai.boot.system.enums.SocialPlatformEnum; +import com.youlai.boot.common.enums.SocialPlatformEnum; import lombok.Getter; import lombok.Setter; diff --git a/src/main/java/com/youlai/boot/system/security/adapter/PermissionAdapter.java b/src/main/java/com/youlai/boot/system/security/adapter/PermissionAdapter.java new file mode 100644 index 00000000..71cc6422 --- /dev/null +++ b/src/main/java/com/youlai/boot/system/security/adapter/PermissionAdapter.java @@ -0,0 +1,27 @@ +package com.youlai.boot.system.security.adapter; + +import com.youlai.boot.framework.security.port.PermissionPort; +import com.youlai.boot.system.service.RoleMenuService; +import lombok.RequiredArgsConstructor; +import org.springframework.stereotype.Component; + +import java.util.Set; + +/** + * 权限查询适配器。 + *

+ * 实现 framework 层的 {@link PermissionPort},委托 {@link RoleMenuService} 查询权限集合。 + * + * @see PermissionPort + */ +@Component +@RequiredArgsConstructor +public class PermissionAdapter implements PermissionPort { + + private final RoleMenuService roleMenuService; + + @Override + public Set getRolePerms(Set roleCodes) { + return roleMenuService.getRolePermsByRoleCodes(roleCodes); + } +} diff --git a/src/main/java/com/youlai/boot/system/security/adapter/UserAuthenticationAdapter.java b/src/main/java/com/youlai/boot/system/security/adapter/UserAuthenticationAdapter.java new file mode 100644 index 00000000..e12535e9 --- /dev/null +++ b/src/main/java/com/youlai/boot/system/security/adapter/UserAuthenticationAdapter.java @@ -0,0 +1,39 @@ +package com.youlai.boot.system.security.adapter; + +import com.youlai.boot.common.enums.SocialPlatformEnum; +import com.youlai.boot.framework.security.model.SecurityUser; +import com.youlai.boot.framework.security.port.UserAuthenticationPort; +import com.youlai.boot.system.service.UserService; +import com.youlai.boot.system.service.UserSocialService; +import lombok.RequiredArgsConstructor; +import org.springframework.stereotype.Component; + +/** + * 用户认证信息查询适配器。 + *

+ * 实现 framework 层的 {@link UserAuthenticationPort},委托 system 层服务完成查询。 + * + * @see UserAuthenticationPort + */ +@Component +@RequiredArgsConstructor +public class UserAuthenticationAdapter implements UserAuthenticationPort { + + private final UserService userService; + private final UserSocialService userSocialService; + + @Override + public SecurityUser getAuthInfoByUsername(String username) { + return userService.getAuthInfoByUsername(username); + } + + @Override + public SecurityUser getAuthInfoByMobile(String mobile) { + return userService.getAuthInfoByMobile(mobile); + } + + @Override + public SecurityUser getAuthInfoByOpenid(SocialPlatformEnum platform, String openid) { + return userSocialService.getAuthInfoByOpenid(platform, openid); + } +} diff --git a/src/main/java/com/youlai/boot/system/service/UserService.java b/src/main/java/com/youlai/boot/system/service/UserService.java index 48c5f58f..35f30fa9 100644 --- a/src/main/java/com/youlai/boot/system/service/UserService.java +++ b/src/main/java/com/youlai/boot/system/service/UserService.java @@ -3,7 +3,7 @@ package com.youlai.boot.system.service; import com.baomidou.mybatisplus.core.metadata.IPage; import com.baomidou.mybatisplus.extension.service.IService; import com.youlai.boot.common.model.Option; -import com.youlai.boot.framework.security.model.UserAuthInfo; +import com.youlai.boot.framework.security.model.SecurityUser; import com.youlai.boot.system.model.vo.CurrentUserVO; import com.youlai.boot.system.model.vo.UserExportVO; import com.youlai.boot.system.model.entity.SysUser; @@ -78,11 +78,11 @@ public interface UserService extends IService { * 根据用户名获取认证信息 * * @param username 用户名 - * @return {@link UserAuthInfo} + * @return {@link SecurityUser} */ - UserAuthInfo getAuthInfoByUsername(String username); + SecurityUser getAuthInfoByUsername(String username); - default UserAuthInfo getAuthCredentialsByUsername(String username) { + default SecurityUser getAuthCredentialsByUsername(String username) { return getAuthInfoByUsername(username); } @@ -194,11 +194,11 @@ public interface UserService extends IService { * 根据手机号获取用户认证信息 * * @param mobile 手机号 - * @return {@link UserAuthInfo} + * @return {@link SecurityUser} */ - UserAuthInfo getAuthInfoByMobile(String mobile); + SecurityUser getAuthInfoByMobile(String mobile); - default UserAuthInfo getAuthCredentialsByMobile(String mobile) { + default SecurityUser getAuthCredentialsByMobile(String mobile) { return getAuthInfoByMobile(mobile); } diff --git a/src/main/java/com/youlai/boot/system/service/UserSocialService.java b/src/main/java/com/youlai/boot/system/service/UserSocialService.java index 943792dc..7f61ef1c 100644 --- a/src/main/java/com/youlai/boot/system/service/UserSocialService.java +++ b/src/main/java/com/youlai/boot/system/service/UserSocialService.java @@ -1,8 +1,8 @@ package com.youlai.boot.system.service; import com.baomidou.mybatisplus.extension.service.IService; -import com.youlai.boot.framework.security.model.UserAuthInfo; -import com.youlai.boot.system.enums.SocialPlatformEnum; +import com.youlai.boot.framework.security.model.SecurityUser; +import com.youlai.boot.common.enums.SocialPlatformEnum; import com.youlai.boot.system.model.entity.UserSocial; /** @@ -57,7 +57,7 @@ public interface UserSocialService extends IService { * @param openid openid * @return 用户认证信息 */ - UserAuthInfo getAuthInfoByOpenid(SocialPlatformEnum platform, String openid); + SecurityUser getAuthInfoByOpenid(SocialPlatformEnum platform, String openid); /** * 更新session_key diff --git a/src/main/java/com/youlai/boot/system/service/impl/UserServiceImpl.java b/src/main/java/com/youlai/boot/system/service/impl/UserServiceImpl.java index 8ffef8d4..a9f37574 100644 --- a/src/main/java/com/youlai/boot/system/service/impl/UserServiceImpl.java +++ b/src/main/java/com/youlai/boot/system/service/impl/UserServiceImpl.java @@ -16,7 +16,7 @@ import com.youlai.boot.framework.integration.mail.service.MailService; import com.youlai.boot.framework.integration.sms.enums.SmsTypeEnum; import com.youlai.boot.framework.integration.sms.service.SmsService; import com.youlai.boot.framework.security.model.RoleDataScope; -import com.youlai.boot.framework.security.model.UserAuthInfo; +import com.youlai.boot.framework.security.model.SecurityUser; import com.youlai.boot.framework.security.token.TokenManager; import com.youlai.boot.framework.security.util.SecurityUtils; import com.youlai.boot.system.converter.UserConverter; @@ -206,39 +206,34 @@ public class UserServiceImpl extends ServiceImpl implements * 根据用户名获取认证凭证信息 * * @param username 用户名 - * @return 用户认证凭证信息 {@link UserAuthInfo} + * @return 用户认证凭证信息 {@link SecurityUser} */ @Override - public UserAuthInfo getAuthInfoByUsername(String username) { - UserAuthInfo userAuthInfo = this.baseMapper.getAuthInfoByUsername(username); - if (userAuthInfo != null) { - Set roles = userAuthInfo.getRoles(); - // 获取数据权限列表(用于并集策略) + public SecurityUser getAuthInfoByUsername(String username) { + SecurityUser securityUser = this.baseMapper.getAuthInfoByUsername(username); + if (securityUser != null) { + Set roles = securityUser.getRoles(); List dataScopes = roleService.getRoleDataScopes(roles); - userAuthInfo.setDataScopes(dataScopes); + securityUser.setDataScopes(dataScopes); } - return userAuthInfo; + return securityUser; } /** * 根据手机号获取用户认证信息 - * - * @param mobile 手机号 - * @return 用户认证信息 */ @Override - public UserAuthInfo getAuthInfoByMobile(String mobile) { + public SecurityUser getAuthInfoByMobile(String mobile) { if (StrUtil.isBlank(mobile)) { return null; } - UserAuthInfo userAuthInfo = this.baseMapper.getAuthInfoByMobile(mobile); - if (userAuthInfo != null) { - Set roles = userAuthInfo.getRoles(); - // 获取数据权限列表(用于并集策略) + SecurityUser securityUser = this.baseMapper.getAuthInfoByMobile(mobile); + if (securityUser != null) { + Set roles = securityUser.getRoles(); List dataScopes = roleService.getRoleDataScopes(roles); - userAuthInfo.setDataScopes(dataScopes); + securityUser.setDataScopes(dataScopes); } - return userAuthInfo; + return securityUser; } /** diff --git a/src/main/java/com/youlai/boot/system/service/impl/UserSocialServiceImpl.java b/src/main/java/com/youlai/boot/system/service/impl/UserSocialServiceImpl.java index f9a37c59..96063870 100644 --- a/src/main/java/com/youlai/boot/system/service/impl/UserSocialServiceImpl.java +++ b/src/main/java/com/youlai/boot/system/service/impl/UserSocialServiceImpl.java @@ -3,8 +3,8 @@ package com.youlai.boot.system.service.impl; import cn.hutool.core.util.StrUtil; import com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper; import com.baomidou.mybatisplus.extension.service.impl.ServiceImpl; -import com.youlai.boot.framework.security.model.UserAuthInfo; -import com.youlai.boot.system.enums.SocialPlatformEnum; +import com.youlai.boot.framework.security.model.SecurityUser; +import com.youlai.boot.common.enums.SocialPlatformEnum; import com.youlai.boot.system.mapper.UserSocialMapper; import com.youlai.boot.system.model.entity.UserSocial; import com.youlai.boot.system.service.UserSocialService; @@ -86,7 +86,7 @@ public class UserSocialServiceImpl extends ServiceImpl - + diff --git a/src/main/resources/mapper/system/UserSocialMapper.xml b/src/main/resources/mapper/system/UserSocialMapper.xml index abd7bd64..80a62a56 100644 --- a/src/main/resources/mapper/system/UserSocialMapper.xml +++ b/src/main/resources/mapper/system/UserSocialMapper.xml @@ -5,7 +5,7 @@ - + diff --git a/src/test/java/com/youlai/boot/security/token/JwtTokenManagerTest.java b/src/test/java/com/youlai/boot/security/token/JwtTokenManagerTest.java index afd1e13d..14979f0d 100644 --- a/src/test/java/com/youlai/boot/security/token/JwtTokenManagerTest.java +++ b/src/test/java/com/youlai/boot/security/token/JwtTokenManagerTest.java @@ -3,7 +3,7 @@ package com.youlai.boot.security.token; import com.youlai.boot.framework.security.config.SecurityProperties; import com.youlai.boot.framework.security.model.AuthenticationToken; import com.youlai.boot.framework.security.model.RoleDataScope; -import com.youlai.boot.framework.security.model.SysUserDetails; +import com.youlai.boot.framework.security.model.SecurityUserDetails; import com.youlai.boot.framework.security.token.JwtTokenManager; import org.junit.jupiter.api.BeforeEach; import org.junit.jupiter.api.DisplayName; @@ -16,7 +16,6 @@ import org.springframework.data.redis.core.RedisTemplate; import org.springframework.data.redis.core.ValueOperations; import org.springframework.security.authentication.UsernamePasswordAuthenticationToken; import org.springframework.security.core.Authentication; -import org.springframework.security.core.authority.SimpleGrantedAuthority; import java.util.Collection; import java.util.List; @@ -92,7 +91,7 @@ class JwtTokenManagerTest { assertThat(parsed).isNotNull(); assertThat(parsed.getName()).isEqualTo("testuser"); - SysUserDetails userDetails = (SysUserDetails) parsed.getPrincipal(); + SecurityUserDetails userDetails = (SecurityUserDetails) parsed.getPrincipal(); assertThat(userDetails.getUserId()).isEqualTo(1L); assertThat(userDetails.getDeptId()).isEqualTo(100L); } @@ -120,7 +119,7 @@ class JwtTokenManagerTest { AuthenticationToken token = tokenManager.generateToken(authentication); Authentication parsed = tokenManager.parseToken(token.getAccessToken()); - SysUserDetails userDetails = (SysUserDetails) parsed.getPrincipal(); + SecurityUserDetails userDetails = (SecurityUserDetails) parsed.getPrincipal(); List dataScopes = userDetails.getDataScopes(); assertThat(dataScopes).hasSize(2); @@ -270,9 +269,9 @@ class JwtTokenManagerTest { Authentication parsed = tokenManager.parseToken(token.getAccessToken()); assertThat(parsed).isInstanceOf(UsernamePasswordAuthenticationToken.class); - assertThat(parsed.getPrincipal()).isInstanceOf(SysUserDetails.class); + assertThat(parsed.getPrincipal()).isInstanceOf(SecurityUserDetails.class); - SysUserDetails userDetails = (SysUserDetails) parsed.getPrincipal(); + SecurityUserDetails userDetails = (SecurityUserDetails) parsed.getPrincipal(); assertThat(userDetails.getUserId()).isEqualTo(1L); assertThat(userDetails.getDeptId()).isEqualTo(100L); assertThat(userDetails.getUsername()).isEqualTo("testuser"); @@ -285,7 +284,7 @@ class JwtTokenManagerTest { AuthenticationToken token = tokenManager.generateToken(authentication); Authentication parsed = tokenManager.parseToken(token.getAccessToken()); - SysUserDetails userDetails = (SysUserDetails) parsed.getPrincipal(); + SecurityUserDetails userDetails = (SecurityUserDetails) parsed.getPrincipal(); List dataScopes = userDetails.getDataScopes(); assertThat(dataScopes).hasSize(1); @@ -313,7 +312,7 @@ class JwtTokenManagerTest { } private Authentication createTestAuthentication() { - SysUserDetails userDetails = new SysUserDetails(); + SecurityUserDetails userDetails = new SecurityUserDetails(); userDetails.setUserId(1L); userDetails.setUsername("testuser"); userDetails.setDeptId(100L); @@ -322,10 +321,7 @@ class JwtTokenManagerTest { new RoleDataScope("ADMIN", 1, null), // 全部数据权限 new RoleDataScope("USER", 4, null) // 本人数据权限 )); - userDetails.setAuthorities(Set.of( - new SimpleGrantedAuthority("ROLE_ADMIN"), - new SimpleGrantedAuthority("ROLE_USER") - )); + userDetails.setRoles(Set.of("ADMIN", "USER")); return new UsernamePasswordAuthenticationToken( userDetails, @@ -335,7 +331,7 @@ class JwtTokenManagerTest { } private Authentication createTestAuthenticationWithCustomScope() { - SysUserDetails userDetails = new SysUserDetails(); + SecurityUserDetails userDetails = new SecurityUserDetails(); userDetails.setUserId(2L); userDetails.setUsername("customuser"); userDetails.setDeptId(200L); @@ -343,9 +339,7 @@ class JwtTokenManagerTest { userDetails.setDataScopes(List.of( new RoleDataScope("CUSTOM_ROLE", 5, List.of(10L, 20L, 30L)) // 自定义部门权限 )); - userDetails.setAuthorities(Set.of( - new SimpleGrantedAuthority("ROLE_CUSTOM") - )); + userDetails.setRoles(Set.of("CUSTOM")); return new UsernamePasswordAuthenticationToken( userDetails,