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:
@@ -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) {
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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;
|
||||
protected boolean shouldNotFilter(HttpServletRequest request) {
|
||||
String path = request.getServletPath();
|
||||
return !path.startsWith("/api/") || path.equals("/api/auth/login");
|
||||
}
|
||||
|
||||
String authHeader = req.getHeader("Authorization");
|
||||
@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(resp, "缺少或无效的认证令牌");
|
||||
return false;
|
||||
writeUnauthorized(response, "缺少或无效的认证令牌");
|
||||
return;
|
||||
}
|
||||
|
||||
String token = authHeader.substring(7).trim();
|
||||
String tokenKey = RedisKeyManager.tokenKey(token);
|
||||
Map<Object, Object> tokenData = redisService.hGetAll(tokenKey);
|
||||
Map<Object, Object> tokenData = redisService.hGetAll(RedisKeyManager.tokenKey(token));
|
||||
|
||||
if (tokenData == null || tokenData.isEmpty()) {
|
||||
writeUnauthorized(resp, "令牌已过期或不存在");
|
||||
return false;
|
||||
writeUnauthorized(response, "令牌已过期或不存在");
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
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();
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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<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;
|
||||
}
|
||||
}
|
||||
16
src/main/java/com/label/module/user/dto/LoginRequest.java
Normal file
16
src/main/java/com/label/module/user/dto/LoginRequest.java
Normal 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;
|
||||
}
|
||||
22
src/main/java/com/label/module/user/dto/LoginResponse.java
Normal file
22
src/main/java/com/label/module/user/dto/LoginResponse.java
Normal file
@@ -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;
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
34
src/main/java/com/label/module/user/entity/SysCompany.java
Normal file
34
src/main/java/com/label/module/user/entity/SysCompany.java
Normal 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;
|
||||
}
|
||||
49
src/main/java/com/label/module/user/entity/SysUser.java
Normal file
49
src/main/java/com/label/module/user/entity/SysUser.java
Normal 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;
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
127
src/main/java/com/label/module/user/service/AuthService.java
Normal file
127
src/main/java/com/label/module/user/service/AuthService.java
Normal 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 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
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -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:
|
||||
|
||||
160
src/test/java/com/label/integration/AuthIntegrationTest.java
Normal file
160
src/test/java/com/label/integration/AuthIntegrationTest.java
Normal 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);
|
||||
}
|
||||
}
|
||||
@@ -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 Token:companyId=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 Token:companyId=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);
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user