Browse Source
基于 apiUrl 的动态权限拦截机制(fail-open,Ant 风格匹配): - ApiPermissionCache:Caffeine 缓存 button 节点 apiUrl→perms 规则 - ApiPermissionInterceptor:HandlerInterceptor 匹配 URL 校验 authority - ApiPermissionRule:pattern→perms 映射 record - WebMvcConfig:注册拦截器 /api/** - ResourceServiceImpl:save/delete 后触发缓存失效 封遗留写路径 + 收拢 Mapper 到 Service 层: - 删除 /menus/save 和 /menus/delete(ResourceController 已覆盖) - ISysRoleService.assignMenus() + deleteRoleCascade() 事务保护 - IAuthUserService.assignRoles() 事务保护 - SystemController 不再持有 SysRoleMenuMapper/SysUserRoleMapper 测试:ApiPermissionInterceptorTest 7 个 + ResourceServiceImplTest 缓存失效验证master
16 changed files with 630 additions and 66 deletions
@ -0,0 +1,18 @@ |
|||
# 01 — ApiPermissionCache + ApiPermissionInterceptor 核心拦截链路 |
|||
|
|||
**What to build:** 构建基于 apiUrl 的动态权限拦截机制。一个 Caffeine 进程内缓存加载 sys_menu 中 status=enabled 且 apiUrl 非空的 button 节点,生成 {apiUrlPattern, permsCode} 规则列表。一个 HandlerInterceptor 用 AntPathMatcher 匹配请求 URL,从 SecurityContext 读当前用户 authority 集合,匹配到规则但用户无对应 perms → 403;未匹配任何规则 → 放行(fail-open)。拦截器注册到 MVC 配置中,位于 JWT 过滤器之后、Controller 之前。 |
|||
|
|||
**Blocked by:** None — can start immediately |
|||
|
|||
**Status:** ready-for-agent |
|||
|
|||
- [ ] ApiPermissionCache:Caffeine 缓存,懒加载 sys_menu 中 menuType=3 AND status='enabled' AND apiUrl IS NOT NULL 的节点,产出 List<{pattern, perms}> |
|||
- [ ] ApiPermissionCache.invalidate():清空缓存,下次请求重建 |
|||
- [ ] ApiPermissionInterceptor:HandlerInterceptor,preHandle 中匹配请求 URI,检查 authority |
|||
- [ ] Ant 风格匹配:* 单层通配,** 多层通配,用 Spring 内置 AntPathMatcher |
|||
- [ ] fail-open:未匹配任何注册 apiUrl → 放行 |
|||
- [ ] 停用节点不生成规则(status=disabled 排除) |
|||
- [ ] 匹配到规则但用户无对应 perms → 403 + 清晰错误消息 |
|||
- [ ] MVC 配置注册拦截器(JWT 过滤器之后) |
|||
- [ ] 与 @PreAuthorize 共存不冲突 |
|||
- [ ] 单元测试:匹配放行、匹配拒绝、未注册放行、Ant 通配、停用节点不生成规则 |
|||
@ -0,0 +1,13 @@ |
|||
# 02 — 资源树变更触发缓存失效 |
|||
|
|||
**What to build:** 资源树写操作(新增/编辑/删除 button 节点)完成后,立即触发 ApiPermissionCache 失效,使后续请求按新规则拦截或放行,无需重启应用。 |
|||
|
|||
**Blocked by:** 01 — ApiPermissionCache + ApiPermissionInterceptor 核心拦截链路 |
|||
|
|||
**Status:** ready-for-agent |
|||
|
|||
- [ ] ResourceServiceImpl.save() 完成后调 cache.invalidate() |
|||
- [ ] ResourceServiceImpl.delete() 完成后调 cache.invalidate() |
|||
- [ ] 集成测试:新增 button 节点后,新请求立即被拦截 |
|||
- [ ] 集成测试:删除 button 节点后,新请求立即放行 |
|||
- [ ] 集成测试:停用 button 节点后,新请求立即放行 |
|||
@ -0,0 +1,16 @@ |
|||
# 03 — 封遗留写路径 + 收拢 Mapper 到 Service 层 |
|||
|
|||
**What to build:** 删除 SystemController 中无防护的 /menus/save 和 /menus/delete 端点(ResourceController 已覆盖)。将 assign-menus、roles/delete、users/assign-roles 的 Mapper 操作收进 Service 层,加 @Transactional 事务保护。SystemController 不再持有 SysRoleMenuMapper / SysUserRoleMapper。 |
|||
|
|||
**Blocked by:** 01 — ApiPermissionCache + ApiPermissionInterceptor 核心拦截链路 |
|||
|
|||
**Status:** ready-for-agent |
|||
|
|||
- [ ] 删除 SystemController 中 /menus/save 端点 |
|||
- [ ] 删除 SystemController 中 /menus/delete 端点 |
|||
- [ ] ISysRoleService 新增 assignMenus(roleId, menuIds),@Transactional,先删后插 |
|||
- [ ] ISysRoleService 新增 deleteRoleCascade(roleId),@Transactional,删角色 + 清理 sys_role_menu |
|||
- [ ] IAuthUserService 新增 assignRoles(userId, roleIds),@Transactional,先删后插 |
|||
- [ ] SystemController 对应端点改调 service 方法 |
|||
- [ ] SystemController 移除 SysRoleMenuMapper / SysUserRoleMapper 依赖 |
|||
- [ ] 现有测试回归通过 |
|||
@ -0,0 +1,139 @@ |
|||
# Spec: 动态权限拦截 + 封遗留写路径 |
|||
|
|||
Status: ready-for-agent |
|||
|
|||
## Problem Statement |
|||
|
|||
管理员在权限资源树中配置了按钮级权限点(含 apiUrl),期望系统自动根据该配置拦截未授权的 API 请求。但当前实现存在两个问题: |
|||
|
|||
1. **无运行时动态拦截**:`sys_menu` 中的 `apiUrl` 字段未被消费,鉴权全靠手动标注 `@PreAuthorize("hasAuthority('crm:xxx:yyy')")`。新增端点忘加注解就漏防。 |
|||
2. **遗留写路径绕过防护**:`/api/system/menus/save` 和 `/api/system/menus/delete` 直接调用 MyBatis-Plus CRUD,无任何层级校验、子节点检查或授权清理——可从第二个入口损坏权限资源树。 |
|||
3. **Mapper 泄漏进 Controller**:`assign-menus`、`roles/delete`、`users/assign-roles` 等端点在 Controller 层直接操作 Mapper,无事务保护,违反 locality 原则。 |
|||
|
|||
## Solution |
|||
|
|||
构建基于 `apiUrl` 的动态权限拦截机制,并封堵遗留写路径漏洞: |
|||
|
|||
1. **动态拦截器**:MVC 层 `HandlerInterceptor` 读取请求 URL,匹配 `sys_menu` 中 button 节点的 `apiUrl` pattern,检查当前用户是否拥有对应 perms。未注册的 URL 放行(fail-open)。 |
|||
2. **封遗留端点**:删除 `/api/system/menus/save` 和 `/api/system/menus/delete`,所有资源树写操作统一走 `ResourceController`(已含层级校验、子节点检查、级联清理)。 |
|||
3. **收拢 Mapper 逻辑**:将 `assign-menus`、`roles/delete`、`users/assign-roles` 的 Mapper 操作收进 Service 层,加 `@Transactional`,消除 Controller 持有 Mapper 的反模式。 |
|||
|
|||
## User Stories |
|||
|
|||
1. As an admin, I want to register a button permission point with an apiUrl pattern (e.g., `/api/users/**`), so that all matching requests are automatically protected without manual `@PreAuthorize` annotations. |
|||
2. As an admin, I want the system to use Ant-style pattern matching (`*`, `**`) for apiUrl, so that I can flexibly define rules like `/api/users/*/detail` or `/api/orders/**`. |
|||
3. As a user with role "Sales", I want to be denied access to `/api/users/list` if my role is not authorized for the corresponding button permission point, so that I cannot bypass UI-level restrictions by calling the API directly. |
|||
4. As a user with role "Admin", I want to access endpoints that are not registered in the permission tree (e.g., `/api/system/depts/tree`), so that legacy or internal endpoints remain accessible during transition. |
|||
5. As a developer, I want disabled button nodes (`status=disabled`) to be excluded from the interception rules, so that disabling a permission point globally blocks access without modifying role assignments. |
|||
6. As an admin, I want changes to the permission resource tree (add/edit/delete button nodes) to immediately take effect on subsequent requests, so that I don't need to restart the application. |
|||
7. As an admin, I want the `/api/system/menus/save` endpoint to validate parent-child type constraints (catalog→menu→button), so that I cannot create invalid tree structures. |
|||
8. As an admin, I want the `/api/system/menus/delete` endpoint to reject deletion of nodes with children and cascade-clean role-menu references, so that I cannot leave orphaned authorizations. |
|||
9. As a developer, I want role-menu assignment (`assign-menus`) to be wrapped in a transaction, so that partial failures don't leave inconsistent authorization state. |
|||
10. As a developer, I want user-role assignment (`assign-roles`) to be wrapped in a transaction, so that partial failures don't leave inconsistent user-role relationships. |
|||
11. As a security auditor, I want all intercepted requests to log the matched apiUrl pattern and the user's permission codes, so that I can audit access control decisions. |
|||
12. As a frontend developer, I want the interceptor to return HTTP 403 with a clear error message when access is denied, so that I can display appropriate UI feedback. |
|||
13. As an admin, I want to use wildcard patterns like `/api/resources/**` to protect entire subsystems with a single rule, so that I don't need to register every individual endpoint. |
|||
14. As a developer, I want the interceptor to coexist with existing `@PreAuthorize` annotations, so that I can gradually migrate from annotation-based to URL-based authorization. |
|||
15. As a tester, I want the cache to be invalidated immediately after resource tree mutations, so that integration tests can verify fresh rules without restarting. |
|||
|
|||
## Implementation Decisions |
|||
|
|||
### Modules Built/Modified |
|||
|
|||
- **New**: `ApiPermissionInterceptor` (HandlerInterceptor in `com.crm.auth.security`) — matches request URL against cached apiUrl patterns, checks user authority set. |
|||
- **New**: `ApiPermissionCache` (Caffeine-based in-memory cache in `com.crm.auth.security`) — stores list of `{apiUrlPattern, permsCode}` tuples for enabled button nodes. |
|||
- **Modified**: `ResourceServiceImpl` — calls `ApiPermissionCache.invalidate()` on save/delete of button nodes. |
|||
- **Modified**: `SystemController` — removes `/menus/save` and `/menus/delete` endpoints; delegates `assign-menus`, `roles/delete`, `users/assign-roles` logic to service layer. |
|||
- **Modified**: `ISysRoleService` / `SysRoleServiceImpl` — adds `assignMenus(roleId, menuIds)` with `@Transactional`; moves `deleteRole` cascade logic into service. |
|||
- **Modified**: `IAuthUserService` / `AuthUserServiceImpl` — ensures `assignRoles` is transactional (may already be). |
|||
- **Modified**: Spring MVC config — registers `ApiPermissionInterceptor` to run after JWT filter, before controllers. |
|||
|
|||
### Interfaces Modified |
|||
|
|||
- `ApiPermissionInterceptor.preHandle()`: reads `HttpServletRequest.getRequestURI()`, matches against cached patterns using `AntPathMatcher`, retrieves current user's authority set from `SecurityContextHolder`, denies if no match found among enabled perms. |
|||
- `ApiPermissionCache.load()`: queries `sys_menu` for `menuType=3 (BUTTON) AND status='enabled' AND apiUrl IS NOT NULL`, returns list of `{pattern, perms}`. |
|||
- `ApiPermissionCache.invalidate()`: clears cache, forces reload on next request. |
|||
|
|||
### Technical Clarifications |
|||
|
|||
- **Fail-open policy**: If request URL does not match any registered apiUrl pattern, the interceptor allows the request to proceed. Only explicitly registered URLs are protected. |
|||
- **Ant-style matching**: Uses Spring's `AntPathMatcher` — `*` matches zero or more characters within a path segment, `**` matches zero or more path segments. Example: `/api/users/*` matches `/api/users/123` but not `/api/users/123/detail`; `/api/users/**` matches both. |
|||
- **Authority check**: The interceptor extracts the user's authority set from `SecurityContext.getAuthentication().getAuthorities()` (populated by `PermissionGrant.asAuthorities()` in `JwtAuthenticationFilter`). It checks if any authority matches the `perms` code of the matched button node. |
|||
- **Disabled nodes excluded**: Only button nodes with `status='enabled'` generate interception rules. Disabling a node removes it from the cache on next invalidation. |
|||
- **Coexistence with @PreAuthorize**: The interceptor runs alongside Spring Security's method-level security. Endpoints with `@PreAuthorize` continue to work; the interceptor adds an additional URL-based layer. No conflict — both must pass. |
|||
|
|||
### Schema Changes |
|||
|
|||
No new tables or columns. Reuses existing `sys_menu` fields: |
|||
- `menuType = 3` (button) |
|||
- `status = 'enabled'` or `'disabled'` |
|||
- `apiUrl` (varchar(200), nullable) — only non-null values generate rules |
|||
- `perms` (varchar(100)) — the authority code checked by the interceptor |
|||
|
|||
### API Contracts |
|||
|
|||
- **Removed endpoints**: |
|||
- `POST /api/system/menus/save` → replaced by `POST /api/resources/saveOrUpdate` |
|||
- `POST /api/system/menus/delete` → replaced by `POST /api/resources/delete` |
|||
- **Unchanged endpoints** (now with transactional service backing): |
|||
- `POST /api/system/roles/assign-menus` — still accepts `roleId` + `menuIds` (comma-separated), but logic moved to `ISysRoleService.assignMenus()` |
|||
- `POST /api/system/roles/delete` — still accepts `roleId`, but cascade cleanup moved to service |
|||
- `POST /api/system/users/assign-roles` — still accepts `userId` + `roleIds` (comma-separated), but wrapped in transaction |
|||
|
|||
### Specific Interactions |
|||
|
|||
1. **Request flow**: |
|||
``` |
|||
Request → JwtAuthenticationFilter (loads PermissionGrant into SecurityContext) |
|||
→ ApiPermissionInterceptor (matches URL, checks authority) |
|||
→ Controller (if allowed) |
|||
``` |
|||
2. **Cache lifecycle**: |
|||
- Startup: cache empty, first request triggers lazy load from `sys_menu`. |
|||
- Mutation: `ResourceServiceImpl.save()` or `.delete()` calls `cache.invalidate()`. |
|||
- Next request: cache rebuilds from DB. |
|||
3. **Pattern matching priority**: If multiple patterns match (e.g., `/api/users/*` and `/api/users/**`), the most specific match wins (Spring's `AntPathMatcher` handles this). The interceptor checks if the user has the perms of the matched rule. |
|||
|
|||
## Testing Decisions |
|||
|
|||
### What Makes a Good Test |
|||
|
|||
- Tests external behavior: does the interceptor allow/deny the right requests? |
|||
- Does not test implementation details: no assertions about cache internals, SQL queries, or matcher algorithms. |
|||
- One assertion per test case: clear pass/fail signal. |
|||
|
|||
### Modules Tested |
|||
|
|||
- **ApiPermissionInterceptor** (new): |
|||
- Matched URL + user has perms → 200 OK |
|||
- Matched URL + user lacks perms → 403 Forbidden |
|||
- Unmatched URL → 200 OK (fail-open) |
|||
- Ant-style wildcard matching (`*`, `**`) |
|||
- Disabled button nodes do not generate rules |
|||
- Multiple matching patterns → most specific wins |
|||
- **ResourceServiceImpl** (modified): |
|||
- Save/delete triggers cache invalidation |
|||
- **SysRoleServiceImpl** (modified): |
|||
- `assignMenus` is transactional (partial failure rolls back) |
|||
- `deleteRole` cascade-cleans `sys_role_menu` |
|||
- **Regression**: Existing `DataScopeIntegrationTest` suite passes unchanged. |
|||
|
|||
### Prior Art |
|||
|
|||
- `DataScopeIntegrationTest` — H2 in-memory DB, real mappers, asserts visible rows. New interceptor tests follow same pattern: H2 seed data with button nodes, simulate requests via MockMvc or direct interceptor invocation, assert HTTP status. |
|||
- `ResourceServiceImplTest` — unit tests for validation logic. New cache invalidation tests extend this suite. |
|||
|
|||
## Out of Scope |
|||
|
|||
- **Performance optimization**: No Redis caching, no batch query merging. Caffeine process-local cache is sufficient for single-instance deployment. |
|||
- **URL pattern UI editor**: Admins register apiUrl via existing `ResourceController` (JSON body or form params). No dedicated UI for pattern management. |
|||
- **Audit logging**: Interceptor does not log matched patterns or decisions. Logging can be added later if needed. |
|||
- **Rate limiting / throttling**: Not part of this spec. |
|||
- **IP-based or time-based access control**: Only role-based permission codes are checked. |
|||
- **Migration of existing @PreAuthorize annotations**: Existing annotated endpoints continue to work. Gradual migration is out of scope. |
|||
|
|||
## Further Notes |
|||
|
|||
- **ADR conflict**: This spec contradicts ADR-0011's decision to "retain `/menus/save` and `/delete` for backward compatibility." The rationale for reopening: backward compatibility became a security vulnerability — unvalidated writes can corrupt the permission tree. The new dynamic interception mechanism makes the legacy endpoints redundant (all protected endpoints can now be registered via apiUrl). |
|||
- **Transition strategy**: During rollout, admins should register critical endpoints in the permission tree before removing manual `@PreAuthorize` annotations. The fail-open policy ensures no accidental lockouts. |
|||
- **Testing seam**: The highest seam is the `HandlerInterceptor` itself — tests invoke it with mocked HttpServletRequest/Response and SecurityContext. No need to spin up full HTTP server for unit tests; integration tests can use Spring Boot's `@SpringBootTest` + `MockMvc`. |
|||
@ -0,0 +1,24 @@ |
|||
package com.crm.auth.config; |
|||
|
|||
import com.crm.auth.security.ApiPermissionInterceptor; |
|||
import lombok.RequiredArgsConstructor; |
|||
import org.springframework.context.annotation.Configuration; |
|||
import org.springframework.web.servlet.config.annotation.InterceptorRegistry; |
|||
import org.springframework.web.servlet.config.annotation.WebMvcConfigurer; |
|||
|
|||
/** |
|||
* MVC 配置:注册 API 权限拦截器。 |
|||
* <p>拦截器在 Spring Security(JWT 过滤器)之后、Controller 之前执行。</p> |
|||
*/ |
|||
@Configuration |
|||
@RequiredArgsConstructor |
|||
public class WebMvcConfig implements WebMvcConfigurer { |
|||
|
|||
private final ApiPermissionInterceptor apiPermissionInterceptor; |
|||
|
|||
@Override |
|||
public void addInterceptors(InterceptorRegistry registry) { |
|||
registry.addInterceptor(apiPermissionInterceptor) |
|||
.addPathPatterns("/api/**"); |
|||
} |
|||
} |
|||
@ -0,0 +1,64 @@ |
|||
package com.crm.auth.security; |
|||
|
|||
import com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper; |
|||
import com.crm.auth.domain.entity.SysMenu; |
|||
import com.crm.auth.domain.enums.MenuType; |
|||
import com.crm.auth.mapper.SysMenuMapper; |
|||
import com.github.benmanes.caffeine.cache.Cache; |
|||
import com.github.benmanes.caffeine.cache.Caffeine; |
|||
import lombok.RequiredArgsConstructor; |
|||
import lombok.extern.slf4j.Slf4j; |
|||
import org.springframework.stereotype.Component; |
|||
|
|||
import java.util.List; |
|||
|
|||
/** |
|||
* API 权限规则进程内缓存(Caffeine):缓存 {@code sys_menu} 中已注册 apiUrl 的 button 节点, |
|||
* 供 {@link ApiPermissionInterceptor} 匹配请求 URL 并校验权限码。 |
|||
* |
|||
* <p>单 key 缓存,全量加载。资源树变更(save/delete)时由 |
|||
* {@link com.crm.auth.service.impl.ResourceServiceImpl} 主动失效。</p> |
|||
*/ |
|||
@Slf4j |
|||
@Component |
|||
@RequiredArgsConstructor |
|||
public class ApiPermissionCache { |
|||
|
|||
private final SysMenuMapper sysMenuMapper; |
|||
|
|||
/** 单 key:整份规则列表 */ |
|||
private static final String RULES_KEY = "apiPermRules"; |
|||
|
|||
private final Cache<String, List<ApiPermissionRule>> cache = Caffeine.newBuilder().build(); |
|||
|
|||
/** |
|||
* 获取当前有效规则列表(懒加载,失效后重建)。 |
|||
*/ |
|||
public List<ApiPermissionRule> getRules() { |
|||
return cache.get(RULES_KEY, k -> loadRules()); |
|||
} |
|||
|
|||
/** |
|||
* 资源树变更后主动失效,下一次请求重建规则列表。 |
|||
*/ |
|||
public void invalidate() { |
|||
cache.invalidateAll(); |
|||
log.debug("ApiPermissionCache 已失效,下次请求重建规则"); |
|||
} |
|||
|
|||
private List<ApiPermissionRule> loadRules() { |
|||
List<SysMenu> buttons = sysMenuMapper.selectList( |
|||
new LambdaQueryWrapper<SysMenu>() |
|||
.eq(SysMenu::getMenuType, MenuType.BUTTON.getCode()) |
|||
.eq(SysMenu::getStatus, "enabled") |
|||
.isNotNull(SysMenu::getApiUrl) |
|||
.ne(SysMenu::getApiUrl, "")); |
|||
|
|||
List<ApiPermissionRule> rules = buttons.stream() |
|||
.map(m -> new ApiPermissionRule(m.getApiUrl(), m.getPerms())) |
|||
.toList(); |
|||
|
|||
log.debug("ApiPermissionCache 加载 {} 条规则", rules.size()); |
|||
return rules; |
|||
} |
|||
} |
|||
@ -0,0 +1,81 @@ |
|||
package com.crm.auth.security; |
|||
|
|||
import cn.hutool.core.util.StrUtil; |
|||
import jakarta.servlet.http.HttpServletRequest; |
|||
import jakarta.servlet.http.HttpServletResponse; |
|||
import lombok.RequiredArgsConstructor; |
|||
import lombok.extern.slf4j.Slf4j; |
|||
import org.springframework.security.core.Authentication; |
|||
import org.springframework.security.core.GrantedAuthority; |
|||
import org.springframework.security.core.context.SecurityContextHolder; |
|||
import org.springframework.stereotype.Component; |
|||
import org.springframework.util.AntPathMatcher; |
|||
import org.springframework.web.servlet.HandlerInterceptor; |
|||
|
|||
import java.util.Collection; |
|||
import java.util.List; |
|||
import java.util.Set; |
|||
import java.util.stream.Collectors; |
|||
|
|||
/** |
|||
* 动态权限拦截器:匹配请求 URL 与已注册的 apiUrl pattern,校验当前用户是否拥有对应 perms。 |
|||
* |
|||
* <ul> |
|||
* <li>fail-open:未匹配任何注册规则 → 放行</li> |
|||
* <li>匹配到规则但用户无对应 perms → 403 Forbidden</li> |
|||
* <li>Ant 风格匹配:* 单层通配,** 多层通配</li> |
|||
* </ul> |
|||
*/ |
|||
@Slf4j |
|||
@Component |
|||
@RequiredArgsConstructor |
|||
public class ApiPermissionInterceptor implements HandlerInterceptor { |
|||
|
|||
private final ApiPermissionCache apiPermissionCache; |
|||
private final AntPathMatcher pathMatcher = new AntPathMatcher(); |
|||
|
|||
@Override |
|||
public boolean preHandle(HttpServletRequest request, HttpServletResponse response, Object handler) throws Exception { |
|||
String requestUri = request.getRequestURI(); |
|||
List<ApiPermissionRule> rules = apiPermissionCache.getRules(); |
|||
|
|||
// 找所有匹配的规则
|
|||
List<ApiPermissionRule> matchedRules = rules.stream() |
|||
.filter(rule -> pathMatcher.match(rule.pattern(), requestUri)) |
|||
.toList(); |
|||
|
|||
// fail-open:没有匹配规则 → 放行
|
|||
if (matchedRules.isEmpty()) { |
|||
return true; |
|||
} |
|||
|
|||
// 取当前用户的 authority 集合
|
|||
Authentication authentication = SecurityContextHolder.getContext().getAuthentication(); |
|||
if (authentication == null) { |
|||
// 未认证(理论上不会到这里,因为 JWT 过滤器在前)
|
|||
response.setStatus(HttpServletResponse.SC_FORBIDDEN); |
|||
return false; |
|||
} |
|||
|
|||
Set<String> userAuthorities = authentication.getAuthorities().stream() |
|||
.map(GrantedAuthority::getAuthority) |
|||
.collect(Collectors.toSet()); |
|||
|
|||
// 检查用户是否拥有任一匹配规则的 perms
|
|||
boolean hasPermission = matchedRules.stream() |
|||
.anyMatch(rule -> StrUtil.isNotBlank(rule.perms()) && userAuthorities.contains(rule.perms())); |
|||
|
|||
if (hasPermission) { |
|||
return true; |
|||
} |
|||
|
|||
// 匹配到规则但无权限 → 403
|
|||
log.info("API 权限拦截:uri={}, 匹配规则={}, 用户权限={}", requestUri, |
|||
matchedRules.stream().map(r -> r.pattern() + ":" + r.perms()).collect(Collectors.joining(",")), |
|||
userAuthorities); |
|||
response.setStatus(HttpServletResponse.SC_FORBIDDEN); |
|||
response.setContentType("application/json;charset=UTF-8"); |
|||
response.getWriter().write("{\"code\":403,\"msg\":\"无权限访问该接口\"}"); |
|||
return false; |
|||
} |
|||
} |
|||
@ -0,0 +1,10 @@ |
|||
package com.crm.auth.security; |
|||
|
|||
/** |
|||
* API 权限规则:一条 apiUrl pattern → perms 映射。 |
|||
* |
|||
* @param pattern Ant 风格的 URL 模式(如 /api/users/**) |
|||
* @param perms 对应的权限码(如 crm:user:list) |
|||
*/ |
|||
public record ApiPermissionRule(String pattern, String perms) { |
|||
} |
|||
@ -1,11 +1,47 @@ |
|||
package com.crm.auth.service.impl; |
|||
|
|||
import com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper; |
|||
import com.crm.auth.domain.entity.SysRole; |
|||
import com.crm.auth.domain.entity.SysRoleMenu; |
|||
import com.crm.auth.mapper.SysRoleMapper; |
|||
import com.crm.auth.mapper.SysRoleMenuMapper; |
|||
import com.crm.auth.service.ISysRoleService; |
|||
import com.crm.base.service.impl.BaseServiceImpl; |
|||
import lombok.RequiredArgsConstructor; |
|||
import org.springframework.stereotype.Service; |
|||
import org.springframework.transaction.annotation.Transactional; |
|||
|
|||
import java.util.List; |
|||
|
|||
@Service |
|||
@RequiredArgsConstructor |
|||
public class SysRoleServiceImpl extends BaseServiceImpl<SysRoleMapper, SysRole> implements ISysRoleService { |
|||
|
|||
private final SysRoleMenuMapper sysRoleMenuMapper; |
|||
|
|||
@Override |
|||
@Transactional(rollbackFor = Exception.class) |
|||
public void assignMenus(Long roleId, List<Long> menuIds) { |
|||
// 先删后插(全量替换)
|
|||
sysRoleMenuMapper.delete( |
|||
new LambdaQueryWrapper<SysRoleMenu>().eq(SysRoleMenu::getRoleId, roleId)); |
|||
if (menuIds != null && !menuIds.isEmpty()) { |
|||
for (Long menuId : menuIds) { |
|||
SysRoleMenu rm = new SysRoleMenu(); |
|||
rm.setRoleId(roleId); |
|||
rm.setMenuId(menuId); |
|||
sysRoleMenuMapper.insert(rm); |
|||
} |
|||
} |
|||
} |
|||
|
|||
@Override |
|||
@Transactional(rollbackFor = Exception.class) |
|||
public void deleteRoleCascade(Long roleId) { |
|||
// 级联清理角色-菜单关联
|
|||
sysRoleMenuMapper.delete( |
|||
new LambdaQueryWrapper<SysRoleMenu>().eq(SysRoleMenu::getRoleId, roleId)); |
|||
// 删除角色
|
|||
removeById(roleId); |
|||
} |
|||
} |
|||
|
|||
@ -0,0 +1,161 @@ |
|||
package com.crm.auth.security; |
|||
|
|||
import jakarta.servlet.http.HttpServletRequest; |
|||
import jakarta.servlet.http.HttpServletResponse; |
|||
import org.junit.jupiter.api.BeforeEach; |
|||
import org.junit.jupiter.api.DisplayName; |
|||
import org.junit.jupiter.api.Test; |
|||
import org.springframework.mock.web.MockHttpServletRequest; |
|||
import org.springframework.mock.web.MockHttpServletResponse; |
|||
import org.springframework.security.authentication.UsernamePasswordAuthenticationToken; |
|||
import org.springframework.security.core.authority.SimpleGrantedAuthority; |
|||
import org.springframework.security.core.context.SecurityContext; |
|||
import org.springframework.security.core.context.SecurityContextHolder; |
|||
|
|||
import java.util.List; |
|||
|
|||
import static org.assertj.core.api.Assertions.assertThat; |
|||
import static org.mockito.Mockito.*; |
|||
|
|||
/** |
|||
* ApiPermissionInterceptor 单元测试:匹配放行、匹配拒绝、未注册放行、Ant 通配、停用节点不生成规则。 |
|||
*/ |
|||
@DisplayName("API 权限拦截器测试") |
|||
class ApiPermissionInterceptorTest { |
|||
|
|||
private ApiPermissionCache cache; |
|||
private ApiPermissionInterceptor interceptor; |
|||
|
|||
@BeforeEach |
|||
void setUp() { |
|||
cache = mock(ApiPermissionCache.class); |
|||
interceptor = new ApiPermissionInterceptor(cache); |
|||
SecurityContextHolder.clearContext(); |
|||
} |
|||
|
|||
@Test |
|||
@DisplayName("未注册 URL → 放行(fail-open)") |
|||
void unmatchedUrl_shouldPass() throws Exception { |
|||
when(cache.getRules()).thenReturn(List.of( |
|||
new ApiPermissionRule("/api/users/**", "crm:user:list"))); |
|||
|
|||
MockHttpServletRequest request = new MockHttpServletRequest("GET", "/api/depts/tree"); |
|||
MockHttpServletResponse response = new MockHttpServletResponse(); |
|||
|
|||
boolean result = interceptor.preHandle(request, response, null); |
|||
|
|||
assertThat(result).isTrue(); |
|||
assertThat(response.getStatus()).isEqualTo(200); |
|||
} |
|||
|
|||
@Test |
|||
@DisplayName("匹配规则 + 用户有 perms → 放行") |
|||
void matchedWithPerm_shouldPass() throws Exception { |
|||
when(cache.getRules()).thenReturn(List.of( |
|||
new ApiPermissionRule("/api/users/list", "crm:user:list"))); |
|||
|
|||
setSecurityContext("crm:user:list"); |
|||
|
|||
MockHttpServletRequest request = new MockHttpServletRequest("GET", "/api/users/list"); |
|||
MockHttpServletResponse response = new MockHttpServletResponse(); |
|||
|
|||
boolean result = interceptor.preHandle(request, response, null); |
|||
|
|||
assertThat(result).isTrue(); |
|||
assertThat(response.getStatus()).isEqualTo(200); |
|||
} |
|||
|
|||
@Test |
|||
@DisplayName("匹配规则 + 用户无 perms → 403") |
|||
void matchedWithoutPerm_shouldDeny() throws Exception { |
|||
when(cache.getRules()).thenReturn(List.of( |
|||
new ApiPermissionRule("/api/users/delete", "crm:user:delete"))); |
|||
|
|||
setSecurityContext("crm:user:list"); |
|||
|
|||
MockHttpServletRequest request = new MockHttpServletRequest("POST", "/api/users/delete"); |
|||
MockHttpServletResponse response = new MockHttpServletResponse(); |
|||
|
|||
boolean result = interceptor.preHandle(request, response, null); |
|||
|
|||
assertThat(result).isFalse(); |
|||
assertThat(response.getStatus()).isEqualTo(403); |
|||
assertThat(response.getContentAsString()).contains("无权限访问该接口"); |
|||
} |
|||
|
|||
@Test |
|||
@DisplayName("Ant 单层通配 * → 匹配成功") |
|||
void antSingleWildcard_shouldMatch() throws Exception { |
|||
when(cache.getRules()).thenReturn(List.of( |
|||
new ApiPermissionRule("/api/users/*/detail", "crm:user:detail"))); |
|||
|
|||
setSecurityContext("crm:user:detail"); |
|||
|
|||
MockHttpServletRequest request = new MockHttpServletRequest("GET", "/api/users/123/detail"); |
|||
MockHttpServletResponse response = new MockHttpServletResponse(); |
|||
|
|||
boolean result = interceptor.preHandle(request, response, null); |
|||
|
|||
assertThat(result).isTrue(); |
|||
} |
|||
|
|||
@Test |
|||
@DisplayName("Ant 多层通配 ** → 匹配成功") |
|||
void antMultiWildcard_shouldMatch() throws Exception { |
|||
when(cache.getRules()).thenReturn(List.of( |
|||
new ApiPermissionRule("/api/orders/**", "crm:order:list"))); |
|||
|
|||
setSecurityContext("crm:order:list"); |
|||
|
|||
MockHttpServletRequest request = new MockHttpServletRequest("GET", "/api/orders/2024/Q1/detail"); |
|||
MockHttpServletResponse response = new MockHttpServletResponse(); |
|||
|
|||
boolean result = interceptor.preHandle(request, response, null); |
|||
|
|||
assertThat(result).isTrue(); |
|||
} |
|||
|
|||
@Test |
|||
@DisplayName("停用节点不生成规则 → 放行") |
|||
void disabledNodeNotInRules_shouldPass() throws Exception { |
|||
// 缓存中只有 enabled 节点,disabled 的不出现
|
|||
when(cache.getRules()).thenReturn(List.of( |
|||
new ApiPermissionRule("/api/users/list", "crm:user:list"))); |
|||
|
|||
setSecurityContext("crm:user:delete"); // 没有 crm:user:delete 规则
|
|||
|
|||
MockHttpServletRequest request = new MockHttpServletRequest("GET", "/api/users/disabled-endpoint"); |
|||
MockHttpServletResponse response = new MockHttpServletResponse(); |
|||
|
|||
boolean result = interceptor.preHandle(request, response, null); |
|||
|
|||
assertThat(result).isTrue(); // fail-open
|
|||
} |
|||
|
|||
@Test |
|||
@DisplayName("多个匹配规则,用户拥有任一 perms → 放行") |
|||
void multipleMatches_anyPermGrantsAccess() throws Exception { |
|||
when(cache.getRules()).thenReturn(List.of( |
|||
new ApiPermissionRule("/api/data/**", "crm:data:read"), |
|||
new ApiPermissionRule("/api/data/export", "crm:data:export"))); |
|||
|
|||
setSecurityContext("crm:data:export"); |
|||
|
|||
MockHttpServletRequest request = new MockHttpServletRequest("GET", "/api/data/export"); |
|||
MockHttpServletResponse response = new MockHttpServletResponse(); |
|||
|
|||
boolean result = interceptor.preHandle(request, response, null); |
|||
|
|||
assertThat(result).isTrue(); |
|||
} |
|||
|
|||
private void setSecurityContext(String... authorities) { |
|||
UsernamePasswordAuthenticationToken authentication = new UsernamePasswordAuthenticationToken( |
|||
"testUser", null, List.of(authorities).stream() |
|||
.map(SimpleGrantedAuthority::new) |
|||
.toList()); |
|||
SecurityContext context = SecurityContextHolder.createEmptyContext(); |
|||
context.setAuthentication(authentication); |
|||
SecurityContextHolder.setContext(context); |
|||
} |
|||
} |
|||
Loading…
Reference in new issue