mirror of
https://github.com/iflytek/skillhub.git
synced 2026-09-05 08:05:56 +00:00
fix(auth): support bearer tokens behind sub-path proxies
Signed-off-by: XiaoSeS <87064762+XiaoSeS@users.noreply.github.com>
This commit is contained in:
parent
2babc0935b
commit
60fed4d94f
8 changed files with 98 additions and 14 deletions
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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));
|
||||
};
|
||||
|
|
|
|||
|
|
@ -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();
|
||||
|
|
|
|||
|
|
@ -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/"));
|
||||
|
|
|
|||
|
|
@ -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/"));
|
||||
|
|
|
|||
|
|
@ -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());
|
||||
|
|
|
|||
|
|
@ -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",
|
||||
|
|
|
|||
|
|
@ -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());
|
||||
}
|
||||
}
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue