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());
+ }
}