Commit 22e97160a3b2f5e33d4e0c0978e9f190113223a8

Authored by zichun
1 parent 95dcd379

feat(usr): JwtHandlerInterceptor + @RequireSuperAdmin REQ-USR-002

backend/src/main/java/com/xly/erp/common/config/WebMvcConfig.java 0 → 100644
  1 +package com.xly.erp.common.config;
  2 +
  3 +import com.xly.erp.common.security.JwtHandlerInterceptor;
  4 +import lombok.RequiredArgsConstructor;
  5 +import org.springframework.context.annotation.Configuration;
  6 +import org.springframework.web.servlet.config.annotation.InterceptorRegistry;
  7 +import org.springframework.web.servlet.config.annotation.WebMvcConfigurer;
  8 +
  9 +@Configuration
  10 +@RequiredArgsConstructor
  11 +public class WebMvcConfig implements WebMvcConfigurer {
  12 +
  13 + private final JwtHandlerInterceptor jwtInterceptor;
  14 +
  15 + @Override
  16 + public void addInterceptors(InterceptorRegistry registry) {
  17 + registry.addInterceptor(jwtInterceptor)
  18 + .addPathPatterns("/api/v1/**")
  19 + .excludePathPatterns("/api/v1/auth/login");
  20 + }
  21 +}
backend/src/main/java/com/xly/erp/common/security/JwtHandlerInterceptor.java 0 → 100644
  1 +package com.xly.erp.common.security;
  2 +
  3 +import com.xly.erp.common.exception.BizException;
  4 +import com.xly.erp.common.response.ErrorCode;
  5 +import com.xly.erp.module.usr.entity.SysUser;
  6 +import com.xly.erp.module.usr.mapper.SysUserMapper;
  7 +import jakarta.servlet.http.HttpServletRequest;
  8 +import jakarta.servlet.http.HttpServletResponse;
  9 +import lombok.RequiredArgsConstructor;
  10 +import lombok.extern.slf4j.Slf4j;
  11 +import org.springframework.stereotype.Component;
  12 +import org.springframework.web.method.HandlerMethod;
  13 +import org.springframework.web.servlet.HandlerInterceptor;
  14 +
  15 +import java.time.LocalDateTime;
  16 +import java.util.Map;
  17 +
  18 +/**
  19 + * REQ-USR-002 鉴权与角色守卫拦截器。
  20 + * - 解析 Authorization Bearer → 校验 user 状态 → set LoginContext
  21 + * - 若 handler 标注 @RequireSuperAdmin,强制 userType == SUPER_ADMIN
  22 + * - afterCompletion 清理 ThreadLocal
  23 + */
  24 +@Component
  25 +@RequiredArgsConstructor
  26 +@Slf4j
  27 +public class JwtHandlerInterceptor implements HandlerInterceptor {
  28 +
  29 + private static final String BEARER_PREFIX = "Bearer ";
  30 +
  31 + private final JwtUtil jwtUtil;
  32 + private final SysUserMapper userMapper;
  33 +
  34 + @Override
  35 + public boolean preHandle(HttpServletRequest request, HttpServletResponse response, Object handler) {
  36 + String authHeader = request.getHeader("Authorization");
  37 + if (authHeader == null || !authHeader.startsWith(BEARER_PREFIX)) {
  38 + throw new BizException(ErrorCode.BAD_CREDENTIALS, "未携带 token");
  39 + }
  40 + String token = authHeader.substring(BEARER_PREFIX.length());
  41 +
  42 + Map<String, Object> claims = jwtUtil.parse(token);
  43 + String username = (String) claims.get("username");
  44 + if (username == null || username.isBlank()) {
  45 + throw new BizException(ErrorCode.BAD_CREDENTIALS, "token 缺 username claim");
  46 + }
  47 +
  48 + SysUser user = userMapper.selectByUsername(username);
  49 + if (user == null) {
  50 + throw new BizException(ErrorCode.BAD_CREDENTIALS, "token 关联用户不存在");
  51 + }
  52 + if (Integer.valueOf(1).equals(user.getIIsDeleted())) {
  53 + throw new BizException(ErrorCode.BAD_CREDENTIALS, "token 关联用户已作废");
  54 + }
  55 + if (user.getTLockUntil() != null && user.getTLockUntil().isAfter(LocalDateTime.now())) {
  56 + throw new BizException(ErrorCode.BAD_CREDENTIALS, "token 关联用户已锁定");
  57 + }
  58 +
  59 + String companyCode = (String) claims.get("companyCode");
  60 + LoginContext.set(new LoginContext.LoginUser(
  61 + user.getIIncrement(),
  62 + user.getSUsername(),
  63 + user.getSUserType(),
  64 + companyCode));
  65 +
  66 + if (handler instanceof HandlerMethod hm) {
  67 + if (hm.getMethodAnnotation(RequireSuperAdmin.class) != null
  68 + && !"SUPER_ADMIN".equals(user.getSUserType())) {
  69 + throw new BizException(ErrorCode.FORBIDDEN, "权限不足,仅超级管理员可调用");
  70 + }
  71 + }
  72 + return true;
  73 + }
  74 +
  75 + @Override
  76 + public void afterCompletion(HttpServletRequest request, HttpServletResponse response,
  77 + Object handler, Exception ex) {
  78 + LoginContext.clear();
  79 + }
  80 +}
