Commit 2bd4442cd49e76f92e3868842160eadba65feaa5
1 parent
a6ef4aa1
feat(mod): jwt filter + security config (role stub) REQ-MOD-001
Showing
4 changed files
with
197 additions
and
0 deletions
backend/src/main/java/com/xly/erp/common/security/JwtAuthenticationFilter.java
0 → 100644
| 1 | +package com.xly.erp.common.security; | ||
| 2 | + | ||
| 3 | +import com.fasterxml.jackson.databind.ObjectMapper; | ||
| 4 | +import com.xly.erp.common.exception.BizException; | ||
| 5 | +import com.xly.erp.common.response.Result; | ||
| 6 | +import jakarta.servlet.FilterChain; | ||
| 7 | +import jakarta.servlet.ServletException; | ||
| 8 | +import jakarta.servlet.http.HttpServletRequest; | ||
| 9 | +import jakarta.servlet.http.HttpServletResponse; | ||
| 10 | +import org.springframework.beans.factory.annotation.Autowired; | ||
| 11 | +import org.springframework.security.authentication.UsernamePasswordAuthenticationToken; | ||
| 12 | +import org.springframework.security.core.context.SecurityContextHolder; | ||
| 13 | +import org.springframework.stereotype.Component; | ||
| 14 | +import org.springframework.web.filter.OncePerRequestFilter; | ||
| 15 | + | ||
| 16 | +import java.io.IOException; | ||
| 17 | +import java.nio.charset.StandardCharsets; | ||
| 18 | +import java.util.Collections; | ||
| 19 | + | ||
| 20 | +@Component | ||
| 21 | +public class JwtAuthenticationFilter extends OncePerRequestFilter { | ||
| 22 | + | ||
| 23 | + private static final String HEADER = "Authorization"; | ||
| 24 | + private static final String PREFIX = "Bearer "; | ||
| 25 | + | ||
| 26 | + private final JwtUtil jwtUtil; | ||
| 27 | + private final ObjectMapper objectMapper; | ||
| 28 | + | ||
| 29 | + @Autowired | ||
| 30 | + public JwtAuthenticationFilter(JwtUtil jwtUtil, ObjectMapper objectMapper) { | ||
| 31 | + this.jwtUtil = jwtUtil; | ||
| 32 | + this.objectMapper = objectMapper; | ||
| 33 | + } | ||
| 34 | + | ||
| 35 | + @Override | ||
| 36 | + protected void doFilterInternal(HttpServletRequest request, | ||
| 37 | + HttpServletResponse response, | ||
| 38 | + FilterChain chain) throws ServletException, IOException { | ||
| 39 | + String header = request.getHeader(HEADER); | ||
| 40 | + if (header == null || !header.startsWith(PREFIX)) { | ||
| 41 | + chain.doFilter(request, response); | ||
| 42 | + return; | ||
| 43 | + } | ||
| 44 | + | ||
| 45 | + String token = header.substring(PREFIX.length()); | ||
| 46 | + String userNo; | ||
| 47 | + try { | ||
| 48 | + userNo = jwtUtil.parse(token); | ||
| 49 | + } catch (BizException e) { | ||
| 50 | + writeJsonResult(response, e.getCode(), e.getMessage()); | ||
| 51 | + return; | ||
| 52 | + } | ||
| 53 | + | ||
| 54 | + UsernamePasswordAuthenticationToken auth = new UsernamePasswordAuthenticationToken( | ||
| 55 | + userNo, null, Collections.emptyList()); | ||
| 56 | + SecurityContextHolder.getContext().setAuthentication(auth); | ||
| 57 | + chain.doFilter(request, response); | ||
| 58 | + } | ||
| 59 | + | ||
| 60 | + private void writeJsonResult(HttpServletResponse response, int code, String msg) throws IOException { | ||
| 61 | + response.setStatus(HttpServletResponse.SC_OK); | ||
| 62 | + response.setContentType("application/json;charset=UTF-8"); | ||
| 63 | + response.setCharacterEncoding(StandardCharsets.UTF_8.name()); | ||
| 64 | + response.getWriter().write(objectMapper.writeValueAsString(Result.fail(code, msg))); | ||
| 65 | + } | ||
| 66 | +} |
backend/src/main/java/com/xly/erp/common/security/SecurityConfig.java
0 → 100644
| 1 | +package com.xly.erp.common.security; | ||
| 2 | + | ||
| 3 | +import org.springframework.context.annotation.Bean; | ||
| 4 | +import org.springframework.context.annotation.Configuration; | ||
| 5 | +import org.springframework.http.HttpMethod; | ||
| 6 | +import org.springframework.security.config.annotation.web.builders.HttpSecurity; | ||
| 7 | +import org.springframework.security.config.http.SessionCreationPolicy; | ||
| 8 | +import org.springframework.security.web.SecurityFilterChain; | ||
| 9 | +import org.springframework.security.web.authentication.UsernamePasswordAuthenticationFilter; | ||
| 10 | + | ||
| 11 | +@Configuration | ||
| 12 | +public class SecurityConfig { | ||
| 13 | + | ||
| 14 | + private final JwtAuthenticationFilter jwtFilter; | ||
| 15 | + | ||
| 16 | + public SecurityConfig(JwtAuthenticationFilter jwtFilter) { | ||
| 17 | + this.jwtFilter = jwtFilter; | ||
| 18 | + } | ||
| 19 | + | ||
| 20 | + @Bean | ||
| 21 | + SecurityFilterChain filterChain(HttpSecurity http) throws Exception { | ||
| 22 | + http.csrf(csrf -> csrf.disable()) | ||
| 23 | + .sessionManagement(sm -> sm.sessionCreationPolicy(SessionCreationPolicy.STATELESS)) | ||
| 24 | + .authorizeHttpRequests(auth -> auth | ||
| 25 | + // REQ-MOD-001 stub: see USR-004 follow-up — 角色硬校验在 USR-004 完成后回填为 hasAuthority('SUPER_ADMIN') | ||
| 26 | + .requestMatchers(HttpMethod.POST, "/api/mod/modules").permitAll() | ||
| 27 | + .anyRequest().authenticated() | ||
| 28 | + ) | ||
| 29 | + .addFilterBefore(jwtFilter, UsernamePasswordAuthenticationFilter.class); | ||
| 30 | + return http.build(); | ||
| 31 | + } | ||
| 32 | +} |
backend/src/main/java/com/xly/erp/common/security/SecurityContextHelper.java
0 → 100644
| 1 | +package com.xly.erp.common.security; | ||
| 2 | + | ||
| 3 | +import org.springframework.security.core.Authentication; | ||
| 4 | +import org.springframework.security.core.context.SecurityContextHolder; | ||
| 5 | + | ||
| 6 | +public final class SecurityContextHelper { | ||
| 7 | + | ||
| 8 | + private SecurityContextHelper() { | ||
| 9 | + } | ||
| 10 | + | ||
| 11 | + public static String currentUserNo() { | ||
| 12 | + Authentication auth = SecurityContextHolder.getContext().getAuthentication(); | ||
| 13 | + if (auth == null || auth.getPrincipal() == null) { | ||
| 14 | + return null; | ||
| 15 | + } | ||
| 16 | + Object p = auth.getPrincipal(); | ||
| 17 | + return p instanceof String s ? s : null; | ||
| 18 | + } | ||
| 19 | +} |
backend/src/test/java/com/xly/erp/common/security/JwtAuthenticationFilterTest.java
0 → 100644
| 1 | +package com.xly.erp.common.security; | ||
| 2 | + | ||
| 3 | +import com.fasterxml.jackson.databind.JsonNode; | ||
| 4 | +import com.fasterxml.jackson.databind.ObjectMapper; | ||
| 5 | +import jakarta.servlet.FilterChain; | ||
| 6 | +import org.junit.jupiter.api.AfterEach; | ||
| 7 | +import org.junit.jupiter.api.BeforeEach; | ||
| 8 | +import org.junit.jupiter.api.Test; | ||
| 9 | +import org.springframework.mock.web.MockHttpServletRequest; | ||
| 10 | +import org.springframework.mock.web.MockHttpServletResponse; | ||
| 11 | +import org.springframework.security.core.Authentication; | ||
| 12 | +import org.springframework.security.core.context.SecurityContextHolder; | ||
| 13 | + | ||
| 14 | +import static org.assertj.core.api.Assertions.assertThat; | ||
| 15 | +import static org.mockito.Mockito.mock; | ||
| 16 | +import static org.mockito.Mockito.never; | ||
| 17 | +import static org.mockito.Mockito.times; | ||
| 18 | +import static org.mockito.Mockito.verify; | ||
| 19 | + | ||
| 20 | +class JwtAuthenticationFilterTest { | ||
| 21 | + | ||
| 22 | + private static final String SECRET = "f8d4be76bff13bf32fa33ca0b14a4b152ad01ca5719f57df18ec4ecf2370b235"; | ||
| 23 | + | ||
| 24 | + private JwtUtil jwtUtil; | ||
| 25 | + private JwtAuthenticationFilter filter; | ||
| 26 | + | ||
| 27 | + @BeforeEach | ||
| 28 | + void setUp() { | ||
| 29 | + jwtUtil = new JwtUtil(SECRET); | ||
| 30 | + filter = new JwtAuthenticationFilter(jwtUtil, new ObjectMapper()); | ||
| 31 | + } | ||
| 32 | + | ||
| 33 | + @AfterEach | ||
| 34 | + void clearContext() { | ||
| 35 | + SecurityContextHolder.clearContext(); | ||
| 36 | + } | ||
| 37 | + | ||
| 38 | + @Test | ||
| 39 | + void validJwt_setsPrincipalInSecurityContext_andCallsChain() throws Exception { | ||
| 40 | + String token = jwtUtil.sign("USER001"); | ||
| 41 | + MockHttpServletRequest req = new MockHttpServletRequest("POST", "/api/anything"); | ||
| 42 | + req.addHeader("Authorization", "Bearer " + token); | ||
| 43 | + MockHttpServletResponse resp = new MockHttpServletResponse(); | ||
| 44 | + FilterChain chain = mock(FilterChain.class); | ||
| 45 | + | ||
| 46 | + filter.doFilter(req, resp, chain); | ||
| 47 | + | ||
| 48 | + Authentication auth = SecurityContextHolder.getContext().getAuthentication(); | ||
| 49 | + assertThat(auth).isNotNull(); | ||
| 50 | + assertThat(auth.getPrincipal()).isEqualTo("USER001"); | ||
| 51 | + verify(chain, times(1)).doFilter(req, resp); | ||
| 52 | + } | ||
| 53 | + | ||
| 54 | + @Test | ||
| 55 | + void tamperedJwt_writesJsonResultWith20001_andDoesNotCallChain() throws Exception { | ||
| 56 | + MockHttpServletRequest req = new MockHttpServletRequest("POST", "/api/anything"); | ||
| 57 | + req.addHeader("Authorization", "Bearer not.a.real.jwt"); | ||
| 58 | + MockHttpServletResponse resp = new MockHttpServletResponse(); | ||
| 59 | + FilterChain chain = mock(FilterChain.class); | ||
| 60 | + | ||
| 61 | + filter.doFilter(req, resp, chain); | ||
| 62 | + | ||
| 63 | + verify(chain, never()).doFilter(req, resp); | ||
| 64 | + assertThat(resp.getContentType()).contains("application/json"); | ||
| 65 | + JsonNode body = new ObjectMapper().readTree(resp.getContentAsString()); | ||
| 66 | + assertThat(body.get("code").asInt()).isEqualTo(20001); | ||
| 67 | + } | ||
| 68 | + | ||
| 69 | + @Test | ||
| 70 | + void noJwtHeader_passesChainThrough_withoutSettingContext() throws Exception { | ||
| 71 | + MockHttpServletRequest req = new MockHttpServletRequest("POST", "/api/anything"); | ||
| 72 | + MockHttpServletResponse resp = new MockHttpServletResponse(); | ||
| 73 | + FilterChain chain = mock(FilterChain.class); | ||
| 74 | + | ||
| 75 | + filter.doFilter(req, resp, chain); | ||
| 76 | + | ||
| 77 | + assertThat(SecurityContextHolder.getContext().getAuthentication()).isNull(); | ||
| 78 | + verify(chain, times(1)).doFilter(req, resp); | ||
| 79 | + } | ||
| 80 | +} |