From 346acbcfa331ae0f37781b4ffa998a7412bb9387 Mon Sep 17 00:00:00 2001 From: XiaoSeS <87064762+XiaoSeS@users.noreply.github.com> Date: Mon, 31 Aug 2026 12:36:14 +0800 Subject: [PATCH] fix(auth): preserve protected invalid-session handling Signed-off-by: XiaoSeS <87064762+XiaoSeS@users.noreply.github.com> --- .../skillhub/auth/config/SecurityConfig.java | 10 ++ .../session/ExpiredPublicSessionFilter.java | 76 +++++++++++++++ .../ExpiredPublicSessionFilterTest.java | 92 +++++++++++++++++++ 3 files changed, 178 insertions(+) create mode 100644 server/skillhub-auth/src/main/java/com/iflytek/skillhub/auth/session/ExpiredPublicSessionFilter.java create mode 100644 server/skillhub-auth/src/test/java/com/iflytek/skillhub/auth/session/ExpiredPublicSessionFilterTest.java diff --git a/server/skillhub-auth/src/main/java/com/iflytek/skillhub/auth/config/SecurityConfig.java b/server/skillhub-auth/src/main/java/com/iflytek/skillhub/auth/config/SecurityConfig.java index 9e736ab2..e205fb7a 100644 --- a/server/skillhub-auth/src/main/java/com/iflytek/skillhub/auth/config/SecurityConfig.java +++ b/server/skillhub-auth/src/main/java/com/iflytek/skillhub/auth/config/SecurityConfig.java @@ -7,14 +7,17 @@ import com.iflytek.skillhub.auth.oauth.OAuth2LoginSuccessHandler; import com.iflytek.skillhub.auth.oauth.SkillHubOAuth2AuthorizationRequestResolver; import com.iflytek.skillhub.auth.mock.MockAuthFilter; import com.iflytek.skillhub.auth.policy.RouteSecurityPolicyRegistry; +import com.iflytek.skillhub.auth.session.ExpiredPublicSessionFilter; import com.iflytek.skillhub.auth.token.ApiTokenAuthenticationFilter; import com.iflytek.skillhub.auth.token.ApiTokenScopeFilter; import jakarta.servlet.http.Cookie; import jakarta.servlet.http.HttpServletRequest; +import jakarta.servlet.http.HttpServletResponse; import org.springframework.beans.factory.ObjectProvider; import org.springframework.context.annotation.Bean; import org.springframework.context.annotation.Configuration; import org.springframework.http.HttpStatus; +import org.springframework.http.MediaType; import org.springframework.security.crypto.bcrypt.BCryptPasswordEncoder; import org.springframework.security.crypto.password.PasswordEncoder; import org.springframework.security.web.AuthenticationEntryPoint; @@ -28,6 +31,7 @@ import org.springframework.security.web.access.AccessDeniedHandler; import org.springframework.security.web.authentication.UsernamePasswordAuthenticationFilter; import org.springframework.security.web.authentication.AnonymousAuthenticationFilter; import org.springframework.security.web.csrf.CookieCsrfTokenRepository; +import org.springframework.security.web.csrf.CsrfFilter; import org.springframework.security.web.csrf.CsrfTokenRequestAttributeHandler; import org.springframework.security.web.header.writers.ReferrerPolicyHeaderWriter.ReferrerPolicy; import org.springframework.security.web.util.matcher.AntPathRequestMatcher; @@ -135,6 +139,11 @@ public class SecurityConfig { ) .sessionManagement(session -> session .sessionCreationPolicy(SessionCreationPolicy.IF_REQUIRED) + .invalidSessionStrategy((request, response) -> { + response.setStatus(HttpServletResponse.SC_UNAUTHORIZED); + response.setContentType(MediaType.APPLICATION_JSON_VALUE); + response.getWriter().write("{\"code\":401,\"msg\":\"Session expired\"}"); + }) ) .exceptionHandling(exceptions -> exceptions .accessDeniedHandler(apiAccessDeniedHandler) @@ -154,6 +163,7 @@ public class SecurityConfig { .invalidateHttpSession(true) .deleteCookies("SESSION") ) + .addFilterBefore(new ExpiredPublicSessionFilter(routeSecurityPolicyRegistry), CsrfFilter.class) .addFilterBefore(apiTokenAuthenticationFilter, UsernamePasswordAuthenticationFilter.class) .addFilterAfter(apiTokenScopeFilter, ApiTokenAuthenticationFilter.class); diff --git a/server/skillhub-auth/src/main/java/com/iflytek/skillhub/auth/session/ExpiredPublicSessionFilter.java b/server/skillhub-auth/src/main/java/com/iflytek/skillhub/auth/session/ExpiredPublicSessionFilter.java new file mode 100644 index 00000000..934cbe81 --- /dev/null +++ b/server/skillhub-auth/src/main/java/com/iflytek/skillhub/auth/session/ExpiredPublicSessionFilter.java @@ -0,0 +1,76 @@ +package com.iflytek.skillhub.auth.session; + +import com.iflytek.skillhub.auth.policy.RouteSecurityPolicyRegistry; +import jakarta.servlet.FilterChain; +import jakarta.servlet.ServletException; +import jakarta.servlet.http.Cookie; +import jakarta.servlet.http.HttpServletRequest; +import jakarta.servlet.http.HttpServletRequestWrapper; +import jakarta.servlet.http.HttpServletResponse; +import java.io.IOException; +import java.util.Arrays; +import java.util.Set; +import org.springframework.web.filter.OncePerRequestFilter; + +/** + * Treats an expired session cookie as absent only for routes that already allow anonymous access. + */ +public final class ExpiredPublicSessionFilter extends OncePerRequestFilter { + + private static final Set SESSION_COOKIES = Set.of("SESSION", "JSESSIONID"); + + private final RouteSecurityPolicyRegistry routeSecurityPolicyRegistry; + + public ExpiredPublicSessionFilter(RouteSecurityPolicyRegistry routeSecurityPolicyRegistry) { + this.routeSecurityPolicyRegistry = routeSecurityPolicyRegistry; + } + + @Override + protected void doFilterInternal( + HttpServletRequest request, + HttpServletResponse response, + FilterChain filterChain) throws ServletException, IOException { + String requestPath = RouteSecurityPolicyRegistry.requestPath(request); + boolean publicRoute = routeSecurityPolicyRegistry.accessLevel(request.getMethod(), requestPath) + == RouteSecurityPolicyRegistry.AccessLevel.PERMIT_ALL; + if (publicRoute && request.getRequestedSessionId() != null && !request.isRequestedSessionIdValid()) { + filterChain.doFilter(new SessionlessRequest(request), response); + return; + } + filterChain.doFilter(request, response); + } + + private static final class SessionlessRequest extends HttpServletRequestWrapper { + + private SessionlessRequest(HttpServletRequest request) { + super(request); + } + + @Override + public String getRequestedSessionId() { + return null; + } + + @Override + public boolean isRequestedSessionIdValid() { + return false; + } + + @Override + public boolean isRequestedSessionIdFromCookie() { + return false; + } + + @Override + public Cookie[] getCookies() { + Cookie[] cookies = super.getCookies(); + if (cookies == null) { + return null; + } + Cookie[] retained = Arrays.stream(cookies) + .filter(cookie -> !SESSION_COOKIES.contains(cookie.getName())) + .toArray(Cookie[]::new); + return retained.length == 0 ? null : retained; + } + } +} diff --git a/server/skillhub-auth/src/test/java/com/iflytek/skillhub/auth/session/ExpiredPublicSessionFilterTest.java b/server/skillhub-auth/src/test/java/com/iflytek/skillhub/auth/session/ExpiredPublicSessionFilterTest.java new file mode 100644 index 00000000..9f7d3eba --- /dev/null +++ b/server/skillhub-auth/src/test/java/com/iflytek/skillhub/auth/session/ExpiredPublicSessionFilterTest.java @@ -0,0 +1,92 @@ +package com.iflytek.skillhub.auth.session; + +import static org.junit.jupiter.api.Assertions.assertArrayEquals; +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertNull; +import static org.junit.jupiter.api.Assertions.assertSame; +import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.verify; + +import com.iflytek.skillhub.auth.policy.RouteSecurityPolicyRegistry; +import jakarta.servlet.FilterChain; +import jakarta.servlet.ServletRequest; +import jakarta.servlet.ServletResponse; +import jakarta.servlet.http.Cookie; +import jakarta.servlet.http.HttpServletRequest; +import org.junit.jupiter.api.Test; +import org.mockito.ArgumentCaptor; +import org.springframework.mock.web.MockHttpServletRequest; +import org.springframework.mock.web.MockHttpServletResponse; + +class ExpiredPublicSessionFilterTest { + + private final ExpiredPublicSessionFilter filter = + new ExpiredPublicSessionFilter(new RouteSecurityPolicyRegistry()); + + @Test + void expiredSessionOnPublicRoute_shouldBeHiddenFromDownstreamSecurityFilters() throws Exception { + MockHttpServletRequest request = expiredSessionRequest("GET", "/api/v1/skills"); + request.setCookies( + new Cookie("SESSION", "expired"), + new Cookie("JSESSIONID", "expired-servlet"), + new Cookie("locale", "zh")); + FilterChain chain = mock(FilterChain.class); + + filter.doFilter(request, new MockHttpServletResponse(), chain); + + HttpServletRequest downstream = capturedRequest(chain); + assertNull(downstream.getRequestedSessionId()); + assertArrayEquals(new String[]{"locale"}, + java.util.Arrays.stream(downstream.getCookies()).map(Cookie::getName).toArray(String[]::new)); + } + + @Test + void expiredSessionOnProtectedMethod_shouldRemainVisibleForUnauthorizedResponse() throws Exception { + MockHttpServletRequest request = expiredSessionRequest("POST", "/api/v1/skills"); + FilterChain chain = mock(FilterChain.class); + + filter.doFilter(request, new MockHttpServletResponse(), chain); + + assertSame(request, capturedRequest(chain)); + assertEquals("expired", request.getRequestedSessionId()); + } + + @Test + void validSessionOnPublicRoute_shouldRemainUnchanged() throws Exception { + MockHttpServletRequest request = expiredSessionRequest("GET", "/api/v1/search"); + request.setRequestedSessionIdValid(true); + FilterChain chain = mock(FilterChain.class); + + filter.doFilter(request, new MockHttpServletResponse(), chain); + + assertSame(request, capturedRequest(chain)); + } + + @Test + void forwardedPrefix_shouldUseServletPathForPublicRouteDecision() throws Exception { + MockHttpServletRequest request = expiredSessionRequest("GET", "/skillhub/api/v1/search"); + request.setContextPath("/skillhub"); + request.setServletPath("/api/v1/search"); + FilterChain chain = mock(FilterChain.class); + + filter.doFilter(request, new MockHttpServletResponse(), chain); + + assertNull(capturedRequest(chain).getRequestedSessionId()); + } + + private static MockHttpServletRequest expiredSessionRequest(String method, String path) { + MockHttpServletRequest request = new MockHttpServletRequest(); + request.setMethod(method); + request.setRequestURI(path); + request.setRequestedSessionId("expired"); + request.setRequestedSessionIdValid(false); + return request; + } + + private static HttpServletRequest capturedRequest(FilterChain chain) throws Exception { + ArgumentCaptor requestCaptor = ArgumentCaptor.forClass(ServletRequest.class); + ArgumentCaptor responseCaptor = ArgumentCaptor.forClass(ServletResponse.class); + verify(chain).doFilter(requestCaptor.capture(), responseCaptor.capture()); + return (HttpServletRequest) requestCaptor.getValue(); + } +}