From 22e97160a3b2f5e33d4e0c0978e9f190113223a8 Mon Sep 17 00:00:00 2001 From: zichun Date: Fri, 15 May 2026 09:13:58 +0800 Subject: [PATCH] feat(usr): JwtHandlerInterceptor + @RequireSuperAdmin REQ-USR-002 --- backend/src/main/java/com/xly/erp/common/config/WebMvcConfig.java | 21 +++++++++++++++++++++ backend/src/main/java/com/xly/erp/common/security/JwtHandlerInterceptor.java | 80 ++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++ backend/src/main/java/com/xly/erp/common/security/RequireSuperAdmin.java | 15 +++++++++++++++ backend/src/test/java/com/xly/erp/common/security/JwtHandlerInterceptorTest.java | 148 ++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++ 4 files changed, 264 insertions(+), 0 deletions(-) create mode 100644 backend/src/main/java/com/xly/erp/common/config/WebMvcConfig.java create mode 100644 backend/src/main/java/com/xly/erp/common/security/JwtHandlerInterceptor.java create mode 100644 backend/src/main/java/com/xly/erp/common/security/RequireSuperAdmin.java create mode 100644 backend/src/test/java/com/xly/erp/common/security/JwtHandlerInterceptorTest.java diff --git a/backend/src/main/java/com/xly/erp/common/config/WebMvcConfig.java b/backend/src/main/java/com/xly/erp/common/config/WebMvcConfig.java new file mode 100644 index 0000000..98fee6d --- /dev/null +++ b/backend/src/main/java/com/xly/erp/common/config/WebMvcConfig.java @@ -0,0 +1,21 @@ +package com.xly.erp.common.config; + +import com.xly.erp.common.security.JwtHandlerInterceptor; +import lombok.RequiredArgsConstructor; +import org.springframework.context.annotation.Configuration; +import org.springframework.web.servlet.config.annotation.InterceptorRegistry; +import org.springframework.web.servlet.config.annotation.WebMvcConfigurer; + +@Configuration +@RequiredArgsConstructor +public class WebMvcConfig implements WebMvcConfigurer { + + private final JwtHandlerInterceptor jwtInterceptor; + + @Override + public void addInterceptors(InterceptorRegistry registry) { + registry.addInterceptor(jwtInterceptor) + .addPathPatterns("/api/v1/**") + .excludePathPatterns("/api/v1/auth/login"); + } +} diff --git a/backend/src/main/java/com/xly/erp/common/security/JwtHandlerInterceptor.java b/backend/src/main/java/com/xly/erp/common/security/JwtHandlerInterceptor.java new file mode 100644 index 0000000..263e363 --- /dev/null +++ b/backend/src/main/java/com/xly/erp/common/security/JwtHandlerInterceptor.java @@ -0,0 +1,80 @@ +package com.xly.erp.common.security; + +import com.xly.erp.common.exception.BizException; +import com.xly.erp.common.response.ErrorCode; +import com.xly.erp.module.usr.entity.SysUser; +import com.xly.erp.module.usr.mapper.SysUserMapper; +import jakarta.servlet.http.HttpServletRequest; +import jakarta.servlet.http.HttpServletResponse; +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.springframework.stereotype.Component; +import org.springframework.web.method.HandlerMethod; +import org.springframework.web.servlet.HandlerInterceptor; + +import java.time.LocalDateTime; +import java.util.Map; + +/** + * REQ-USR-002 鉴权与角色守卫拦截器。 + * - 解析 Authorization Bearer → 校验 user 状态 → set LoginContext + * - 若 handler 标注 @RequireSuperAdmin,强制 userType == SUPER_ADMIN + * - afterCompletion 清理 ThreadLocal + */ +@Component +@RequiredArgsConstructor +@Slf4j +public class JwtHandlerInterceptor implements HandlerInterceptor { + + private static final String BEARER_PREFIX = "Bearer "; + + private final JwtUtil jwtUtil; + private final SysUserMapper userMapper; + + @Override + public boolean preHandle(HttpServletRequest request, HttpServletResponse response, Object handler) { + String authHeader = request.getHeader("Authorization"); + if (authHeader == null || !authHeader.startsWith(BEARER_PREFIX)) { + throw new BizException(ErrorCode.BAD_CREDENTIALS, "未携带 token"); + } + String token = authHeader.substring(BEARER_PREFIX.length()); + + Map claims = jwtUtil.parse(token); + String username = (String) claims.get("username"); + if (username == null || username.isBlank()) { + throw new BizException(ErrorCode.BAD_CREDENTIALS, "token 缺 username claim"); + } + + SysUser user = userMapper.selectByUsername(username); + if (user == null) { + throw new BizException(ErrorCode.BAD_CREDENTIALS, "token 关联用户不存在"); + } + if (Integer.valueOf(1).equals(user.getIIsDeleted())) { + throw new BizException(ErrorCode.BAD_CREDENTIALS, "token 关联用户已作废"); + } + if (user.getTLockUntil() != null && user.getTLockUntil().isAfter(LocalDateTime.now())) { + throw new BizException(ErrorCode.BAD_CREDENTIALS, "token 关联用户已锁定"); + } + + String companyCode = (String) claims.get("companyCode"); + LoginContext.set(new LoginContext.LoginUser( + user.getIIncrement(), + user.getSUsername(), + user.getSUserType(), + companyCode)); + + if (handler instanceof HandlerMethod hm) { + if (hm.getMethodAnnotation(RequireSuperAdmin.class) != null + && !"SUPER_ADMIN".equals(user.getSUserType())) { + throw new BizException(ErrorCode.FORBIDDEN, "权限不足,仅超级管理员可调用"); + } + } + return true; + } + + @Override + public void afterCompletion(HttpServletRequest request, HttpServletResponse response, + Object handler, Exception ex) { + LoginContext.clear(); + } +} diff --git a/backend/src/main/java/com/xly/erp/common/security/RequireSuperAdmin.java b/backend/src/main/java/com/xly/erp/common/security/RequireSuperAdmin.java new file mode 100644 index 0000000..3f90aac --- /dev/null +++ b/backend/src/main/java/com/xly/erp/common/security/RequireSuperAdmin.java @@ -0,0 +1,15 @@ +package com.xly.erp.common.security; + +import java.lang.annotation.ElementType; +import java.lang.annotation.Retention; +import java.lang.annotation.RetentionPolicy; +import java.lang.annotation.Target; + +/** + * 标注在 controller 方法上,要求当前登录用户 userType == SUPER_ADMIN。 + * 由 JwtHandlerInterceptor 在 preHandle 时校验。 + */ +@Target(ElementType.METHOD) +@Retention(RetentionPolicy.RUNTIME) +public @interface RequireSuperAdmin { +} diff --git a/backend/src/test/java/com/xly/erp/common/security/JwtHandlerInterceptorTest.java b/backend/src/test/java/com/xly/erp/common/security/JwtHandlerInterceptorTest.java new file mode 100644 index 0000000..0e2c51a --- /dev/null +++ b/backend/src/test/java/com/xly/erp/common/security/JwtHandlerInterceptorTest.java @@ -0,0 +1,148 @@ +package com.xly.erp.common.security; + +import com.xly.erp.common.response.ErrorCode; +import com.xly.erp.module.usr.support.LoginTestSeeder; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.boot.test.autoconfigure.web.servlet.AutoConfigureMockMvc; +import org.springframework.boot.test.context.SpringBootTest; +import org.springframework.test.context.ActiveProfiles; +import org.springframework.test.web.servlet.MockMvc; +import org.springframework.web.bind.annotation.GetMapping; +import org.springframework.web.bind.annotation.RequestMapping; +import org.springframework.web.bind.annotation.RestController; + +import java.util.HashMap; +import java.util.Map; + +import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.get; +import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.post; +import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.jsonPath; +import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status; + +@SpringBootTest +@AutoConfigureMockMvc +@ActiveProfiles("test") +@org.springframework.context.annotation.Import(JwtHandlerInterceptorTest.GuardedTestController.class) +class JwtHandlerInterceptorTest { + + @Autowired private MockMvc mvc; + @Autowired private JwtUtil jwtUtil; + @Autowired private LoginTestSeeder seeder; + @Autowired private org.springframework.jdbc.core.JdbcTemplate jdbc; + + private LoginTestSeeder.Fixture fx; + + @BeforeEach + void setUp() { + fx = seeder.reset(); + } + + private String issueToken(String username, String userType, String companyCode) { + Map claims = new HashMap<>(); + claims.put("sub", 1); + claims.put("username", username); + claims.put("userType", userType); + claims.put("companyCode", companyCode); + claims.put("language", "zh-CN"); + return jwtUtil.issue(claims, 7200); + } + + // ===== 鉴权基础 ===== + + @Test + void noAuthHeader_returns401_40101() throws Exception { + mvc.perform(get("/api/v1/_test/any-auth")) + .andExpect(status().isUnauthorized()) + .andExpect(jsonPath("$.code").value(ErrorCode.BAD_CREDENTIALS)); + } + + @Test + void invalidToken_returns401_40101() throws Exception { + mvc.perform(get("/api/v1/_test/any-auth").header("Authorization", "Bearer bogus.jwt.token")) + .andExpect(status().isUnauthorized()) + .andExpect(jsonPath("$.code").value(ErrorCode.BAD_CREDENTIALS)); + } + + @Test + void tokenForUnknownUser_returns401_40101() throws Exception { + String token = issueToken("nobody", "NORMAL", "HQ"); + mvc.perform(get("/api/v1/_test/any-auth").header("Authorization", "Bearer " + token)) + .andExpect(status().isUnauthorized()) + .andExpect(jsonPath("$.code").value(ErrorCode.BAD_CREDENTIALS)); + } + + @Test + void tokenForDeletedUser_returns401_40101() throws Exception { + String token = issueToken(LoginTestSeeder.USER_DELETED, "NORMAL", "HQ"); + mvc.perform(get("/api/v1/_test/any-auth").header("Authorization", "Bearer " + token)) + .andExpect(status().isUnauthorized()) + .andExpect(jsonPath("$.code").value(ErrorCode.BAD_CREDENTIALS)); + } + + @Test + void tokenForLockedUser_returns401_40101() throws Exception { + jdbc.update("UPDATE sys_user SET tLockUntil=DATE_ADD(NOW(), INTERVAL 30 MINUTE) WHERE sUsername=?", + LoginTestSeeder.USER_OK); + String token = issueToken(LoginTestSeeder.USER_OK, "NORMAL", "HQ"); + mvc.perform(get("/api/v1/_test/any-auth").header("Authorization", "Bearer " + token)) + .andExpect(status().isUnauthorized()) + .andExpect(jsonPath("$.code").value(ErrorCode.BAD_CREDENTIALS)); + } + + @Test + void validToken_normalUser_canAccessAnyAuthEndpoint() throws Exception { + String token = issueToken(LoginTestSeeder.USER_OK, "NORMAL", "HQ"); + mvc.perform(get("/api/v1/_test/any-auth").header("Authorization", "Bearer " + token)) + .andExpect(status().isOk()) + .andExpect(jsonPath("$.code").value(ErrorCode.OK)) + .andExpect(jsonPath("$.data").value("ok-" + LoginTestSeeder.USER_OK)); + } + + // ===== @RequireSuperAdmin ===== + + @Test + void validToken_normalUser_cannotAccessAdminOnly_returns403_40301() throws Exception { + String token = issueToken(LoginTestSeeder.USER_OK, "NORMAL", "HQ"); + mvc.perform(get("/api/v1/_test/admin-only").header("Authorization", "Bearer " + token)) + .andExpect(status().isForbidden()) + .andExpect(jsonPath("$.code").value(ErrorCode.FORBIDDEN)); + } + + @Test + void validToken_superAdmin_canAccessAdminOnly() throws Exception { + // 把 alice 升级为 SUPER_ADMIN + jdbc.update("UPDATE sys_user SET sUserType='SUPER_ADMIN' WHERE sUsername=?", LoginTestSeeder.USER_OK); + String token = issueToken(LoginTestSeeder.USER_OK, "SUPER_ADMIN", "HQ"); + mvc.perform(get("/api/v1/_test/admin-only").header("Authorization", "Bearer " + token)) + .andExpect(status().isOk()); + } + + // ===== 放行 /api/v1/auth/login ===== + + @Test + void loginEndpointPath_skipsInterceptor() throws Exception { + // 不带 auth 头调登录接口;应进入 controller 并返参数校验错(不是 40101) + mvc.perform(post("/api/v1/auth/login").contentType("application/json").content("{}")) + .andExpect(status().isBadRequest()) + .andExpect(jsonPath("$.code").value(ErrorCode.BAD_REQUEST)); + } + + @RestController + @RequestMapping("/api/v1/_test") + static class GuardedTestController { + + @GetMapping("/any-auth") + public com.xly.erp.common.response.Result anyAuth() { + LoginContext.LoginUser u = LoginContext.current(); + return com.xly.erp.common.response.Result.ok("ok-" + (u == null ? "null" : u.username())); + } + + @GetMapping("/admin-only") + @RequireSuperAdmin + public com.xly.erp.common.response.Result adminOnly() { + return com.xly.erp.common.response.Result.ok("admin"); + } + } +} -- libgit2 0.22.2