From 2bd4442cd49e76f92e3868842160eadba65feaa5 Mon Sep 17 00:00:00 2001 From: zichun Date: Wed, 29 Apr 2026 17:02:36 +0800 Subject: [PATCH] feat(mod): jwt filter + security config (role stub) REQ-MOD-001 --- backend/src/main/java/com/xly/erp/common/security/JwtAuthenticationFilter.java | 66 ++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++ backend/src/main/java/com/xly/erp/common/security/SecurityConfig.java | 32 ++++++++++++++++++++++++++++++++ backend/src/main/java/com/xly/erp/common/security/SecurityContextHelper.java | 19 +++++++++++++++++++ backend/src/test/java/com/xly/erp/common/security/JwtAuthenticationFilterTest.java | 80 ++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++ 4 files changed, 197 insertions(+), 0 deletions(-) create mode 100644 backend/src/main/java/com/xly/erp/common/security/JwtAuthenticationFilter.java create mode 100644 backend/src/main/java/com/xly/erp/common/security/SecurityConfig.java create mode 100644 backend/src/main/java/com/xly/erp/common/security/SecurityContextHelper.java create mode 100644 backend/src/test/java/com/xly/erp/common/security/JwtAuthenticationFilterTest.java diff --git a/backend/src/main/java/com/xly/erp/common/security/JwtAuthenticationFilter.java b/backend/src/main/java/com/xly/erp/common/security/JwtAuthenticationFilter.java new file mode 100644 index 0000000..a332602 --- /dev/null +++ b/backend/src/main/java/com/xly/erp/common/security/JwtAuthenticationFilter.java @@ -0,0 +1,66 @@ +package com.xly.erp.common.security; + +import com.fasterxml.jackson.databind.ObjectMapper; +import com.xly.erp.common.exception.BizException; +import com.xly.erp.common.response.Result; +import jakarta.servlet.FilterChain; +import jakarta.servlet.ServletException; +import jakarta.servlet.http.HttpServletRequest; +import jakarta.servlet.http.HttpServletResponse; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.security.authentication.UsernamePasswordAuthenticationToken; +import org.springframework.security.core.context.SecurityContextHolder; +import org.springframework.stereotype.Component; +import org.springframework.web.filter.OncePerRequestFilter; + +import java.io.IOException; +import java.nio.charset.StandardCharsets; +import java.util.Collections; + +@Component +public class JwtAuthenticationFilter extends OncePerRequestFilter { + + private static final String HEADER = "Authorization"; + private static final String PREFIX = "Bearer "; + + private final JwtUtil jwtUtil; + private final ObjectMapper objectMapper; + + @Autowired + public JwtAuthenticationFilter(JwtUtil jwtUtil, ObjectMapper objectMapper) { + this.jwtUtil = jwtUtil; + this.objectMapper = objectMapper; + } + + @Override + protected void doFilterInternal(HttpServletRequest request, + HttpServletResponse response, + FilterChain chain) throws ServletException, IOException { + String header = request.getHeader(HEADER); + if (header == null || !header.startsWith(PREFIX)) { + chain.doFilter(request, response); + return; + } + + String token = header.substring(PREFIX.length()); + String userNo; + try { + userNo = jwtUtil.parse(token); + } catch (BizException e) { + writeJsonResult(response, e.getCode(), e.getMessage()); + return; + } + + UsernamePasswordAuthenticationToken auth = new UsernamePasswordAuthenticationToken( + userNo, null, Collections.emptyList()); + SecurityContextHolder.getContext().setAuthentication(auth); + chain.doFilter(request, response); + } + + private void writeJsonResult(HttpServletResponse response, int code, String msg) throws IOException { + response.setStatus(HttpServletResponse.SC_OK); + response.setContentType("application/json;charset=UTF-8"); + response.setCharacterEncoding(StandardCharsets.UTF_8.name()); + response.getWriter().write(objectMapper.writeValueAsString(Result.fail(code, msg))); + } +} diff --git a/backend/src/main/java/com/xly/erp/common/security/SecurityConfig.java b/backend/src/main/java/com/xly/erp/common/security/SecurityConfig.java new file mode 100644 index 0000000..18d3dd9 --- /dev/null +++ b/backend/src/main/java/com/xly/erp/common/security/SecurityConfig.java @@ -0,0 +1,32 @@ +package com.xly.erp.common.security; + +import org.springframework.context.annotation.Bean; +import org.springframework.context.annotation.Configuration; +import org.springframework.http.HttpMethod; +import org.springframework.security.config.annotation.web.builders.HttpSecurity; +import org.springframework.security.config.http.SessionCreationPolicy; +import org.springframework.security.web.SecurityFilterChain; +import org.springframework.security.web.authentication.UsernamePasswordAuthenticationFilter; + +@Configuration +public class SecurityConfig { + + private final JwtAuthenticationFilter jwtFilter; + + public SecurityConfig(JwtAuthenticationFilter jwtFilter) { + this.jwtFilter = jwtFilter; + } + + @Bean + SecurityFilterChain filterChain(HttpSecurity http) throws Exception { + http.csrf(csrf -> csrf.disable()) + .sessionManagement(sm -> sm.sessionCreationPolicy(SessionCreationPolicy.STATELESS)) + .authorizeHttpRequests(auth -> auth + // REQ-MOD-001 stub: see USR-004 follow-up — 角色硬校验在 USR-004 完成后回填为 hasAuthority('SUPER_ADMIN') + .requestMatchers(HttpMethod.POST, "/api/mod/modules").permitAll() + .anyRequest().authenticated() + ) + .addFilterBefore(jwtFilter, UsernamePasswordAuthenticationFilter.class); + return http.build(); + } +} diff --git a/backend/src/main/java/com/xly/erp/common/security/SecurityContextHelper.java b/backend/src/main/java/com/xly/erp/common/security/SecurityContextHelper.java new file mode 100644 index 0000000..0e9be2b --- /dev/null +++ b/backend/src/main/java/com/xly/erp/common/security/SecurityContextHelper.java @@ -0,0 +1,19 @@ +package com.xly.erp.common.security; + +import org.springframework.security.core.Authentication; +import org.springframework.security.core.context.SecurityContextHolder; + +public final class SecurityContextHelper { + + private SecurityContextHelper() { + } + + public static String currentUserNo() { + Authentication auth = SecurityContextHolder.getContext().getAuthentication(); + if (auth == null || auth.getPrincipal() == null) { + return null; + } + Object p = auth.getPrincipal(); + return p instanceof String s ? s : null; + } +} diff --git a/backend/src/test/java/com/xly/erp/common/security/JwtAuthenticationFilterTest.java b/backend/src/test/java/com/xly/erp/common/security/JwtAuthenticationFilterTest.java new file mode 100644 index 0000000..dae0882 --- /dev/null +++ b/backend/src/test/java/com/xly/erp/common/security/JwtAuthenticationFilterTest.java @@ -0,0 +1,80 @@ +package com.xly.erp.common.security; + +import com.fasterxml.jackson.databind.JsonNode; +import com.fasterxml.jackson.databind.ObjectMapper; +import jakarta.servlet.FilterChain; +import org.junit.jupiter.api.AfterEach; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; +import org.springframework.mock.web.MockHttpServletRequest; +import org.springframework.mock.web.MockHttpServletResponse; +import org.springframework.security.core.Authentication; +import org.springframework.security.core.context.SecurityContextHolder; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.never; +import static org.mockito.Mockito.times; +import static org.mockito.Mockito.verify; + +class JwtAuthenticationFilterTest { + + private static final String SECRET = "f8d4be76bff13bf32fa33ca0b14a4b152ad01ca5719f57df18ec4ecf2370b235"; + + private JwtUtil jwtUtil; + private JwtAuthenticationFilter filter; + + @BeforeEach + void setUp() { + jwtUtil = new JwtUtil(SECRET); + filter = new JwtAuthenticationFilter(jwtUtil, new ObjectMapper()); + } + + @AfterEach + void clearContext() { + SecurityContextHolder.clearContext(); + } + + @Test + void validJwt_setsPrincipalInSecurityContext_andCallsChain() throws Exception { + String token = jwtUtil.sign("USER001"); + MockHttpServletRequest req = new MockHttpServletRequest("POST", "/api/anything"); + req.addHeader("Authorization", "Bearer " + token); + MockHttpServletResponse resp = new MockHttpServletResponse(); + FilterChain chain = mock(FilterChain.class); + + filter.doFilter(req, resp, chain); + + Authentication auth = SecurityContextHolder.getContext().getAuthentication(); + assertThat(auth).isNotNull(); + assertThat(auth.getPrincipal()).isEqualTo("USER001"); + verify(chain, times(1)).doFilter(req, resp); + } + + @Test + void tamperedJwt_writesJsonResultWith20001_andDoesNotCallChain() throws Exception { + MockHttpServletRequest req = new MockHttpServletRequest("POST", "/api/anything"); + req.addHeader("Authorization", "Bearer not.a.real.jwt"); + MockHttpServletResponse resp = new MockHttpServletResponse(); + FilterChain chain = mock(FilterChain.class); + + filter.doFilter(req, resp, chain); + + verify(chain, never()).doFilter(req, resp); + assertThat(resp.getContentType()).contains("application/json"); + JsonNode body = new ObjectMapper().readTree(resp.getContentAsString()); + assertThat(body.get("code").asInt()).isEqualTo(20001); + } + + @Test + void noJwtHeader_passesChainThrough_withoutSettingContext() throws Exception { + MockHttpServletRequest req = new MockHttpServletRequest("POST", "/api/anything"); + MockHttpServletResponse resp = new MockHttpServletResponse(); + FilterChain chain = mock(FilterChain.class); + + filter.doFilter(req, resp, chain); + + assertThat(SecurityContextHolder.getContext().getAuthentication()).isNull(); + verify(chain, times(1)).doFilter(req, resp); + } +} -- libgit2 0.22.2