backend/src/main/java/com/xly/erp/common/security/RequireSuperAdmin.java 0 → 100644
  1 +package com.xly.erp.common.security;
  2 +
  3 +import java.lang.annotation.ElementType;
  4 +import java.lang.annotation.Retention;
  5 +import java.lang.annotation.RetentionPolicy;
  6 +import java.lang.annotation.Target;
  7 +
  8 +/**
  9 + * 标注在 controller 方法上,要求当前登录用户 userType == SUPER_ADMIN。
  10 + * 由 JwtHandlerInterceptor 在 preHandle 时校验。
  11 + */
  12 +@Target(ElementType.METHOD)
  13 +@Retention(RetentionPolicy.RUNTIME)
  14 +public @interface RequireSuperAdmin {
  15 +}
backend/src/test/java/com/xly/erp/common/security/JwtHandlerInterceptorTest.java 0 → 100644
  1 +package com.xly.erp.common.security;
  2 +
  3 +import com.xly.erp.common.response.ErrorCode;
  4 +import com.xly.erp.module.usr.support.LoginTestSeeder;
  5 +import org.junit.jupiter.api.BeforeEach;
  6 +import org.junit.jupiter.api.Test;
  7 +import org.springframework.beans.factory.annotation.Autowired;
  8 +import org.springframework.boot.test.autoconfigure.web.servlet.AutoConfigureMockMvc;
  9 +import org.springframework.boot.test.context.SpringBootTest;
  10 +import org.springframework.test.context.ActiveProfiles;
  11 +import org.springframework.test.web.servlet.MockMvc;
  12 +import org.springframework.web.bind.annotation.GetMapping;
  13 +import org.springframework.web.bind.annotation.RequestMapping;
  14 +import org.springframework.web.bind.annotation.RestController;
  15 +
  16 +import java.util.HashMap;
  17 +import java.util.Map;
  18 +
  19 +import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.get;
  20 +import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.post;
  21 +import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.jsonPath;
  22 +import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status;
  23 +
  24 +@SpringBootTest
  25 +@AutoConfigureMockMvc
  26 +@ActiveProfiles("test")
  27 +@org.springframework.context.annotation.Import(JwtHandlerInterceptorTest.GuardedTestController.class)
  28 +class JwtHandlerInterceptorTest {
  29 +
  30 + @Autowired private MockMvc mvc;
  31 + @Autowired private JwtUtil jwtUtil;
  32 + @Autowired private LoginTestSeeder seeder;
  33 + @Autowired private org.springframework.jdbc.core.JdbcTemplate jdbc;
  34 +
  35 + private LoginTestSeeder.Fixture fx;
  36 +
  37 + @BeforeEach
  38 + void setUp() {
  39 + fx = seeder.reset();
  40 + }
  41 +
  42 + private String issueToken(String username, String userType, String companyCode) {
  43 + Map<String, Object> claims = new HashMap<>();
  44 + claims.put("sub", 1);
  45 + claims.put("username", username);
  46 + claims.put("userType", userType);
  47 + claims.put("companyCode", companyCode);
  48 + claims.put("language", "zh-CN");
  49 + return jwtUtil.issue(claims, 7200);
  50 + }
  51 +
  52 + // ===== 鉴权基础 =====
  53 +
  54 + @Test
  55 + void noAuthHeader_returns401_40101() throws Exception {
  56 + mvc.perform(get("/api/v1/_test/any-auth"))
  57 + .andExpect(status().isUnauthorized())
  58 + .andExpect(jsonPath("$.code").value(ErrorCode.BAD_CREDENTIALS));
  59 + }
  60 +
  61 + @Test
  62 + void invalidToken_returns401_40101() throws Exception {
  63 + mvc.perform(get("/api/v1/_test/any-auth").header("Authorization", "Bearer bogus.jwt.token"))
  64 + .andExpect(status().isUnauthorized())
  65 + .andExpect(jsonPath("$.code").value(ErrorCode.BAD_CREDENTIALS));
  66 + }
  67 +
  68 + @Test
  69 + void tokenForUnknownUser_returns401_40101() throws Exception {
  70 + String token = issueToken("nobody", "NORMAL", "HQ");
  71 + mvc.perform(get("/api/v1/_test/any-auth").header("Authorization", "Bearer " + token))
  72 + .andExpect(status().isUnauthorized())
  73 + .andExpect(jsonPath("$.code").value(ErrorCode.BAD_CREDENTIALS));
  74 + }
  75 +
  76 + @Test
  77 + void tokenForDeletedUser_returns401_40101() throws Exception {
  78 + String token = issueToken(LoginTestSeeder.USER_DELETED, "NORMAL", "HQ");
  79 + mvc.perform(get("/api/v1/_test/any-auth").header("Authorization", "Bearer " + token))
  80 + .andExpect(status().isUnauthorized())
  81 + .andExpect(jsonPath("$.code").value(ErrorCode.BAD_CREDENTIALS));
  82 + }
  83 +
  84 + @Test
  85 + void tokenForLockedUser_returns401_40101() throws Exception {
  86 + jdbc.update("UPDATE sys_user SET tLockUntil=DATE_ADD(NOW(), INTERVAL 30 MINUTE) WHERE sUsername=?",
  87 + LoginTestSeeder.USER_OK);
  88 + String token = issueToken(LoginTestSeeder.USER_OK, "NORMAL", "HQ");
  89 + mvc.perform(get("/api/v1/_test/any-auth").header("Authorization", "Bearer " + token))
  90 + .andExpect(status().isUnauthorized())
  91 + .andExpect(jsonPath("$.code").value(ErrorCode.BAD_CREDENTIALS));
  92 + }
  93 +
  94 + @Test
  95 + void validToken_normalUser_canAccessAnyAuthEndpoint() throws Exception {
  96 + String token = issueToken(LoginTestSeeder.USER_OK, "NORMAL", "HQ");
  97 + mvc.perform(get("/api/v1/_test/any-auth").header("Authorization", "Bearer " + token))
  98 + .andExpect(status().isOk())
  99 + .andExpect(jsonPath("$.code").value(ErrorCode.OK))
  100 + .andExpect(jsonPath("$.data").value("ok-" + LoginTestSeeder.USER_OK));
  101 + }
  102 +
  103 + // ===== @RequireSuperAdmin =====
  104 +
  105 + @Test
  106 + void validToken_normalUser_cannotAccessAdminOnly_returns403_40301() throws Exception {
  107 + String token = issueToken(LoginTestSeeder.USER_OK, "NORMAL", "HQ");
  108 + mvc.perform(get("/api/v1/_test/admin-only").header("Authorization", "Bearer " + token))
  109 + .andExpect(status().isForbidden())
  110 + .andExpect(jsonPath("$.code").value(ErrorCode.FORBIDDEN));
  111 + }
  112 +
  113 + @Test
  114 + void validToken_superAdmin_canAccessAdminOnly() throws Exception {
  115 + // 把 alice 升级为 SUPER_ADMIN
  116 + jdbc.update("UPDATE sys_user SET sUserType='SUPER_ADMIN' WHERE sUsername=?", LoginTestSeeder.USER_OK);
  117 + String token = issueToken(LoginTestSeeder.USER_OK, "SUPER_ADMIN", "HQ");
  118 + mvc.perform(get("/api/v1/_test/admin-only").header("Authorization", "Bearer " + token))
  119 + .andExpect(status().isOk());
  120 + }
  121 +
  122 + // ===== 放行 /api/v1/auth/login =====
  123 +
  124 + @Test
  125 + void loginEndpointPath_skipsInterceptor() throws Exception {
  126 + // 不带 auth 头调登录接口;应进入 controller 并返参数校验错(不是 40101)
  127 + mvc.perform(post("/api/v1/auth/login").contentType("application/json").content("{}"))
  128 + .andExpect(status().isBadRequest())
  129 + .andExpect(jsonPath("$.code").value(ErrorCode.BAD_REQUEST));
  130 + }
  131 +
  132 + @RestController
  133 + @RequestMapping("/api/v1/_test")
  134 + static class GuardedTestController {
  135 +
  136 + @GetMapping("/any-auth")
  137 + public com.xly.erp.common.response.Result<String> anyAuth() {
  138 + LoginContext.LoginUser u = LoginContext.current();
  139 + return com.xly.erp.common.response.Result.ok("ok-" + (u == null ? "null" : u.username()));
  140 + }
  141 +
  142 + @GetMapping("/admin-only")
  143 + @RequireSuperAdmin
  144 + public com.xly.erp.common.response.Result<String> adminOnly() {
  145 + return com.xly.erp.common.response.Result.ok("admin");
  146 + }
  147 + }
  148 +}