This is an automated email from the ASF dual-hosted git repository.
lizhimins pushed a commit to branch rocketmq-studio
in repository https://gitbox.apache.org/repos/asf/rocketmq-dashboard.git
The following commit(s) were added to refs/heads/rocketmq-studio by this push:
new 69abafac fix(auth): enforce admin access for mutating APIs (#1007)
69abafac is described below
commit 69abafacd5d2b96f41c3c03f85adf21db426ba2e
Author: yyqdbngt <[email protected]>
AuthorDate: Wed Aug 5 17:44:43 2026 +0800
fix(auth): enforce admin access for mutating APIs (#1007)
Non-admin sessions were authenticated but their role was ignored, so
direct API requests could bypass the read-only frontend controls.
Deny protected mutations by default while preserving explicit read-only
POST endpoints for reader sessions.
---
docs/api-spec.md | 5 ++
.../rocketmq/studio/auth/AuthInterceptor.java | 43 ++++++++--
.../apache/rocketmq/studio/auth/AuthService.java | 13 +++
.../studio/auth/AuthCorsIntegrationTest.java | 20 +++++
.../rocketmq/studio/auth/AuthInterceptorTest.java | 96 +++++++++++++++++++++-
.../rocketmq/studio/auth/AuthServiceTest.java | 2 +
6 files changed, 170 insertions(+), 9 deletions(-)
diff --git a/docs/api-spec.md b/docs/api-spec.md
index dafe277f..f83f2894 100644
--- a/docs/api-spec.md
+++ b/docs/api-spec.md
@@ -170,6 +170,11 @@ POST /api/auth/login
| `user.username` | `string` | 用户名 |
| `user.admin` | `boolean` | 是否管理员 |
+启用 `studio.auth.login-required` 后,非管理员会话为只读角色。GET、HEAD
+和只读查询接口可访问;创建、更新、删除、重启、发送消息等写操作要求
+`user.admin=true`,否则返回 HTTP 403。登录、登出、CORS 预检及明确的只读
+POST 查询不受管理员限制。
+
### 1.2 登出
```
diff --git
a/server/src/main/java/org/apache/rocketmq/studio/auth/AuthInterceptor.java
b/server/src/main/java/org/apache/rocketmq/studio/auth/AuthInterceptor.java
index df42a591..be8aeff9 100644
--- a/server/src/main/java/org/apache/rocketmq/studio/auth/AuthInterceptor.java
+++ b/server/src/main/java/org/apache/rocketmq/studio/auth/AuthInterceptor.java
@@ -21,14 +21,25 @@ import jakarta.servlet.http.HttpServletRequest;
import jakarta.servlet.http.HttpServletResponse;
import lombok.RequiredArgsConstructor;
import org.springframework.http.HttpHeaders;
+import org.springframework.http.HttpMethod;
import org.springframework.http.HttpStatus;
import org.springframework.http.MediaType;
import org.springframework.web.cors.CorsUtils;
import org.springframework.web.servlet.HandlerInterceptor;
+import java.util.Set;
+
@RequiredArgsConstructor
public class AuthInterceptor implements HandlerInterceptor {
+ private static final Set<String> READER_POST_PATHS = Set.of(
+ "/api/auth/logout",
+ "/api/ai/chat",
+ "/api/clusters/test-connection",
+ "/api/llm/config/test",
+ "/api/metrics/query",
+ "/api/settings/datasources/test");
+
private final AuthProperties authProperties;
private final AuthService authService;
@@ -41,16 +52,34 @@ public class AuthInterceptor implements HandlerInterceptor {
return true;
}
String authorization = request.getHeader(HttpHeaders.AUTHORIZATION);
- if (authService.isAuthenticated(authorization)) {
- authService.getAuthenticatedUser(authorization)
- .ifPresent(user ->
AuthenticatedUserContext.setUsername(user.getUsername()));
- return true;
+ if (!authService.isAuthenticated(authorization)) {
+ writeError(response, HttpStatus.UNAUTHORIZED, "Unauthorized");
+ return false;
+ }
+ authService.getAuthenticatedUser(authorization)
+ .ifPresent(user ->
AuthenticatedUserContext.setUsername(user.getUsername()));
+ if (requiresAdmin(request, requestPath(request)) &&
!authService.isAdmin(authorization)) {
+ writeError(response, HttpStatus.FORBIDDEN, "Admin permission
required");
+ return false;
}
+ return true;
+ }
+
+ private boolean requiresAdmin(HttpServletRequest request, String path) {
+ String method = request.getMethod();
+ if (HttpMethod.GET.matches(method) || HttpMethod.HEAD.matches(method)
+ || HttpMethod.OPTIONS.matches(method)) {
+ return false;
+ }
+ return !HttpMethod.POST.matches(method) ||
!READER_POST_PATHS.contains(normalizePath(path));
+ }
- response.setStatus(HttpStatus.UNAUTHORIZED.value());
+ private void writeError(HttpServletResponse response, HttpStatus status,
String message)
+ throws Exception {
+ response.setStatus(status.value());
response.setContentType(MediaType.APPLICATION_JSON_VALUE);
-
response.getWriter().write("{\"code\":401,\"message\":\"Unauthorized\",\"data\":null}");
- return false;
+ response.getWriter().write("{\"code\":" + status.value()
+ + ",\"message\":\"" + message + "\",\"data\":null}");
}
@Override
diff --git
a/server/src/main/java/org/apache/rocketmq/studio/auth/AuthService.java
b/server/src/main/java/org/apache/rocketmq/studio/auth/AuthService.java
index 5e60d8bc..b81ce2ba 100644
--- a/server/src/main/java/org/apache/rocketmq/studio/auth/AuthService.java
+++ b/server/src/main/java/org/apache/rocketmq/studio/auth/AuthService.java
@@ -108,6 +108,19 @@ public class AuthService {
return Optional.of(session.user());
}
+ public boolean isAdmin(String authorization) {
+ Optional<String> token = tokenFromAuthorization(authorization);
+ if (token.isEmpty()) {
+ return false;
+ }
+ AuthSession session = activeTokens.get(token.get());
+ if (session == null || session.expiresAtMillis() <= clock.millis()) {
+ activeTokens.remove(token.get());
+ return false;
+ }
+ return session.user().isAdmin();
+ }
+
public void logout(String authorization) {
tokenFromAuthorization(authorization).ifPresent(activeTokens::remove);
log.info("User logged out");
diff --git
a/server/src/test/java/org/apache/rocketmq/studio/auth/AuthCorsIntegrationTest.java
b/server/src/test/java/org/apache/rocketmq/studio/auth/AuthCorsIntegrationTest.java
index 0818af6d..0d5701ae 100644
---
a/server/src/test/java/org/apache/rocketmq/studio/auth/AuthCorsIntegrationTest.java
+++
b/server/src/test/java/org/apache/rocketmq/studio/auth/AuthCorsIntegrationTest.java
@@ -31,11 +31,14 @@ import org.springframework.http.HttpHeaders;
import org.springframework.test.web.servlet.MockMvc;
import static org.mockito.Mockito.verify;
+import static org.mockito.Mockito.verifyNoInteractions;
import static org.mockito.Mockito.verifyNoMoreInteractions;
import static org.mockito.Mockito.when;
import static
org.springframework.test.web.servlet.request.MockMvcRequestBuilders.get;
import static
org.springframework.test.web.servlet.request.MockMvcRequestBuilders.options;
+import static
org.springframework.test.web.servlet.request.MockMvcRequestBuilders.post;
import static
org.springframework.test.web.servlet.result.MockMvcResultMatchers.header;
+import static
org.springframework.test.web.servlet.result.MockMvcResultMatchers.jsonPath;
import static
org.springframework.test.web.servlet.result.MockMvcResultMatchers.status;
@WebMvcTest(value = InstanceController.class, properties =
"studio.auth.login-required=true")
@@ -81,4 +84,21 @@ class AuthCorsIntegrationTest {
verify(authService).isAuthenticated(null);
}
+ @Test
+ void shouldRejectNonAdminMutationBeforeControllerExecution() throws
Exception {
+ String authorization = "Bearer reader-token";
+ when(authService.isAuthenticated(authorization)).thenReturn(true);
+ when(authService.isAdmin(authorization)).thenReturn(false);
+
+ mockMvc.perform(post("/api/instances/create")
+ .header(HttpHeaders.AUTHORIZATION, authorization)
+ .contentType("application/json")
+ .content("{}"))
+ .andExpect(status().isForbidden())
+ .andExpect(jsonPath("$.code").value(403))
+ .andExpect(jsonPath("$.message").value("Admin permission
required"));
+
+ verifyNoInteractions(instanceService);
+ }
+
}
diff --git
a/server/src/test/java/org/apache/rocketmq/studio/auth/AuthInterceptorTest.java
b/server/src/test/java/org/apache/rocketmq/studio/auth/AuthInterceptorTest.java
index e1d5951f..d1f77071 100644
---
a/server/src/test/java/org/apache/rocketmq/studio/auth/AuthInterceptorTest.java
+++
b/server/src/test/java/org/apache/rocketmq/studio/auth/AuthInterceptorTest.java
@@ -146,10 +146,102 @@ class AuthInterceptorTest {
}
private AuthService authService(AuthProperties properties) {
+ return new AuthService(properties, settingsRepositoryWithTimeout(30));
+ }
+
+ private SettingsRepository settingsRepositoryWithTimeout(int
sessionTimeout) {
SettingsRepository settingsRepository = mock(SettingsRepository.class);
when(settingsRepository.loadGeneralSettings()).thenReturn(GeneralSettingsVO.builder()
- .sessionTimeout(30)
+ .sessionTimeout(sessionTimeout)
.build());
- return new AuthService(properties, settingsRepository);
+ return settingsRepository;
}
+
+ @Test
+ void shouldAllowReadOnlyGetForNonAdminUser() throws Exception {
+ TestSession session = login(false);
+ MockHttpServletRequest request = authenticatedRequest(
+ "GET", "/api/clusters", session.token());
+
+ boolean allowed = session.interceptor().preHandle(
+ request, new MockHttpServletResponse(), new Object());
+
+ assertThat(allowed).isTrue();
+ }
+
+ @Test
+ void shouldRejectMutatingPostForNonAdminUser() throws Exception {
+ TestSession session = login(false);
+ MockHttpServletRequest request = authenticatedRequest(
+ "POST", "/api/ops/updateUseTLS", session.token());
+ MockHttpServletResponse response = new MockHttpServletResponse();
+
+ boolean allowed = session.interceptor().preHandle(request, response,
new Object());
+
+ assertThat(allowed).isFalse();
+ assertThat(response.getStatus()).isEqualTo(403);
+ assertThat(response.getContentAsString()).contains("Admin permission
required");
+ }
+
+ @Test
+ void shouldAllowMutatingPostForAdminUser() throws Exception {
+ TestSession session = login(true);
+ MockHttpServletRequest request = authenticatedRequest(
+ "POST", "/api/ops/updateUseTLS", session.token());
+
+ boolean allowed = session.interceptor().preHandle(
+ request, new MockHttpServletResponse(), new Object());
+
+ assertThat(allowed).isTrue();
+ }
+
+ @Test
+ void shouldAllowReadOnlyPostForNonAdminUser() throws Exception {
+ TestSession session = login(false);
+ MockHttpServletRequest request = authenticatedRequest(
+ "POST", "/api/metrics/query/", session.token());
+
+ boolean allowed = session.interceptor().preHandle(
+ request, new MockHttpServletResponse(), new Object());
+
+ assertThat(allowed).isTrue();
+ }
+
+ @Test
+ void shouldAllowLogoutForNonAdminUser() throws Exception {
+ TestSession session = login(false);
+ MockHttpServletRequest request = authenticatedRequest(
+ "POST", "/api/auth/logout", session.token());
+
+ boolean allowed = session.interceptor().preHandle(
+ request, new MockHttpServletResponse(), new Object());
+
+ assertThat(allowed).isTrue();
+ }
+
+ private TestSession login(boolean admin) {
+ AuthProperties properties = new AuthProperties();
+ properties.setLoginRequired(true);
+ AuthProperties.User user = new AuthProperties.User();
+ user.setUsername("test-user");
+ user.setPassword("secret");
+ user.setAdmin(admin);
+ properties.setUsers(List.of(user));
+ AuthService authService = new AuthService(properties,
settingsRepositoryWithTimeout(30));
+ LoginDTO login = new LoginDTO();
+ login.setUsername("test-user");
+ login.setPassword("secret");
+ String token = authService.login(login).getToken();
+ return new TestSession(new AuthInterceptor(properties, authService),
token);
+ }
+
+ private MockHttpServletRequest authenticatedRequest(String method, String
path, String token) {
+ MockHttpServletRequest request = new MockHttpServletRequest(method,
path);
+ request.addHeader(HttpHeaders.AUTHORIZATION, "Bearer " + token);
+ return request;
+ }
+
+ private record TestSession(AuthInterceptor interceptor, String token) {
+ }
+
}
diff --git
a/server/src/test/java/org/apache/rocketmq/studio/auth/AuthServiceTest.java
b/server/src/test/java/org/apache/rocketmq/studio/auth/AuthServiceTest.java
index 5ef26650..35eb94c1 100644
--- a/server/src/test/java/org/apache/rocketmq/studio/auth/AuthServiceTest.java
+++ b/server/src/test/java/org/apache/rocketmq/studio/auth/AuthServiceTest.java
@@ -77,6 +77,7 @@ class AuthServiceTest {
assertThat(authService.getAuthenticatedUser("Bearer " +
response.getToken()))
.hasValueSatisfying(userInfo ->
assertThat(userInfo.getUsername()).isEqualTo("testuser"));
assertThat(authService.getAuthenticatedUser("Bearer
unknown-token")).isEmpty();
+ assertThat(authService.isAdmin("Bearer " +
response.getToken())).isFalse();
}
@Test
@@ -94,6 +95,7 @@ class AuthServiceTest {
assertThat(response.getUser().getUsername()).isEqualTo("admin");
assertThat(response.getUser().isAdmin()).isTrue();
+ assertThat(authService.isAdmin("Bearer " +
response.getToken())).isTrue();
}
@Test