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">

Reply via email to