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

@@ -3,7 +3,19 @@ package com.label;
import org.springframework.boot.SpringApplication;
import org.springframework.boot.autoconfigure.SpringBootApplication;
@SpringBootApplication
/**
* 应用入口。
*
* 排除 Shiro Web 自动配置ShiroWebAutoConfiguration、ShiroWebFilterConfiguration、
* ShiroWebMvcAutoConfiguration避免其依赖的 ShiroFilterjavax.servlet.Filter
* 与 Spring Boot 3.x 的 jakarta.servlet 命名空间冲突。
* 认证/授权逻辑改由 TokenFilterOncePerRequestFilter+ ShiroConfig 手动装配。
*/
@SpringBootApplication(excludeName = {
"org.apache.shiro.spring.config.web.autoconfigure.ShiroWebAutoConfiguration",
"org.apache.shiro.spring.config.web.autoconfigure.ShiroWebFilterConfiguration",
"org.apache.shiro.spring.config.web.autoconfigure.ShiroWebMvcAutoConfiguration"
})
public class LabelBackendApplication {
public static void main(String[] args) {

View File

@@ -2,6 +2,8 @@ package com.label.common.exception;
import com.label.common.result.Result;
import lombok.extern.slf4j.Slf4j;
import org.apache.shiro.authz.AuthorizationException;
import org.springframework.http.HttpStatus;
import org.springframework.http.ResponseEntity;
import org.springframework.web.bind.annotation.ExceptionHandler;
import org.springframework.web.bind.annotation.RestControllerAdvice;
@@ -18,6 +20,17 @@ public class GlobalExceptionHandler {
.body(Result.failure(e.getCode(), e.getMessage()));
}
/**
* 处理 Shiro 权限不足异常(@RequiresRoles / subject.checkRole() 抛出)→ 403
*/
@ExceptionHandler(AuthorizationException.class)
public ResponseEntity<Result<?>> handleAuthorizationException(AuthorizationException e) {
log.warn("权限不足: {}", e.getMessage());
return ResponseEntity
.status(HttpStatus.FORBIDDEN)
.body(Result.failure("FORBIDDEN", "权限不足"));
}
@ExceptionHandler(Exception.class)
public ResponseEntity<Result<?>> handleException(Exception e) {
log.error("系统异常", e);

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();
}
}

View File

@@ -0,0 +1,64 @@
package com.label.module.user.controller;
import com.label.common.result.Result;
import com.label.common.shiro.TokenPrincipal;
import com.label.module.user.dto.LoginRequest;
import com.label.module.user.dto.LoginResponse;
import com.label.module.user.dto.UserInfoResponse;
import com.label.module.user.service.AuthService;
import jakarta.servlet.http.HttpServletRequest;
import lombok.RequiredArgsConstructor;
import org.springframework.web.bind.annotation.*;
/**
* 认证接口:登录、退出、获取当前用户。
*
* 路由设计:
* - POST /api/auth/login → 匿名TokenFilter.shouldNotFilter 跳过)
* - POST /api/auth/logout → 需要有效 TokenTokenFilter 校验)
* - GET /api/auth/me → 需要有效 TokenTokenFilter 校验)
*/
@RestController
@RequestMapping("/api/auth")
@RequiredArgsConstructor
public class AuthController {
private final AuthService authService;
/**
* 登录接口(匿名,无需 Token
*/
@PostMapping("/login")
public Result<LoginResponse> login(@RequestBody LoginRequest request) {
return Result.success(authService.login(request));
}
/**
* 退出登录,立即删除 Redis Token。
*/
@PostMapping("/logout")
public Result<Void> logout(HttpServletRequest request) {
String token = extractToken(request);
authService.logout(token);
return Result.success(null);
}
/**
* 获取当前登录用户信息。
* TokenPrincipal 由 TokenFilter 写入请求属性 "__token_principal__"。
*/
@GetMapping("/me")
public Result<UserInfoResponse> me(HttpServletRequest request) {
TokenPrincipal principal = (TokenPrincipal) request.getAttribute("__token_principal__");
return Result.success(authService.me(principal));
}
/** 从 Authorization 头提取 Bearer token 字符串 */
private String extractToken(HttpServletRequest request) {
String authHeader = request.getHeader("Authorization");
if (authHeader != null && authHeader.startsWith("Bearer ")) {
return authHeader.substring(7).trim();
}
return null;
}
}

View File

@@ -0,0 +1,16 @@
package com.label.module.user.dto;
import lombok.Data;
/**
* 登录请求体。
*/
@Data
public class LoginRequest {
/** 公司代码(英文简写),用于确定租户 */
private String companyCode;
/** 登录用户名 */
private String username;
/** 明文密码(传输层应使用 HTTPS 保护) */
private String password;
}

View File

@@ -0,0 +1,22 @@
package com.label.module.user.dto;
import lombok.AllArgsConstructor;
import lombok.Data;
/**
* 登录成功响应体。
*/
@Data
@AllArgsConstructor
public class LoginResponse {
/** Bearer TokenUUID v4后续请求放入 Authorization 头 */
private String token;
/** 用户主键 */
private Long userId;
/** 登录用户名 */
private String username;
/** 角色UPLOADER / ANNOTATOR / REVIEWER / ADMIN */
private String role;
/** Token 有效期(秒) */
private Long expiresIn;
}

View File

@@ -0,0 +1,18 @@
package com.label.module.user.dto;
import lombok.AllArgsConstructor;
import lombok.Data;
/**
* GET /api/auth/me 响应体,包含当前登录用户的详细信息。
*/
@Data
@AllArgsConstructor
public class UserInfoResponse {
private Long id;
private String username;
private String realName;
private String role;
private Long companyId;
private String companyName;
}

View File

@@ -0,0 +1,34 @@
package com.label.module.user.entity;
import com.baomidou.mybatisplus.annotation.IdType;
import com.baomidou.mybatisplus.annotation.TableId;
import com.baomidou.mybatisplus.annotation.TableName;
import lombok.Data;
import java.time.LocalDateTime;
/**
* 租户公司实体,对应 sys_company 表。
* status 取值ACTIVE / DISABLED
*/
@Data
@TableName("sys_company")
public class SysCompany {
/** 公司主键,自增 */
@TableId(type = IdType.AUTO)
private Long id;
/** 公司全称,全局唯一 */
private String companyName;
/** 公司代码(英文简写),全局唯一 */
private String companyCode;
/** 状态ACTIVE / DISABLED */
private String status;
private LocalDateTime createdAt;
private LocalDateTime updatedAt;
}

View File

@@ -0,0 +1,49 @@
package com.label.module.user.entity;
import com.baomidou.mybatisplus.annotation.IdType;
import com.baomidou.mybatisplus.annotation.TableId;
import com.baomidou.mybatisplus.annotation.TableName;
import com.fasterxml.jackson.annotation.JsonIgnore;
import lombok.Data;
import java.time.LocalDateTime;
/**
* 系统用户实体,对应 sys_user 表。
* role 取值UPLOADER / ANNOTATOR / REVIEWER / ADMIN
* status 取值ACTIVE / DISABLED
*/
@Data
@TableName("sys_user")
public class SysUser {
/** 用户主键,自增 */
@TableId(type = IdType.AUTO)
private Long id;
/** 所属公司 ID多租户键 */
private Long companyId;
/** 登录用户名(同公司内唯一) */
private String username;
/**
* BCrypt 哈希密码strength ≥ 10
* 序列化时排除,防止密码哈希泄漏到 API 响应。
*/
@JsonIgnore
private String passwordHash;
/** 真实姓名 */
private String realName;
/** 角色UPLOADER / ANNOTATOR / REVIEWER / ADMIN */
private String role;
/** 状态ACTIVE / DISABLED */
private String status;
private LocalDateTime createdAt;
private LocalDateTime updatedAt;
}

View File

@@ -0,0 +1,23 @@
package com.label.module.user.mapper;
import com.baomidou.mybatisplus.core.mapper.BaseMapper;
import com.label.module.user.entity.SysCompany;
import org.apache.ibatis.annotations.Mapper;
import org.apache.ibatis.annotations.Select;
/**
* sys_company 表 Mapper。
* 继承 BaseMapper 获得标准 CRUD自定义方法用注解 SQL。
*/
@Mapper
public interface SysCompanyMapper extends BaseMapper<SysCompany> {
/**
* 按公司代码查询公司忽略多租户过滤sys_company 无 company_id 字段)。
*
* @param companyCode 公司代码
* @return 公司实体,不存在则返回 null
*/
@Select("SELECT * FROM sys_company WHERE company_code = #{companyCode}")
SysCompany selectByCompanyCode(String companyCode);
}

View File

@@ -0,0 +1,34 @@
package com.label.module.user.mapper;
import com.baomidou.mybatisplus.annotation.InterceptorIgnore;
import com.baomidou.mybatisplus.core.mapper.BaseMapper;
import com.label.module.user.entity.SysUser;
import org.apache.ibatis.annotations.Mapper;
import org.apache.ibatis.annotations.Param;
import org.apache.ibatis.annotations.Select;
/**
* sys_user 表 Mapper。
* 继承 BaseMapper 获得标准 CRUD自定义登录查询方法绕过多租户过滤器
* 由调用方显式传入 companyId。
*/
@Mapper
public interface SysUserMapper extends BaseMapper<SysUser> {
/**
* 按公司 ID + 用户名查询用户(登录场景使用)。
* <p>
* 使用 @InterceptorIgnore 绕过 TenantLineInnerInterceptor
* 由参数 companyId 显式限定租户,防止登录时 CompanyContext 尚未注入
* 导致查询条件变为 {@code company_id = NULL}。
* </p>
*
* @param companyId 公司 ID
* @param username 用户名
* @return 用户实体(含 passwordHash不存在则返回 null
*/
@InterceptorIgnore(tenantLine = "true")
@Select("SELECT * FROM sys_user WHERE company_id = #{companyId} AND username = #{username} AND status = 'ACTIVE'")
SysUser selectByCompanyAndUsername(@Param("companyId") Long companyId,
@Param("username") String username);
}

View File

@@ -0,0 +1,127 @@
package com.label.module.user.service;
import com.label.common.exception.BusinessException;
import com.label.common.redis.RedisKeyManager;
import com.label.common.redis.RedisService;
import com.label.common.shiro.TokenPrincipal;
import com.label.module.user.dto.LoginRequest;
import com.label.module.user.dto.LoginResponse;
import com.label.module.user.dto.UserInfoResponse;
import com.label.module.user.entity.SysCompany;
import com.label.module.user.entity.SysUser;
import com.label.module.user.mapper.SysCompanyMapper;
import com.label.module.user.mapper.SysUserMapper;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import org.springframework.beans.factory.annotation.Value;
import org.springframework.http.HttpStatus;
import org.springframework.security.crypto.bcrypt.BCryptPasswordEncoder;
import org.springframework.stereotype.Service;
import java.util.HashMap;
import java.util.Map;
import java.util.UUID;
/**
* 认证服务:登录、退出、查询当前用户信息。
*
* Token 生命周期:
* - 登录成功 → UUID v4 → Redis Hash token:{uuid} → TTL = token.ttl-seconds
* - 退出登录 → 直接 DEL token:{uuid}(立即失效)
*/
@Slf4j
@Service
@RequiredArgsConstructor
public class AuthService {
private final SysCompanyMapper companyMapper;
private final SysUserMapper userMapper;
private final RedisService redisService;
/** BCryptPasswordEncoder 线程安全,可复用 */
private static final BCryptPasswordEncoder PASSWORD_ENCODER = new BCryptPasswordEncoder(10);
@Value("${token.ttl-seconds:7200}")
private long tokenTtlSeconds;
/**
* 用户登录。
*
* @param request 包含 companyCode / username / password
* @return LoginResponse含 token、userId、role、expiresIn
* @throws BusinessException USER_NOT_FOUND(401) 凭证错误
* @throws BusinessException USER_DISABLED(403) 账号已禁用
*/
public LoginResponse login(LoginRequest request) {
// 1. 查公司绕过多租户过滤器sys_company 无 company_id 字段)
SysCompany company = companyMapper.selectByCompanyCode(request.getCompanyCode());
if (company == null || !"ACTIVE".equals(company.getStatus())) {
// 公司不存在或禁用,统一报 USER_NOT_FOUND 防止信息泄漏
throw new BusinessException("USER_NOT_FOUND", "用户名或密码错误", HttpStatus.UNAUTHORIZED);
}
// 2. 查用户(显式传入 companyId绕过多租户拦截器
SysUser user = userMapper.selectByCompanyAndUsername(company.getId(), request.getUsername());
if (user == null) {
throw new BusinessException("USER_NOT_FOUND", "用户名或密码错误", HttpStatus.UNAUTHORIZED);
}
// 3. 账号禁用检查(先于密码校验,防止暴力破解已知用户状态)
if (!"ACTIVE".equals(user.getStatus())) {
throw new BusinessException("USER_DISABLED", "账号已禁用,请联系管理员", HttpStatus.FORBIDDEN);
}
// 4. BCrypt 密码校验
if (!PASSWORD_ENCODER.matches(request.getPassword(), user.getPasswordHash())) {
throw new BusinessException("USER_NOT_FOUND", "用户名或密码错误", HttpStatus.UNAUTHORIZED);
}
// 5. 生成 UUID v4 Token写入 Redis Hash
String token = UUID.randomUUID().toString();
Map<String, String> tokenData = new HashMap<>();
tokenData.put("userId", user.getId().toString());
tokenData.put("role", user.getRole());
tokenData.put("companyId", user.getCompanyId().toString());
tokenData.put("username", user.getUsername());
redisService.hSetAll(RedisKeyManager.tokenKey(token), tokenData, tokenTtlSeconds);
log.debug("用户登录成功: companyCode={}, username={}", request.getCompanyCode(), request.getUsername());
return new LoginResponse(token, user.getId(), user.getUsername(), user.getRole(), tokenTtlSeconds);
}
/**
* 退出登录,立即删除 Redis TokenToken 立即失效)。
*
* @param token 来自 Authorization 头的 Bearer token
*/
public void logout(String token) {
if (token != null && !token.isBlank()) {
redisService.delete(RedisKeyManager.tokenKey(token));
log.debug("用户退出Token 已删除: {}", token);
}
}
/**
* 获取当前登录用户详情(含 realName、companyName
*
* @param principal TokenFilter 注入的当前用户主体
* @return 用户信息响应体
*/
public UserInfoResponse me(TokenPrincipal principal) {
// 从 DB 获取 realNameToken 中未存储)
SysUser user = userMapper.selectById(principal.getUserId());
SysCompany company = companyMapper.selectById(principal.getCompanyId());
String realName = (user != null) ? user.getRealName() : principal.getUsername();
String companyName = (company != null) ? company.getCompanyName() : "";
return new UserInfoResponse(
principal.getUserId(),
principal.getUsername(),
realName,
principal.getRole(),
principal.getCompanyId(),
companyName
);
}
}

View File

@@ -53,13 +53,8 @@ ai-service:
base-url: ${AI_SERVICE_BASE_URL:http://localhost:8000}
timeout: 30000 # milliseconds
shiro:
loginUrl: /api/auth/login
successUrl: /
unauthorizedUrl: /api/auth/unauthorized
sessionManager:
sessionIdCookieEnabled: false # REST API不使用基于 Cookie 的会话
sessionIdUrlRewritingEnabled: false
token:
ttl-seconds: 7200 # Token 默认有效期(秒),与 sys_config token_ttl_seconds 保持一致
logging:
level:

View File

@@ -0,0 +1,160 @@
package com.label.integration;
import com.label.AbstractIntegrationTest;
import com.label.common.result.Result;
import com.label.module.user.dto.LoginRequest;
import org.junit.jupiter.api.DisplayName;
import org.junit.jupiter.api.Test;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.boot.test.web.client.TestRestTemplate;
import org.springframework.core.ParameterizedTypeReference;
import org.springframework.http.*;
import java.util.Map;
import static org.assertj.core.api.Assertions.assertThat;
/**
* 认证流程集成测试US1
*
* 测试场景:
* 1. 正确密码登录 → 返回 token
* 2. 错误密码登录 → 401
* 3. 不存在的公司代码 → 401
* 4. 有效 Token 访问 /api/auth/me → 200返回用户信息
* 5. 主动退出后,原 Token 访问 /api/auth/me → 401
*
* 测试数据来自 init.sql 种子DEMO 公司 / admin / admin123
*/
public class AuthIntegrationTest extends AbstractIntegrationTest {
@Autowired
private TestRestTemplate restTemplate;
// ------------------------------------------------------------------ 登录测试 --
@Test
@DisplayName("正确密码登录 → 返回 token")
void login_withCorrectCredentials_returnsToken() {
ResponseEntity<Map> response = doLogin("DEMO", "admin", "admin123");
assertThat(response.getStatusCode()).isEqualTo(HttpStatus.OK);
Map<?, ?> body = response.getBody();
assertThat(body).isNotNull();
assertThat(body.get("code")).isEqualTo("SUCCESS");
@SuppressWarnings("unchecked")
Map<String, Object> data = (Map<String, Object>) body.get("data");
assertThat(data.get("token")).isNotNull().isInstanceOf(String.class);
assertThat((String) data.get("token")).isNotBlank();
assertThat(data.get("role")).isEqualTo("ADMIN");
}
@Test
@DisplayName("错误密码登录 → 401 Unauthorized")
void login_withWrongPassword_returns401() {
ResponseEntity<Map> response = doLogin("DEMO", "admin", "wrong_password");
assertThat(response.getStatusCode()).isEqualTo(HttpStatus.UNAUTHORIZED);
}
@Test
@DisplayName("不存在的公司代码 → 401 Unauthorized")
void login_withUnknownCompany_returns401() {
ResponseEntity<Map> response = doLogin("NONEXIST", "admin", "admin123");
assertThat(response.getStatusCode()).isEqualTo(HttpStatus.UNAUTHORIZED);
}
// ------------------------------------------------------------------ /me 测试 --
@Test
@DisplayName("有效 Token 访问 /api/auth/me → 200返回用户信息")
void me_withValidToken_returns200WithUserInfo() {
String token = loginAndGetToken("DEMO", "admin", "admin123");
assertThat(token).isNotBlank();
ResponseEntity<Map> response = restTemplate.exchange(
baseUrl("/api/auth/me"),
HttpMethod.GET,
bearerRequest(token),
Map.class);
assertThat(response.getStatusCode()).isEqualTo(HttpStatus.OK);
@SuppressWarnings("unchecked")
Map<String, Object> data = (Map<String, Object>) response.getBody().get("data");
assertThat(data.get("username")).isEqualTo("admin");
assertThat(data.get("role")).isEqualTo("ADMIN");
assertThat(data.get("companyId")).isNotNull();
}
@Test
@DisplayName("无 Token 访问 /api/auth/me → 401")
void me_withNoToken_returns401() {
ResponseEntity<String> response = restTemplate.getForEntity(
baseUrl("/api/auth/me"), String.class);
assertThat(response.getStatusCode()).isEqualTo(HttpStatus.UNAUTHORIZED);
}
// ------------------------------------------------------------------ 退出测试 --
@Test
@DisplayName("主动退出后,原 Token 访问 /api/auth/me → 401")
void logout_thenMe_returns401() {
String token = loginAndGetToken("DEMO", "admin", "admin123");
assertThat(token).isNotBlank();
// 确认登录有效
ResponseEntity<Map> meResponse = restTemplate.exchange(
baseUrl("/api/auth/me"),
HttpMethod.GET,
bearerRequest(token),
Map.class);
assertThat(meResponse.getStatusCode()).isEqualTo(HttpStatus.OK);
// 退出
ResponseEntity<Map> logoutResponse = restTemplate.exchange(
baseUrl("/api/auth/logout"),
HttpMethod.POST,
bearerRequest(token),
Map.class);
assertThat(logoutResponse.getStatusCode()).isEqualTo(HttpStatus.OK);
// 退出后再访问 /me → 401
ResponseEntity<Map> meAfterLogout = restTemplate.exchange(
baseUrl("/api/auth/me"),
HttpMethod.GET,
bearerRequest(token),
Map.class);
assertThat(meAfterLogout.getStatusCode()).isEqualTo(HttpStatus.UNAUTHORIZED);
}
// ------------------------------------------------------------------ 工具方法 --
/** 发起登录请求,返回原始 ResponseEntity */
private ResponseEntity<Map> doLogin(String companyCode, String username, String password) {
LoginRequest req = new LoginRequest();
req.setCompanyCode(companyCode);
req.setUsername(username);
req.setPassword(password);
return restTemplate.postForEntity(baseUrl("/api/auth/login"), req, Map.class);
}
/** 登录并提取 token 字符串;失败时返回 null */
private String loginAndGetToken(String companyCode, String username, String password) {
ResponseEntity<Map> response = doLogin(companyCode, username, password);
if (!response.getStatusCode().is2xxSuccessful()) {
return null;
}
@SuppressWarnings("unchecked")
Map<String, Object> data = (Map<String, Object>) response.getBody().get("data");
return (String) data.get("token");
}
/** 构造带 Bearer Token 的请求实体(无 body */
private HttpEntity<Void> bearerRequest(String token) {
HttpHeaders headers = new HttpHeaders();
headers.set("Authorization", "Bearer " + token);
return new HttpEntity<>(headers);
}
}

View File

@@ -0,0 +1,159 @@
package com.label.integration;
import com.label.AbstractIntegrationTest;
import com.label.common.redis.RedisKeyManager;
import com.label.common.redis.RedisService;
import com.label.common.result.Result;
import org.apache.shiro.SecurityUtils;
import org.junit.jupiter.api.AfterEach;
import org.junit.jupiter.api.BeforeEach;
import org.junit.jupiter.api.DisplayName;
import org.junit.jupiter.api.Test;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.boot.test.context.TestConfiguration;
import org.springframework.boot.test.web.client.TestRestTemplate;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Import;
import org.springframework.http.HttpEntity;
import org.springframework.http.HttpHeaders;
import org.springframework.http.HttpMethod;
import org.springframework.http.HttpStatus;
import org.springframework.http.ResponseEntity;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.RestController;
import java.util.HashMap;
import java.util.Map;
import static org.assertj.core.api.Assertions.assertThat;
/**
* Shiro 过滤器集成测试:
* - 无 Token → 401 Unauthorized
* - Token 不存在(已过期或伪造)→ 401 Unauthorized
* - 有效 Token 但角色不足ANNOTATOR 访问 REVIEWER 端点)→ 403 Forbidden
* - 有效 Token 且角色满足REVIEWER 访问 REVIEWER 端点)→ 200 OK
*/
@Import(ShiroFilterIntegrationTest.TestConfig.class)
public class ShiroFilterIntegrationTest extends AbstractIntegrationTest {
/** 仅供测试的临时 Token测试结束后清理 */
private static final String REVIEWER_TOKEN = "test-reviewer-token-uuid-fixed";
private static final String ANNOTATOR_TOKEN = "test-annotator-token-uuid-fixed";
@Autowired
private TestRestTemplate restTemplate;
@Autowired
private RedisService redisService;
// ------------------------------------------------------------------ 测试 Controller --
/**
* 测试专用配置:注册仅在测试环境存在的端点
*/
@TestConfiguration
static class TestConfig {
@Bean
public ReviewerOnlyController reviewerOnlyController() {
return new ReviewerOnlyController();
}
}
/**
* 需要 REVIEWER 角色的测试端点。
* 调用 subject.checkRole() —— 角色不足时抛出 AuthorizationException → 403。
*/
@RestController
static class ReviewerOnlyController {
@GetMapping("/api/test/reviewer-only")
public Result<String> reviewerOnly() {
// 验证当前 Subject 是否持有 REVIEWER 角色
SecurityUtils.getSubject().checkRole("REVIEWER");
return Result.success("ok");
}
}
// ------------------------------------------------------------------ 测试前后置 --
@BeforeEach
void setupTokens() {
// REVIEWER TokencompanyId=1, userId=2
Map<String, String> reviewerData = new HashMap<>();
reviewerData.put("userId", "2");
reviewerData.put("role", "REVIEWER");
reviewerData.put("companyId", "1");
reviewerData.put("username", "reviewer01");
redisService.hSetAll(RedisKeyManager.tokenKey(REVIEWER_TOKEN), reviewerData, 3600L);
// ANNOTATOR TokencompanyId=1, userId=3
Map<String, String> annotatorData = new HashMap<>();
annotatorData.put("userId", "3");
annotatorData.put("role", "ANNOTATOR");
annotatorData.put("companyId", "1");
annotatorData.put("username", "annotator01");
redisService.hSetAll(RedisKeyManager.tokenKey(ANNOTATOR_TOKEN), annotatorData, 3600L);
}
@AfterEach
void cleanupTokens() {
redisService.delete(RedisKeyManager.tokenKey(REVIEWER_TOKEN));
redisService.delete(RedisKeyManager.tokenKey(ANNOTATOR_TOKEN));
}
// ------------------------------------------------------------------ 测试用例 --
@Test
@DisplayName("无 Authorization 头 → 401 Unauthorized")
void noToken_returns401() {
ResponseEntity<String> response = restTemplate.getForEntity(
baseUrl("/api/test/reviewer-only"), String.class);
assertThat(response.getStatusCode()).isEqualTo(HttpStatus.UNAUTHORIZED);
}
@Test
@DisplayName("Token 不存在于 Redis → 401 Unauthorized")
void expiredToken_returns401() {
ResponseEntity<String> response = restTemplate.exchange(
baseUrl("/api/test/reviewer-only"),
HttpMethod.GET,
bearerRequest("non-existent-token-xyz"),
String.class);
assertThat(response.getStatusCode()).isEqualTo(HttpStatus.UNAUTHORIZED);
}
@Test
@DisplayName("有效 Token 但角色不足ANNOTATOR 访问 REVIEWER 端点)→ 403 Forbidden")
void annotatorToken_onReviewerEndpoint_returns403() {
ResponseEntity<String> response = restTemplate.exchange(
baseUrl("/api/test/reviewer-only"),
HttpMethod.GET,
bearerRequest(ANNOTATOR_TOKEN),
String.class);
assertThat(response.getStatusCode()).isEqualTo(HttpStatus.FORBIDDEN);
}
@Test
@DisplayName("有效 Token 且角色满足REVIEWER 访问 REVIEWER 端点)→ 200 OK")
void reviewerToken_onReviewerEndpoint_returns200() {
ResponseEntity<String> response = restTemplate.exchange(
baseUrl("/api/test/reviewer-only"),
HttpMethod.GET,
bearerRequest(REVIEWER_TOKEN),
String.class);
assertThat(response.getStatusCode()).isEqualTo(HttpStatus.OK);
}
// ------------------------------------------------------------------ 工具方法 --
/** 构造带 Bearer Token 的请求实体 */
private HttpEntity<Void> bearerRequest(String token) {
HttpHeaders headers = new HttpHeaders();
headers.set("Authorization", "Bearer " + token);
return new HttpEntity<>(headers);
}
}