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 b5f8e2ad fix(auth,audit): harden session lifecycle and operator
attribution (#968)
b5f8e2ad is described below
commit b5f8e2ad7a30fa5272031fc3c8fa3b6f401b2da8
Author: aias00 <[email protected]>
AuthorDate: Wed Aug 5 02:37:25 2026 -0700
fix(auth,audit): harden session lifecycle and operator attribution (#968)
---
.../apache/rocketmq/studio/StudioApplication.java | 2 +
.../studio/audit/OperationAuditService.java | 2 +
.../rocketmq/studio/auth/AuthInterceptor.java | 12 +++-
.../apache/rocketmq/studio/auth/AuthService.java | 54 +++++++++++++----
.../AuthenticatedUserContext.java} | 34 ++++++++---
.../rocketmq/studio/ops/audit/AuditService.java | 2 +
.../studio/audit/OperationAuditServiceTest.java | 69 ++++++++++++++++++++++
.../rocketmq/studio/auth/AuthInterceptorTest.java | 44 +++++++++++---
.../rocketmq/studio/auth/AuthServiceTest.java | 57 ++++++++++++++++--
.../studio/ops/audit/AuditServiceTest.java | 18 ++++++
web/src/pages/settings/index.tsx | 13 +++-
11 files changed, 271 insertions(+), 36 deletions(-)
diff --git
a/server/src/main/java/org/apache/rocketmq/studio/StudioApplication.java
b/server/src/main/java/org/apache/rocketmq/studio/StudioApplication.java
index 7972e774..38a537af 100644
--- a/server/src/main/java/org/apache/rocketmq/studio/StudioApplication.java
+++ b/server/src/main/java/org/apache/rocketmq/studio/StudioApplication.java
@@ -19,8 +19,10 @@ package org.apache.rocketmq.studio;
import org.springframework.boot.SpringApplication;
import org.springframework.boot.autoconfigure.SpringBootApplication;
+import org.springframework.scheduling.annotation.EnableScheduling;
@SpringBootApplication
+@EnableScheduling
public class StudioApplication {
public static void main(String[] args) {
SpringApplication.run(StudioApplication.class, args);
diff --git
a/server/src/main/java/org/apache/rocketmq/studio/audit/OperationAuditService.java
b/server/src/main/java/org/apache/rocketmq/studio/audit/OperationAuditService.java
index 97b9b305..b64f3689 100644
---
a/server/src/main/java/org/apache/rocketmq/studio/audit/OperationAuditService.java
+++
b/server/src/main/java/org/apache/rocketmq/studio/audit/OperationAuditService.java
@@ -17,6 +17,7 @@
package org.apache.rocketmq.studio.audit;
import lombok.extern.slf4j.Slf4j;
+import org.apache.rocketmq.studio.auth.AuthenticatedUserContext;
import org.apache.rocketmq.studio.persistence.entity.RmqOperationAudit;
import org.apache.rocketmq.studio.persistence.mapper.RmqOperationAuditMapper;
import org.springframework.stereotype.Service;
@@ -43,6 +44,7 @@ public class OperationAuditService {
audit.setDetail(detail);
audit.setResult(result);
audit.setErrorMessage(errorMessage);
+ audit.setOperator(AuthenticatedUserContext.currentUsernameOrSystem());
audit.setOperatedAt(LocalDateTime.now());
auditMapper.insert(audit);
log.debug("Audit recorded: {} {} {}", operation, resourceType,
resourceName);
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 1b1ae46d..df42a591 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
@@ -35,11 +35,15 @@ public class AuthInterceptor implements HandlerInterceptor {
@Override
public boolean preHandle(HttpServletRequest request, HttpServletResponse
response,
Object handler) throws Exception {
+ AuthenticatedUserContext.clear();
if (!authProperties.isLoginRequired() ||
CorsUtils.isPreFlightRequest(request)
|| isPublicPath(requestPath(request))) {
return true;
}
- if
(authService.isAuthenticated(request.getHeader(HttpHeaders.AUTHORIZATION))) {
+ String authorization = request.getHeader(HttpHeaders.AUTHORIZATION);
+ if (authService.isAuthenticated(authorization)) {
+ authService.getAuthenticatedUser(authorization)
+ .ifPresent(user ->
AuthenticatedUserContext.setUsername(user.getUsername()));
return true;
}
@@ -49,6 +53,12 @@ public class AuthInterceptor implements HandlerInterceptor {
return false;
}
+ @Override
+ public void afterCompletion(HttpServletRequest request,
HttpServletResponse response,
+ Object handler, Exception ex) throws Exception
{
+ AuthenticatedUserContext.clear();
+ }
+
private boolean isPublicPath(String path) {
path = normalizePath(path);
return path.equals("/api/auth/login")
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 102e1cf1..5e60d8bc 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
@@ -18,11 +18,15 @@
package org.apache.rocketmq.studio.auth;
import org.apache.rocketmq.studio.common.exception.BusinessException;
+import org.apache.rocketmq.studio.settings.GeneralSettingsVO;
+import org.apache.rocketmq.studio.settings.SettingsRepository;
import lombok.extern.slf4j.Slf4j;
import org.springframework.beans.factory.annotation.Autowired;
+import org.springframework.scheduling.annotation.Scheduled;
import org.springframework.stereotype.Service;
import java.time.Clock;
+import java.time.Duration;
import java.util.Map;
import java.util.Optional;
import java.util.concurrent.ConcurrentHashMap;
@@ -32,20 +36,24 @@ import java.util.UUID;
@Service
public class AuthService {
- private static final int TOKEN_TTL_SECONDS = 86400;
+ private static final int DEFAULT_SESSION_TIMEOUT_MINUTES = 30;
+ private static final int MIN_SESSION_TIMEOUT_MINUTES = 5;
+ private static final int MAX_SESSION_TIMEOUT_MINUTES = 1440;
private static final String TOKEN_PREFIX = "Bearer ";
private final AuthProperties authProperties;
+ private final SettingsRepository settingsRepository;
private final Clock clock;
private final Map<String, AuthSession> activeTokens = new
ConcurrentHashMap<>();
@Autowired
- public AuthService(AuthProperties authProperties) {
- this(authProperties, Clock.systemUTC());
+ public AuthService(AuthProperties authProperties, SettingsRepository
settingsRepository) {
+ this(authProperties, settingsRepository, Clock.systemUTC());
}
- AuthService(AuthProperties authProperties, Clock clock) {
+ AuthService(AuthProperties authProperties, SettingsRepository
settingsRepository, Clock clock) {
this.authProperties = authProperties;
+ this.settingsRepository = settingsRepository;
this.clock = clock;
}
@@ -65,13 +73,14 @@ public class AuthService {
LoginVO.UserInfo user = authenticate(request);
long now = clock.millis();
- activeTokens.entrySet().removeIf(entry ->
entry.getValue().expiresAtMillis() <= now);
+ purgeExpiredSessions(now);
+ int tokenTtlSeconds = sessionTimeoutSeconds();
String token = "studio-jwt-" + UUID.randomUUID();
- activeTokens.put(token, new AuthSession(user, now + TOKEN_TTL_SECONDS
* 1000L));
+ activeTokens.put(token, new AuthSession(user, now + tokenTtlSeconds *
1000L));
LoginVO response = LoginVO.builder()
.token(token)
- .expiresIn(TOKEN_TTL_SECONDS)
+ .expiresIn(tokenTtlSeconds)
.user(user)
.build();
@@ -80,19 +89,23 @@ public class AuthService {
}
public boolean isAuthenticated(String authorization) {
+ return getAuthenticatedUser(authorization).isPresent();
+ }
+
+ public Optional<LoginVO.UserInfo> getAuthenticatedUser(String
authorization) {
Optional<String> token = tokenFromAuthorization(authorization);
if (token.isEmpty()) {
- return false;
+ return Optional.empty();
}
AuthSession session = activeTokens.get(token.get());
if (session == null) {
- return false;
+ return Optional.empty();
}
if (session.expiresAtMillis() <= clock.millis()) {
activeTokens.remove(token.get());
- return false;
+ return Optional.empty();
}
- return true;
+ return Optional.of(session.user());
}
public void logout(String authorization) {
@@ -100,6 +113,25 @@ public class AuthService {
log.info("User logged out");
}
+ @Scheduled(fixedDelayString =
"${studio.auth.session-cleanup-interval:PT5M}")
+ public void purgeExpiredSessions() {
+ purgeExpiredSessions(clock.millis());
+ }
+
+ private void purgeExpiredSessions(long now) {
+ activeTokens.entrySet().removeIf(entry ->
entry.getValue().expiresAtMillis() <= now);
+ }
+
+ private int sessionTimeoutSeconds() {
+ GeneralSettingsVO settings = settingsRepository.loadGeneralSettings();
+ int minutes = settings == null ? DEFAULT_SESSION_TIMEOUT_MINUTES :
settings.getSessionTimeout();
+ if (minutes < MIN_SESSION_TIMEOUT_MINUTES || minutes >
MAX_SESSION_TIMEOUT_MINUTES) {
+ log.warn("Ignoring invalid persisted session timeout: {} minutes",
minutes);
+ minutes = DEFAULT_SESSION_TIMEOUT_MINUTES;
+ }
+ return Math.toIntExact(Duration.ofMinutes(minutes).toSeconds());
+ }
+
private LoginVO.UserInfo authenticate(LoginDTO request) {
var configuredUsers = authProperties.configuredUsers();
if (configuredUsers.isEmpty()) {
diff --git
a/server/src/main/java/org/apache/rocketmq/studio/StudioApplication.java
b/server/src/main/java/org/apache/rocketmq/studio/auth/AuthenticatedUserContext.java
similarity index 50%
copy from server/src/main/java/org/apache/rocketmq/studio/StudioApplication.java
copy to
server/src/main/java/org/apache/rocketmq/studio/auth/AuthenticatedUserContext.java
index 7972e774..9a8746a1 100644
--- a/server/src/main/java/org/apache/rocketmq/studio/StudioApplication.java
+++
b/server/src/main/java/org/apache/rocketmq/studio/auth/AuthenticatedUserContext.java
@@ -15,14 +15,34 @@
* limitations under the License.
*/
-package org.apache.rocketmq.studio;
+package org.apache.rocketmq.studio.auth;
-import org.springframework.boot.SpringApplication;
-import org.springframework.boot.autoconfigure.SpringBootApplication;
+/**
+ * Holds the authenticated username for the current request thread.
+ */
+public final class AuthenticatedUserContext {
+
+ public static final String SYSTEM_ACTOR = "system";
+
+ private static final ThreadLocal<String> CURRENT_USERNAME = new
ThreadLocal<>();
+
+ private AuthenticatedUserContext() {
+ }
+
+ public static void setUsername(String username) {
+ if (username == null || username.isBlank()) {
+ clear();
+ return;
+ }
+ CURRENT_USERNAME.set(username);
+ }
+
+ public static String currentUsernameOrSystem() {
+ String username = CURRENT_USERNAME.get();
+ return username == null ? SYSTEM_ACTOR : username;
+ }
-@SpringBootApplication
-public class StudioApplication {
- public static void main(String[] args) {
- SpringApplication.run(StudioApplication.class, args);
+ public static void clear() {
+ CURRENT_USERNAME.remove();
}
}
diff --git
a/server/src/main/java/org/apache/rocketmq/studio/ops/audit/AuditService.java
b/server/src/main/java/org/apache/rocketmq/studio/ops/audit/AuditService.java
index 47f79512..61fbdd1d 100644
---
a/server/src/main/java/org/apache/rocketmq/studio/ops/audit/AuditService.java
+++
b/server/src/main/java/org/apache/rocketmq/studio/ops/audit/AuditService.java
@@ -16,6 +16,7 @@
*/
package org.apache.rocketmq.studio.ops.audit;
+import org.apache.rocketmq.studio.auth.AuthenticatedUserContext;
import org.apache.rocketmq.studio.common.domain.PageResult;
import org.apache.rocketmq.studio.common.exception.BusinessException;
import lombok.RequiredArgsConstructor;
@@ -79,6 +80,7 @@ public class AuditService {
public void record(String operationType, String target, String detail,
String result) {
AuditRecordVO record = AuditRecordVO.builder()
.timestamp(LocalDateTime.now())
+ .operator(AuthenticatedUserContext.currentUsernameOrSystem())
.operationType(operationType)
.target(target)
.detail(detail)
diff --git
a/server/src/test/java/org/apache/rocketmq/studio/audit/OperationAuditServiceTest.java
b/server/src/test/java/org/apache/rocketmq/studio/audit/OperationAuditServiceTest.java
new file mode 100644
index 00000000..645365a8
--- /dev/null
+++
b/server/src/test/java/org/apache/rocketmq/studio/audit/OperationAuditServiceTest.java
@@ -0,0 +1,69 @@
+/*
+ * 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.audit;
+
+import org.apache.rocketmq.studio.auth.AuthenticatedUserContext;
+import org.apache.rocketmq.studio.persistence.entity.RmqOperationAudit;
+import org.apache.rocketmq.studio.persistence.mapper.RmqOperationAuditMapper;
+import org.junit.jupiter.api.AfterEach;
+import org.junit.jupiter.api.Test;
+import org.junit.jupiter.api.extension.ExtendWith;
+import org.mockito.ArgumentCaptor;
+import org.mockito.Mock;
+import org.mockito.junit.jupiter.MockitoExtension;
+
+import static org.assertj.core.api.Assertions.assertThat;
+import static org.mockito.Mockito.verify;
+
+@ExtendWith(MockitoExtension.class)
+class OperationAuditServiceTest {
+
+ @Mock
+ private RmqOperationAuditMapper auditMapper;
+
+ @AfterEach
+ void clearAuthenticatedUser() {
+ AuthenticatedUserContext.clear();
+ }
+
+ @Test
+ void recordShouldCaptureAuthenticatedOperator() {
+ OperationAuditService service = new OperationAuditService(auditMapper);
+ AuthenticatedUserContext.setUsername("operator-user");
+
+ service.record("CREATE", "TOPIC", "topic-a", "cluster-a", "created
topic",
+ "SUCCESS", null);
+
+ ArgumentCaptor<RmqOperationAudit> captor =
ArgumentCaptor.forClass(RmqOperationAudit.class);
+ verify(auditMapper).insert(captor.capture());
+ assertThat(captor.getValue().getOperator()).isEqualTo("operator-user");
+ }
+
+ @Test
+ void recordShouldUseSystemOperatorWithoutAuthenticatedRequest() {
+ OperationAuditService service = new OperationAuditService(auditMapper);
+
+ service.record("CREATE", "TOPIC", "topic-a", "cluster-a", "created
topic",
+ "SUCCESS", null);
+
+ ArgumentCaptor<RmqOperationAudit> captor =
ArgumentCaptor.forClass(RmqOperationAudit.class);
+ verify(auditMapper).insert(captor.capture());
+ assertThat(captor.getValue().getOperator())
+ .isEqualTo(AuthenticatedUserContext.SYSTEM_ACTOR);
+ }
+}
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 afc91025..e1d5951f 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
@@ -17,6 +17,9 @@
package org.apache.rocketmq.studio.auth;
+import org.junit.jupiter.api.AfterEach;
+import org.apache.rocketmq.studio.settings.GeneralSettingsVO;
+import org.apache.rocketmq.studio.settings.SettingsRepository;
import org.junit.jupiter.api.Test;
import org.springframework.http.HttpHeaders;
import org.springframework.mock.web.MockHttpServletRequest;
@@ -25,25 +28,34 @@ import org.springframework.mock.web.MockHttpServletResponse;
import java.util.List;
import static org.assertj.core.api.Assertions.assertThat;
+import static org.mockito.Mockito.mock;
+import static org.mockito.Mockito.when;
class AuthInterceptorTest {
+ @AfterEach
+ void clearAuthenticatedUser() {
+ AuthenticatedUserContext.clear();
+ }
+
@Test
void shouldAllowRequestsWhenLoginIsDisabled() throws Exception {
AuthProperties properties = new AuthProperties();
- AuthInterceptor interceptor = new AuthInterceptor(properties, new
AuthService(properties));
+ AuthInterceptor interceptor = new AuthInterceptor(properties,
authService(properties));
MockHttpServletRequest request = new MockHttpServletRequest("GET",
"/api/clusters");
boolean allowed = interceptor.preHandle(request, new
MockHttpServletResponse(), new Object());
assertThat(allowed).isTrue();
+ assertThat(AuthenticatedUserContext.currentUsernameOrSystem())
+ .isEqualTo(AuthenticatedUserContext.SYSTEM_ACTOR);
}
@Test
void shouldRejectProtectedApiWithoutTokenWhenLoginIsEnabled() throws
Exception {
AuthProperties properties = new AuthProperties();
properties.setLoginRequired(true);
- AuthInterceptor interceptor = new AuthInterceptor(properties, new
AuthService(properties));
+ AuthInterceptor interceptor = new AuthInterceptor(properties,
authService(properties));
MockHttpServletRequest request = new MockHttpServletRequest("GET",
"/api/clusters");
MockHttpServletResponse response = new MockHttpServletResponse();
@@ -63,7 +75,7 @@ class AuthInterceptorTest {
user.setPassword("secret");
user.setAdmin(true);
properties.setUsers(List.of(user));
- AuthService authService = new AuthService(properties);
+ AuthService authService = authService(properties);
AuthInterceptor interceptor = new AuthInterceptor(properties,
authService);
LoginDTO login = new LoginDTO();
login.setUsername("admin");
@@ -72,16 +84,24 @@ class AuthInterceptorTest {
MockHttpServletRequest request = new MockHttpServletRequest("GET",
"/api/clusters");
request.addHeader(HttpHeaders.AUTHORIZATION, "Bearer " + token);
- boolean allowed = interceptor.preHandle(request, new
MockHttpServletResponse(), new Object());
+ MockHttpServletResponse response = new MockHttpServletResponse();
+ Object handler = new Object();
+ boolean allowed = interceptor.preHandle(request, response, handler);
assertThat(allowed).isTrue();
+
assertThat(AuthenticatedUserContext.currentUsernameOrSystem()).isEqualTo("admin");
+
+ interceptor.afterCompletion(request, response, handler, null);
+
+ assertThat(AuthenticatedUserContext.currentUsernameOrSystem())
+ .isEqualTo(AuthenticatedUserContext.SYSTEM_ACTOR);
}
@Test
void shouldAllowLoginEndpointWhenLoginIsEnabled() throws Exception {
AuthProperties properties = new AuthProperties();
properties.setLoginRequired(true);
- AuthInterceptor interceptor = new AuthInterceptor(properties, new
AuthService(properties));
+ AuthInterceptor interceptor = new AuthInterceptor(properties,
authService(properties));
MockHttpServletRequest request = new MockHttpServletRequest("POST",
"/api/auth/login");
boolean allowed = interceptor.preHandle(request, new
MockHttpServletResponse(), new Object());
@@ -93,7 +113,7 @@ class AuthInterceptorTest {
void shouldAllowLoginEndpointWithTrailingSlashWhenLoginIsEnabled() throws
Exception {
AuthProperties properties = new AuthProperties();
properties.setLoginRequired(true);
- AuthInterceptor interceptor = new AuthInterceptor(properties, new
AuthService(properties));
+ AuthInterceptor interceptor = new AuthInterceptor(properties,
authService(properties));
MockHttpServletRequest request = new MockHttpServletRequest("POST",
"/api/auth/login/");
boolean allowed = interceptor.preHandle(request, new
MockHttpServletResponse(), new Object());
@@ -105,7 +125,7 @@ class AuthInterceptorTest {
void shouldAllowAuthStatusEndpointWhenLoginIsEnabled() throws Exception {
AuthProperties properties = new AuthProperties();
properties.setLoginRequired(true);
- AuthInterceptor interceptor = new AuthInterceptor(properties, new
AuthService(properties));
+ AuthInterceptor interceptor = new AuthInterceptor(properties,
authService(properties));
MockHttpServletRequest request = new MockHttpServletRequest("GET",
"/api/auth/status");
boolean allowed = interceptor.preHandle(request, new
MockHttpServletResponse(), new Object());
@@ -117,11 +137,19 @@ class AuthInterceptorTest {
void shouldAllowAuthStatusEndpointWithTrailingSlashWhenLoginIsEnabled()
throws Exception {
AuthProperties properties = new AuthProperties();
properties.setLoginRequired(true);
- AuthInterceptor interceptor = new AuthInterceptor(properties, new
AuthService(properties));
+ AuthInterceptor interceptor = new AuthInterceptor(properties,
authService(properties));
MockHttpServletRequest request = new MockHttpServletRequest("GET",
"/api/auth/status/");
boolean allowed = interceptor.preHandle(request, new
MockHttpServletResponse(), new Object());
assertThat(allowed).isTrue();
}
+
+ private AuthService authService(AuthProperties properties) {
+ SettingsRepository settingsRepository = mock(SettingsRepository.class);
+
when(settingsRepository.loadGeneralSettings()).thenReturn(GeneralSettingsVO.builder()
+ .sessionTimeout(30)
+ .build());
+ return new AuthService(properties, settingsRepository);
+ }
}
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 21a8d4ac..5ef26650 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
@@ -18,6 +18,8 @@
package org.apache.rocketmq.studio.auth;
import org.apache.rocketmq.studio.common.exception.BusinessException;
+import org.apache.rocketmq.studio.settings.GeneralSettingsVO;
+import org.apache.rocketmq.studio.settings.SettingsRepository;
import org.junit.jupiter.api.BeforeEach;
import org.junit.jupiter.api.Test;
import org.junit.jupiter.api.extension.ExtendWith;
@@ -33,6 +35,7 @@ import java.util.Map;
import static org.assertj.core.api.Assertions.assertThat;
import static org.assertj.core.api.Assertions.assertThatThrownBy;
import static org.mockito.Mockito.mock;
+import static org.mockito.Mockito.lenient;
import static org.mockito.Mockito.when;
@ExtendWith(MockitoExtension.class)
@@ -40,11 +43,14 @@ class AuthServiceTest {
private AuthService authService;
private AuthProperties authProperties;
+ private SettingsRepository settingsRepository;
@BeforeEach
void setUp() {
authProperties = new AuthProperties();
- authService = new AuthService(authProperties,
Clock.fixed(Instant.EPOCH, ZoneOffset.UTC));
+ settingsRepository = mock(SettingsRepository.class);
+
lenient().when(settingsRepository.loadGeneralSettings()).thenReturn(sessionSettings(30));
+ authService = new AuthService(authProperties, settingsRepository,
Clock.fixed(Instant.EPOCH, ZoneOffset.UTC));
}
@Test
@@ -61,13 +67,16 @@ class AuthServiceTest {
assertThat(response).isNotNull();
assertThat(response.getToken()).startsWith("studio-jwt-");
- assertThat(response.getExpiresIn()).isEqualTo(86400);
+ assertThat(response.getExpiresIn()).isEqualTo(1800);
assertThat(response.getUser()).isNotNull();
assertThat(response.getUser().getUsername()).isEqualTo("testuser");
assertThat(response.getUser().isAdmin()).isFalse();
assertThat(authService.isAuthenticated("Bearer " +
response.getToken())).isTrue();
assertThat(authService.isAuthenticated("bearer " +
response.getToken())).isTrue();
assertThat(authService.isAuthenticated("bEaReR " +
response.getToken())).isTrue();
+ assertThat(authService.getAuthenticatedUser("Bearer " +
response.getToken()))
+ .hasValueSatisfying(userInfo ->
assertThat(userInfo.getUsername()).isEqualTo("testuser"));
+ assertThat(authService.getAuthenticatedUser("Bearer
unknown-token")).isEmpty();
}
@Test
@@ -168,10 +177,10 @@ class AuthServiceTest {
@Test
@SuppressWarnings("unchecked")
- void loginShouldRemoveExpiredSessions() {
+ void scheduledCleanupShouldRemoveExpiredSessions() {
Clock clock = mock(Clock.class);
when(clock.millis()).thenReturn(0L);
- authService = new AuthService(authProperties, clock);
+ authService = new AuthService(authProperties, settingsRepository,
clock);
AuthProperties.User user = new AuthProperties.User();
user.setUsername("testuser");
user.setPassword("testpass");
@@ -182,10 +191,42 @@ class AuthServiceTest {
LoginVO expiredSession = authService.login(request);
when(clock.millis()).thenReturn(expiredSession.getExpiresIn() * 1000L);
- LoginVO activeSession = authService.login(request);
+ authService.purgeExpiredSessions();
Map<String, ?> activeTokens = (Map<String, ?>)
ReflectionTestUtils.getField(authService, "activeTokens");
- assertThat(activeTokens).containsOnlyKeys(activeSession.getToken());
+ assertThat(activeTokens).isEmpty();
+ }
+
+ @Test
+ void loginShouldUsePersistedSessionTimeout() {
+ AuthProperties.User user = new AuthProperties.User();
+ user.setUsername("testuser");
+ user.setPassword("testpass");
+ authProperties.setUsers(List.of(user));
+
when(settingsRepository.loadGeneralSettings()).thenReturn(sessionSettings(45));
+ LoginDTO request = new LoginDTO();
+ request.setUsername("testuser");
+ request.setPassword("testpass");
+
+ LoginVO response = authService.login(request);
+
+ assertThat(response.getExpiresIn()).isEqualTo(2700);
+ }
+
+ @Test
+ void loginShouldFallBackToDefaultForInvalidPersistedSessionTimeout() {
+ AuthProperties.User user = new AuthProperties.User();
+ user.setUsername("testuser");
+ user.setPassword("testpass");
+ authProperties.setUsers(List.of(user));
+
when(settingsRepository.loadGeneralSettings()).thenReturn(sessionSettings(0));
+ LoginDTO request = new LoginDTO();
+ request.setUsername("testuser");
+ request.setPassword("testpass");
+
+ LoginVO response = authService.login(request);
+
+ assertThat(response.getExpiresIn()).isEqualTo(1800);
}
@Test
@@ -244,4 +285,8 @@ class AuthServiceTest {
void logoutShouldCompleteWithoutError() {
authService.logout(null);
}
+
+ private GeneralSettingsVO sessionSettings(int minutes) {
+ return GeneralSettingsVO.builder().sessionTimeout(minutes).build();
+ }
}
diff --git
a/server/src/test/java/org/apache/rocketmq/studio/ops/audit/AuditServiceTest.java
b/server/src/test/java/org/apache/rocketmq/studio/ops/audit/AuditServiceTest.java
index 7cd76090..62efce2d 100644
---
a/server/src/test/java/org/apache/rocketmq/studio/ops/audit/AuditServiceTest.java
+++
b/server/src/test/java/org/apache/rocketmq/studio/ops/audit/AuditServiceTest.java
@@ -16,9 +16,11 @@
*/
package org.apache.rocketmq.studio.ops.audit;
+import org.apache.rocketmq.studio.auth.AuthenticatedUserContext;
import org.apache.rocketmq.studio.common.domain.PageResult;
import org.apache.rocketmq.studio.common.exception.BusinessException;
import org.junit.jupiter.api.Test;
+import org.junit.jupiter.api.AfterEach;
import org.junit.jupiter.api.extension.ExtendWith;
import org.mockito.ArgumentCaptor;
import org.mockito.InjectMocks;
@@ -45,6 +47,22 @@ class AuditServiceTest {
@InjectMocks
private AuditService auditService;
+ @AfterEach
+ void clearAuthenticatedUser() {
+ AuthenticatedUserContext.clear();
+ }
+
+ @Test
+ void recordShouldCaptureAuthenticatedOperator() {
+ AuthenticatedUserContext.setUsername("operator-user");
+
+ auditService.record("CREATE", "topic-a", "created topic", "SUCCESS");
+
+ ArgumentCaptor<AuditRecordVO> captor =
ArgumentCaptor.forClass(AuditRecordVO.class);
+ verify(auditRepository).save(captor.capture());
+ assertThat(captor.getValue().getOperator()).isEqualTo("operator-user");
+ }
+
@Test
void queryLogsDelegatesPaginationAndFiltersToRepository() {
AuditRecordVO record =
AuditRecordVO.builder().operationType("CREATE").build();
diff --git a/web/src/pages/settings/index.tsx b/web/src/pages/settings/index.tsx
index 97acac9d..374b486d 100644
--- a/web/src/pages/settings/index.tsx
+++ b/web/src/pages/settings/index.tsx
@@ -191,13 +191,20 @@ const GeneralSettingsTab = () => {
</Title>
</Divider>
- <Form.Item label="会话超时" name="sessionTimeout">
+ <Form.Item
+ label="会话超时"
+ name="sessionTimeout"
+ extra="应用于新创建的会话,已登录用户保持原到期时间"
+ >
<InputNumber min={5} max={1440} addonAfter="分钟" />
</Form.Item>
- <Form.Item label="需要登录" name="requireLogin" valuePropName="checked">
- <Switch />
+ <Form.Item name="requireLogin" hidden>
+ <Input />
</Form.Item>
+ <Text type="secondary">
+ 登录保护由服务端 STUDIO_AUTH_LOGIN_REQUIRED 配置决定,修改后重启服务生效。
+ </Text>
{/* ── AI 配置 ── */}
<Divider orientation="left">