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 e55409633 fix(auth): bound the login username and correct the
wrong-password status (#4684)
e55409633 is described below
commit e554096330229da9c93ca84ffe604f4b54ae59bf
Author: btlqql <[email protected]>
AuthorDate: Mon Sep 21 20:15:29 2026 +0800
fix(auth): bound the login username and correct the wrong-password status
(#4684)
Two login-path fixes from the same author, consolidated into one change.
1. `AuthService.validateLogin` accepted a username of any length before
touching the database: `LoginDTO` has no `@Size`, `AuthController` does not
apply `@Valid`, and `LoginRateLimiter` bounds the number of attempts but not
the key length, so an unauthenticated caller could put arbitrarily long strings
into the rate-limiter map and the query. Usernames longer than the 128-char
column now fail with `BusinessException(400)`, matching the bound the user API
already enforces.
2. Changing a password with a wrong current password answered 401.
`GlobalExceptionHandler` maps the code straight through and `PUBLIC_AUTH_PATHS`
does not cover `/auth/password`, so the caller looked unauthenticated rather
than wrong; the DTO's own validation for the same field already uses 400 and
`docs/api-spec.md` reserves 401 for authentication failures. The status is now
400.
Consolidates #4684 and #4689 (same author, same file); both commits keep
their original authorship on the PR branch.
---
.../apache/rocketmq/studio/auth/AuthService.java | 17 +++-
.../AuthPasswordChangeStatusIntegrationTest.java | 110 +++++++++++++++++++++
.../studio/auth/AuthServiceDatabaseTest.java | 48 +++++++++
3 files changed, 172 insertions(+), 3 deletions(-)
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 ae577c9d9..0d6799fdd 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
@@ -76,6 +76,7 @@ public class AuthService {
private static final Duration STALE_SESSION_THRESHOLD =
Duration.ofMinutes(15);
private static final int MAX_USER_PAGE_SIZE = 100;
private static final int MAX_USER_SEARCH_LENGTH = 128;
+ private static final int MAX_USERNAME_LENGTH = 128;
private static final String TOKEN_PREFIX = "Bearer ";
private static final String EXPIRING_SOON_CUTOFF_PARAM =
"expiringSoonCutoff";
private static final String STALE_CUTOFF_PARAM = "staleCutoff";
@@ -348,7 +349,11 @@ public class AuthService {
requireDatabaseBacked();
RmqStudioUser user = getUser(userId);
if (requireCurrentPassword && !passwordHasher.matches(currentPassword,
user.getPasswordHash())) {
- throw new BusinessException(401, "Current password is incorrect");
+ // The request is already authenticated; this is a payload
problem, not a session one.
+ // 401 is what the Studio client reads as "the session is gone"
and answers by clearing
+ // the session and redirecting to the login page, and the same
field already answers 400
+ // when it is blank (ChangePasswordDTO validation).
+ throw new BusinessException(400, "Current password is incorrect");
}
validatePassword(newPassword);
userMapper.update(null, new UpdateWrapper<RmqStudioUser>()
@@ -561,11 +566,17 @@ public class AuthService {
if (request.getPassword() == null || request.getPassword().isBlank()) {
throw new BusinessException(400, "Password is required");
}
+ if (request.getUsername().trim().length() > MAX_USERNAME_LENGTH) {
+ throw new BusinessException(400,
+ "Username must contain 1 to " + MAX_USERNAME_LENGTH + "
characters");
+ }
}
private void validateUsername(String username) {
- if (username == null || username.isBlank() || username.trim().length()
> 128) {
- throw new BusinessException(400, "Username must contain 1 to 128
characters");
+ if (username == null || username.isBlank()
+ || username.trim().length() > MAX_USERNAME_LENGTH) {
+ throw new BusinessException(400,
+ "Username must contain 1 to " + MAX_USERNAME_LENGTH + "
characters");
}
}
diff --git
a/server/src/test/java/org/apache/rocketmq/studio/auth/AuthPasswordChangeStatusIntegrationTest.java
b/server/src/test/java/org/apache/rocketmq/studio/auth/AuthPasswordChangeStatusIntegrationTest.java
new file mode 100644
index 000000000..80202a414
--- /dev/null
+++
b/server/src/test/java/org/apache/rocketmq/studio/auth/AuthPasswordChangeStatusIntegrationTest.java
@@ -0,0 +1,110 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one or more
+ * contributor license agreements. See the NOTICE file distributed with
+ * this work for additional information regarding copyright ownership.
+ * The ASF licenses this file to You 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 org.apache.rocketmq.studio.auth;
+
+import com.baomidou.mybatisplus.core.conditions.query.QueryWrapper;
+import org.apache.rocketmq.studio.persistence.entity.RmqStudioUser;
+import org.apache.rocketmq.studio.persistence.mapper.RmqStudioUserMapper;
+import org.junit.jupiter.api.AfterEach;
+import org.junit.jupiter.api.BeforeEach;
+import org.junit.jupiter.api.Test;
+import org.springframework.beans.factory.annotation.Autowired;
+import org.springframework.boot.webmvc.test.autoconfigure.AutoConfigureMockMvc;
+import org.springframework.boot.test.context.SpringBootTest;
+import org.springframework.http.HttpHeaders;
+import org.springframework.http.MediaType;
+import org.springframework.test.context.ActiveProfiles;
+import org.springframework.test.web.servlet.MockMvc;
+
+import static
org.springframework.test.web.servlet.request.MockMvcRequestBuilders.get;
+import static
org.springframework.test.web.servlet.request.MockMvcRequestBuilders.post;
+import static
org.springframework.test.web.servlet.result.MockMvcResultMatchers.jsonPath;
+import static
org.springframework.test.web.servlet.result.MockMvcResultMatchers.status;
+
+/**
+ * The status {@code POST /api/auth/password} answers when the caller's own
current password does not
+ * match. The request is already authenticated at that point, so the failure
is about the payload, and
+ * the Studio client keys its session handling on the status: any 401 outside
{@code /auth/login} and
+ * {@code /auth/status} makes it clear the stored session and redirect to the
login page
+ * ({@code web/src/api/client.ts}), which logs an operator out for mistyping
this one field. The same
+ * field already answers 400 when it is blank ({@code ChangePasswordDTO}
validation), so the two
+ * payload problems must agree.
+ *
+ * <p>A real call is needed rather than a controller slice: the status is
produced by
+ * {@code GlobalExceptionHandler}, which maps the {@code BusinessException}
code onto the HTTP status.
+ */
+@SpringBootTest(properties = "studio.auth.login-required=true")
+@AutoConfigureMockMvc
+@ActiveProfiles("dev")
+class AuthPasswordChangeStatusIntegrationTest {
+
+ private static final String USERNAME = "password-change-status-it";
+ private static final String CURRENT_PASSWORD = "current-password-1";
+ private static final String NEW_PASSWORD = "new-password-2";
+
+ @Autowired
+ private MockMvc mockMvc;
+
+ @Autowired
+ private AuthService authService;
+
+ @Autowired
+ private RmqStudioUserMapper userMapper;
+
+ private String authorization;
+
+ @BeforeEach
+ void seedUserAndLogIn() {
+ userMapper.delete(new QueryWrapper<RmqStudioUser>().eq("username",
USERNAME));
+ authService.createUser(USERNAME, CURRENT_PASSWORD, false);
+ LoginDTO request = new LoginDTO();
+ request.setUsername(USERNAME);
+ request.setPassword(CURRENT_PASSWORD);
+ authorization = "Bearer " + authService.login(request).getToken();
+ }
+
+ @AfterEach
+ void deleteSeededUser() {
+ userMapper.delete(new QueryWrapper<RmqStudioUser>().eq("username",
USERNAME));
+ }
+
+ @Test
+ void aWrongCurrentPasswordIsAPayloadErrorAndLeavesTheSessionUsableTest()
throws Exception {
+ mockMvc.perform(post("/api/auth/password")
+ .header(HttpHeaders.AUTHORIZATION, authorization)
+ .contentType(MediaType.APPLICATION_JSON)
+
.content("{\"currentPassword\":\"not-the-current-password\","
+ + "\"newPassword\":\"" + NEW_PASSWORD + "\"}"))
+ .andExpect(status().isBadRequest())
+ .andExpect(jsonPath("$.code").value(400))
+ .andExpect(jsonPath("$.message").value("Current password is
incorrect"));
+
+
mockMvc.perform(get("/api/auth/status").header(HttpHeaders.AUTHORIZATION,
authorization))
+ .andExpect(status().isOk())
+ .andExpect(jsonPath("$.data.authenticated").value(true));
+ }
+
+ @Test
+ void aBlankCurrentPasswordIsRejectedAsAPayloadErrorTest() throws Exception
{
+ mockMvc.perform(post("/api/auth/password")
+ .header(HttpHeaders.AUTHORIZATION, authorization)
+ .contentType(MediaType.APPLICATION_JSON)
+
.content("{\"currentPassword\":\"\",\"newPassword\":\"" + NEW_PASSWORD + "\"}"))
+ .andExpect(status().isBadRequest())
+ .andExpect(jsonPath("$.code").value(400));
+ }
+}
\ No newline at end of file
diff --git
a/server/src/test/java/org/apache/rocketmq/studio/auth/AuthServiceDatabaseTest.java
b/server/src/test/java/org/apache/rocketmq/studio/auth/AuthServiceDatabaseTest.java
index 965e304ca..9e0a43c9f 100644
---
a/server/src/test/java/org/apache/rocketmq/studio/auth/AuthServiceDatabaseTest.java
+++
b/server/src/test/java/org/apache/rocketmq/studio/auth/AuthServiceDatabaseTest.java
@@ -551,6 +551,54 @@ class AuthServiceDatabaseTest {
.hasMessageStartingWith("Too many failed login attempts");
}
+ /**
+ * Every other username entry point bounds the value at 128 characters:
{@code
+ * CreateStudioUserDTO} and {@link AuthService#validateUsername} both
reject longer names and the
+ * column is {@code VARCHAR(128)}. Login accepted any length, issued the
lookup with it and kept
+ * it as an in-memory rate-limiter key.
+ */
+ @Test
+ void loginShouldRejectAnOverlongUsernameBeforeTouchingTheDatabase() {
+ when(userMapper.selectCount(isNull())).thenReturn(1L);
+ when(userMapper.selectOne(any(Wrapper.class))).thenReturn(null);
+ LoginDTO request = new LoginDTO();
+ request.setUsername("u".repeat(129));
+ request.setPassword("password-1");
+
+ assertThatThrownBy(() -> authService.login(request))
+ .isInstanceOf(BusinessException.class)
+ .satisfies(exception -> assertThat(((BusinessException)
exception).getCode())
+ .isEqualTo(400))
+ .hasMessage("Username must contain 1 to 128 characters");
+
+ verifyNoInteractions(userMapper, sessionMapper);
+ }
+
+ /**
+ * The rate limiter bounds how many usernames it tracks, never how long
they are: an
+ * unauthenticated caller could park arbitrarily large keys in it (and in
the retry window of
+ * the surrounding map) by failing a login with an oversized name.
+ */
+ @Test
+ void anOverlongUsernameIsNeverRetainedAsARateLimiterKey() {
+ Clock clock = Clock.fixed(Instant.parse("2026-08-13T00:00:00Z"),
ZoneOffset.UTC);
+ SettingsRepository databaseSettingsRepository =
mock(SettingsRepository.class);
+ when(databaseSettingsRepository.loadGeneralSettings())
+
.thenReturn(GeneralSettingsVO.builder().sessionTimeout(30).build());
+ LoginRateLimiter rateLimiter = new LoginRateLimiter(clock);
+ authService = new AuthService(new AuthProperties(),
databaseSettingsRepository, clock,
+ userMapper, sessionMapper, passwordHasher, rateLimiter);
+ when(userMapper.selectCount(isNull())).thenReturn(1L);
+ when(userMapper.selectOne(any(Wrapper.class))).thenReturn(null);
+ LoginDTO request = new LoginDTO();
+ request.setUsername("u".repeat(4_096));
+ request.setPassword("password-1");
+
+ assertThatThrownBy(() ->
authService.login(request)).isInstanceOf(BusinessException.class);
+
+ assertThat(rateLimiter.trackedUsernameCount()).isZero();
+ }
+
private RmqStudioSession activeSession(Long id, Long userId, LocalDateTime
lastSeenAt) {
RmqStudioSession session = new RmqStudioSession();
session.setId(id);