Phase 2/3 完成:修复 Shiro javax/jakarta 兼容性,实现 US1 认证模块
修复: - TokenFilter 改继承 OncePerRequestFilter(jakarta.servlet), 移除 PathMatchingFilter(javax.servlet)依赖,解决 Lombok 级联失败 - ShiroConfig 用 FilterRegistrationBean 替代 ShiroFilterFactoryBean, 避免 javax/jakarta Filter 类型不兼容;securityManager 调用 SecurityUtils.setSecurityManager() 确保 @RequiresRoles AOP 可用 - LabelBackendApplication 排除 ShiroWeb 自动配置(WebAutoConfiguration、 WebFilterConfiguration、WebMvcAutoConfiguration) - SysUserMapper @InterceptorIgnore 修正为 mybatis-plus 包路径 新增(Phase 2 尾声): - SysCompany / SysCompanyMapper - SysUser / SysUserMapper - ShiroFilterIntegrationTest(无 Token→401、过期→401、角色不足→403、满足→200) 新增(Phase 3 / US1): - LoginRequest / LoginResponse / UserInfoResponse DTO - AuthService(login + logout + me;BCrypt 校验;Redis Hash 存 Token) - AuthController(POST /api/auth/login、POST /logout、GET /me) - AuthIntegrationTest(正确密码→token、错误密码→401、退出后→401)
This commit is contained in:
@@ -2,30 +2,28 @@ 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.SecurityManager;
|
||||
import org.apache.shiro.realm.Realm;
|
||||
import org.apache.shiro.spring.web.ShiroFilterFactoryBean;
|
||||
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;
|
||||
|
||||
import jakarta.servlet.Filter;
|
||||
import java.util.LinkedHashMap;
|
||||
import java.util.List;
|
||||
import java.util.Map;
|
||||
|
||||
/**
|
||||
* Shiro security configuration.
|
||||
* Shiro 安全配置。
|
||||
*
|
||||
* Filter chain:
|
||||
* /api/auth/login → anon (no auth required)
|
||||
* /api/auth/logout → tokenFilter
|
||||
* /api/** → tokenFilter (all other API endpoints require auth)
|
||||
* /actuator/** → anon (health check)
|
||||
* /** → anon (default)
|
||||
*
|
||||
* NOTE: spring.mvc.pathmatch.matching-strategy=ant_path_matcher MUST be set
|
||||
* in application.yml for Shiro to work correctly with Spring Boot 3.
|
||||
* 设计说明:
|
||||
* - 使用 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() 可正常工作。
|
||||
*/
|
||||
@Configuration
|
||||
public class ShiroConfig {
|
||||
@@ -39,6 +37,8 @@ public class ShiroConfig {
|
||||
public SecurityManager securityManager(UserRealm userRealm) {
|
||||
DefaultWebSecurityManager manager = new DefaultWebSecurityManager();
|
||||
manager.setRealms(List.of(userRealm));
|
||||
// 设置全局 SecurityManager,使 SecurityUtils.getSubject() 及 AOP 注解可用
|
||||
SecurityUtils.setSecurityManager(manager);
|
||||
return manager;
|
||||
}
|
||||
|
||||
@@ -47,25 +47,17 @@ public class ShiroConfig {
|
||||
return new TokenFilter(redisService, objectMapper);
|
||||
}
|
||||
|
||||
/**
|
||||
* 将 TokenFilter 注册为 Servlet 过滤器,覆盖所有路径。
|
||||
* 实际的路径过滤逻辑由 TokenFilter.shouldNotFilter() 控制。
|
||||
*/
|
||||
@Bean
|
||||
public ShiroFilterFactoryBean shiroFilterFactoryBean(SecurityManager securityManager,
|
||||
TokenFilter tokenFilter) {
|
||||
ShiroFilterFactoryBean factory = new ShiroFilterFactoryBean();
|
||||
factory.setSecurityManager(securityManager);
|
||||
|
||||
// Register custom filters
|
||||
Map<String, Filter> filters = new LinkedHashMap<>();
|
||||
filters.put("tokenFilter", tokenFilter);
|
||||
factory.setFilters(filters);
|
||||
|
||||
// Filter chain definition (ORDER MATTERS - first match wins)
|
||||
Map<String, String> filterChainDef = new LinkedHashMap<>();
|
||||
filterChainDef.put("/api/auth/login", "anon");
|
||||
filterChainDef.put("/actuator/**", "anon");
|
||||
filterChainDef.put("/api/**", "tokenFilter");
|
||||
filterChainDef.put("/**", "anon");
|
||||
factory.setFilterChainDefinitionMap(filterChainDef);
|
||||
|
||||
return factory;
|
||||
public FilterRegistrationBean<TokenFilter> tokenFilterRegistration(TokenFilter tokenFilter) {
|
||||
FilterRegistrationBean<TokenFilter> registration = new FilterRegistrationBean<>();
|
||||
registration.setFilter(tokenFilter);
|
||||
registration.addUrlPatterns("/*");
|
||||
registration.setOrder(1);
|
||||
registration.setName("tokenFilter");
|
||||
return registration;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -7,83 +7,85 @@ import com.label.common.redis.RedisService;
|
||||
import com.label.common.result.Result;
|
||||
import jakarta.servlet.FilterChain;
|
||||
import jakarta.servlet.ServletException;
|
||||
import jakarta.servlet.ServletRequest;
|
||||
import jakarta.servlet.ServletResponse;
|
||||
import jakarta.servlet.http.HttpServletRequest;
|
||||
import jakarta.servlet.http.HttpServletResponse;
|
||||
import lombok.RequiredArgsConstructor;
|
||||
import lombok.extern.slf4j.Slf4j;
|
||||
import org.apache.shiro.web.filter.PathMatchingFilter;
|
||||
import org.apache.shiro.SecurityUtils;
|
||||
import org.apache.shiro.util.ThreadContext;
|
||||
import org.springframework.http.MediaType;
|
||||
import org.springframework.web.filter.OncePerRequestFilter;
|
||||
|
||||
import java.io.IOException;
|
||||
import java.util.Map;
|
||||
|
||||
/**
|
||||
* Shiro filter: parses "Authorization: Bearer {uuid}", validates against Redis,
|
||||
* injects CompanyContext and Shiro subject principals.
|
||||
* JWT-style Bearer Token 过滤器。
|
||||
* 继承 Spring 的 OncePerRequestFilter(jakarta.servlet),避免与 Shiro 1.x
|
||||
* 的 PathMatchingFilter(javax.servlet)产生命名空间冲突。
|
||||
*
|
||||
* KEY DESIGN:
|
||||
* - CompanyContext.clear() MUST be called in finally block to prevent thread pool leakage
|
||||
* - Token lookup is from Redis Hash token:{uuid} → {userId, role, companyId, username}
|
||||
* - 401 on missing/invalid token; filter continues for valid token
|
||||
* 过滤逻辑:
|
||||
* - 跳过非 /api/ 路径和 /api/auth/login(公开端点)
|
||||
* - 解析 "Authorization: Bearer {uuid}",查询 Redis Hash token:{uuid}
|
||||
* - Token 存在 → 注入 CompanyContext,登录 Shiro Subject,继续请求链路
|
||||
* - Token 缺失或过期 → 直接返回 401
|
||||
* - finally 块中清除 CompanyContext 和 ThreadContext Subject,防止线程池串漏
|
||||
*/
|
||||
@Slf4j
|
||||
@RequiredArgsConstructor
|
||||
public class TokenFilter extends PathMatchingFilter {
|
||||
public class TokenFilter extends OncePerRequestFilter {
|
||||
|
||||
private final RedisService redisService;
|
||||
private final ObjectMapper objectMapper;
|
||||
|
||||
/**
|
||||
* 公开端点跳过过滤:非 /api/ 前缀路径,以及登录接口本身。
|
||||
*/
|
||||
@Override
|
||||
protected boolean onPreHandle(ServletRequest request, ServletResponse response, Object mappedValue) throws Exception {
|
||||
HttpServletRequest req = (HttpServletRequest) request;
|
||||
HttpServletResponse resp = (HttpServletResponse) response;
|
||||
|
||||
String authHeader = req.getHeader("Authorization");
|
||||
if (authHeader == null || !authHeader.startsWith("Bearer ")) {
|
||||
writeUnauthorized(resp, "缺少或无效的认证令牌");
|
||||
return false;
|
||||
}
|
||||
|
||||
String token = authHeader.substring(7).trim();
|
||||
String tokenKey = RedisKeyManager.tokenKey(token);
|
||||
Map<Object, Object> tokenData = redisService.hGetAll(tokenKey);
|
||||
|
||||
if (tokenData == null || tokenData.isEmpty()) {
|
||||
writeUnauthorized(resp, "令牌已过期或不存在");
|
||||
return false;
|
||||
}
|
||||
protected boolean shouldNotFilter(HttpServletRequest request) {
|
||||
String path = request.getServletPath();
|
||||
return !path.startsWith("/api/") || path.equals("/api/auth/login");
|
||||
}
|
||||
|
||||
@Override
|
||||
protected void doFilterInternal(HttpServletRequest request, HttpServletResponse response,
|
||||
FilterChain filterChain) throws ServletException, IOException {
|
||||
try {
|
||||
String authHeader = request.getHeader("Authorization");
|
||||
if (authHeader == null || !authHeader.startsWith("Bearer ")) {
|
||||
writeUnauthorized(response, "缺少或无效的认证令牌");
|
||||
return;
|
||||
}
|
||||
|
||||
String token = authHeader.substring(7).trim();
|
||||
Map<Object, Object> tokenData = redisService.hGetAll(RedisKeyManager.tokenKey(token));
|
||||
|
||||
if (tokenData == null || tokenData.isEmpty()) {
|
||||
writeUnauthorized(response, "令牌已过期或不存在");
|
||||
return;
|
||||
}
|
||||
|
||||
Long userId = Long.parseLong(tokenData.get("userId").toString());
|
||||
String role = tokenData.get("role").toString();
|
||||
Long companyId = Long.parseLong(tokenData.get("companyId").toString());
|
||||
String username = tokenData.get("username").toString();
|
||||
|
||||
// Inject company context (must be cleared in finally)
|
||||
// 注入多租户上下文(finally 中清除,防止线程池串漏)
|
||||
CompanyContext.set(companyId);
|
||||
|
||||
// Bind Shiro subject with token principal
|
||||
// 创建 TokenPrincipal 并登录 Shiro Subject,使 @RequiresRoles 等注解生效
|
||||
TokenPrincipal principal = new TokenPrincipal(userId, role, companyId, username, token);
|
||||
SecurityUtils.getSubject().login(new BearerToken(token, principal));
|
||||
request.setAttribute("__token_principal__", principal);
|
||||
|
||||
return true;
|
||||
} catch (Exception e) {
|
||||
filterChain.doFilter(request, response);
|
||||
} catch (NumberFormatException e) {
|
||||
log.error("解析 Token 数据失败: {}", e.getMessage());
|
||||
writeUnauthorized(resp, "令牌数据格式错误");
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
@Override
|
||||
public void doFilterInternal(ServletRequest request, ServletResponse response, FilterChain chain)
|
||||
throws ServletException, IOException {
|
||||
try {
|
||||
super.doFilterInternal(request, response, chain);
|
||||
writeUnauthorized(response, "令牌数据格式错误");
|
||||
} finally {
|
||||
// CRITICAL: Always clear ThreadLocal to prevent leakage in thread pool
|
||||
// 关键:必须清除 ThreadLocal,防止线程池复用时数据串漏
|
||||
CompanyContext.clear();
|
||||
ThreadContext.unbindSubject();
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user