fix(auth): support bearer tokens behind sub-path proxies

Signed-off-by: XiaoSeS <87064762+XiaoSeS@users.noreply.github.com>
This commit is contained in:
XiaoSeS 2026-08-29 15:05:09 +08:00
parent 2babc0935b
commit 60fed4d94f
8 changed files with 98 additions and 14 deletions

View file

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

View file

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

View file

@ -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.
*
* <p>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.</p>
*/
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<String> tokenScopes) {
if (!isApiPath(path)) {
return ApiTokenAuthorizationDecision.allow();

View file

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

View file

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

View file

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

View file

@ -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<String> cliReadRoutes() {
return Stream.of(
"/api/cli/v1/skills/search",

View file

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