Commit 22e97160a3b2f5e33d4e0c0978e9f190113223a8
1 parent
95dcd379
feat(usr): JwtHandlerInterceptor + @RequireSuperAdmin REQ-USR-002
Showing
4 changed files
with
264 additions
and
0 deletions
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 | +} |