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:
@@ -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
|
||||
);
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user