diff --git a/src/main/java/com/label/LabelBackendApplication.java b/src/main/java/com/label/LabelBackendApplication.java index a3650bd..5ec3012 100644 --- a/src/main/java/com/label/LabelBackendApplication.java +++ b/src/main/java/com/label/LabelBackendApplication.java @@ -3,7 +3,19 @@ package com.label; import org.springframework.boot.SpringApplication; import org.springframework.boot.autoconfigure.SpringBootApplication; -@SpringBootApplication +/** + * 应用入口。 + * + * 排除 Shiro Web 自动配置(ShiroWebAutoConfiguration、ShiroWebFilterConfiguration、 + * ShiroWebMvcAutoConfiguration),避免其依赖的 ShiroFilter(javax.servlet.Filter) + * 与 Spring Boot 3.x 的 jakarta.servlet 命名空间冲突。 + * 认证/授权逻辑改由 TokenFilter(OncePerRequestFilter)+ 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) { diff --git a/src/main/java/com/label/common/exception/GlobalExceptionHandler.java b/src/main/java/com/label/common/exception/GlobalExceptionHandler.java index 327e4e4..676fc89 100644 --- a/src/main/java/com/label/common/exception/GlobalExceptionHandler.java +++ b/src/main/java/com/label/common/exception/GlobalExceptionHandler.java @@ -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> handleAuthorizationException(AuthorizationException e) { + log.warn("权限不足: {}", e.getMessage()); + return ResponseEntity + .status(HttpStatus.FORBIDDEN) + .body(Result.failure("FORBIDDEN", "权限不足")); + } + @ExceptionHandler(Exception.class) public ResponseEntity> handleException(Exception e) { log.error("系统异常", e); diff --git a/src/main/java/com/label/common/shiro/ShiroConfig.java b/src/main/java/com/label/common/shiro/ShiroConfig.java index b199f5d..12c1f7b 100644 --- a/src/main/java/com/label/common/shiro/ShiroConfig.java +++ b/src/main/java/com/label/common/shiro/ShiroConfig.java @@ -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 filters = new LinkedHashMap<>(); - filters.put("tokenFilter", tokenFilter); - factory.setFilters(filters); - - // Filter chain definition (ORDER MATTERS - first match wins) - Map 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 tokenFilterRegistration(TokenFilter tokenFilter) { + FilterRegistrationBean registration = new FilterRegistrationBean<>(); + registration.setFilter(tokenFilter); + registration.addUrlPatterns("/*"); + registration.setOrder(1); + registration.setName("tokenFilter"); + return registration; } } diff --git a/src/main/java/com/label/common/shiro/TokenFilter.java b/src/main/java/com/label/common/shiro/TokenFilter.java index 19ce508..bd03a22 100644 --- a/src/main/java/com/label/common/shiro/TokenFilter.java +++ b/src/main/java/com/label/common/shiro/TokenFilter.java @@ -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 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 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(); } } diff --git a/src/main/java/com/label/module/user/controller/AuthController.java b/src/main/java/com/label/module/user/controller/AuthController.java new file mode 100644 index 0000000..ab06cee --- /dev/null +++ b/src/main/java/com/label/module/user/controller/AuthController.java @@ -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 → 需要有效 Token(TokenFilter 校验) + * - GET /api/auth/me → 需要有效 Token(TokenFilter 校验) + */ +@RestController +@RequestMapping("/api/auth") +@RequiredArgsConstructor +public class AuthController { + + private final AuthService authService; + + /** + * 登录接口(匿名,无需 Token)。 + */ + @PostMapping("/login") + public Result login(@RequestBody LoginRequest request) { + return Result.success(authService.login(request)); + } + + /** + * 退出登录,立即删除 Redis Token。 + */ + @PostMapping("/logout") + public Result logout(HttpServletRequest request) { + String token = extractToken(request); + authService.logout(token); + return Result.success(null); + } + + /** + * 获取当前登录用户信息。 + * TokenPrincipal 由 TokenFilter 写入请求属性 "__token_principal__"。 + */ + @GetMapping("/me") + public Result 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; + } +} diff --git a/src/main/java/com/label/module/user/dto/LoginRequest.java b/src/main/java/com/label/module/user/dto/LoginRequest.java new file mode 100644 index 0000000..9bbcc44 --- /dev/null +++ b/src/main/java/com/label/module/user/dto/LoginRequest.java @@ -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; +} diff --git a/src/main/java/com/label/module/user/dto/LoginResponse.java b/src/main/java/com/label/module/user/dto/LoginResponse.java new file mode 100644 index 0000000..6c9ccff --- /dev/null +++ b/src/main/java/com/label/module/user/dto/LoginResponse.java @@ -0,0 +1,22 @@ +package com.label.module.user.dto; + +import lombok.AllArgsConstructor; +import lombok.Data; + +/** + * 登录成功响应体。 + */ +@Data +@AllArgsConstructor +public class LoginResponse { + /** Bearer Token(UUID v4),后续请求放入 Authorization 头 */ + private String token; + /** 用户主键 */ + private Long userId; + /** 登录用户名 */ + private String username; + /** 角色:UPLOADER / ANNOTATOR / REVIEWER / ADMIN */ + private String role; + /** Token 有效期(秒) */ + private Long expiresIn; +} diff --git a/src/main/java/com/label/module/user/dto/UserInfoResponse.java b/src/main/java/com/label/module/user/dto/UserInfoResponse.java new file mode 100644 index 0000000..7173c1b --- /dev/null +++ b/src/main/java/com/label/module/user/dto/UserInfoResponse.java @@ -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; +} diff --git a/src/main/java/com/label/module/user/entity/SysCompany.java b/src/main/java/com/label/module/user/entity/SysCompany.java new file mode 100644 index 0000000..9f79582 --- /dev/null +++ b/src/main/java/com/label/module/user/entity/SysCompany.java @@ -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; +} diff --git a/src/main/java/com/label/module/user/entity/SysUser.java b/src/main/java/com/label/module/user/entity/SysUser.java new file mode 100644 index 0000000..95307f7 --- /dev/null +++ b/src/main/java/com/label/module/user/entity/SysUser.java @@ -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; +} diff --git a/src/main/java/com/label/module/user/mapper/SysCompanyMapper.java b/src/main/java/com/label/module/user/mapper/SysCompanyMapper.java new file mode 100644 index 0000000..f22b053 --- /dev/null +++ b/src/main/java/com/label/module/user/mapper/SysCompanyMapper.java @@ -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 { + + /** + * 按公司代码查询公司(忽略多租户过滤,sys_company 无 company_id 字段)。 + * + * @param companyCode 公司代码 + * @return 公司实体,不存在则返回 null + */ + @Select("SELECT * FROM sys_company WHERE company_code = #{companyCode}") + SysCompany selectByCompanyCode(String companyCode); +} diff --git a/src/main/java/com/label/module/user/mapper/SysUserMapper.java b/src/main/java/com/label/module/user/mapper/SysUserMapper.java new file mode 100644 index 0000000..fae02b5 --- /dev/null +++ b/src/main/java/com/label/module/user/mapper/SysUserMapper.java @@ -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 { + + /** + * 按公司 ID + 用户名查询用户(登录场景使用)。 + *

+ * 使用 @InterceptorIgnore 绕过 TenantLineInnerInterceptor, + * 由参数 companyId 显式限定租户,防止登录时 CompanyContext 尚未注入 + * 导致查询条件变为 {@code company_id = NULL}。 + *

+ * + * @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); +} diff --git a/src/main/java/com/label/module/user/service/AuthService.java b/src/main/java/com/label/module/user/service/AuthService.java new file mode 100644 index 0000000..d1379f3 --- /dev/null +++ b/src/main/java/com/label/module/user/service/AuthService.java @@ -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 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 Token(Token 立即失效)。 + * + * @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 获取 realName(Token 中未存储) + 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 + ); + } +} diff --git a/src/main/resources/application.yml b/src/main/resources/application.yml index 76c49bc..9f89b84 100644 --- a/src/main/resources/application.yml +++ b/src/main/resources/application.yml @@ -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: diff --git a/src/test/java/com/label/integration/AuthIntegrationTest.java b/src/test/java/com/label/integration/AuthIntegrationTest.java new file mode 100644 index 0000000..9bc8b94 --- /dev/null +++ b/src/test/java/com/label/integration/AuthIntegrationTest.java @@ -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 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 data = (Map) 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 response = doLogin("DEMO", "admin", "wrong_password"); + assertThat(response.getStatusCode()).isEqualTo(HttpStatus.UNAUTHORIZED); + } + + @Test + @DisplayName("不存在的公司代码 → 401 Unauthorized") + void login_withUnknownCompany_returns401() { + ResponseEntity 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 response = restTemplate.exchange( + baseUrl("/api/auth/me"), + HttpMethod.GET, + bearerRequest(token), + Map.class); + + assertThat(response.getStatusCode()).isEqualTo(HttpStatus.OK); + + @SuppressWarnings("unchecked") + Map data = (Map) 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 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 meResponse = restTemplate.exchange( + baseUrl("/api/auth/me"), + HttpMethod.GET, + bearerRequest(token), + Map.class); + assertThat(meResponse.getStatusCode()).isEqualTo(HttpStatus.OK); + + // 退出 + ResponseEntity logoutResponse = restTemplate.exchange( + baseUrl("/api/auth/logout"), + HttpMethod.POST, + bearerRequest(token), + Map.class); + assertThat(logoutResponse.getStatusCode()).isEqualTo(HttpStatus.OK); + + // 退出后再访问 /me → 401 + ResponseEntity meAfterLogout = restTemplate.exchange( + baseUrl("/api/auth/me"), + HttpMethod.GET, + bearerRequest(token), + Map.class); + assertThat(meAfterLogout.getStatusCode()).isEqualTo(HttpStatus.UNAUTHORIZED); + } + + // ------------------------------------------------------------------ 工具方法 -- + + /** 发起登录请求,返回原始 ResponseEntity */ + private ResponseEntity 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 response = doLogin(companyCode, username, password); + if (!response.getStatusCode().is2xxSuccessful()) { + return null; + } + @SuppressWarnings("unchecked") + Map data = (Map) response.getBody().get("data"); + return (String) data.get("token"); + } + + /** 构造带 Bearer Token 的请求实体(无 body) */ + private HttpEntity bearerRequest(String token) { + HttpHeaders headers = new HttpHeaders(); + headers.set("Authorization", "Bearer " + token); + return new HttpEntity<>(headers); + } +} diff --git a/src/test/java/com/label/integration/ShiroFilterIntegrationTest.java b/src/test/java/com/label/integration/ShiroFilterIntegrationTest.java new file mode 100644 index 0000000..1bf982d --- /dev/null +++ b/src/test/java/com/label/integration/ShiroFilterIntegrationTest.java @@ -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 reviewerOnly() { + // 验证当前 Subject 是否持有 REVIEWER 角色 + SecurityUtils.getSubject().checkRole("REVIEWER"); + return Result.success("ok"); + } + } + + // ------------------------------------------------------------------ 测试前后置 -- + + @BeforeEach + void setupTokens() { + // REVIEWER Token:companyId=1, userId=2 + Map 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 Token:companyId=1, userId=3 + Map 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 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 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 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 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 bearerRequest(String token) { + HttpHeaders headers = new HttpHeaders(); + headers.set("Authorization", "Bearer " + token); + return new HttpEntity<>(headers); + } +}