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:
wh
2026-04-09 15:16:49 +08:00
parent b5f35a7414
commit a28fecd16a
16 changed files with 805 additions and 85 deletions

View File

@@ -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 注册 TokenFilterjakarta.servlet
* 替代 Shiro 的 ShiroFilterFactoryBeanjavax.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;
}
}

View File

@@ -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 的 OncePerRequestFilterjakarta.servlet避免与 Shiro 1.x
* 的 PathMatchingFilterjavax.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();
}
}