From e8235eeec5578e616c08cabd549b47f47eae287a Mon Sep 17 00:00:00 2001 From: wh Date: Mon, 13 Apr 2026 19:58:49 +0800 Subject: [PATCH] =?UTF-8?q?=E4=BF=AE=E6=94=B9shiro=20=E5=85=BC=E5=AE=B9?= =?UTF-8?q?=E6=80=A7=E9=97=AE=E9=A2=98?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../com/label/common/shiro/ShiroConfig.java | 44 ++++++++--------- .../com/label/common/shiro/TokenFilter.java | 21 +++++++-- .../source/controller/SourceController.java | 2 +- src/main/resources/application.yml | 2 +- .../java/com/label/unit/ShiroConfigTest.java | 39 +++++++++++++++ .../java/com/label/unit/TokenFilterTest.java | 47 ++++++------------- 6 files changed, 94 insertions(+), 61 deletions(-) create mode 100644 src/test/java/com/label/unit/ShiroConfigTest.java diff --git a/src/main/java/com/label/common/shiro/ShiroConfig.java b/src/main/java/com/label/common/shiro/ShiroConfig.java index 12c1f7b..4987113 100644 --- a/src/main/java/com/label/common/shiro/ShiroConfig.java +++ b/src/main/java/com/label/common/shiro/ShiroConfig.java @@ -2,9 +2,10 @@ package com.label.common.shiro; import com.fasterxml.jackson.databind.ObjectMapper; import com.label.common.redis.RedisService; -import org.apache.shiro.SecurityUtils; +import org.apache.shiro.mgt.DefaultSessionStorageEvaluator; +import org.apache.shiro.mgt.DefaultSecurityManager; +import org.apache.shiro.mgt.DefaultSubjectDAO; import org.apache.shiro.mgt.SecurityManager; -import org.apache.shiro.web.mgt.DefaultWebSecurityManager; import org.springframework.boot.web.servlet.FilterRegistrationBean; import org.springframework.context.annotation.Bean; import org.springframework.context.annotation.Configuration; @@ -12,18 +13,7 @@ import org.springframework.context.annotation.Configuration; import java.util.List; /** - * Shiro 安全配置。 - * - * 设计说明: - * - 使用 Spring 的 FilterRegistrationBean 注册 TokenFilter(jakarta.servlet), - * 替代 Shiro 的 ShiroFilterFactoryBean(javax.servlet),避免 Shiro 1.x 与 - * Spring Boot 3.x 之间的 javax/jakarta 命名空间冲突。 - * - URL 路由逻辑内聚于 TokenFilter.shouldNotFilter(): - * /api/auth/login → 跳过(公开) - * 非 /api/ 路径 → 跳过(公开) - * /api/** → 强制校验 Bearer Token - * - SecurityUtils.setSecurityManager() 必须在此处调用, - * 以便 @RequiresRoles 等 AOP 注解和 SecurityUtils.getSubject() 可正常工作。 + * Shiro security configuration for the Jakarta servlet stack. */ @Configuration public class ShiroConfig { @@ -35,22 +25,28 @@ public class ShiroConfig { @Bean public SecurityManager securityManager(UserRealm userRealm) { - DefaultWebSecurityManager manager = new DefaultWebSecurityManager(); + // Keep Shiro on the core stack. Shiro 1.x web classes depend on javax.servlet. + DefaultSecurityManager manager = new DefaultSecurityManager(); manager.setRealms(List.of(userRealm)); - // 设置全局 SecurityManager,使 SecurityUtils.getSubject() 及 AOP 注解可用 - SecurityUtils.setSecurityManager(manager); + manager.setSubjectDAO(statelessSubjectDao()); return manager; } - @Bean - public TokenFilter tokenFilter(RedisService redisService, ObjectMapper objectMapper) { - return new TokenFilter(redisService, objectMapper); + private DefaultSubjectDAO statelessSubjectDao() { + DefaultSessionStorageEvaluator evaluator = new DefaultSessionStorageEvaluator(); + evaluator.setSessionStorageEnabled(false); + + DefaultSubjectDAO subjectDAO = new DefaultSubjectDAO(); + subjectDAO.setSessionStorageEvaluator(evaluator); + return subjectDAO; + } + + @Bean + public TokenFilter tokenFilter(RedisService redisService, ObjectMapper objectMapper, + SecurityManager securityManager) { + return new TokenFilter(redisService, objectMapper, securityManager); } - /** - * 将 TokenFilter 注册为 Servlet 过滤器,覆盖所有路径。 - * 实际的路径过滤逻辑由 TokenFilter.shouldNotFilter() 控制。 - */ @Bean public FilterRegistrationBean tokenFilterRegistration(TokenFilter tokenFilter) { FilterRegistrationBean registration = new FilterRegistrationBean<>(); diff --git a/src/main/java/com/label/common/shiro/TokenFilter.java b/src/main/java/com/label/common/shiro/TokenFilter.java index 558629f..0aeb3d7 100644 --- a/src/main/java/com/label/common/shiro/TokenFilter.java +++ b/src/main/java/com/label/common/shiro/TokenFilter.java @@ -11,7 +11,9 @@ import jakarta.servlet.http.HttpServletRequest; import jakarta.servlet.http.HttpServletResponse; import lombok.RequiredArgsConstructor; import lombok.extern.slf4j.Slf4j; -import org.apache.shiro.SecurityUtils; +import org.apache.shiro.mgt.SecurityManager; +import org.apache.shiro.subject.SimplePrincipalCollection; +import org.apache.shiro.subject.Subject; import org.apache.shiro.util.ThreadContext; import org.springframework.beans.factory.annotation.Value; import org.springframework.http.MediaType; @@ -38,6 +40,7 @@ public class TokenFilter extends OncePerRequestFilter { private final RedisService redisService; private final ObjectMapper objectMapper; + private final SecurityManager securityManager; @Value("${shiro.auth.enabled:true}") private boolean authEnabled; @@ -78,7 +81,7 @@ public class TokenFilter extends OncePerRequestFilter { TokenPrincipal principal = new TokenPrincipal( mockUserId, mockRole, mockCompanyId, mockUsername, "mock-token"); CompanyContext.set(mockCompanyId); - SecurityUtils.getSubject().login(new BearerToken("mock-token", principal)); + bindSubject(principal); request.setAttribute("__token_principal__", principal); filterChain.doFilter(request, response); return; @@ -113,7 +116,7 @@ public class TokenFilter extends OncePerRequestFilter { // 创建 TokenPrincipal 并登录 Shiro Subject,使 @RequiresRoles 等注解生效 TokenPrincipal principal = new TokenPrincipal(userId, role, companyId, username, token); - SecurityUtils.getSubject().login(new BearerToken(token, principal)); + bindSubject(principal); request.setAttribute("__token_principal__", principal); redisService.expire(RedisKeyManager.tokenKey(token), tokenTtlSeconds); redisService.expire(RedisKeyManager.userSessionsKey(userId), tokenTtlSeconds); @@ -126,9 +129,21 @@ public class TokenFilter extends OncePerRequestFilter { // 关键:必须清除 ThreadLocal,防止线程池复用时数据串漏 CompanyContext.clear(); ThreadContext.unbindSubject(); + ThreadContext.unbindSecurityManager(); } } + private void bindSubject(TokenPrincipal principal) { + SimplePrincipalCollection principals = new SimplePrincipalCollection(principal, UserRealm.class.getName()); + Subject subject = new Subject.Builder(securityManager) + .principals(principals) + .authenticated(true) + .sessionCreationEnabled(false) + .buildSubject(); + ThreadContext.bind(securityManager); + ThreadContext.bind(subject); + } + private void writeUnauthorized(HttpServletResponse resp, String message) throws IOException { resp.setStatus(HttpServletResponse.SC_UNAUTHORIZED); resp.setContentType(MediaType.APPLICATION_JSON_VALUE + ";charset=UTF-8"); diff --git a/src/main/java/com/label/module/source/controller/SourceController.java b/src/main/java/com/label/module/source/controller/SourceController.java index 57323d0..379272f 100644 --- a/src/main/java/com/label/module/source/controller/SourceController.java +++ b/src/main/java/com/label/module/source/controller/SourceController.java @@ -33,7 +33,7 @@ public class SourceController { * 上传文件(multipart/form-data)。 * 返回 201 Created + 资料摘要。 */ - @Operation(summary = "上传原始资料") + @Operation(summary = "上传原始资料", description = "dataType: text,image, video") @PostMapping("/upload") @RequiresRoles("UPLOADER") @ResponseStatus(HttpStatus.CREATED) diff --git a/src/main/resources/application.yml b/src/main/resources/application.yml index 917f6c8..be46b7f 100644 --- a/src/main/resources/application.yml +++ b/src/main/resources/application.yml @@ -65,7 +65,7 @@ ai-service: shiro: auth: - enabled: true + enabled: false mock-company-id: 1 mock-user-id: 1 mock-role: ADMIN diff --git a/src/test/java/com/label/unit/ShiroConfigTest.java b/src/test/java/com/label/unit/ShiroConfigTest.java new file mode 100644 index 0000000..1d12b06 --- /dev/null +++ b/src/test/java/com/label/unit/ShiroConfigTest.java @@ -0,0 +1,39 @@ +package com.label.unit; + +import com.label.common.redis.RedisService; +import com.label.common.shiro.ShiroConfig; +import com.label.common.shiro.UserRealm; +import org.apache.shiro.SecurityUtils; +import org.apache.shiro.mgt.SecurityManager; +import org.apache.shiro.util.ThreadContext; +import org.apache.shiro.web.mgt.DefaultWebSecurityManager; +import org.junit.jupiter.api.AfterEach; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatCode; +import static org.mockito.Mockito.mock; + +@DisplayName("ShiroConfig 单元测试") +class ShiroConfigTest { + + @AfterEach + void tearDown() { + org.apache.shiro.util.ThreadContext.remove(); + } + + @Test + @DisplayName("securityManager 不应依赖 DefaultWebSecurityManager,以避免 javax.servlet 兼容性问题") + void securityManager_shouldNotDependOnDefaultWebSecurityManager() { + ShiroConfig config = new ShiroConfig(); + RedisService redisService = mock(RedisService.class); + UserRealm realm = config.userRealm(redisService); + + SecurityManager securityManager = config.securityManager(realm); + + assertThat(securityManager).isNotInstanceOf(DefaultWebSecurityManager.class); + ThreadContext.bind(securityManager); + assertThatCode(SecurityUtils::getSubject).doesNotThrowAnyException(); + } +} diff --git a/src/test/java/com/label/unit/TokenFilterTest.java b/src/test/java/com/label/unit/TokenFilterTest.java index ccdf502..02168a0 100644 --- a/src/test/java/com/label/unit/TokenFilterTest.java +++ b/src/test/java/com/label/unit/TokenFilterTest.java @@ -4,18 +4,12 @@ import com.fasterxml.jackson.databind.ObjectMapper; import com.label.common.context.CompanyContext; import com.label.common.redis.RedisKeyManager; import com.label.common.redis.RedisService; -import com.label.common.shiro.BearerToken; +import com.label.common.shiro.ShiroConfig; import com.label.common.shiro.TokenFilter; import com.label.common.shiro.TokenPrincipal; +import com.label.common.shiro.UserRealm; import org.apache.shiro.SecurityUtils; -import org.apache.shiro.authc.AuthenticationInfo; -import org.apache.shiro.authc.AuthenticationToken; -import org.apache.shiro.authc.SimpleAuthenticationInfo; -import org.apache.shiro.authz.AuthorizationInfo; -import org.apache.shiro.authz.SimpleAuthorizationInfo; import org.apache.shiro.mgt.DefaultSecurityManager; -import org.apache.shiro.realm.AuthorizingRealm; -import org.apache.shiro.subject.PrincipalCollection; import org.apache.shiro.util.ThreadContext; import org.junit.jupiter.api.AfterEach; import org.junit.jupiter.api.BeforeEach; @@ -38,12 +32,15 @@ class TokenFilterTest { private RedisService redisService; private TestableTokenFilter filter; + private DefaultSecurityManager securityManager; @BeforeEach void setUp() { redisService = mock(RedisService.class); - filter = new TestableTokenFilter(redisService, new ObjectMapper()); - SecurityUtils.setSecurityManager(new DefaultSecurityManager(new BearerTokenRealm())); + UserRealm userRealm = new UserRealm(redisService); + securityManager = (DefaultSecurityManager) new ShiroConfig().securityManager(userRealm); + filter = new TestableTokenFilter(redisService, new ObjectMapper(), securityManager); + SecurityUtils.setSecurityManager(securityManager); } @AfterEach @@ -74,6 +71,7 @@ class TokenFilterTest { assertThat(response.getStatus()).isEqualTo(200); assertThat(chain.principal).isInstanceOf(TokenPrincipal.class); + assertThat(chain.roleChecked).isTrue(); verify(redisService).expire(RedisKeyManager.tokenKey(token), 7200L); } @@ -98,41 +96,26 @@ class TokenFilterTest { assertThat(principal.getUserId()).isEqualTo(4L); assertThat(principal.getRole()).isEqualTo("ADMIN"); assertThat(principal.getUsername()).isEqualTo("mock-admin"); + assertThat(chain.roleChecked).isTrue(); verify(redisService, never()).hGetAll(anyString()); } - private static final class BearerTokenRealm extends AuthorizingRealm { - @Override - public boolean supports(AuthenticationToken token) { - return token instanceof BearerToken; - } - - @Override - protected AuthenticationInfo doGetAuthenticationInfo(AuthenticationToken token) { - return new SimpleAuthenticationInfo(token.getPrincipal(), token.getCredentials(), getName()); - } - - @Override - protected AuthorizationInfo doGetAuthorizationInfo(PrincipalCollection principals) { - TokenPrincipal principal = (TokenPrincipal) principals.getPrimaryPrincipal(); - SimpleAuthorizationInfo info = new SimpleAuthorizationInfo(); - info.addRole(principal.getRole()); - return info; - } - } - private static final class RecordingChain implements FilterChain { private TokenPrincipal principal; + private boolean roleChecked; @Override public void doFilter(ServletRequest request, ServletResponse response) { principal = (TokenPrincipal) request.getAttribute("__token_principal__"); + SecurityUtils.getSubject().checkRole(principal.getRole()); + roleChecked = true; } } private static final class TestableTokenFilter extends TokenFilter { - private TestableTokenFilter(RedisService redisService, ObjectMapper objectMapper) { - super(redisService, objectMapper); + private TestableTokenFilter(RedisService redisService, ObjectMapper objectMapper, + DefaultSecurityManager securityManager) { + super(redisService, objectMapper, securityManager); } private void invoke(MockHttpServletRequest request, MockHttpServletResponse response, FilterChain chain)