fix(auth): preserve protected invalid-session handling

Signed-off-by: XiaoSeS <87064762+XiaoSeS@users.noreply.github.com>
This commit is contained in:
XiaoSeS 2026-08-31 12:36:14 +08:00
parent 98a0e1d2f9
commit 346acbcfa3
3 changed files with 178 additions and 0 deletions

View file

@ -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);

View file

@ -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<String> 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;
}
}
}

View file

@ -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<ServletRequest> requestCaptor = ArgumentCaptor.forClass(ServletRequest.class);
ArgumentCaptor<ServletResponse> responseCaptor = ArgumentCaptor.forClass(ServletResponse.class);
verify(chain).doFilter(requestCaptor.capture(), responseCaptor.capture());
return (HttpServletRequest) requestCaptor.getValue();
}
}