Refactor controller method resolution with Spring MVC (#15686)

Reuse the active RequestMappingHandlerMapping for authorization method lookup, retain a deprecated legacy fallback, and add auth-enabled integration coverage for module permissions and ambiguous URI forms.

Assisted-by: Claude Code
This commit is contained in:
杨翊 SionYang
2026-08-10 18:32:25 +08:00
committed by GitHub
parent 3a508ca047
commit b7a122e481
16 changed files with 1012 additions and 3 deletions
+63
View File
@@ -0,0 +1,63 @@
# This workflow validates Nacos with Open, Admin, and Console API auth enabled.
name: Auth Integration Test
on:
pull_request:
branches: [ develop ]
permissions:
contents: read
concurrency:
group: ${{ github.workflow }}-${{ github.event.pull_request.number || github.ref }}
cancel-in-progress: ${{ github.event_name == 'pull_request' }}
jobs:
auth-integration-test:
name: Auth Integration Test
runs-on: [self-hosted, Linux, X64, nacos-ci, nacos-java]
steps:
- name: Checkout code
uses: actions/checkout@v4
- name: Set up JDK 17
uses: actions/setup-java@v4
with:
java-version: '17'
distribution: 'temurin'
cache: 'maven'
- name: Build with Maven
run: mvn -B clean install -Prelease-nacos -DskipTests=true
- name: Start Server With All Auth Scopes
run: |
PROP_FILE=$(ls distribution/target/nacos-server-*/nacos/conf/application.properties)
sed 's|^#\?nacos.plugin.auth.nacos.token.secret.key=.*|nacos.plugin.auth.nacos.token.secret.key=VGhpc0lzTXlDdXN0b21TZWNyZXRLZXkwMTIzNDU2Nzg=|' "$PROP_FILE" > "${PROP_FILE}.tmp" && mv "${PROP_FILE}.tmp" "$PROP_FILE"
sed 's|nacos.core.auth.server.identity.key=.*|nacos.core.auth.server.identity.key=testKey|' "$PROP_FILE" > "${PROP_FILE}.tmp" && mv "${PROP_FILE}.tmp" "$PROP_FILE"
sed 's|nacos.core.auth.server.identity.value=.*|nacos.core.auth.server.identity.value=testValue|' "$PROP_FILE" > "${PROP_FILE}.tmp" && mv "${PROP_FILE}.tmp" "$PROP_FILE"
sed 's|nacos.core.auth.enabled=.*|nacos.core.auth.enabled=true|' "$PROP_FILE" > "${PROP_FILE}.tmp" && mv "${PROP_FILE}.tmp" "$PROP_FILE"
sed 's|nacos.core.auth.admin.enabled=.*|nacos.core.auth.admin.enabled=true|' "$PROP_FILE" > "${PROP_FILE}.tmp" && mv "${PROP_FILE}.tmp" "$PROP_FILE"
sed 's|nacos.core.auth.console.enabled=.*|nacos.core.auth.console.enabled=true|' "$PROP_FILE" > "${PROP_FILE}.tmp" && mv "${PROP_FILE}.tmp" "$PROP_FILE"
sed 's|nacos.plugin.auth.nacos.caching.enabled=.*|nacos.plugin.auth.nacos.caching.enabled=false|' "$PROP_FILE" > "${PROP_FILE}.tmp" && mv "${PROP_FILE}.tmp" "$PROP_FILE"
bash distribution/target/nacos-server-*/nacos/bin/startup.sh -m standalone
- name: Wait for Server Startup
run: |
if ! timeout 60s bash -c 'until curl -s localhost:8080 > /dev/null; do sleep 2; done'; then
LOG_DIR=$(ls -d distribution/target/nacos-server-*/nacos/logs 2>/dev/null | head -n 1 || true)
if [ -n "$LOG_DIR" ]; then
tail -300 "$LOG_DIR/startup.log" || true
tail -300 "$LOG_DIR/nacos.log" || true
fi
exit 1
fi
- name: Bootstrap Administrator
run: |
curl -fsS -X POST --data-urlencode 'password=NacosAuth123!' \
localhost:8848/nacos/v3/auth/user/admin
- name: Run Auth Integration Tests
run: mvn -B -pl test/auth-test clean verify -Pauth-integration-test -DskipTests=false
@@ -305,6 +305,11 @@ nacos.core.auth.admin.enabled=true
# Whether open nacos console API auth system. It controls /v3/console/* HTTP request authentication only.
nacos.core.auth.console.enabled=true
### Whether to downgrade controller-method resolution to the Nacos legacy annotation cache.
### The legacy resolver is deprecated since 3.3.0, will be removed in 3.4.0,
### and may differ from Spring MVC path matching.
nacos.core.auth.controller-method-cache.legacy-enabled=false
### worked when nacos.core.auth.enabled=true
### The two properties is the white list for auth and used by identity the request from other server.
nacos.core.auth.server.identity.key=
@@ -19,8 +19,10 @@ package com.alibaba.nacos.console.config;
import com.alibaba.nacos.console.handler.impl.remote.EnabledRemoteHandler;
import com.alibaba.nacos.core.code.ControllerMethodsCache;
import com.alibaba.nacos.naming.selector.SelectorManager;
import org.springframework.beans.factory.ObjectProvider;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
import org.springframework.web.servlet.mvc.method.annotation.RequestMappingHandlerMapping;
/**
* Load Beans for {@link com.alibaba.nacos.sys.env.DeploymentType#CONSOLE} type.
@@ -32,8 +34,9 @@ import org.springframework.context.annotation.Configuration;
public class ConsoleDeploymentConfig {
@Bean
public ControllerMethodsCache controllerMethodsCache() {
return new ControllerMethodsCache();
public ControllerMethodsCache controllerMethodsCache(
ObjectProvider<RequestMappingHandlerMapping> handlerMappingProvider) {
return new ControllerMethodsCache(handlerMappingProvider);
}
@Bean
@@ -19,12 +19,13 @@ package com.alibaba.nacos.console.config;
import org.junit.jupiter.api.Test;
import static org.junit.jupiter.api.Assertions.assertNotNull;
import static org.mockito.Mockito.mock;
class ConsoleDeploymentConfigTest {
@Test
void controllerMethodsCache() {
assertNotNull(new ConsoleDeploymentConfig().controllerMethodsCache());
assertNotNull(new ConsoleDeploymentConfig().controllerMethodsCache(mock()));
}
@Test
@@ -29,7 +29,15 @@ import com.alibaba.nacos.sys.env.EnvUtil;
import jakarta.servlet.http.HttpServletRequest;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import org.springframework.beans.factory.ObjectProvider;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.stereotype.Component;
import org.springframework.web.method.HandlerMethod;
import org.springframework.web.servlet.HandlerExecutionChain;
import org.springframework.web.servlet.mvc.method.annotation.RequestMappingHandlerMapping;
import org.springframework.web.context.WebApplicationContext;
import org.springframework.web.context.support.WebApplicationContextUtils;
import org.springframework.web.util.ServletRequestPathUtils;
import org.springframework.web.bind.annotation.DeleteMapping;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.PatchMapping;
@@ -59,6 +67,11 @@ import static com.alibaba.nacos.sys.env.Constants.REQUEST_PATH_SEPARATOR;
@Component
public class ControllerMethodsCache {
public static final String LEGACY_RESOLVER_ENABLED =
"nacos.core.auth.controller-method-cache.legacy-enabled";
private static final String MVC_HANDLER_MAPPING_BEAN_NAME = "requestMappingHandlerMapping";
private static final Logger LOGGER = LoggerFactory.getLogger(ControllerMethodsCache.class);
private ConcurrentMap<RequestMappingInfo, Method> methods = new ConcurrentHashMap<>();
@@ -68,7 +81,92 @@ public class ControllerMethodsCache {
private final Set<Class> scannedClass = new HashSet<>();
private final ObjectProvider<RequestMappingHandlerMapping> handlerMappingProvider;
/**
* Create a controller method resolver backed by Spring MVC's handler mapping.
*
* @param handlerMappingProvider lazy provider used to avoid controller initialization cycles
*/
@Autowired
public ControllerMethodsCache(
ObjectProvider<RequestMappingHandlerMapping> handlerMappingProvider) {
this.handlerMappingProvider = handlerMappingProvider;
}
/**
* Create a resolver using the legacy annotation cache.
*
* @deprecated since 3.3.0, only retained for tests and compatibility and will be removed in
* 3.4.0. Use Spring MVC handler mapping.
*/
@Deprecated(since = "3.3.0", forRemoval = true)
public ControllerMethodsCache() {
this.handlerMappingProvider = null;
}
public Method getMethod(HttpServletRequest request) {
if (handlerMappingProvider != null && !isLegacyResolverEnabled()) {
return getMethodFromHandlerMapping(request);
}
return getMethodFromLegacyCache(request);
}
private boolean isLegacyResolverEnabled() {
String systemProperty = System.getProperty(LEGACY_RESOLVER_ENABLED);
if (systemProperty != null) {
return Boolean.parseBoolean(systemProperty);
}
return EnvUtil.getProperty(LEGACY_RESOLVER_ENABLED, Boolean.class, false);
}
private Method getMethodFromHandlerMapping(HttpServletRequest request) {
RequestMappingHandlerMapping handlerMapping = resolveHandlerMapping(request);
if (handlerMapping == null) {
throw new NacosRuntimeException(NacosException.SERVER_ERROR,
"Spring MVC RequestMappingHandlerMapping is unavailable");
}
boolean parsedRequestPath = false;
try {
if (handlerMapping.usesPathPatterns()
&& !ServletRequestPathUtils.hasParsedRequestPath(request)) {
ServletRequestPathUtils.parseAndCache(request);
parsedRequestPath = true;
}
HandlerExecutionChain handler = handlerMapping.getHandler(request);
if (handler == null || !(handler.getHandler() instanceof HandlerMethod)) {
return null;
}
return ((HandlerMethod) handler.getHandler()).getMethod();
} catch (Exception e) {
throw new NacosRuntimeException(NacosException.SERVER_ERROR,
"Failed to resolve Spring MVC controller method", e);
} finally {
if (parsedRequestPath) {
ServletRequestPathUtils.clearParsedRequestPath(request);
}
}
}
private RequestMappingHandlerMapping resolveHandlerMapping(HttpServletRequest request) {
WebApplicationContext webApplicationContext =
WebApplicationContextUtils.getWebApplicationContext(request.getServletContext());
if (webApplicationContext != null
&& webApplicationContext.containsBean(MVC_HANDLER_MAPPING_BEAN_NAME)) {
return webApplicationContext.getBean(MVC_HANDLER_MAPPING_BEAN_NAME,
RequestMappingHandlerMapping.class);
}
return handlerMappingProvider.getIfUnique();
}
/**
* Resolve a method with the original Nacos-maintained annotation cache.
*
* @deprecated since 3.3.0, use Spring MVC handler mapping so authorization and dispatch share
* one resolver. This legacy resolver will be removed in 3.4.0.
*/
@Deprecated(since = "3.3.0", forRemoval = true)
private Method getMethodFromLegacyCache(HttpServletRequest request) {
String path = getPath(request);
String httpMethod = request.getMethod();
String urlKey = httpMethod + REQUEST_PATH_SEPARATOR
@@ -21,7 +21,13 @@ import com.alibaba.nacos.sys.env.EnvUtil;
import org.junit.jupiter.api.AfterEach;
import org.junit.jupiter.api.BeforeEach;
import org.junit.jupiter.api.Test;
import org.springframework.beans.factory.ObjectProvider;
import org.springframework.mock.web.MockHttpServletRequest;
import org.springframework.mock.web.MockServletContext;
import org.springframework.web.context.WebApplicationContext;
import org.springframework.web.method.HandlerMethod;
import org.springframework.web.servlet.HandlerExecutionChain;
import org.springframework.web.servlet.mvc.method.annotation.RequestMappingHandlerMapping;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.PostMapping;
import org.springframework.web.bind.annotation.PutMapping;
@@ -40,6 +46,8 @@ import static org.junit.jupiter.api.Assertions.assertNotNull;
import static org.junit.jupiter.api.Assertions.assertNull;
import static org.junit.jupiter.api.Assertions.assertThrows;
import static org.junit.jupiter.api.Assertions.assertTrue;
import static org.mockito.Mockito.mock;
import static org.mockito.Mockito.when;
/**
* Unit test for {@link ControllerMethodsCache}.
@@ -52,11 +60,65 @@ class ControllerMethodsCacheTest {
void setUp() {
cache = new ControllerMethodsCache();
EnvUtil.setContextPath("/nacos");
System.setProperty(ControllerMethodsCache.LEGACY_RESOLVER_ENABLED, "false");
}
@AfterEach
void tearDown() {
EnvUtil.setContextPath(null);
System.clearProperty(ControllerMethodsCache.LEGACY_RESOLVER_ENABLED);
}
@Test
void getMethodUsesSpringMvcHandlerMappingByDefault() throws Exception {
ObjectProvider<RequestMappingHandlerMapping> provider = mock(ObjectProvider.class);
RequestMappingHandlerMapping handlerMapping = mock(RequestMappingHandlerMapping.class);
MockHttpServletRequest request =
new MockHttpServletRequest("GET", "/n%61cos/api/get");
Method expected = TestController.class.getMethod("get");
HandlerMethod handlerMethod = new HandlerMethod(new TestController(), expected);
when(provider.getIfUnique()).thenReturn(handlerMapping);
when(handlerMapping.getHandler(request))
.thenReturn(new HandlerExecutionChain(handlerMethod));
ControllerMethodsCache springCache = new ControllerMethodsCache(provider);
assertEquals(expected, springCache.getMethod(request));
}
@Test
void getMethodResolvesHandlerMappingFromRequestWebContext() throws Exception {
ObjectProvider<RequestMappingHandlerMapping> parentProvider = mock(ObjectProvider.class);
RequestMappingHandlerMapping handlerMapping = mock(RequestMappingHandlerMapping.class);
WebApplicationContext webApplicationContext = mock(WebApplicationContext.class);
MockServletContext servletContext = new MockServletContext();
servletContext.setAttribute(WebApplicationContext.ROOT_WEB_APPLICATION_CONTEXT_ATTRIBUTE,
webApplicationContext);
MockHttpServletRequest request =
new MockHttpServletRequest(servletContext, "GET", "/nacos/api/get");
Method expected = TestController.class.getMethod("get");
when(webApplicationContext.containsBean("requestMappingHandlerMapping")).thenReturn(true);
when(webApplicationContext.getBean("requestMappingHandlerMapping",
RequestMappingHandlerMapping.class)).thenReturn(handlerMapping);
when(handlerMapping.getHandler(request)).thenReturn(
new HandlerExecutionChain(new HandlerMethod(new TestController(), expected)));
ControllerMethodsCache springCache = new ControllerMethodsCache(parentProvider);
assertEquals(expected, springCache.getMethod(request));
}
@Test
void getMethodCanDowngradeToLegacyResolver() throws Exception {
ObjectProvider<RequestMappingHandlerMapping> provider = mock(ObjectProvider.class);
ControllerMethodsCache springCache = new ControllerMethodsCache(provider);
springCache.initClassMethod(Collections.singleton(TestController.class));
System.setProperty(ControllerMethodsCache.LEGACY_RESOLVER_ENABLED, "true");
MockHttpServletRequest request = new MockHttpServletRequest("GET", "/nacos/api/get");
request.setRequestURI("/nacos/api/get");
request.setParameter("required", "yes");
assertEquals("get", springCache.getMethod(request).getName());
}
@Test
+5
View File
@@ -334,6 +334,11 @@ nacos.core.auth.admin.enabled=true
# Whether open nacos console API auth system. It controls /v3/console/* HTTP request authentication only.
nacos.core.auth.console.enabled=true
### Whether to downgrade controller-method resolution to the Nacos legacy annotation cache.
### The legacy resolver is deprecated since 3.3.0, will be removed in 3.4.0,
### and may differ from Spring MVC path matching.
nacos.core.auth.controller-method-cache.legacy-enabled=false
### worked when nacos.core.auth.enabled=true
### The two properties is the white list for auth and used by identity the request from other server.
nacos.core.auth.server.identity.key=
@@ -99,6 +99,22 @@ Filter order rules:
model when the filter owns the rejection. Unexpected infrastructure failures
may be rethrown for global exception handling.
HTTP controller-method resolution rules:
- Components that resolve controller methods before Spring MVC dispatch must reuse the active
Spring MVC `RequestMappingHandlerMapping`. Authorization and dispatch must therefore select the
same controller method from the same servlet request, including its request-specific context
path and configured path matching rules.
- Literal path parameters, single or repeated percent encoding, duplicate empty segments, dot
segments, malformed encodings, invalid UTF-8, control characters, Unicode separator lookalikes,
absolute-form request targets, and encoded path separators must not be processed by an
independent authorization-only normalization algorithm.
- Query parameters do not participate in controller path matching.
- `nacos.core.auth.controller-method-cache.legacy-enabled=true` may temporarily downgrade method
resolution to the legacy annotation cache. The legacy resolver is deprecated since 3.3.0,
scheduled for removal in 3.4.0, and can differ from Spring MVC path matching, so it must remain
disabled by default.
## 4. gRPC Request Filter Model
gRPC business requests are accepted by `GrpcRequestAcceptor`, parsed into
@@ -80,6 +80,19 @@ filter 顺序规则:
- 当 filter 拥有拒绝逻辑时,filter 异常应转换为统一异常或 result 模型。未预期的基础设施失败
可以抛出给全局异常处理。
HTTP Controller 方法解析规则:
- 在 Spring MVC 分发前解析 Controller 方法的组件必须复用当前 Spring MVC 的
`RequestMappingHandlerMapping`。鉴权与分发必须基于同一个 Servlet request、请求级
context path 和路径匹配配置选择同一个 Controller 方法。
- 字面量 path parameter、单次或多次百分号编码、重复空 segment、dot segment、非法编码、
非法 UTF-8、控制字符、Unicode 分隔符近似字符、absolute-form request target 和编码后的
路径分隔符,不得由鉴权流程使用独立的归一化算法处理。
- query parameter 不参与 Controller 路径匹配。
- 可通过 `nacos.core.auth.controller-method-cache.legacy-enabled=true` 临时降级到旧注解缓存
解析器。旧解析器从 3.3.0 起废弃,计划在 3.4.0 移除,且可能与 Spring MVC 路径匹配结果
不一致,因此默认必须关闭。
## 4. gRPC 请求过滤模型
gRPC 业务请求由 `GrpcRequestAcceptor` 接收,解析为 `Request` 对象,匹配到 `RequestHandler`
+32
View File
@@ -0,0 +1,32 @@
<!--
Copyright 1999-2026 Alibaba Group Holding Ltd.
Licensed under the Apache License, Version 2.0 (the "License");
you may not use this file except in compliance with the License.
You may obtain a copy of the License at
http://www.apache.org/licenses/LICENSE-2.0
Unless required by applicable law or agreed to in writing, software
distributed under the License is distributed on an "AS IS" BASIS,
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
See the License for the specific language governing permissions and
limitations under the License.
-->
# Auth Integration Test Scenarios
The `auth-test` module runs against standalone Nacos with all three auth scopes
enabled: Open API, Admin API, and Console API auth.
| Scenario group | Coverage |
| --- | --- |
| Module authorization | Representative Config, Naming, AI, Core, and Console APIs reject missing identity, invalid identity, and an authenticated user without authority, then accept the same user after the required read permission is granted. |
| Default auth APIs | Administrator login plus user, role, and permission create/query/delete workflows, including rejection of non-admin management attempts. |
| Ambiguous URI handling | Single and double percent encoding, hex case variants, encoded unreserved characters, matrix parameters, duplicate separators, literal and encoded dot segments, slash/backslash variants, Unicode slash lookalikes, malformed UTF-8 and percent escapes, control characters, absolute-form targets, and query confusion cannot reach a protected controller without authorization. |
The malformed-path set is intentionally exercised against a real standalone
server because mock servlet requests do not reproduce connector and servlet
canonicalization. Routable equivalent URIs must return the normal 403 auth response. Invalid or
ambiguous request targets may instead be rejected with a 4xx/5xx response or a closed connection;
redirects and successful responses fail the test because they may expose protected business data.
+80
View File
@@ -0,0 +1,80 @@
<?xml version="1.0" encoding="UTF-8"?>
<!--
~ Copyright 1999-2026 Alibaba Group Holding Ltd.
~
~ Licensed under the Apache License, Version 2.0 (the "License");
~ you may not use this file except in compliance with the License.
~ You may obtain a copy of the License at
~
~ http://www.apache.org/licenses/LICENSE-2.0
~
~ Unless required by applicable law or agreed to in writing, software
~ distributed under the License is distributed on an "AS IS" BASIS,
~ WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
~ See the License for the specific language governing permissions and
~ limitations under the License.
-->
<project xmlns="http://maven.apache.org/POM/4.0.0"
xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
xsi:schemaLocation="http://maven.apache.org/POM/4.0.0 http://maven.apache.org/xsd/maven-4.0.0.xsd">
<modelVersion>4.0.0</modelVersion>
<parent>
<groupId>com.alibaba.nacos</groupId>
<artifactId>nacos-test</artifactId>
<version>${revision}</version>
</parent>
<artifactId>auth-test</artifactId>
<name>nacos-auth-test ${project.version}</name>
<url>https://nacos.io</url>
<properties>
<maven.compiler.source>17</maven.compiler.source>
<maven.compiler.target>17</maven.compiler.target>
<project.build.sourceEncoding>UTF-8</project.build.sourceEncoding>
</properties>
<dependencies>
<dependency>
<groupId>${project.groupId}</groupId>
<artifactId>nacos-common</artifactId>
</dependency>
<dependency>
<groupId>org.junit.jupiter</groupId>
<artifactId>junit-jupiter</artifactId>
<scope>test</scope>
</dependency>
</dependencies>
<profiles>
<profile>
<id>auth-integration-test</id>
<build>
<plugins>
<plugin>
<groupId>org.apache.maven.plugins</groupId>
<artifactId>maven-failsafe-plugin</artifactId>
<version>${maven-failsafe-plugin.version}</version>
<executions>
<execution>
<goals>
<goal>integration-test</goal>
<goal>verify</goal>
</goals>
</execution>
</executions>
<configuration>
<systemPropertyVariables>
<nacos.host>127.0.0.1</nacos.host>
<nacos.port>8848</nacos.port>
<nacos.console.port>8080</nacos.console.port>
<nacos.auth.username>nacos</nacos.auth.username>
<nacos.auth.password>NacosAuth123!</nacos.auth.password>
</systemPropertyVariables>
</configuration>
</plugin>
</plugins>
</build>
</profile>
</profiles>
</project>
@@ -0,0 +1,167 @@
/*
* Copyright 1999-2026 Alibaba Group Holding Ltd.
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
package com.alibaba.nacos.test.auth;
import org.junit.jupiter.api.Test;
import org.junit.jupiter.params.ParameterizedTest;
import org.junit.jupiter.params.provider.Arguments;
import org.junit.jupiter.params.provider.MethodSource;
import org.junit.jupiter.params.provider.ValueSource;
import java.util.stream.Stream;
import static org.junit.jupiter.api.Assertions.assertEquals;
/**
* Authentication bypass regression tests for ambiguous request URI forms.
*
* @author Nacos
*/
public class AmbiguousUriAuthITCase extends AuthITCase {
private static final String PROTECTED_PATH = "/v3/admin/core/namespace/list";
@ParameterizedTest
@ValueSource(strings = {
"/n%61cos/v3/admin/core/namespace/list",
"/%6Eacos/v3/admin/core/namespace/list",
"/na%63os/v3/admin/core/namespace/list",
"/naco%73/v3/admin/core/namespace/list",
"/nacos/v3/admin/core/namespace/l%69st",
"/nacos/v3/admin/core/namespace/%6Cist",
"/nacos/v3/admin/core/namespace/lis%74",
"/nacos/v3/admin/core/namespace/list?",
"/nacos/v3/admin/core/namespace/list?foo=bar&answer=42",
"/nacos/v3/admin/core/namespace/list?next=%2Fnacos%2Fv3%2Fadmin",
"/nacos/v3/admin/core/namespace/list?foo=first&foo=second"
})
void testEquivalentRoutedUriIsAuthenticated(String path) throws Exception {
assertDenied(get(SERVER_BASE_URL, path, null));
}
@ParameterizedTest(name = "{0}: {1}")
@MethodSource("suspiciousRequestTargets")
void testSuspiciousRequestTargetIsBlocked(String category, String requestTarget)
throws Exception {
assertBlocked(rawGet(requestTarget));
}
@Test
void testCanonicalProtectedPathStillRequiresAuthentication() throws Exception {
assertEquals(403, get(SERVER_BASE_URL, CONTEXT_PATH + PROTECTED_PATH, null).status());
}
private static Stream<Arguments> suspiciousRequestTargets() {
return Stream.of(
Arguments.of("matrix-param-context",
"/nacos;tenant=other/v3/admin/core/namespace/list"),
Arguments.of("matrix-param-middle",
"/nacos/v3;ignored=true/admin/core/namespace/list"),
Arguments.of("matrix-param-end",
"/nacos/v3/admin/core/namespace/list;ignored=true"),
Arguments.of("encoded-semicolon",
"/nacos/v3/admin/core/namespace/list%3Bignored=true"),
Arguments.of("duplicate-slash-context",
"/nacos//v3/admin/core/namespace/list"),
Arguments.of("duplicate-slash-middle",
"/nacos/v3//admin/core/namespace/list"),
Arguments.of("multiple-leading-slashes",
"//nacos/v3/admin/core/namespace/list"),
Arguments.of("triple-slash",
"/nacos///v3/admin/core/namespace/list"),
Arguments.of("trailing-slash",
"/nacos/v3/admin/core/namespace/list/"),
Arguments.of("literal-current-segment",
"/nacos/v3/admin/core/namespace/./list"),
Arguments.of("literal-parent-segment",
"/nacos/v3/admin/core/namespace/other/../list"),
Arguments.of("encoded-current-segment",
"/nacos/v3/admin/core/namespace/%2e/list"),
Arguments.of("encoded-parent-segment",
"/nacos/v3/admin/core/namespace/other/%2e%2e/list"),
Arguments.of("mixed-encoded-parent-segment",
"/nacos/v3/admin/core/namespace/other/.%2e/list"),
Arguments.of("double-encoded-dot-segment",
"/nacos/v3/admin/core/namespace/other/%252e%252e/list"),
Arguments.of("encoded-forward-slash",
"/nacos/v3/admin/core/namespace%2Flist"),
Arguments.of("lowercase-encoded-forward-slash",
"/nacos/v3/admin/core/namespace%2flist"),
Arguments.of("double-encoded-forward-slash",
"/nacos/v3/admin/core/namespace%252Flist"),
Arguments.of("encoded-backslash",
"/nacos/v3/admin/core/namespace%5Clist"),
Arguments.of("lowercase-encoded-backslash",
"/nacos/v3/admin/core/namespace%5clist"),
Arguments.of("double-encoded-backslash",
"/nacos/v3/admin/core/namespace%255Clist"),
Arguments.of("literal-backslash-context",
"/nacos\\v3/admin/core/namespace/list"),
Arguments.of("literal-backslash-endpoint",
"/nacos/v3/admin/core/namespace\\list"),
Arguments.of("unicode-division-slash",
"/nacos/v3/admin/core/namespace%E2%88%95list"),
Arguments.of("unicode-fraction-slash",
"/nacos/v3/admin/core/namespace%E2%81%84list"),
Arguments.of("unicode-fullwidth-slash",
"/nacos/v3/admin/core/namespace%EF%BC%8Flist"),
Arguments.of("double-encoded-context",
"/n%2561cos/v3/admin/core/namespace/list"),
Arguments.of("double-encoded-controller-segment",
"/nacos/v3/admin/core/namespace/l%2569st"),
Arguments.of("encoded-percent",
"/nacos/v3/admin/core/namespace%25/list"),
Arguments.of("encoded-question-mark",
"/nacos/v3/admin/core/namespace%3Fignored/list"),
Arguments.of("encoded-fragment-marker",
"/nacos/v3/admin/core/namespace%23ignored/list"),
Arguments.of("encoded-space",
"/nacos/v3/admin/core/namespace%20/list"),
Arguments.of("encoded-tab",
"/nacos/v3/admin/core/namespace%09/list"),
Arguments.of("encoded-carriage-return",
"/nacos/v3/admin/core/namespace%0D/list"),
Arguments.of("encoded-line-feed",
"/nacos/v3/admin/core/namespace%0A/list"),
Arguments.of("encoded-null",
"/nacos/v3/admin/core/namespace%00/list"),
Arguments.of("invalid-utf8-byte",
"/nacos/v3/admin/core/namespace%FF/list"),
Arguments.of("overlong-utf8-slash",
"/nacos/v3/admin/core/namespace%C0%AFlist"),
Arguments.of("invalid-utf8-sequence",
"/nacos/v3/admin/core/namespace%C3%28/list"),
Arguments.of("utf8-surrogate",
"/nacos/v3/admin/core/namespace%ED%A0%80/list"),
Arguments.of("malformed-percent-only",
"/nacos/v3/admin/core/namespace%/list"),
Arguments.of("malformed-percent-short",
"/nacos/v3/admin/core/namespace%2/list"),
Arguments.of("malformed-percent-non-hex",
"/nacos/v3/admin/core/namespace%GG/list"),
Arguments.of("non-standard-unicode-escape",
"/n%u0061cos/v3/admin/core/namespace/list"),
Arguments.of("absolute-form-request-target",
"http://127.0.0.1:" + NACOS_PORT
+ "/nacos/v3/admin/core/namespace/list"),
Arguments.of("asterisk-form-on-get", "*"),
Arguments.of("userinfo-like-path",
"/nacos@other/v3/admin/core/namespace/list"),
Arguments.of("case-variant-context",
"/NACOS/v3/admin/core/namespace/list"));
}
}
@@ -0,0 +1,293 @@
/*
* Copyright 1999-2026 Alibaba Group Holding Ltd.
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
package com.alibaba.nacos.test.auth;
import com.alibaba.nacos.common.utils.JacksonUtils;
import com.fasterxml.jackson.databind.JsonNode;
import org.junit.jupiter.api.AfterEach;
import org.junit.jupiter.api.BeforeEach;
import java.net.InetSocketAddress;
import java.net.URI;
import java.net.URLEncoder;
import java.net.Socket;
import java.net.http.HttpClient;
import java.net.http.HttpRequest;
import java.net.http.HttpResponse.BodyHandlers;
import java.nio.charset.StandardCharsets;
import java.time.Duration;
import java.util.ArrayDeque;
import java.util.LinkedHashMap;
import java.util.Map;
import java.util.UUID;
import static org.junit.jupiter.api.Assertions.assertEquals;
import static org.junit.jupiter.api.Assertions.assertNotNull;
import static org.junit.jupiter.api.Assertions.assertTrue;
/**
* Shared auth-enabled standalone-server integration test support.
*
* @author Nacos
*/
abstract class AuthITCase {
protected static final String NACOS_HOST =
System.getProperty("nacos.host", "127.0.0.1");
protected static final int NACOS_PORT =
Integer.parseInt(System.getProperty("nacos.port", "8848"));
protected static final int NACOS_CONSOLE_PORT =
Integer.parseInt(System.getProperty("nacos.console.port", "8080"));
protected static final String SERVER_BASE_URL =
"http://" + NACOS_HOST + ':' + NACOS_PORT;
protected static final String CONSOLE_BASE_URL =
"http://" + NACOS_HOST + ':' + NACOS_CONSOLE_PORT;
protected static final String CONTEXT_PATH = "/nacos";
private static final String ADMIN_USERNAME =
System.getProperty("nacos.auth.username", "nacos");
private static final String ADMIN_PASSWORD =
System.getProperty("nacos.auth.password", "NacosAuth123!");
private static final String USER_PATH = CONTEXT_PATH + "/v3/auth/user";
private static final String ROLE_PATH = CONTEXT_PATH + "/v3/auth/role";
private static final String PERMISSION_PATH =
CONTEXT_PATH + "/v3/auth/permission";
private final ArrayDeque<CleanupAction> cleanupActions = new ArrayDeque<>();
protected HttpClient httpClient;
private String adminToken;
@BeforeEach
void setUpAuthClient() throws Exception {
httpClient = HttpClient.newBuilder().connectTimeout(Duration.ofSeconds(5)).build();
adminToken = login(ADMIN_USERNAME, ADMIN_PASSWORD);
}
@AfterEach
void tearDownAuthData() throws Exception {
Exception failure = null;
while (!cleanupActions.isEmpty()) {
try {
cleanupActions.removeLast().run();
} catch (Exception e) {
if (failure == null) {
failure = e;
} else {
failure.addSuppressed(e);
}
}
}
if (failure != null) {
throw failure;
}
}
protected String adminToken() {
return adminToken;
}
protected TestIdentity createIdentityWithoutPermission(String prefix) throws Exception {
String suffix = UUID.randomUUID().toString().replace("-", "").substring(0, 10);
String username = prefix + '-' + suffix;
String password = "AuthTest123!";
String role = "ROLE_" + prefix.toUpperCase().replace('-', '_') + '_' + suffix;
assertSuccess(postForm(SERVER_BASE_URL, USER_PATH, adminToken,
params("username", username, "password", password)));
cleanupActions.add(() -> deleteForm(SERVER_BASE_URL, USER_PATH, adminToken,
params("username", username)));
assertSuccess(postForm(SERVER_BASE_URL, ROLE_PATH, adminToken,
params("role", role, "username", username)));
cleanupActions.add(() -> deleteForm(SERVER_BASE_URL, ROLE_PATH, adminToken,
params("role", role, "username", username)));
return new TestIdentity(username, role, awaitLogin(username, password));
}
protected void grantReadPermission(TestIdentity identity, String resource)
throws Exception {
assertSuccess(postForm(SERVER_BASE_URL, PERMISSION_PATH, adminToken,
params("role", identity.role(), "resource", resource, "action", "r")));
cleanupActions.add(() -> deleteForm(SERVER_BASE_URL, PERMISSION_PATH, adminToken,
params("role", identity.role(), "resource", resource, "action", "r")));
}
protected void addCleanup(CleanupAction action) {
cleanupActions.add(action);
}
protected String login(String username, String password) throws Exception {
Response response = postForm(SERVER_BASE_URL, USER_PATH + "/login", null,
params("username", username, "password", password));
assertEquals(200, response.status(), response.body());
JsonNode root = JacksonUtils.toObj(response.body());
assertNotNull(root, response.body());
assertTrue(root.hasNonNull("accessToken"), response.body());
return root.get("accessToken").asText();
}
protected String awaitLogin(String username, String password) throws Exception {
long deadline = System.nanoTime() + Duration.ofSeconds(20).toNanos();
AssertionError lastFailure = null;
do {
try {
return login(username, password);
} catch (AssertionError e) {
lastFailure = e;
Thread.sleep(250L);
}
} while (System.nanoTime() < deadline);
throw lastFailure;
}
protected Response get(String baseUrl, String path, String token) throws Exception {
HttpRequest.Builder builder = HttpRequest.newBuilder(URI.create(baseUrl + path))
.timeout(Duration.ofSeconds(15)).GET();
addToken(builder, token);
return execute(builder.build());
}
protected Response rawGet(String requestTarget) throws Exception {
try (Socket socket = new Socket()) {
socket.connect(new InetSocketAddress(NACOS_HOST, NACOS_PORT), 5000);
socket.setSoTimeout(15000);
String request = "GET " + requestTarget + " HTTP/1.1\r\nHost: " + NACOS_HOST + ':'
+ NACOS_PORT + "\r\nConnection: close\r\n\r\n";
socket.getOutputStream().write(request.getBytes(StandardCharsets.US_ASCII));
socket.getOutputStream().flush();
String response = new String(socket.getInputStream().readAllBytes(),
StandardCharsets.ISO_8859_1);
if (response.isEmpty()) {
return new Response(0, "Connection closed without an HTTP response");
}
int firstSpace = response.indexOf(' ');
int secondSpace = response.indexOf(' ', firstSpace + 1);
if (firstSpace < 0 || secondSpace < 0) {
return new Response(0, response);
}
return new Response(Integer.parseInt(response.substring(firstSpace + 1, secondSpace)),
response);
}
}
protected Response postForm(String baseUrl, String path, String token,
Map<String, String> parameters) throws Exception {
HttpRequest.Builder builder = HttpRequest.newBuilder(URI.create(baseUrl + path))
.timeout(Duration.ofSeconds(15))
.header("Content-Type", "application/x-www-form-urlencoded")
.POST(HttpRequest.BodyPublishers.ofString(encodeParameters(parameters)));
addToken(builder, token);
return execute(builder.build());
}
protected Response putForm(String baseUrl, String path, String token,
Map<String, String> parameters) throws Exception {
HttpRequest.Builder builder = HttpRequest.newBuilder(URI.create(baseUrl + path))
.timeout(Duration.ofSeconds(15))
.header("Content-Type", "application/x-www-form-urlencoded")
.PUT(HttpRequest.BodyPublishers.ofString(encodeParameters(parameters)));
addToken(builder, token);
return execute(builder.build());
}
protected Response deleteForm(String baseUrl, String path, String token,
Map<String, String> parameters) throws Exception {
String requestPath = path + '?' + encodeParameters(parameters);
HttpRequest.Builder builder = HttpRequest.newBuilder(URI.create(baseUrl + requestPath))
.timeout(Duration.ofSeconds(15)).DELETE();
addToken(builder, token);
return execute(builder.build());
}
protected void assertDenied(Response response) {
assertEquals(403, response.status(), response.body());
JsonNode root = JacksonUtils.toObj(response.body());
assertEquals(10001, root.get("code").asInt(), response.body());
}
protected void assertBlocked(Response response) {
assertTrue(response.status() == 0
|| response.status() >= 400 && response.status() < 600,
"Suspicious URI must not return business data or redirect: "
+ response.status() + ": " + response.body());
}
protected JsonNode assertSuccess(Response response) {
assertEquals(200, response.status(), response.body());
JsonNode root = JacksonUtils.toObj(response.body());
assertNotNull(root, response.body());
assertEquals(0, root.get("code").asInt(), response.body());
return root;
}
protected static Map<String, String> params(String... pairs) {
Map<String, String> result = new LinkedHashMap<>();
for (int i = 0; i < pairs.length; i += 2) {
result.put(pairs[i], pairs[i + 1]);
}
return result;
}
private Response execute(HttpRequest request) throws Exception {
java.net.http.HttpResponse<String> response =
httpClient.send(request, BodyHandlers.ofString(StandardCharsets.UTF_8));
return new Response(response.statusCode(), response.body());
}
private void addToken(HttpRequest.Builder builder, String token) {
if (token != null) {
builder.header("Authorization", "Bearer " + token);
}
}
private String encodeParameters(Map<String, String> parameters) {
StringBuilder result = new StringBuilder();
for (Map.Entry<String, String> entry : parameters.entrySet()) {
if (!result.isEmpty()) {
result.append('&');
}
result.append(URLEncoder.encode(entry.getKey(), StandardCharsets.UTF_8));
result.append('=');
result.append(URLEncoder.encode(entry.getValue(), StandardCharsets.UTF_8));
}
return result.toString();
}
protected record Response(int status, String body) {
}
protected record TestIdentity(String username, String role, String token) {
}
@FunctionalInterface
protected interface CleanupAction {
void run() throws Exception;
}
}
@@ -0,0 +1,88 @@
/*
* Copyright 1999-2026 Alibaba Group Holding Ltd.
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
package com.alibaba.nacos.test.auth;
import com.fasterxml.jackson.databind.JsonNode;
import org.junit.jupiter.api.Test;
import java.util.UUID;
import static org.junit.jupiter.api.Assertions.assertFalse;
import static org.junit.jupiter.api.Assertions.assertTrue;
/**
* Auth-enabled integration tests for the default user, role, and permission APIs.
*
* @author Nacos
*/
public class DefaultAuthApiITCase extends AuthITCase {
private static final String USER_PATH = CONTEXT_PATH + "/v3/auth/user";
private static final String ROLE_PATH = CONTEXT_PATH + "/v3/auth/role";
private static final String PERMISSION_PATH = CONTEXT_PATH + "/v3/auth/permission";
@Test
void testUserRoleAndPermissionManagement() throws Exception {
String suffix = UUID.randomUUID().toString().replace("-", "").substring(0, 10);
String username = "auth-api-" + suffix;
String role = "ROLE_AUTH_API_" + suffix;
String resource = "public:DEFAULT_GROUP:config/auth-api-" + suffix;
assertSuccess(postForm(SERVER_BASE_URL, USER_PATH, adminToken(),
params("username", username, "password", "AuthTest123!")));
addCleanup(() -> deleteForm(SERVER_BASE_URL, USER_PATH, adminToken(),
params("username", username)));
String userToken = awaitLogin(username, "AuthTest123!");
assertDenied(postForm(SERVER_BASE_URL, USER_PATH, userToken,
params("username", "forbidden-" + suffix, "password", "AuthTest123!")));
JsonNode users = assertSuccess(get(SERVER_BASE_URL,
USER_PATH + "/list?pageNo=1&pageSize=20&username=" + username,
adminToken()));
assertTrue(users.toString().contains(username), users.toString());
assertSuccess(postForm(SERVER_BASE_URL, ROLE_PATH, adminToken(),
params("role", role, "username", username)));
addCleanup(() -> deleteForm(SERVER_BASE_URL, ROLE_PATH, adminToken(),
params("role", role, "username", username)));
JsonNode roles = assertSuccess(get(SERVER_BASE_URL,
ROLE_PATH + "/list?pageNo=1&pageSize=20&username=" + username,
adminToken()));
assertTrue(roles.toString().contains(role), roles.toString());
assertSuccess(postForm(SERVER_BASE_URL, PERMISSION_PATH, adminToken(),
params("role", role, "resource", resource, "action", "r")));
addCleanup(() -> deleteForm(SERVER_BASE_URL, PERMISSION_PATH, adminToken(),
params("role", role, "resource", resource, "action", "r")));
JsonNode duplicate = assertSuccess(get(SERVER_BASE_URL,
PERMISSION_PATH + "?role=" + role + "&resource=" + resource + "&action=r",
adminToken()));
assertTrue(duplicate.get("data").asBoolean(), duplicate.toString());
assertSuccess(deleteForm(SERVER_BASE_URL, PERMISSION_PATH, adminToken(),
params("role", role, "resource", resource, "action", "r")));
JsonNode removed = assertSuccess(get(SERVER_BASE_URL,
PERMISSION_PATH + "?role=" + role + "&resource=" + resource + "&action=r",
adminToken()));
assertFalse(removed.get("data").asBoolean(), removed.toString());
}
}
@@ -0,0 +1,82 @@
/*
* Copyright 1999-2026 Alibaba Group Holding Ltd.
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
package com.alibaba.nacos.test.auth;
import org.junit.jupiter.api.Test;
import static org.junit.jupiter.api.Assertions.assertEquals;
/**
* Representative authorization scenarios for Nacos HTTP API modules.
*
* <p>Each scenario verifies missing identity, invalid identity, valid identity without
* authority, and valid identity with the required authority.</p>
*
* @author Nacos
*/
public class ModuleAuthorizationITCase extends AuthITCase {
@Test
void testConfigOpenApiAuthorization() throws Exception {
verifyAuthorization("config", SERVER_BASE_URL,
CONTEXT_PATH + "/v3/client/cs/config?namespaceId=public"
+ "&groupName=DEFAULT_GROUP&dataId=auth-it-missing",
"public:DEFAULT_GROUP:config/auth-it-missing");
}
@Test
void testNamingAdminApiAuthorization() throws Exception {
verifyAuthorization("naming", SERVER_BASE_URL,
CONTEXT_PATH + "/v3/admin/ns/service/list?namespaceId=public"
+ "&pageNo=1&pageSize=10",
"public:*:naming/*");
}
@Test
void testAiAdminApiAuthorization() throws Exception {
verifyAuthorization("ai", SERVER_BASE_URL,
CONTEXT_PATH + "/v3/admin/ai/agents/list?namespaceId=public"
+ "&pageNo=1&pageSize=10",
"public:DEFAULT_GROUP:ai/*");
}
@Test
void testCoreAdminApiAuthorization() throws Exception {
verifyAuthorization("core", SERVER_BASE_URL,
CONTEXT_PATH + "/v3/admin/core/namespace/list",
"/v3/admin/core/namespace");
}
@Test
void testConsoleApiAuthorization() throws Exception {
verifyAuthorization("console", CONSOLE_BASE_URL,
"/v3/console/cs/config/list?namespaceId=public&pageNo=1&pageSize=10",
"public:*:config/*");
}
private void verifyAuthorization(String prefix, String baseUrl, String path,
String permissionResource) throws Exception {
assertDenied(get(baseUrl, path, null));
assertDenied(get(baseUrl, path, "invalid-token"));
TestIdentity identity = createIdentityWithoutPermission(prefix);
assertDenied(get(baseUrl, path, identity.token()));
grantReadPermission(identity, permissionResource);
assertEquals(200, get(baseUrl, path, identity.token()).status());
}
}
+1
View File
@@ -32,6 +32,7 @@
<url>https://nacos.io</url>
<modules>
<module>openapi-test</module>
<module>auth-test</module>
<module>lock-test</module>
<module>java-sdk-test</module>
<module>maintainer-sdk-test</module>