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

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