From 60fed4d94ff5909f5105cbfac719af5d700a23ec Mon Sep 17 00:00:00 2001 From: XiaoSeS <87064762+XiaoSeS@users.noreply.github.com> Date: Sat, 29 Aug 2026 15:05:09 +0800 Subject: [PATCH] fix(auth): support bearer tokens behind sub-path proxies Signed-off-by: XiaoSeS <87064762+XiaoSeS@users.noreply.github.com> --- .../skillhub/filter/AuthContextFilter.java | 11 +----- .../skillhub/auth/config/SecurityConfig.java | 2 +- .../policy/RouteSecurityPolicyRegistry.java | 17 +++++++++ .../token/ApiTokenAuthenticationFilter.java | 3 +- .../auth/token/ApiTokenScopeFilter.java | 8 ++-- .../RouteSecurityPolicyRegistryTest.java | 13 +++++++ .../ApiTokenAuthenticationFilterTest.java | 20 ++++++++++ .../auth/token/ApiTokenScopeFilterTest.java | 38 +++++++++++++++++++ 8 files changed, 98 insertions(+), 14 deletions(-) diff --git a/server/skillhub-app/src/main/java/com/iflytek/skillhub/filter/AuthContextFilter.java b/server/skillhub-app/src/main/java/com/iflytek/skillhub/filter/AuthContextFilter.java index a53ee303..4ca626c2 100644 --- a/server/skillhub-app/src/main/java/com/iflytek/skillhub/filter/AuthContextFilter.java +++ b/server/skillhub-app/src/main/java/com/iflytek/skillhub/filter/AuthContextFilter.java @@ -59,7 +59,8 @@ public class AuthContextFilter extends OncePerRequestFilter { HttpServletRequest request, HttpServletResponse response, FilterChain filterChain) throws ServletException, IOException { - if (!routeSecurityPolicyRegistry.shouldProjectRequestContext(requestPath(request))) { + if (!routeSecurityPolicyRegistry.shouldProjectRequestContext( + RouteSecurityPolicyRegistry.requestPath(request))) { filterChain.doFilter(request, response); return; } @@ -88,14 +89,6 @@ public class AuthContextFilter extends OncePerRequestFilter { filterChain.doFilter(request, response); } - private String requestPath(HttpServletRequest request) { - String servletPath = request.getServletPath(); - if (servletPath != null && !servletPath.isBlank()) { - return servletPath; - } - return request.getRequestURI(); - } - private boolean isInactiveUser(String userId) { if (!enforceActiveUserCheck) { return false; 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 812bfac3..f529b480 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 @@ -103,7 +103,7 @@ public class SecurityConfig { var csrfHandler = new CsrfTokenRequestAttributeHandler(); csrfHandler.setCsrfRequestAttributeName(null); RequestMatcher csrfIgnoreMatcher = request -> { - String path = request.getRequestURI(); + String path = RouteSecurityPolicyRegistry.requestPath(request); String authorization = request.getHeader("Authorization"); return routeSecurityPolicyRegistry.shouldIgnoreCsrf(request.getMethod(), path, authorization, hasSessionCookie(request)); }; diff --git a/server/skillhub-auth/src/main/java/com/iflytek/skillhub/auth/policy/RouteSecurityPolicyRegistry.java b/server/skillhub-auth/src/main/java/com/iflytek/skillhub/auth/policy/RouteSecurityPolicyRegistry.java index 1d136076..f2ecbfa6 100644 --- a/server/skillhub-auth/src/main/java/com/iflytek/skillhub/auth/policy/RouteSecurityPolicyRegistry.java +++ b/server/skillhub-auth/src/main/java/com/iflytek/skillhub/auth/policy/RouteSecurityPolicyRegistry.java @@ -2,6 +2,7 @@ package com.iflytek.skillhub.auth.policy; import java.util.List; import java.util.Set; +import jakarta.servlet.http.HttpServletRequest; import org.springframework.http.HttpMethod; import org.springframework.security.web.util.matcher.AntPathRequestMatcher; import org.springframework.security.web.util.matcher.RequestMatcher; @@ -184,6 +185,22 @@ public class RouteSecurityPolicyRegistry { return (method == null ? "ANY" : method.name()) + " " + pattern; } + /** + * Returns the application-relative request path used by security policies. + * + *

When a reverse proxy supplies {@code X-Forwarded-Prefix}, Spring exposes + * that external prefix through {@code getRequestURI()} while keeping the + * application route in {@code getServletPath()}. Security filters must match + * the latter or bearer authentication is skipped for sub-path deployments.

+ */ + public static String requestPath(HttpServletRequest request) { + String servletPath = request.getServletPath(); + if (servletPath != null && !servletPath.isBlank()) { + return servletPath; + } + return request.getRequestURI(); + } + public ApiTokenAuthorizationDecision authorizeApiToken(String method, String path, Set tokenScopes) { if (!isApiPath(path)) { return ApiTokenAuthorizationDecision.allow(); diff --git a/server/skillhub-auth/src/main/java/com/iflytek/skillhub/auth/token/ApiTokenAuthenticationFilter.java b/server/skillhub-auth/src/main/java/com/iflytek/skillhub/auth/token/ApiTokenAuthenticationFilter.java index 8b24aa86..841bad25 100644 --- a/server/skillhub-auth/src/main/java/com/iflytek/skillhub/auth/token/ApiTokenAuthenticationFilter.java +++ b/server/skillhub-auth/src/main/java/com/iflytek/skillhub/auth/token/ApiTokenAuthenticationFilter.java @@ -1,6 +1,7 @@ package com.iflytek.skillhub.auth.token; import com.iflytek.skillhub.auth.entity.ApiToken; +import com.iflytek.skillhub.auth.policy.RouteSecurityPolicyRegistry; import com.iflytek.skillhub.auth.rbac.PlatformPrincipal; import com.iflytek.skillhub.auth.rbac.PlatformRoleDefaults; import com.iflytek.skillhub.auth.repository.UserRoleBindingRepository; @@ -114,7 +115,7 @@ public class ApiTokenAuthenticationFilter extends OncePerRequestFilter { @Override protected boolean shouldNotFilter(HttpServletRequest request) { - String path = request.getRequestURI(); + String path = RouteSecurityPolicyRegistry.requestPath(request); return !(path.startsWith("/api/v1/") || path.startsWith("/api/web/") || path.startsWith("/api/cli/")); diff --git a/server/skillhub-auth/src/main/java/com/iflytek/skillhub/auth/token/ApiTokenScopeFilter.java b/server/skillhub-auth/src/main/java/com/iflytek/skillhub/auth/token/ApiTokenScopeFilter.java index 5182ce7f..55bcf385 100644 --- a/server/skillhub-auth/src/main/java/com/iflytek/skillhub/auth/token/ApiTokenScopeFilter.java +++ b/server/skillhub-auth/src/main/java/com/iflytek/skillhub/auth/token/ApiTokenScopeFilter.java @@ -1,6 +1,7 @@ package com.iflytek.skillhub.auth.token; import com.iflytek.skillhub.auth.rbac.PlatformPrincipal; +import com.iflytek.skillhub.auth.policy.RouteSecurityPolicyRegistry; import jakarta.servlet.FilterChain; import jakarta.servlet.ServletException; import jakarta.servlet.http.HttpServletRequest; @@ -47,9 +48,10 @@ public class ApiTokenScopeFilter extends OncePerRequestFilter { .map(authority -> authority.substring("SCOPE_".length())) .collect(Collectors.toSet()); + String requestPath = RouteSecurityPolicyRegistry.requestPath(request); ApiTokenScopeService.AuthorizationDecision decision = apiTokenScopeService.authorize( request.getMethod(), - request.getRequestURI(), + requestPath, tokenScopes ); @@ -60,13 +62,13 @@ public class ApiTokenScopeFilter extends OncePerRequestFilter { ApiTokenAccessDeniedException exception = decision.requiredScope() != null ? ApiTokenAccessDeniedException.missingScope(decision.requiredScope()) - : ApiTokenAccessDeniedException.unsupportedEndpoint(request.getRequestURI()); + : ApiTokenAccessDeniedException.unsupportedEndpoint(requestPath); accessDeniedHandler.handle(request, response, exception); } @Override protected boolean shouldNotFilter(HttpServletRequest request) { - String path = request.getRequestURI(); + String path = RouteSecurityPolicyRegistry.requestPath(request); return path == null || (!path.startsWith("/api/v1/") && !path.startsWith("/api/web/") && !path.startsWith("/api/cli/")); diff --git a/server/skillhub-auth/src/test/java/com/iflytek/skillhub/auth/policy/RouteSecurityPolicyRegistryTest.java b/server/skillhub-auth/src/test/java/com/iflytek/skillhub/auth/policy/RouteSecurityPolicyRegistryTest.java index 1f3b33d2..36e6b462 100644 --- a/server/skillhub-auth/src/test/java/com/iflytek/skillhub/auth/policy/RouteSecurityPolicyRegistryTest.java +++ b/server/skillhub-auth/src/test/java/com/iflytek/skillhub/auth/policy/RouteSecurityPolicyRegistryTest.java @@ -9,6 +9,7 @@ import java.util.ArrayList; import java.util.List; import java.util.Set; import org.junit.jupiter.api.Test; +import org.springframework.mock.web.MockHttpServletRequest; class RouteSecurityPolicyRegistryTest { @@ -147,6 +148,18 @@ class RouteSecurityPolicyRegistryTest { assertFalse(registry.shouldProjectRequestContext("/assets/index.css")); } + @Test + void requestPathUsesApplicationRouteBehindForwardedPrefix() { + MockHttpServletRequest request = new MockHttpServletRequest( + "GET", + "/skillhub/api/cli/v1/auth/whoami" + ); + request.setContextPath("/skillhub"); + request.setServletPath("/api/cli/v1/auth/whoami"); + + assertEquals("/api/cli/v1/auth/whoami", RouteSecurityPolicyRegistry.requestPath(request)); + } + @Test void authorizeApiToken_allowsPublicLabelCatalogue() { assertTrue(registry.authorizeApiToken("GET", "/api/v1/labels", Set.of()).allowed()); diff --git a/server/skillhub-auth/src/test/java/com/iflytek/skillhub/auth/token/ApiTokenAuthenticationFilterTest.java b/server/skillhub-auth/src/test/java/com/iflytek/skillhub/auth/token/ApiTokenAuthenticationFilterTest.java index fa9fb2cf..ee4e4bcb 100644 --- a/server/skillhub-auth/src/test/java/com/iflytek/skillhub/auth/token/ApiTokenAuthenticationFilterTest.java +++ b/server/skillhub-auth/src/test/java/com/iflytek/skillhub/auth/token/ApiTokenAuthenticationFilterTest.java @@ -227,6 +227,26 @@ class ApiTokenAuthenticationFilterTest { verify(apiTokenService).touchLastUsed(token); } + @Test + void shouldAuthenticateBearerTokensBehindForwardedPrefix() throws Exception { + ApiToken token = new ApiToken("user-4", "cli", "sk_test", "hash", "[\"skill:read\"]"); + UserAccount user = new UserAccount("user-4", "Dana", "dana@example.com", ""); + + when(apiTokenService.validateToken("raw-token")).thenReturn(Optional.of(token)); + when(userAccountRepository.findById("user-4")).thenReturn(Optional.of(user)); + when(roleBindingRepository.findByUserId("user-4")).thenReturn(List.of()); + + MockHttpServletRequest request = new MockHttpServletRequest("GET", "/skillhub/api/cli/v1/auth/whoami"); + request.setContextPath("/skillhub"); + request.setServletPath("/api/cli/v1/auth/whoami"); + request.addHeader("Authorization", "Bearer raw-token"); + + filter.doFilter(request, new MockHttpServletResponse(), new MockFilterChain()); + + assertNotNull(SecurityContextHolder.getContext().getAuthentication()); + verify(apiTokenService).touchLastUsed(token); + } + private static List cliReadRoutes() { return Stream.of( "/api/cli/v1/skills/search", diff --git a/server/skillhub-auth/src/test/java/com/iflytek/skillhub/auth/token/ApiTokenScopeFilterTest.java b/server/skillhub-auth/src/test/java/com/iflytek/skillhub/auth/token/ApiTokenScopeFilterTest.java index 085016f4..e225ef7b 100644 --- a/server/skillhub-auth/src/test/java/com/iflytek/skillhub/auth/token/ApiTokenScopeFilterTest.java +++ b/server/skillhub-auth/src/test/java/com/iflytek/skillhub/auth/token/ApiTokenScopeFilterTest.java @@ -180,4 +180,42 @@ class ApiTokenScopeFilterTest { assertTrue(response.getErrorMessage().contains("Missing API token scope: skill:publish")); verify(chain, never()).doFilter(request, response); } + + @Test + void shouldAuthorizeApplicationPathBehindForwardedPrefix() throws Exception { + AccessDeniedHandler handler = mock(AccessDeniedHandler.class); + ApiTokenScopeFilter filter = new ApiTokenScopeFilter(scopeService, handler); + + PlatformPrincipal principal = new PlatformPrincipal( + "user-5", + "Erin", + "erin@example.com", + "", + "api_token", + Set.of("USER") + ); + var authentication = new UsernamePasswordAuthenticationToken( + principal, + null, + List.of( + new SimpleGrantedAuthority("ROLE_USER"), + new SimpleGrantedAuthority("SCOPE_skill:read") + ) + ); + SecurityContextHolder.getContext().setAuthentication(authentication); + + MockHttpServletRequest request = new MockHttpServletRequest( + "GET", + "/skillhub/api/cli/v1/namespaces/global/skills" + ); + request.setContextPath("/skillhub"); + request.setServletPath("/api/cli/v1/namespaces/global/skills"); + MockHttpServletResponse response = new MockHttpServletResponse(); + FilterChain chain = mock(FilterChain.class); + + filter.doFilter(request, response, chain); + + verify(chain).doFilter(request, response); + verify(handler, never()).handle(eq(request), eq(response), any()); + } }