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 5e6c8971e feat(auth): complete the session lifecycle: idle timeout,
revocation controls, client attribution, expiry redirect (#7311)
5e6c8971e is described below
commit 5e6c8971e6a75d7fb128198e54965c34fc0764f0
Author: xdz999 <[email protected]>
AuthorDate: Mon Oct 26 17:48:34 2026 +0800
feat(auth): complete the session lifecycle: idle timeout, revocation
controls, client attribution, expiry redirect (#7311)
* feat(auth): complete the session lifecycle: idle timeout, revocation
controls, client attribution, expiry redirect
Completes the session lifecycle: a configurable idle timeout that revokes
abandoned sessions, per-session and global revocation controls, client
attribution on every session, and a return-to-page redirect after an expiry.
* fix(auth): leave the idle deadline off by default and out of the shipped
profiles
The idle timeout defaulted to 30 minutes, so an upgrade immediately started
revoking
sessions that were issued under the old absolute-expiry-only rule. Default
it to 0 —
off — and let an operator turn it on. The settings form already accepts 0
and says
what it means; its fallback now matches the server instead of showing 30.
The scheduled purge had also gained an initialDelay, and
application-dev.yml stretched
the cleanup interval to a day so the auth integration tests would not see a
pass fire
mid-suite. Both are reverted: the cases that exercise the idle deadline now
configure
one, which is what makes them about the feature rather than about the
default.
Document in api-spec.md the two new endpoints — POST
/api/studio-users/sessions/revoke-all, which spares the caller's own
sessions, and
DELETE /api/studio-users/sessions/:sessionId, a no-op on an already revoked
row —
plus the sessionIdleTimeout setting and the clientIp / userAgent
attribution fields.
---
docs/api-spec.md | 51 ++++
.../rocketmq/studio/auth/AuthController.java | 17 +-
.../apache/rocketmq/studio/auth/AuthService.java | 158 ++++++++++++-
...ailVO.java => StudioGlobalSessionRevokeVO.java} | 19 +-
.../studio/auth/StudioSessionSchemaMigration.java | 87 +++++++
.../rocketmq/studio/auth/StudioUserController.java | 14 ++
.../studio/auth/StudioUserSessionDetailVO.java | 2 +
.../persistence/entity/RmqStudioSession.java | 6 +
.../studio/settings/GeneralSettingsUpdateDTO.java | 6 +
.../studio/settings/GeneralSettingsVO.java | 5 +
.../rocketmq/studio/settings/SettingsService.java | 4 +
server/src/main/resources/db/schema.sql | 2 +
.../rocketmq/studio/auth/AuthControllerTest.java | 55 ++++-
.../studio/auth/AuthServiceDatabaseTest.java | 256 ++++++++++++++++++++-
.../rocketmq/studio/auth/AuthServiceTest.java | 50 ++++
.../auth/StudioSessionSchemaMigrationTest.java | 86 +++++++
.../studio/auth/StudioUserControllerTest.java | 20 ++
.../studio/settings/SettingsControllerTest.java | 50 ++++
.../studio/settings/SettingsServiceTest.java | 19 ++
web/src/api/client.ts | 8 +-
web/src/api/settings.ts | 2 +
web/src/api/studioUsers.ts | 14 ++
web/src/i18n/translations.ts | 31 +++
web/src/pages/login/index.tsx | 18 +-
web/src/pages/login/redirect.test.tsx | 104 +++++++++
web/src/pages/settings/GeneralSettingsTab.tsx | 41 +++-
.../settings/__tests__/GeneralSettingsTab.test.tsx | 55 ++++-
web/src/pages/studio/UserManagement.tsx | 89 ++++++-
.../pages/studio/__tests__/UserManagement.test.tsx | 60 +++++
29 files changed, 1294 insertions(+), 35 deletions(-)
diff --git a/docs/api-spec.md b/docs/api-spec.md
index 01f5fceba..b9dd7b37d 100644
--- a/docs/api-spec.md
+++ b/docs/api-spec.md
@@ -163,6 +163,8 @@
| 119 | GET | `/api/auth/status` | 登录策略与当前用户 |
| 120 | POST | `/api/auth/password` | 修改当前用户密码 |
| 121 | POST | `/api/ai/conversations/:id/followups` | AI
追问建议(一次小补全;无内容或失败返回空数组) |
+| 122 | POST | `/api/studio-users/sessions/revoke-all` | 注销其他账号的全部有效会话 |
+| 123 | DELETE | `/api/studio-users/sessions/:sessionId` | 注销单条会话 |
## 通用响应格式
@@ -2575,6 +2577,7 @@ GET /api/settings/general
| `desktopNotify` | `boolean` | 桌面通知 |
| `notifySound` | `boolean` | 通知声音 |
| `sessionTimeout` | `number` | 会话超时(分钟,5-1440) |
+| `sessionIdleTimeout` | `number?` | 会话空闲超时(分钟);`null` = 未设置,按服务端默认 **0(关闭)**
处理,`0` = 只按绝对超时判定,1~9 按 10 分钟生效,上限 1440 |
| `requireLogin` | `boolean` | 是否需要登录 |
| `llmProvider` | `string` | LLM 提供商: `openai` / `azure` / `anthropic` /
`deepseek` / `tongyi` / `ollama` / `bedrock` |
| `apiKeyConfigured` | `boolean` | 是否已配置 API Key;响应不会返回密钥内容 |
@@ -2596,6 +2599,7 @@ POST /api/settings/general/save
| `desktopNotify` | `boolean` | 是 | 桌面通知 |
| `notifySound` | `boolean` | 是 | 通知声音 |
| `sessionTimeout` | `number` | 是 | 会话超时(分钟,5-1440) |
+| `sessionIdleTimeout` | `number` | 否 | 会话空闲超时(分钟,0-1440);**省略 =
保留当前值**(老客户端不传也不会被重置),`0` = 关闭空闲判定 |
| `requireLogin` | `boolean` | 是 | 是否需要登录 |
| `llmProvider` | `string` | 是 | LLM 提供商 |
| `apiKey` | `string` | 否 | 新 API Key;省略或传空值时保留现有密钥 |
@@ -3517,6 +3521,53 @@ GET /api/metrics/grafana/dashboards/export
---
+## 17. Studio 会话注销 Studio User Sessions
+
+> 本节只记录会话注销与其归因字段;`/api/studio-users` 的账号管理面尚未整体入档。
+
+### 17.1 注销其他账号的全部会话
+
+```
+POST /api/studio-users/sessions/revoke-all
+```
+
+把**除当前登录账号以外**所有账号的有效会话(`revoked_at IS NULL` 且未过期)一次性注销,用于凭据
+外泄或人员变动后的收口。调用者自己的会话**不受影响**,所以这次调用不会把操作者自己踢下线。
+仅数据库态会话可用(内存态直接拒绝)。
+
+**Response `data`:**
+
+| 字段 | 类型 | 说明 |
+|------|------|------|
+| `revokedSessionCount` | `number` | 本次注销的会话条数 |
+
+### 17.2 注销单条会话
+
+```
+DELETE /api/studio-users/sessions/:sessionId
+```
+
+**Path Parameters:**
+
+| 参数 | 类型 | 必填 | 说明 |
+|------|------|------|------|
+| `sessionId` | `number` | 是 | 会话行 ID |
+
+会话不存在返回 **404**;已注销或已过期的会话是 **no-op**(返回 200),所以一个过期的抽屉反复确认
+不会变成错误。**Response `data`:** `null`。
+
+### 17.3 会话明细的客户端归因字段
+
+会话明细(`GET /api/studio-users/:userId/sessions` 与 `GET
/api/studio-users/sessions/overview`)
+额外返回签发该会话时的客户端信息:
+
+| 字段 | 类型 | 说明 |
+|------|------|------|
+| `clientIp` | `string?` | 签发会话的客户端地址;经反向代理时取 `X-Forwarded-For`
首跳。本次改动之前签发的会话为 `null` |
+| `userAgent` | `string?` | 签发会话的客户端 User-Agent。本次改动之前签发的会话为 `null` |
+
+---
+
## 附录 A:枚举值速查
| 枚举 | 值 |
diff --git
a/server/src/main/java/org/apache/rocketmq/studio/auth/AuthController.java
b/server/src/main/java/org/apache/rocketmq/studio/auth/AuthController.java
index 616339752..68af39906 100644
--- a/server/src/main/java/org/apache/rocketmq/studio/auth/AuthController.java
+++ b/server/src/main/java/org/apache/rocketmq/studio/auth/AuthController.java
@@ -91,7 +91,8 @@ public class AuthController {
if (request == null) {
throw new BusinessException(400, "Login request is required");
}
- LoginVO login = authService.login(request);
+ LoginVO login = authService.login(request, clientIp(servletRequest),
+ servletRequest.getHeader("User-Agent"));
if (AuthCookie.requestsBearerToken(servletRequest)) {
return Result.ok(login);
}
@@ -100,6 +101,20 @@ public class AuthController {
return Result.ok(login);
}
+ /**
+ * Best-effort client address for session attribution. Behind a reverse
proxy the socket
+ * address is the proxy's, so the first X-Forwarded-For hop is preferred
when present;
+ * the value is observability metadata only and never used for
authorization.
+ */
+ static String clientIp(HttpServletRequest request) {
+ String forwarded = request.getHeader("X-Forwarded-For");
+ if (forwarded != null && !forwarded.isBlank()) {
+ int comma = forwarded.indexOf(',');
+ return comma > 0 ? forwarded.substring(0, comma).trim() :
forwarded.trim();
+ }
+ return request.getRemoteAddr();
+ }
+
@PostMapping("/logout")
public Result<Void> logout(HttpServletRequest request, HttpServletResponse
response) {
authService.logout(AuthCookie.authorization(request, authProperties));
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 3c0b18e62..148c05394 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
@@ -71,12 +71,22 @@ public class AuthService {
private static final int DEFAULT_SESSION_TIMEOUT_MINUTES = 1440;
private static final int MIN_SESSION_TIMEOUT_MINUTES = 5;
private static final int MAX_SESSION_TIMEOUT_MINUTES = 1440;
+ // Sessions unused for longer than this window are treated as expired even
before their
+ // absolute expiry. The minimum sits above LAST_SEEN_UPDATE_INTERVAL so
the throttled
+ // last-seen writes can never lag far enough to idle out an actually
active session.
+ // The default is 0 — off — because an upgrade must not start revoking
sessions that were
+ // issued under the old absolute-expiry-only rule; an operator turns the
deadline on.
+ private static final int DEFAULT_SESSION_IDLE_TIMEOUT_MINUTES = 0;
+ private static final int MIN_SESSION_IDLE_TIMEOUT_MINUTES = 10;
+ private static final int MAX_SESSION_IDLE_TIMEOUT_MINUTES = 1440;
private static final Duration LAST_SEEN_UPDATE_INTERVAL =
Duration.ofMinutes(5);
private static final Duration SESSION_EXPIRING_SOON_WINDOW =
Duration.ofMinutes(5);
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 int MAX_CLIENT_IP_LENGTH = 64;
+ private static final int MAX_USER_AGENT_LENGTH = 255;
private static final String TOKEN_PREFIX = "Bearer ";
private static final String EXPIRING_SOON_CUTOFF_PARAM =
"expiringSoonCutoff";
private static final String STALE_CUTOFF_PARAM = "staleCutoff";
@@ -136,10 +146,21 @@ public class AuthService {
}
public LoginVO login(LoginDTO request) {
+ return login(request, null, null);
+ }
+
+ /**
+ * Logs in and attributes the issued session to the requesting client. The
address and
+ * user agent are observability metadata for the session drawer —
best-effort behind
+ * reverse proxies (the first X-Forwarded-For hop) and never used for
authorization.
+ */
+ public LoginVO login(LoginDTO request, String clientIp, String userAgent) {
validateLogin(request);
loginRateLimiter.checkAllowed(request.getUsername());
try {
- LoginVO login = databaseBacked() ? loginDatabaseUser(request) :
loginConfiguredUser(request);
+ LoginVO login = databaseBacked()
+ ? loginDatabaseUser(request, clientIp, userAgent)
+ : loginConfiguredUser(request);
loginRateLimiter.recordSuccess(request.getUsername());
return login;
} catch (BusinessException exception) {
@@ -280,7 +301,8 @@ public class AuthService {
getUser(userId);
LocalDateTime current = now();
return sessionMapper.selectList(activeSessionQuery(current)
- .select("id", "user_id", "last_seen_at", "expires_at",
"gmt_create")
+ .select("id", "user_id", "last_seen_at", "expires_at",
"gmt_create",
+ "client_ip", "user_agent")
.eq("user_id", userId)
.orderByDesc("last_seen_at")
.orderByAsc("id"))
@@ -295,6 +317,48 @@ public class AuthService {
return revokeUserSessions(userId);
}
+ /**
+ * Revokes every active session in one statement. The operator's own
sessions are spared:
+ * the incident responder who pulls this lever must not log themselves out
mid-response,
+ * and can still revoke their own account from its session drawer
afterwards. The
+ * predicate is the active-session half of {@link #revokeUserSessions},
without the
+ * per-user column.
+ */
+ public int revokeAllSessions() {
+ requireDatabaseBacked();
+ LocalDateTime current = now();
+ UpdateWrapper<RmqStudioSession> wrapper = new
UpdateWrapper<RmqStudioSession>()
+ .isNull("revoked_at")
+ .gt("expires_at", current);
+ String currentUserId = AuthenticatedUserContext.currentUserId();
+ if (currentUserId != null) {
+ wrapper.ne("user_id", Long.parseLong(currentUserId));
+ }
+ wrapper.set("revoked_at", current);
+ return sessionMapper.update(null, wrapper);
+ }
+
+ /**
+ * Revokes one session row. Revoking an already-revoked or expired session
is a no-op
+ * rather than an error, so a stale drawer never turns a repeated
confirmation into a
+ * failure toast; the row disappears from the active list either way.
+ */
+ public void revokeSessionById(Long sessionId) {
+ requireDatabaseBacked();
+ RmqStudioSession session = sessionMapper.selectById(sessionId);
+ if (session == null) {
+ throw new BusinessException(404, "Session not found");
+ }
+ LocalDateTime current = now();
+ if (session.getRevokedAt() != null ||
!session.getExpiresAt().isAfter(current)) {
+ return;
+ }
+ sessionMapper.update(null, new UpdateWrapper<RmqStudioSession>()
+ .eq("id", sessionId)
+ .isNull("revoked_at")
+ .set("revoked_at", current));
+ }
+
public RmqStudioUser createUser(String username, String password, boolean
admin) {
requireDatabaseBacked();
validateUsername(username);
@@ -371,16 +435,30 @@ public class AuthService {
revokeUserSessions(user.getId());
}
+ // The first pass waits a full interval instead of running at startup: a
startup run of the
@Scheduled(fixedDelayString =
"${studio.auth.session-cleanup-interval:PT5M}")
public void purgeExpiredSessions() {
if (databaseBacked()) {
- sessionMapper.delete(new
QueryWrapper<RmqStudioSession>().lt("expires_at", now()));
+ LocalDateTime current = now();
+ int idleTimeoutMinutes = sessionIdleTimeoutMinutes();
+ // Sessions that idled out are revoked here so the active-session
views stay
+ // truthful even when the abandoned token is never presented
again; the rows
+ // are then removed by the absolute-expiry delete below.
+ if (idleTimeoutMinutes > 0) {
+ sessionMapper.update(null, new
UpdateWrapper<RmqStudioSession>()
+ .isNull("revoked_at")
+ .isNotNull("last_seen_at")
+ .ge("expires_at", current)
+ .lt("last_seen_at",
current.minusMinutes(idleTimeoutMinutes))
+ .set("revoked_at", current));
+ }
+ sessionMapper.delete(new
QueryWrapper<RmqStudioSession>().lt("expires_at", current));
} else {
purgeExpiredSessions(clock.millis());
}
}
- private LoginVO loginDatabaseUser(LoginDTO request) {
+ private LoginVO loginDatabaseUser(LoginDTO request, String clientIp,
String userAgent) {
ensureBootstrapUsers();
Optional<RmqStudioUser> found =
findUserByUsername(request.getUsername());
if (found.isEmpty()) {
@@ -411,6 +489,8 @@ public class AuthService {
session.setTokenHash(tokenHash(token));
session.setLastSeenAt(current);
session.setExpiresAt(current.plusSeconds(tokenTtlSeconds));
+ session.setClientIp(truncate(clientIp, MAX_CLIENT_IP_LENGTH));
+ session.setUserAgent(truncate(userAgent, MAX_USER_AGENT_LENGTH));
sessionMapper.insert(session);
return loginResponse(userInfo(user), token, tokenTtlSeconds);
}
@@ -421,7 +501,7 @@ public class AuthService {
purgeExpiredSessions(current);
int tokenTtlSeconds = sessionTimeoutSeconds();
String token = "studio-jwt-" + UUID.randomUUID();
- activeTokens.put(token, new AuthSession(user, current +
tokenTtlSeconds * 1000L));
+ activeTokens.put(token, new AuthSession(user, current +
tokenTtlSeconds * 1000L, current));
return loginResponse(user, token, tokenTtlSeconds);
}
@@ -441,6 +521,19 @@ public class AuthService {
return Optional.empty();
}
LocalDateTime current = now();
+ int idleTimeoutMinutes = sessionIdleTimeoutMinutes();
+ // A session whose token has gone unused past the idle window is
revoked rather than
+ // merely rejected, so it disappears from the active-session views
instead of
+ // lingering as a seemingly valid row until its absolute expiry.
last_seen_at is only
+ // absent on rows written before the column existed; those keep the
absolute expiry
+ // as their only deadline.
+ if (idleTimeoutMinutes > 0 && session.getLastSeenAt() != null
+ &&
!session.getLastSeenAt().plusMinutes(idleTimeoutMinutes).isAfter(current)) {
+ sessionMapper.update(null, new UpdateWrapper<RmqStudioSession>()
+ .eq("id", session.getId())
+ .set("revoked_at", current));
+ return Optional.empty();
+ }
if (session.getLastSeenAt() == null
||
!session.getLastSeenAt().plus(LAST_SEEN_UPDATE_INTERVAL).isAfter(current)) {
sessionMapper.update(null, new UpdateWrapper<RmqStudioSession>()
@@ -457,10 +550,18 @@ public class AuthService {
if (session == null) {
return Optional.empty();
}
- if (session.expiresAtMillis() <= clock.millis()) {
+ long current = clock.millis();
+ if (session.expiresAtMillis() <= current) {
activeTokens.remove(token);
return Optional.empty();
}
+ int idleTimeoutMinutes = sessionIdleTimeoutMinutes();
+ if (idleTimeoutMinutes > 0
+ && current - session.lastSeenMillis() > idleTimeoutMinutes *
60_000L) {
+ activeTokens.remove(token);
+ return Optional.empty();
+ }
+ activeTokens.put(token, new AuthSession(session.user(),
session.expiresAtMillis(), current));
return Optional.of(session.user());
}
@@ -555,6 +656,8 @@ public class AuthService {
&&
!expiresAt.isAfter(current.plus(SESSION_EXPIRING_SOON_WINDOW)))
.stale(lastSeenAt != null
&&
lastSeenAt.isBefore(current.minus(STALE_SESSION_THRESHOLD)))
+ .clientIp(session.getClientIp())
+ .userAgent(session.getUserAgent())
.build();
}
@@ -635,10 +738,40 @@ public class AuthService {
return Math.toIntExact(Duration.ofMinutes(minutes).toSeconds());
}
+ /**
+ * Effective idle-timeout window in minutes: {@code 0} disables
enforcement. A missing value
+ * (settings written before the field existed) keeps the default; values
between 1 and the
+ * minimum are raised to it rather than silently flapping against the
throttled last-seen
+ * writes, and out-of-range values fall back to the default like the
absolute timeout does.
+ */
+ private int sessionIdleTimeoutMinutes() {
+ GeneralSettingsVO settings = settingsRepository.loadGeneralSettings();
+ Integer minutes = settings == null ? null :
settings.getSessionIdleTimeout();
+ if (minutes == null) {
+ return DEFAULT_SESSION_IDLE_TIMEOUT_MINUTES;
+ }
+ if (minutes <= 0) {
+ return 0;
+ }
+ if (minutes > MAX_SESSION_IDLE_TIMEOUT_MINUTES) {
+ log.warn("Ignoring invalid persisted session idle timeout: {}
minutes", minutes);
+ return DEFAULT_SESSION_IDLE_TIMEOUT_MINUTES;
+ }
+ return Math.max(minutes, MIN_SESSION_IDLE_TIMEOUT_MINUTES);
+ }
+
private LocalDateTime now() {
return LocalDateTime.ofInstant(Instant.ofEpochMilli(clock.millis()),
ZoneOffset.UTC);
}
+ private static String truncate(String value, int maxLength) {
+ if (value == null) {
+ return null;
+ }
+ String trimmed = value.trim();
+ return trimmed.length() <= maxLength ? trimmed : trimmed.substring(0,
maxLength);
+ }
+
private String newBearerToken() {
byte[] bytes = new byte[32];
TOKEN_RANDOM.nextBytes(bytes);
@@ -664,7 +797,16 @@ public class AuthService {
}
private void purgeExpiredSessions(long current) {
- activeTokens.entrySet().removeIf(entry ->
entry.getValue().expiresAtMillis() <= current);
+ int idleTimeoutMinutes = sessionIdleTimeoutMinutes();
+ activeTokens.entrySet().removeIf(entry ->
sessionExpiredOrIdle(entry.getValue(), current, idleTimeoutMinutes));
+ }
+
+ private boolean sessionExpiredOrIdle(AuthSession session, long current,
int idleTimeoutMinutes) {
+ if (session.expiresAtMillis() <= current) {
+ return true;
+ }
+ return idleTimeoutMinutes > 0
+ && current - session.lastSeenMillis() > idleTimeoutMinutes *
60_000L;
}
private boolean databaseBacked() {
@@ -677,6 +819,6 @@ public class AuthService {
}
}
- private record AuthSession(LoginVO.UserInfo user, long expiresAtMillis) {
+ private record AuthSession(LoginVO.UserInfo user, long expiresAtMillis,
long lastSeenMillis) {
}
}
diff --git
a/server/src/main/java/org/apache/rocketmq/studio/auth/StudioUserSessionDetailVO.java
b/server/src/main/java/org/apache/rocketmq/studio/auth/StudioGlobalSessionRevokeVO.java
similarity index 71%
copy from
server/src/main/java/org/apache/rocketmq/studio/auth/StudioUserSessionDetailVO.java
copy to
server/src/main/java/org/apache/rocketmq/studio/auth/StudioGlobalSessionRevokeVO.java
index 652407b34..ecefd2c77 100644
---
a/server/src/main/java/org/apache/rocketmq/studio/auth/StudioUserSessionDetailVO.java
+++
b/server/src/main/java/org/apache/rocketmq/studio/auth/StudioGlobalSessionRevokeVO.java
@@ -19,19 +19,14 @@ package org.apache.rocketmq.studio.auth;
import lombok.Builder;
import lombok.Data;
-import java.time.LocalDateTime;
-
+/**
+ * Result of the global session revocation. The operator's own sessions are
always spared
+ * (an incident responder must not log themselves out mid-response), which is
why the count
+ * can be lower than the active-session total on the overview.
+ */
@Data
@Builder
-public class StudioUserSessionDetailVO {
+public class StudioGlobalSessionRevokeVO {
- private Long id;
- private Long userId;
- private LocalDateTime lastSeenAt;
- private LocalDateTime expiresAt;
- private LocalDateTime gmtCreate;
- private long remainingSeconds;
- private Long idleSeconds;
- private boolean expiringSoon;
- private boolean stale;
+ private int revokedSessionCount;
}
diff --git
a/server/src/main/java/org/apache/rocketmq/studio/auth/StudioSessionSchemaMigration.java
b/server/src/main/java/org/apache/rocketmq/studio/auth/StudioSessionSchemaMigration.java
new file mode 100644
index 000000000..6654241c6
--- /dev/null
+++
b/server/src/main/java/org/apache/rocketmq/studio/auth/StudioSessionSchemaMigration.java
@@ -0,0 +1,87 @@
+/*
+ * 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 lombok.RequiredArgsConstructor;
+import lombok.extern.slf4j.Slf4j;
+import org.springframework.boot.ApplicationArguments;
+import org.springframework.boot.ApplicationRunner;
+import org.springframework.stereotype.Component;
+
+import javax.sql.DataSource;
+import java.sql.Connection;
+import java.sql.DatabaseMetaData;
+import java.sql.ResultSet;
+import java.sql.SQLException;
+import java.sql.Statement;
+import java.util.List;
+
+/** Adds the session client-attribution columns to Studio databases created
before the current schema. */
+@Slf4j
+@Component
+@RequiredArgsConstructor
+public class StudioSessionSchemaMigration implements ApplicationRunner {
+ private static final List<Column> COLUMNS = List.of(
+ new Column("rmq_studio_session", "client_ip", "VARCHAR(64)"),
+ new Column("rmq_studio_session", "user_agent", "VARCHAR(255)"));
+
+ private final DataSource dataSource;
+
+ @Override
+ public void run(ApplicationArguments args) throws Exception {
+ try (Connection connection = dataSource.getConnection(); Statement
statement = connection.createStatement()) {
+ DatabaseMetaData metadata = connection.getMetaData();
+ String catalog = connection.getCatalog();
+ for (Column column : COLUMNS) {
+ ensureColumn(metadata, catalog, statement, column);
+ }
+ }
+ }
+
+ private static void ensureColumn(DatabaseMetaData metadata, String
catalog, Statement statement, Column column)
+ throws Exception {
+ if (!hasTable(metadata, catalog, column.table())
+ || hasColumn(metadata, catalog, column.table(),
column.name())) {
+ return;
+ }
+ try {
+ log.info("Adding studio session column {}.{}", column.table(),
column.name());
+ statement.executeUpdate("ALTER TABLE " + column.table() + " ADD
COLUMN " + column.name()
+ + " " + column.definition());
+ } catch (SQLException failure) {
+ if (!hasColumn(metadata, catalog, column.table(), column.name())) {
+ throw failure;
+ }
+ }
+ }
+
+ private static boolean hasTable(DatabaseMetaData metadata, String catalog,
String table) throws Exception {
+ try (ResultSet tables = metadata.getTables(catalog, null, table, new
String[]{"TABLE"})) {
+ return tables.next();
+ }
+ }
+
+ private static boolean hasColumn(DatabaseMetaData metadata, String
catalog, String table, String column)
+ throws Exception {
+ try (ResultSet columns = metadata.getColumns(catalog, null, table,
column)) {
+ return columns.next();
+ }
+ }
+
+ private record Column(String table, String name, String definition) {
+ }
+}
diff --git
a/server/src/main/java/org/apache/rocketmq/studio/auth/StudioUserController.java
b/server/src/main/java/org/apache/rocketmq/studio/auth/StudioUserController.java
index 406387a7e..21c5d9540 100644
---
a/server/src/main/java/org/apache/rocketmq/studio/auth/StudioUserController.java
+++
b/server/src/main/java/org/apache/rocketmq/studio/auth/StudioUserController.java
@@ -21,6 +21,7 @@ import lombok.RequiredArgsConstructor;
import org.apache.rocketmq.studio.common.domain.PageResult;
import org.apache.rocketmq.studio.common.domain.Result;
import org.apache.rocketmq.studio.persistence.entity.RmqStudioUser;
+import org.springframework.web.bind.annotation.DeleteMapping;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.PathVariable;
import org.springframework.web.bind.annotation.PostMapping;
@@ -97,4 +98,17 @@ public class StudioUserController {
.revokedSessionCount(revokedSessionCount)
.build());
}
+
+ @PostMapping("/sessions/revoke-all")
+ public Result<StudioGlobalSessionRevokeVO> revokeAllSessions() {
+ return Result.ok(StudioGlobalSessionRevokeVO.builder()
+ .revokedSessionCount(authService.revokeAllSessions())
+ .build());
+ }
+
+ @DeleteMapping("/sessions/{sessionId}")
+ public Result<Void> revokeSession(@PathVariable Long sessionId) {
+ authService.revokeSessionById(sessionId);
+ return Result.ok();
+ }
}
diff --git
a/server/src/main/java/org/apache/rocketmq/studio/auth/StudioUserSessionDetailVO.java
b/server/src/main/java/org/apache/rocketmq/studio/auth/StudioUserSessionDetailVO.java
index 652407b34..aaf55054b 100644
---
a/server/src/main/java/org/apache/rocketmq/studio/auth/StudioUserSessionDetailVO.java
+++
b/server/src/main/java/org/apache/rocketmq/studio/auth/StudioUserSessionDetailVO.java
@@ -34,4 +34,6 @@ public class StudioUserSessionDetailVO {
private Long idleSeconds;
private boolean expiringSoon;
private boolean stale;
+ private String clientIp;
+ private String userAgent;
}
diff --git
a/server/src/main/java/org/apache/rocketmq/studio/persistence/entity/RmqStudioSession.java
b/server/src/main/java/org/apache/rocketmq/studio/persistence/entity/RmqStudioSession.java
index 8a8725d5c..7cd08cded 100644
---
a/server/src/main/java/org/apache/rocketmq/studio/persistence/entity/RmqStudioSession.java
+++
b/server/src/main/java/org/apache/rocketmq/studio/persistence/entity/RmqStudioSession.java
@@ -34,6 +34,12 @@ public class RmqStudioSession {
private LocalDateTime lastSeenAt;
+ /** Client address the session was issued to; first X-Forwarded-For hop
behind a proxy. */
+ private String clientIp;
+
+ /** User-Agent of the client the session was issued to, truncated to the
column length. */
+ private String userAgent;
+
private LocalDateTime gmtCreate;
private LocalDateTime gmtModified;
diff --git
a/server/src/main/java/org/apache/rocketmq/studio/settings/GeneralSettingsUpdateDTO.java
b/server/src/main/java/org/apache/rocketmq/studio/settings/GeneralSettingsUpdateDTO.java
index 65bba18b2..031d9300a 100644
---
a/server/src/main/java/org/apache/rocketmq/studio/settings/GeneralSettingsUpdateDTO.java
+++
b/server/src/main/java/org/apache/rocketmq/studio/settings/GeneralSettingsUpdateDTO.java
@@ -43,6 +43,11 @@ public class GeneralSettingsUpdateDTO {
@Min(5)
@Max(1440)
private Integer sessionTimeout;
+ // Optional so clients written before the field existed keep their stored
value;
+ // 0 disables the idle deadline. Values below the server minimum are
raised to it.
+ @Min(0)
+ @Max(1440)
+ private Integer sessionIdleTimeout;
@NotNull
private Boolean requireLogin;
@NotBlank
@@ -74,6 +79,7 @@ public class GeneralSettingsUpdateDTO {
.desktopNotify(desktopNotify)
.notifySound(notifySound)
.sessionTimeout(sessionTimeout)
+ .sessionIdleTimeout(sessionIdleTimeout)
.requireLogin(requireLogin)
.llmProvider(llmProvider)
.llmEngine(llmEngine)
diff --git
a/server/src/main/java/org/apache/rocketmq/studio/settings/GeneralSettingsVO.java
b/server/src/main/java/org/apache/rocketmq/studio/settings/GeneralSettingsVO.java
index e40230738..bd292fbf3 100644
---
a/server/src/main/java/org/apache/rocketmq/studio/settings/GeneralSettingsVO.java
+++
b/server/src/main/java/org/apache/rocketmq/studio/settings/GeneralSettingsVO.java
@@ -34,6 +34,11 @@ public class GeneralSettingsVO {
private boolean desktopNotify;
private boolean notifySound;
private int sessionTimeout;
+ /**
+ * Minutes a session token may stay unused before it is treated as
expired; {@code null}
+ * keeps the server default and {@code 0} disables the idle deadline
(absolute expiry only).
+ */
+ private Integer sessionIdleTimeout;
private boolean requireLogin;
private String llmProvider;
private String llmEngine;
diff --git
a/server/src/main/java/org/apache/rocketmq/studio/settings/SettingsService.java
b/server/src/main/java/org/apache/rocketmq/studio/settings/SettingsService.java
index b332ec96e..9c3e8c0b6 100644
---
a/server/src/main/java/org/apache/rocketmq/studio/settings/SettingsService.java
+++
b/server/src/main/java/org/apache/rocketmq/studio/settings/SettingsService.java
@@ -190,6 +190,10 @@ public class SettingsService {
if (settings.getTemperature() == null) {
settings.setTemperature(currentSettings.getTemperature());
}
+ // Older clients do not send the idle timeout; their saves must
not reset it.
+ if (settings.getSessionIdleTimeout() == null) {
+
settings.setSessionIdleTimeout(currentSettings.getSessionIdleTimeout());
+ }
}
settings.setClearApiKey(false);
settings.setClearDingtalkSigningSecret(false);
diff --git a/server/src/main/resources/db/schema.sql
b/server/src/main/resources/db/schema.sql
index c44222b0a..0c5a768d8 100644
--- a/server/src/main/resources/db/schema.sql
+++ b/server/src/main/resources/db/schema.sql
@@ -51,6 +51,8 @@ CREATE TABLE IF NOT EXISTS rmq_studio_session (
expires_at DATETIME NOT NULL COMMENT '会话过期时间',
revoked_at DATETIME NULL COMMENT '会话注销时间',
last_seen_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP COMMENT '最近活跃时间',
+ client_ip VARCHAR(64) NULL COMMENT '签发会话的客户端地址(经反代时取 X-Forwarded-For 首跳)',
+ user_agent VARCHAR(255) NULL COMMENT '签发会话的客户端 User-Agent',
PRIMARY KEY (`id`),
UNIQUE KEY uk_studio_session_token_hash (token_hash),
INDEX idx_studio_session_user (user_id),
diff --git
a/server/src/test/java/org/apache/rocketmq/studio/auth/AuthControllerTest.java
b/server/src/test/java/org/apache/rocketmq/studio/auth/AuthControllerTest.java
index c1f503da1..5ce78e2a1 100644
---
a/server/src/test/java/org/apache/rocketmq/studio/auth/AuthControllerTest.java
+++
b/server/src/test/java/org/apache/rocketmq/studio/auth/AuthControllerTest.java
@@ -37,6 +37,7 @@ import java.util.Optional;
import static org.mockito.ArgumentMatchers.any;
import static org.mockito.ArgumentMatchers.eq;
+import static org.mockito.ArgumentMatchers.isNull;
import static org.mockito.Mockito.doNothing;
import static org.mockito.Mockito.never;
import static org.mockito.Mockito.verify;
@@ -171,7 +172,7 @@ class AuthControllerTest {
.build())
.build();
- when(authService.login(any(LoginDTO.class))).thenReturn(mockResponse);
+ when(authService.login(any(LoginDTO.class),
org.mockito.ArgumentMatchers.any(),
org.mockito.ArgumentMatchers.any())).thenReturn(mockResponse);
LoginDTO request = new LoginDTO();
request.setUsername("testuser");
@@ -205,7 +206,7 @@ class AuthControllerTest {
.build())
.build();
- when(authService.login(any(LoginDTO.class))).thenReturn(mockResponse);
+ when(authService.login(any(LoginDTO.class),
org.mockito.ArgumentMatchers.any(),
org.mockito.ArgumentMatchers.any())).thenReturn(mockResponse);
LoginDTO request = new LoginDTO();
request.setUsername("admin");
@@ -226,7 +227,7 @@ class AuthControllerTest {
.expiresIn(86400)
.user(LoginVO.UserInfo.builder().username("automation").build())
.build();
- when(authService.login(any(LoginDTO.class))).thenReturn(mockResponse);
+ when(authService.login(any(LoginDTO.class),
org.mockito.ArgumentMatchers.any(),
org.mockito.ArgumentMatchers.any())).thenReturn(mockResponse);
LoginDTO request = new LoginDTO();
request.setUsername("automation");
@@ -249,7 +250,8 @@ class AuthControllerTest {
.andExpect(jsonPath("$.code").value(400))
.andExpect(jsonPath("$.message").value("Login request is
required"));
- verify(authService, never()).login(any(LoginDTO.class));
+ verify(authService, never()).login(any(LoginDTO.class),
org.mockito.ArgumentMatchers.any(),
+ org.mockito.ArgumentMatchers.any());
}
@Test
@@ -270,4 +272,49 @@ class AuthControllerTest {
verify(authService).logout(eq("Bearer token-1"));
}
+
+ @Test
+ void loginShouldForwardTheFirstForwardedHopAndUserAgentTest() throws
Exception {
+ LoginVO mockResponse = LoginVO.builder()
+ .token("mock-jwt-attributed")
+ .expiresIn(86400)
+ .user(LoginVO.UserInfo.builder().username("testuser").build())
+ .build();
+ when(authService.login(any(LoginDTO.class), eq("198.51.100.9"),
eq("StudioTestAgent/1.0")))
+ .thenReturn(mockResponse);
+ LoginDTO request = new LoginDTO();
+ request.setUsername("testuser");
+ request.setPassword("testpass");
+
+ mockMvc.perform(post("/api/auth/login")
+ .header("X-Forwarded-For", "198.51.100.9, 10.0.0.1")
+ .header(HttpHeaders.USER_AGENT, "StudioTestAgent/1.0")
+ .contentType(MediaType.APPLICATION_JSON)
+ .content(objectMapper.writeValueAsString(request)))
+ .andExpect(status().isOk());
+
+ // The socket address is the proxy's; the first forwarded hop is the
client's.
+ verify(authService).login(any(LoginDTO.class), eq("198.51.100.9"),
eq("StudioTestAgent/1.0"));
+ }
+
+ @Test
+ void loginShouldFallBackToTheSocketAddressWithoutAProxyHeaderTest() throws
Exception {
+ LoginVO mockResponse = LoginVO.builder()
+ .token("mock-jwt-direct")
+ .expiresIn(86400)
+ .user(LoginVO.UserInfo.builder().username("testuser").build())
+ .build();
+ when(authService.login(any(LoginDTO.class), eq("127.0.0.1"), isNull()))
+ .thenReturn(mockResponse);
+ LoginDTO request = new LoginDTO();
+ request.setUsername("testuser");
+ request.setPassword("testpass");
+
+ mockMvc.perform(post("/api/auth/login")
+ .contentType(MediaType.APPLICATION_JSON)
+ .content(objectMapper.writeValueAsString(request)))
+ .andExpect(status().isOk());
+
+ verify(authService).login(any(LoginDTO.class), eq("127.0.0.1"),
isNull());
+ }
}
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 dd993bb8e..505c0658f 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
@@ -28,6 +28,7 @@ import
org.apache.rocketmq.studio.persistence.mapper.RmqStudioSessionMapper;
import org.apache.rocketmq.studio.persistence.mapper.RmqStudioUserMapper;
import org.apache.rocketmq.studio.settings.GeneralSettingsVO;
import org.apache.rocketmq.studio.settings.SettingsRepository;
+import org.junit.jupiter.api.AfterEach;
import org.junit.jupiter.api.BeforeEach;
import org.junit.jupiter.api.Test;
@@ -56,14 +57,20 @@ import static org.mockito.Mockito.when;
class AuthServiceDatabaseTest {
private AuthService authService;
+ private SettingsRepository settingsRepository;
private RmqStudioUserMapper userMapper;
private RmqStudioSessionMapper sessionMapper;
private PasswordHasher passwordHasher;
+ @AfterEach
+ void clearAuthenticatedUserContext() {
+ AuthenticatedUserContext.clear();
+ }
+
@BeforeEach
void setUp() {
AuthProperties authProperties = new AuthProperties();
- SettingsRepository settingsRepository = mock(SettingsRepository.class);
+ settingsRepository = mock(SettingsRepository.class);
GeneralSettingsVO settings =
GeneralSettingsVO.builder().sessionTimeout(30).build();
when(settingsRepository.loadGeneralSettings()).thenReturn(settings);
userMapper = mock(RmqStudioUserMapper.class);
@@ -247,6 +254,45 @@ class AuthServiceDatabaseTest {
assertThat(captor.getValue().getTokenHash()).hasSize(64);
}
+ @Test
+ void databaseLoginAttributesTheSessionToTheRequestingClientTest() {
+ RmqStudioUser user = user(1L, "operator", false, true, "password-1");
+ when(userMapper.selectCount(isNull())).thenReturn(1L);
+ when(userMapper.selectOne(any(Wrapper.class))).thenReturn(user);
+
+ LoginDTO request = new LoginDTO();
+ request.setUsername("operator");
+ request.setPassword("password-1");
+
+ authService.login(request, "203.0.113.7", "M".repeat(300));
+
+ org.mockito.ArgumentCaptor<RmqStudioSession> captor =
+ org.mockito.ArgumentCaptor.forClass(RmqStudioSession.class);
+ verify(sessionMapper).insert(captor.capture());
+ assertThat(captor.getValue().getClientIp()).isEqualTo("203.0.113.7");
+ // Overlong agents are truncated to the column width instead of
failing the insert.
+ assertThat(captor.getValue().getUserAgent()).hasSize(255);
+ }
+
+ @Test
+ void databaseLoginWithoutClientAttributionKeepsTheColumnsNullTest() {
+ RmqStudioUser user = user(1L, "operator", false, true, "password-1");
+ when(userMapper.selectCount(isNull())).thenReturn(1L);
+ when(userMapper.selectOne(any(Wrapper.class))).thenReturn(user);
+
+ LoginDTO request = new LoginDTO();
+ request.setUsername("operator");
+ request.setPassword("password-1");
+
+ authService.login(request);
+
+ org.mockito.ArgumentCaptor<RmqStudioSession> captor =
+ org.mockito.ArgumentCaptor.forClass(RmqStudioSession.class);
+ verify(sessionMapper).insert(captor.capture());
+ assertThat(captor.getValue().getClientIp()).isNull();
+ assertThat(captor.getValue().getUserAgent()).isNull();
+ }
+
@Test
void passwordChangeRevokesExistingSessions() {
RmqStudioUser user = user(1L, "operator", false, true, "password-1");
@@ -285,6 +331,95 @@ class AuthServiceDatabaseTest {
verify(sessionMapper, never()).update(isNull(), any(Wrapper.class));
}
+ @Test
+ void revokingAllSessionsSparesTheOperatorTest() {
+ AuthenticatedUserContext.setUser(1L, "operator", true);
+ when(sessionMapper.update(isNull(), any(Wrapper.class))).thenReturn(7);
+
+ int revoked = authService.revokeAllSessions();
+
+ assertThat(revoked).isEqualTo(7);
+ org.mockito.ArgumentCaptor<UpdateWrapper<RmqStudioSession>>
updateCaptor =
+ org.mockito.ArgumentCaptor.forClass(UpdateWrapper.class);
+ verify(sessionMapper).update(isNull(), updateCaptor.capture());
+ // The active-session predicate plus the operator exclusion; the
incident responder
+ // stays logged in and can still revoke their own account from its
drawer.
+ assertThat(updateCaptor.getValue().getSqlSegment())
+ .contains("revoked_at IS NULL", "expires_at", "user_id");
+ assertThat(updateCaptor.getValue().getSqlSet()).contains("revoked_at");
+
assertThat(updateCaptor.getValue().getParamNameValuePairs().values()).contains(1L);
+ }
+
+ @Test
+ void revokingAllSessionsWithoutAnOperatorContextRevokesEveryoneTest() {
+ when(sessionMapper.update(isNull(), any(Wrapper.class))).thenReturn(9);
+
+ int revoked = authService.revokeAllSessions();
+
+ assertThat(revoked).isEqualTo(9);
+ org.mockito.ArgumentCaptor<UpdateWrapper<RmqStudioSession>>
updateCaptor =
+ org.mockito.ArgumentCaptor.forClass(UpdateWrapper.class);
+ verify(sessionMapper).update(isNull(), updateCaptor.capture());
+ // No authenticated principal (e.g. a scheduled or system-triggered
call): nothing
+ // is excluded, every active session is revoked.
+ assertThat(updateCaptor.getValue().getSqlSegment())
+ .contains("revoked_at IS NULL", "expires_at")
+ .doesNotContain("user_id");
+ assertThat(updateCaptor.getValue().getSqlSet()).contains("revoked_at");
+ }
+
+ @Test
+ void revokingASessionByIdUpdatesOnlyThatRowTest() {
+ RmqStudioSession session = activeSession(19L, 7L,
+ LocalDateTime.parse("2026-08-13T00:00:00"));
+ when(sessionMapper.selectById(19L)).thenReturn(session);
+
+ authService.revokeSessionById(19L);
+
+ org.mockito.ArgumentCaptor<UpdateWrapper<RmqStudioSession>>
updateCaptor =
+ org.mockito.ArgumentCaptor.forClass(UpdateWrapper.class);
+ verify(sessionMapper).update(isNull(), updateCaptor.capture());
+ assertThat(updateCaptor.getValue().getSqlSet()).contains("revoked_at");
+ assertThat(updateCaptor.getValue().getSqlSegment())
+ .contains("id", "revoked_at IS NULL");
+ }
+
+ @Test
+ void revokingAnAlreadyRevokedSessionIsANoOpTest() {
+ RmqStudioSession session = activeSession(19L, 7L,
+ LocalDateTime.parse("2026-08-13T00:00:00"));
+ session.setRevokedAt(LocalDateTime.parse("2026-08-12T23:00:00"));
+ when(sessionMapper.selectById(19L)).thenReturn(session);
+
+ // A stale drawer must not turn a repeated confirmation into a failure.
+ authService.revokeSessionById(19L);
+
+ verify(sessionMapper, never()).update(isNull(), any(Wrapper.class));
+ }
+
+ @Test
+ void revokingAnExpiredSessionIsANoOpTest() {
+ RmqStudioSession session = activeSession(19L, 7L,
+ LocalDateTime.parse("2026-08-12T22:00:00"));
+ session.setExpiresAt(LocalDateTime.parse("2026-08-12T23:00:00"));
+ when(sessionMapper.selectById(19L)).thenReturn(session);
+
+ authService.revokeSessionById(19L);
+
+ verify(sessionMapper, never()).update(isNull(), any(Wrapper.class));
+ }
+
+ @Test
+ void revokingAMissingSessionIsRejectedTest() {
+ when(sessionMapper.selectById(404L)).thenReturn(null);
+
+ assertThatThrownBy(() -> authService.revokeSessionById(404L))
+ .isInstanceOf(BusinessException.class)
+ .hasMessage("Session not found");
+
+ verify(sessionMapper, never()).update(isNull(), any(Wrapper.class));
+ }
+
@Test
void disablingLastEnabledAdministratorIsRejected() {
RmqStudioUser user = user(1L, "admin", true, true, "password-1");
@@ -365,6 +500,125 @@ class AuthServiceDatabaseTest {
verify(sessionMapper).update(isNull(), any(Wrapper.class));
}
+ @Test
+ void idleExpiredSessionsAreRejectedAndRevokedTest() {
+ // Last seen 60 minutes ago against a configured 30-minute idle
window, while the absolute
+ // expiry (2026-08-14) is still a day away: only the idle deadline can
reject it.
+ // The idle deadline is off unless an operator turns it on, so this
case configures it.
+
when(settingsRepository.loadGeneralSettings()).thenReturn(GeneralSettingsVO.builder()
+ .sessionTimeout(30)
+ .sessionIdleTimeout(30)
+ .build());
+ RmqStudioUser user = user(1L, "operator", false, true, "password-1");
+ RmqStudioSession session = activeSession(10L, 1L,
+ LocalDateTime.parse("2026-08-12T23:00:00"));
+ when(sessionMapper.selectOne(any(Wrapper.class))).thenReturn(session);
+ when(userMapper.selectById(1L)).thenReturn(user);
+
+ assertThat(authService.isAuthenticated("Bearer token-1")).isFalse();
+
+ org.mockito.ArgumentCaptor<UpdateWrapper<RmqStudioSession>>
updateCaptor =
+ org.mockito.ArgumentCaptor.forClass(UpdateWrapper.class);
+ verify(sessionMapper).update(isNull(), updateCaptor.capture());
+ assertThat(updateCaptor.getValue().getSqlSet()).contains("revoked_at");
+ }
+
+ @Test
+ void sessionsSeenWithinTheIdleWindowStayAuthenticatedTest() {
+ // Ten minutes old: inside the 30-minute idle window but past the
throttled
+ // last-seen interval, so the session survives and only its
last_seen_at refreshes.
+ RmqStudioUser user = user(1L, "operator", false, true, "password-1");
+ RmqStudioSession session = activeSession(10L, 1L,
+ LocalDateTime.parse("2026-08-12T23:50:00"));
+ when(sessionMapper.selectOne(any(Wrapper.class))).thenReturn(session);
+ when(userMapper.selectById(1L)).thenReturn(user);
+
+ assertThat(authService.isAuthenticated("Bearer token-1")).isTrue();
+
+ org.mockito.ArgumentCaptor<UpdateWrapper<RmqStudioSession>>
updateCaptor =
+ org.mockito.ArgumentCaptor.forClass(UpdateWrapper.class);
+ verify(sessionMapper).update(isNull(), updateCaptor.capture());
+
assertThat(updateCaptor.getValue().getSqlSet()).contains("last_seen_at")
+ .doesNotContain("revoked_at");
+ }
+
+ @Test
+ void configuredIdleWindowShortensTheDeadlineTest() {
+ GeneralSettingsVO configured = GeneralSettingsVO.builder()
+ .sessionTimeout(30)
+ .sessionIdleTimeout(15)
+ .build();
+ when(settingsRepository.loadGeneralSettings()).thenReturn(configured);
+ RmqStudioUser user = user(1L, "operator", false, true, "password-1");
+ RmqStudioSession session = activeSession(10L, 1L,
+ LocalDateTime.parse("2026-08-12T23:45:00"));
+ when(sessionMapper.selectOne(any(Wrapper.class))).thenReturn(session);
+ when(userMapper.selectById(1L)).thenReturn(user);
+
+ // Twenty minutes unused: inside the default window but past the
configured one.
+ assertThat(authService.isAuthenticated("Bearer token-1")).isFalse();
+
+ verify(sessionMapper).update(isNull(), any(Wrapper.class));
+ }
+
+ @Test
+ void zeroIdleTimeoutDisablesTheIdleDeadlineTest() {
+ GeneralSettingsVO configured = GeneralSettingsVO.builder()
+ .sessionTimeout(30)
+ .sessionIdleTimeout(0)
+ .build();
+ when(settingsRepository.loadGeneralSettings()).thenReturn(configured);
+ RmqStudioUser user = user(1L, "operator", false, true, "password-1");
+ RmqStudioSession session = activeSession(10L, 1L,
+ LocalDateTime.parse("2026-08-12T22:00:00"));
+ when(sessionMapper.selectOne(any(Wrapper.class))).thenReturn(session);
+ when(userMapper.selectById(1L)).thenReturn(user);
+
+ // Two hours unused: only the absolute expiry (tomorrow) may reject
this token.
+ assertThat(authService.isAuthenticated("Bearer token-1")).isTrue();
+
+ org.mockito.ArgumentCaptor<UpdateWrapper<RmqStudioSession>>
updateCaptor =
+ org.mockito.ArgumentCaptor.forClass(UpdateWrapper.class);
+ verify(sessionMapper).update(isNull(), updateCaptor.capture());
+
assertThat(updateCaptor.getValue().getSqlSet()).contains("last_seen_at")
+ .doesNotContain("revoked_at");
+ }
+
+ @Test
+ void missingLastSeenSessionsFallBackToTheAbsoluteDeadlineTest() {
+ // Rows written before the column existed have no last_seen_at; the
idle deadline
+ // must not guess one from gmt_create and lock their owners out.
+ RmqStudioUser user = user(1L, "operator", false, true, "password-1");
+ RmqStudioSession session = activeSession(10L, 1L,
LocalDateTime.parse("2026-08-13T00:00:00"));
+ session.setLastSeenAt(null);
+ when(sessionMapper.selectOne(any(Wrapper.class))).thenReturn(session);
+ when(userMapper.selectById(1L)).thenReturn(user);
+
+ assertThat(authService.isAuthenticated("Bearer token-1")).isTrue();
+
+ verify(sessionMapper).update(isNull(), any(Wrapper.class));
+ }
+
+ @Test
+ void scheduledPurgeRevokesIdleExpiredSessionsTest() {
+ // The idle deadline is off unless an operator turns it on, so this
case configures it.
+
when(settingsRepository.loadGeneralSettings()).thenReturn(GeneralSettingsVO.builder()
+ .sessionTimeout(30)
+ .sessionIdleTimeout(30)
+ .build());
+
+ authService.purgeExpiredSessions();
+
+ org.mockito.ArgumentCaptor<UpdateWrapper<RmqStudioSession>>
updateCaptor =
+ org.mockito.ArgumentCaptor.forClass(UpdateWrapper.class);
+ verify(sessionMapper).update(isNull(), updateCaptor.capture());
+ assertThat(updateCaptor.getValue().getSqlSet()).contains("revoked_at");
+ assertThat(updateCaptor.getValue().getSqlSegment())
+ .contains("revoked_at", "last_seen_at", "expires_at");
+ // The absolute-expiry cleanup keeps running alongside the idle
revocation.
+ verify(sessionMapper).delete(any(Wrapper.class));
+ }
+
@Test
void databaseBootstrapIgnoresConcurrentUserCreation() {
AuthProperties.User configuredUser = new AuthProperties.User();
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 fbbe3b5b9..9c60d259e 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
@@ -199,6 +199,56 @@ class AuthServiceTest {
assertThat(activeTokens).isEmpty();
}
+ @Test
+ void idleExpiredTokensAreRejectedBeforeAbsoluteExpiryTest() {
+ Clock clock = mock(Clock.class);
+ when(clock.millis()).thenReturn(0L);
+ authService = new AuthService(authProperties, settingsRepository,
clock);
+ // The idle deadline is off unless an operator turns it on, so this
case configures it.
+
when(settingsRepository.loadGeneralSettings()).thenReturn(GeneralSettingsVO.builder()
+ .sessionTimeout(120)
+ .sessionIdleTimeout(30)
+ .build());
+ AuthProperties.User user = new AuthProperties.User();
+ user.setUsername("testuser");
+ user.setPassword("testpass");
+ authProperties.setUsers(List.of(user));
+ LoginDTO request = new LoginDTO();
+ request.setUsername("testuser");
+ request.setPassword("testpass");
+ LoginVO response = authService.login(request);
+
+ // 31 minutes unused: past the configured 30-minute idle window while
the two-hour
+ // absolute expiry is still far away, so only the idle deadline can
reject it.
+ when(clock.millis()).thenReturn(31 * 60_000L);
+ assertThat(authService.isAuthenticated("Bearer " +
response.getToken())).isFalse();
+
+ // The rejected token is dropped, it does not come back on a later
presentation.
+ assertThat(authService.isAuthenticated("Bearer " +
response.getToken())).isFalse();
+ }
+
+ @Test
+ void zeroIdleTimeoutKeepsUnusedTokensUntilAbsoluteExpiryTest() {
+ Clock clock = mock(Clock.class);
+ when(clock.millis()).thenReturn(0L);
+ authService = new AuthService(authProperties, settingsRepository,
clock);
+
when(settingsRepository.loadGeneralSettings()).thenReturn(GeneralSettingsVO.builder()
+ .sessionTimeout(120)
+ .sessionIdleTimeout(0)
+ .build());
+ AuthProperties.User user = new AuthProperties.User();
+ user.setUsername("testuser");
+ user.setPassword("testpass");
+ authProperties.setUsers(List.of(user));
+ LoginDTO request = new LoginDTO();
+ request.setUsername("testuser");
+ request.setPassword("testpass");
+ LoginVO response = authService.login(request);
+
+ when(clock.millis()).thenReturn(60 * 60_000L);
+ assertThat(authService.isAuthenticated("Bearer " +
response.getToken())).isTrue();
+ }
+
@Test
void loginShouldUsePersistedSessionTimeout() {
AuthProperties.User user = new AuthProperties.User();
diff --git
a/server/src/test/java/org/apache/rocketmq/studio/auth/StudioSessionSchemaMigrationTest.java
b/server/src/test/java/org/apache/rocketmq/studio/auth/StudioSessionSchemaMigrationTest.java
new file mode 100644
index 000000000..1fb716fb7
--- /dev/null
+++
b/server/src/test/java/org/apache/rocketmq/studio/auth/StudioSessionSchemaMigrationTest.java
@@ -0,0 +1,86 @@
+/*
+ * 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 org.h2.jdbcx.JdbcDataSource;
+import org.junit.jupiter.api.Test;
+import org.springframework.boot.DefaultApplicationArguments;
+
+import java.sql.Connection;
+import java.sql.ResultSet;
+import java.sql.Statement;
+
+import static org.assertj.core.api.Assertions.assertThat;
+
+class StudioSessionSchemaMigrationTest {
+
+ @Test
+ void
addsAttributionColumnsToExistingSessionsWithoutDroppingRowsAndIsIdempotent()
throws Exception {
+ JdbcDataSource dataSource = new JdbcDataSource();
+
dataSource.setURL("jdbc:h2:mem:studio-session-schema-migration;MODE=MySQL;"
+ + "DB_CLOSE_DELAY=-1;DATABASE_TO_LOWER=TRUE");
+ dataSource.setUser("sa");
+ try (Connection connection = dataSource.getConnection(); Statement
statement = connection.createStatement()) {
+ statement.execute("CREATE TABLE rmq_studio_session ("
+ + "id BIGINT PRIMARY KEY, user_id BIGINT NOT NULL,
token_hash CHAR(64) NOT NULL, "
+ + "expires_at TIMESTAMP NOT NULL, revoked_at TIMESTAMP, "
+ + "last_seen_at TIMESTAMP NOT NULL, gmt_create TIMESTAMP,
gmt_modified TIMESTAMP)");
+ statement.execute("INSERT INTO rmq_studio_session (id, user_id,
token_hash, expires_at, last_seen_at) "
+ + "VALUES (1, 7, 'hash-of-existing-token', '2027-01-01
00:00:00', '2026-10-01 00:00:00')");
+ }
+
+ StudioSessionSchemaMigration migration = new
StudioSessionSchemaMigration(dataSource);
+ migration.run(new DefaultApplicationArguments());
+ migration.run(new DefaultApplicationArguments());
+
+ try (Connection connection = dataSource.getConnection(); Statement
statement = connection.createStatement()) {
+ for (String column : new String[] {"client_ip", "user_agent"}) {
+ try (ResultSet columns = statement.executeQuery("SELECT
COUNT(*) FROM information_schema.columns "
+ + "WHERE table_name = 'rmq_studio_session' AND
column_name = '" + column + "'")) {
+ columns.next();
+ assertThat(columns.getInt(1)).as("column %s",
column).isEqualTo(1);
+ }
+ }
+ try (ResultSet rows = statement.executeQuery(
+ "SELECT token_hash, client_ip, user_agent FROM
rmq_studio_session")) {
+ rows.next();
+
assertThat(rows.getString("token_hash")).isEqualTo("hash-of-existing-token");
+ assertThat(rows.getString("client_ip")).isNull();
+ assertThat(rows.getString("user_agent")).isNull();
+ assertThat(rows.next()).isFalse();
+ }
+ }
+ }
+
+ @Test
+ void skipsMissingSessionTableSoTheMainSchemaCanCreateItLater() throws
Exception {
+ JdbcDataSource dataSource = new JdbcDataSource();
+
dataSource.setURL("jdbc:h2:mem:studio-session-schema-missing;MODE=MySQL;"
+ + "DB_CLOSE_DELAY=-1;DATABASE_TO_LOWER=TRUE");
+ dataSource.setUser("sa");
+
+ StudioSessionSchemaMigration migration = new
StudioSessionSchemaMigration(dataSource);
+
+ migration.run(new DefaultApplicationArguments());
+
+ try (Connection connection = dataSource.getConnection();
+ ResultSet tables = connection.getMetaData().getTables(null, null,
"rmq_studio_session",
+ new String[] {"TABLE"})) {
+ assertThat(tables.next()).isFalse();
+ }
+ }
+}
diff --git
a/server/src/test/java/org/apache/rocketmq/studio/auth/StudioUserControllerTest.java
b/server/src/test/java/org/apache/rocketmq/studio/auth/StudioUserControllerTest.java
index c35cb9870..a5f339101 100644
---
a/server/src/test/java/org/apache/rocketmq/studio/auth/StudioUserControllerTest.java
+++
b/server/src/test/java/org/apache/rocketmq/studio/auth/StudioUserControllerTest.java
@@ -38,6 +38,7 @@ import java.util.Map;
import static org.mockito.Mockito.verify;
import static org.mockito.Mockito.when;
+import static
org.springframework.test.web.servlet.request.MockMvcRequestBuilders.delete;
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;
@@ -177,4 +178,23 @@ class StudioUserControllerTest extends
WebMvcAuthTestSupport {
verify(authService).revokeSessionsForUser(7L);
}
+
+ @Test
+ void revokeAllSessionsReturnsTheRevokedCount() throws Exception {
+ when(authService.revokeAllSessions()).thenReturn(11);
+
+ mockMvc.perform(post("/api/studio-users/sessions/revoke-all"))
+ .andExpect(status().isOk())
+ .andExpect(jsonPath("$.data.revokedSessionCount").value(11));
+
+ verify(authService).revokeAllSessions();
+ }
+
+ @Test
+ void revokeSingleSessionRevokesExactlyOneRow() throws Exception {
+ mockMvc.perform(delete("/api/studio-users/sessions/19"))
+ .andExpect(status().isOk());
+
+ verify(authService).revokeSessionById(19L);
+ }
}
diff --git
a/server/src/test/java/org/apache/rocketmq/studio/settings/SettingsControllerTest.java
b/server/src/test/java/org/apache/rocketmq/studio/settings/SettingsControllerTest.java
index 60bfffe4b..a982592f3 100644
---
a/server/src/test/java/org/apache/rocketmq/studio/settings/SettingsControllerTest.java
+++
b/server/src/test/java/org/apache/rocketmq/studio/settings/SettingsControllerTest.java
@@ -218,6 +218,56 @@ class SettingsControllerTest extends WebMvcAuthTestSupport
{
verifyNoInteractions(settingsService);
}
+ @Test
+ void saveGeneralSettingsShouldCarryTheSessionIdleTimeoutTest() throws
Exception {
+
doNothing().when(settingsService).saveGeneralSettings(any(GeneralSettingsVO.class));
+
+ mockMvc.perform(post("/api/settings/general/save")
+ .contentType(MediaType.APPLICATION_JSON)
+ .content("""
+ {
+ "theme": "light",
+ "compact": false,
+ "desktopNotify": true,
+ "notifySound": false,
+ "sessionTimeout": 60,
+ "sessionIdleTimeout": 0,
+ "requireLogin": true,
+ "llmProvider": "openai",
+ "model": "gpt-4",
+ "baseUrl": ""
+ }
+ """))
+ .andExpect(status().isOk());
+
+ // 0 is the explicit opt-out and must survive binding instead of being
dropped.
+ verify(settingsService).saveGeneralSettings(argThat(settings ->
+ Integer.valueOf(0).equals(settings.getSessionIdleTimeout())));
+ }
+
+ @Test
+ void saveGeneralSettingsShouldRejectOutOfRangeSessionIdleTimeoutTest()
throws Exception {
+ mockMvc.perform(post("/api/settings/general/save")
+ .contentType(MediaType.APPLICATION_JSON)
+ .content("""
+ {
+ "theme": "light",
+ "compact": false,
+ "desktopNotify": true,
+ "notifySound": false,
+ "sessionTimeout": 60,
+ "sessionIdleTimeout": 1441,
+ "requireLogin": true,
+ "llmProvider": "openai",
+ "model": "gpt-4",
+ "baseUrl": ""
+ }
+ """))
+ .andExpect(status().isBadRequest());
+
+ verifyNoInteractions(settingsService);
+ }
+
@Test
void listDataSourcesShouldReturnAllSourcesTest() throws Exception {
DataSourceVO ds1 =
DataSourceVO.builder().key("ds-1").name("Production").type("Prometheus")
diff --git
a/server/src/test/java/org/apache/rocketmq/studio/settings/SettingsServiceTest.java
b/server/src/test/java/org/apache/rocketmq/studio/settings/SettingsServiceTest.java
index 244569563..dd4f8163a 100644
---
a/server/src/test/java/org/apache/rocketmq/studio/settings/SettingsServiceTest.java
+++
b/server/src/test/java/org/apache/rocketmq/studio/settings/SettingsServiceTest.java
@@ -187,6 +187,25 @@ class SettingsServiceTest {
assertThat(update.getTemperature()).isEqualTo(0.2);
}
+ @Test
+ void saveGeneralSettingsShouldPreserveSessionIdleTimeoutWhenOmittedTest() {
+ // Clients written before the field existed keep sending replacements
without it;
+ // their saves must not silently reset a configured idle deadline to
the default.
+ GeneralSettingsVO existing = GeneralSettingsVO.builder()
+ .sessionIdleTimeout(15)
+ .build();
+ GeneralSettingsVO update = GeneralSettingsVO.builder()
+ .theme("light")
+ .sessionTimeout(60)
+ .build();
+ when(settingsRepository.loadGeneralSettings()).thenReturn(existing);
+
+ settingsService.saveGeneralSettings(update);
+
+ assertThat(update.getSessionIdleTimeout()).isEqualTo(15);
+ verify(settingsRepository).saveGeneralSettings(update);
+ }
+
@Test
void
saveGeneralSettingsShouldRejectMetadataLlmBaseUrlBeforePersistingTest() {
GeneralSettingsVO update = GeneralSettingsVO.builder()
diff --git a/web/src/api/client.ts b/web/src/api/client.ts
index bd0d6679a..747988653 100644
--- a/web/src/api/client.ts
+++ b/web/src/api/client.ts
@@ -80,7 +80,13 @@ function isPublicAuthRequest(url?: string): boolean {
export function handleSessionUnauthorized(): void {
clearAuthSession();
- window.location.href = '/login';
+ // Carry the page the operator was on so the login flow can return to it
after
+ // re-authenticating; an expired session while already on /login must not
self-reference.
+ const current = `${window.location.pathname}${window.location.search}`;
+ const target = current && current !== '/login'
+ ? `/login?redirect=${encodeURIComponent(current)}`
+ : '/login';
+ window.location.href = target;
}
const client = axios.create({
diff --git a/web/src/api/settings.ts b/web/src/api/settings.ts
index b01e15248..ff5c78052 100644
--- a/web/src/api/settings.ts
+++ b/web/src/api/settings.ts
@@ -24,6 +24,8 @@ export interface GeneralSettings {
desktopNotify: boolean;
notifySound: boolean;
sessionTimeout: number;
+ /** Minutes a session token may stay unused before it is treated as expired;
0 disables the idle deadline. */
+ sessionIdleTimeout?: number;
requireLogin: boolean;
llmProvider: string;
apiKeyConfigured: boolean;
diff --git a/web/src/api/studioUsers.ts b/web/src/api/studioUsers.ts
index 9f89c9ecc..c2ec38cf4 100644
--- a/web/src/api/studioUsers.ts
+++ b/web/src/api/studioUsers.ts
@@ -72,6 +72,9 @@ export interface StudioUserSessionDetail {
idleSeconds?: number | null;
expiringSoon: boolean;
stale: boolean;
+ /** Client address the session was issued to; absent for rows created before
attribution. */
+ clientIp?: string | null;
+ userAgent?: string | null;
}
export async function listStudioUsers(query: StudioUserQuery = {}) {
@@ -137,3 +140,14 @@ export async function revokeStudioUserSessions(userId:
number) {
);
return response.data.data;
}
+
+export async function revokeAllStudioUserSessions() {
+ const response = await client.post<{ data: { revokedSessionCount: number }
}>(
+ '/studio-users/sessions/revoke-all',
+ );
+ return response.data.data.revokedSessionCount;
+}
+
+export async function revokeStudioSession(sessionId: number) {
+ await client.delete(`/studio-users/sessions/${sessionId}`);
+}
diff --git a/web/src/i18n/translations.ts b/web/src/i18n/translations.ts
index 83ace3add..ca2312a0f 100644
--- a/web/src/i18n/translations.ts
+++ b/web/src/i18n/translations.ts
@@ -1368,6 +1368,13 @@ const translations: Record<string, Record<Lang, string>>
= {
},
'settings.sessionTimeoutRequired': { zh: '请输入会话超时时长', en: 'Enter a session
timeout.' },
'settings.sessionTimeoutUnit': { zh: '会话超时单位', en: 'Session timeout unit' },
+ 'settings.sessionIdleTimeout': { zh: '会话空闲超时', en: 'Session idle timeout' },
+ 'settings.sessionIdleTimeoutHelp': {
+ zh: '令牌连续未使用的时长超过该值后会话即失效;0 表示仅按绝对超时判定,低于 10 分钟按 10 分钟生效。',
+ en: 'A session whose token stays unused longer than this is revoked; 0
keeps only the absolute timeout, and values below 10 minutes apply as 10.',
+ },
+ 'settings.sessionIdleTimeoutRequired': { zh: '请输入会话空闲超时时长', en: 'Enter a
session idle timeout.' },
+ 'settings.sessionIdleTimeoutUnit': { zh: '会话空闲超时单位', en: 'Session idle
timeout unit' },
'settings.minutes': { zh: '分钟', en: 'minutes' },
'settings.saveSettings': { zh: '保存设置', en: 'Save settings' },
'settings.loginRequiredHelp': {
@@ -3186,8 +3193,15 @@ const translations: Record<string, Record<Lang, string>>
= {
'userMgmt.userSessions': { zh: '用户会话', en: 'User Sessions' },
'userMgmt.sessionsOf': { zh: '{username} 的会话', en: 'Sessions of {username}'
},
'userMgmt.revokeAll': { zh: '注销全部', en: 'Revoke All' },
+ 'userMgmt.revokeSession': { zh: '注销会话', en: 'Revoke session' },
+ 'userMgmt.revokeSessionConfirm': {
+ zh: '注销会话 #{id}?该用户的其他会话不受影响。',
+ en: 'Revoke session #{id}? Other sessions of this user stay active.',
+ },
+ 'userMgmt.sessionRevoked': { zh: '已注销会话 #{id}', en: 'Revoked session #{id}'
},
'userMgmt.accountStatus': { zh: '账号状态', en: 'Account Status' },
'userMgmt.passwordChangedAt': { zh: '密码修改时间', en: 'Password Changed At' },
+ 'userMgmt.clientOrigin': { zh: '来源', en: 'Origin' },
'userMgmt.noActiveSessions': { zh: '暂无活跃会话', en: 'No active sessions' },
'userMgmt.createTitle': { zh: '新建 Studio 用户', en: 'Create Studio User' },
'userMgmt.initialPassword': { zh: '初始密码', en: 'Initial Password' },
@@ -3230,6 +3244,23 @@ const translations: Record<string, Record<Lang, string>>
= {
},
'userMgmt.nothingToRevoke': { zh: '没有可注销的活跃会话', en: 'No active sessions to
revoke' },
'userMgmt.revokeFailed': { zh: '注销用户会话失败', en: 'Failed to revoke user
sessions' },
+ 'userMgmt.revokeAllSessions': { zh: '注销全部会话', en: 'Revoke all sessions' },
+ 'userMgmt.revokeAllConfirmTitle': {
+ zh: '注销其他全部用户的活跃会话?',
+ en: 'Revoke all other active sessions?',
+ },
+ 'userMgmt.revokeAllConfirmDescription': {
+ zh: '所有用户(你自己除外)都会被退出登录并需要重新登录。此操作用于疑似凭据泄露时的事件响应。',
+ en: 'Every user except you is signed out and must log in again. Use this
to respond to a suspected credential leak.',
+ },
+ 'userMgmt.revokedAllCount': {
+ zh: '已注销 {count} 个活跃会话(不含你自己的)',
+ en: 'Revoked {count} active sessions (your own were kept)',
+ },
+ 'userMgmt.revokeAllFailed': {
+ zh: '注销全部会话失败',
+ en: 'Failed to revoke all sessions',
+ },
'userMgmt.exportedCount': { zh: '已导出 {count} 个用户', en: 'Exported {count}
users' },
'userMgmt.exportFailed': {
zh: '导出用户列表失败,请稍后重试',
diff --git a/web/src/pages/login/index.tsx b/web/src/pages/login/index.tsx
index 0fcb5f53d..f938f507d 100644
--- a/web/src/pages/login/index.tsx
+++ b/web/src/pages/login/index.tsx
@@ -24,7 +24,7 @@ import {
UserOutlined,
} from '@ant-design/icons';
import { App, Button, Form, Input, Typography } from 'antd';
-import { useNavigate } from 'react-router-dom';
+import { useNavigate, useSearchParams } from 'react-router-dom';
import { useLang } from '../../i18n/LangContext';
import useAuthStore from '../../stores/authStore';
import { login as loginApi } from '../../api/auth';
@@ -38,6 +38,18 @@ interface LoginFormValues {
password: string;
}
+/**
+ * Only same-app absolute paths may be used as the post-login destination:
protocol-relative
+ * URLs (`//evil.com`) and anything with a scheme or a backslash must fall
back to home.
+ */
+function sanitizeReturnPath(candidate: string | null): string | null {
+ if (!candidate) return null;
+ if (!candidate.startsWith('/') || candidate.startsWith('//') ||
candidate.includes('\\')) {
+ return null;
+ }
+ return candidate;
+}
+
const LoginPage = () => {
const [loading, setLoading] = useState(false);
const loginInFlightRef = useRef(false);
@@ -45,6 +57,7 @@ const LoginPage = () => {
const { t } = useLang();
const { message } = App.useApp();
const navigate = useNavigate();
+ const [searchParams] = useSearchParams();
const authLogin = useAuthStore((s) => s.login);
const { darkMode, toggleTheme } = useTheme();
@@ -58,7 +71,8 @@ const LoginPage = () => {
const data = await loginApi(values.username, values.password);
authLogin(data.user.username, data.user.userId, data.user.admin);
message.success(t('login.success'));
- navigate('/', { replace: true });
+ // A session that expired mid-work redirects here with ?redirect=<page>;
go back to it.
+ navigate(sanitizeReturnPath(searchParams.get('redirect')) ?? '/', {
replace: true });
} catch (err: unknown) {
const errorMsg = err instanceof Error ? err.message : t('login.failed');
message.error(errorMsg);
diff --git a/web/src/pages/login/redirect.test.tsx
b/web/src/pages/login/redirect.test.tsx
new file mode 100644
index 000000000..db2a945c6
--- /dev/null
+++ b/web/src/pages/login/redirect.test.tsx
@@ -0,0 +1,104 @@
+import { fireEvent, render, screen, waitFor } from '@testing-library/react';
+import { App as AntdApp } from 'antd';
+import { MemoryRouter } from 'react-router-dom';
+import { beforeEach, describe, expect, it, vi } from 'vitest';
+
+const navigateMock = vi.hoisted(() => vi.fn());
+const loginApiMock = vi.hoisted(() => vi.fn());
+const loginStoreMock = vi.hoisted(() => vi.fn());
+
+vi.mock('react-router-dom', async (importOriginal) => {
+ const actual = await importOriginal<typeof import('react-router-dom')>();
+ return { ...actual, useNavigate: () => navigateMock };
+});
+
+vi.mock('../../api/auth', () => ({ login: loginApiMock }));
+
+vi.mock('../../stores/authStore', () => ({
+ default: (selector: (state: { login: typeof loginStoreMock }) => unknown) =>
+ selector({ login: loginStoreMock }),
+}));
+
+vi.mock('../../i18n/LangContext', () => ({
+ useLang: () => ({ t: (key: string) => key }),
+}));
+
+vi.mock('../../theme/useTheme', () => ({
+ useTheme: () => ({ darkMode: false, toggleTheme: vi.fn() }),
+}));
+
+import LoginPage from './index';
+
+// antd's responsive observer calls window.matchMedia; jsdom does not
implement it.
+Object.defineProperty(window, 'matchMedia', {
+ writable: true,
+ value: vi.fn().mockImplementation((query: string) => ({
+ matches: false,
+ media: query,
+ onchange: null,
+ addListener: vi.fn(),
+ removeListener: vi.fn(),
+ addEventListener: vi.fn(),
+ removeEventListener: vi.fn(),
+ dispatchEvent: vi.fn(),
+ })),
+});
+
+const fillCredentials = () => {
+ fireEvent.change(screen.getByPlaceholderText('login.usernamePlaceholder'), {
+ target: { value: 'alice' },
+ });
+ fireEvent.change(screen.getByPlaceholderText('login.passwordPlaceholder'), {
+ target: { value: 'secret' },
+ });
+};
+
+const renderPage = (initialEntry = '/login') =>
+ render(
+ <MemoryRouter initialEntries={[initialEntry]}>
+ <AntdApp>
+ <LoginPage />
+ </AntdApp>
+ </MemoryRouter>,
+ );
+
+describe('LoginPage post-login redirect', () => {
+ beforeEach(() => {
+ navigateMock.mockClear();
+ loginApiMock.mockClear();
+ loginStoreMock.mockClear();
+ loginApiMock.mockResolvedValue({
+ user: { username: 'alice', userId: 42, admin: true },
+ });
+ });
+
+ it('returns to the page carried by ?redirect after a successful login',
async () => {
+ renderPage('/login?redirect=%2Fops%2Falerts%3Flevel%3Derror');
+ fillCredentials();
+ fireEvent.click(screen.getByRole('button', { name: 'login.title' }));
+
+ await waitFor(() => expect(loginStoreMock).toHaveBeenCalledWith('alice',
42, true));
+ expect(navigateMock).toHaveBeenCalledWith('/ops/alerts?level=error', {
replace: true });
+ });
+
+ it('falls back to home when no redirect is present', async () => {
+ renderPage('/login');
+ fillCredentials();
+ fireEvent.click(screen.getByRole('button', { name: 'login.title' }));
+
+ await waitFor(() => expect(navigateMock).toHaveBeenCalledWith('/', {
replace: true }));
+ });
+
+ it.each([
+ ['protocol-relative URL', '/login?redirect=%2F%2Fevil.example%2Fphish'],
+ ['absolute external URL',
'/login?redirect=https%3A%2F%2Fevil.example%2Fphish'],
+ ['relative path without a leading slash', '/login?redirect=ops%2Falerts'],
+ ['backslash trick', '/login?redirect=%2F%5Cevil.example'],
+ ])('rejects a redirect that is a %s', async (_label, entry) => {
+ renderPage(entry);
+ fillCredentials();
+ fireEvent.click(screen.getByRole('button', { name: 'login.title' }));
+
+ await waitFor(() => expect(navigateMock).toHaveBeenCalledWith('/', {
replace: true }));
+ });
+});
diff --git a/web/src/pages/settings/GeneralSettingsTab.tsx
b/web/src/pages/settings/GeneralSettingsTab.tsx
index 113f7168c..a654b6073 100644
--- a/web/src/pages/settings/GeneralSettingsTab.tsx
+++ b/web/src/pages/settings/GeneralSettingsTab.tsx
@@ -49,6 +49,8 @@ const buildPayload = (settings: GeneralSettings):
GeneralSettingsUpdate => ({
desktopNotify: settings.desktopNotify,
notifySound: settings.notifySound,
sessionTimeout: settings.sessionTimeout,
+ // Backends predating the field omit it; saving the server default keeps the
two in sync.
+ sessionIdleTimeout: settings.sessionIdleTimeout ?? 0,
requireLogin: settings.requireLogin,
llmProvider: settings.llmProvider,
model: settings.model,
@@ -94,7 +96,10 @@ export const GeneralSettingsTab = () => {
.then((loaded) => {
if (cancelled) return;
setSettings(loaded);
- securityForm.setFieldsValue({ sessionTimeout: loaded.sessionTimeout });
+ securityForm.setFieldsValue({
+ sessionTimeout: loaded.sessionTimeout,
+ sessionIdleTimeout: loaded.sessionIdleTimeout ?? 0,
+ });
notifyForm.setFieldsValue({
dingtalkWebhook: loaded.dingtalkWebhook,
dingtalkSigningSecret: '',
@@ -179,12 +184,20 @@ export const GeneralSettingsTab = () => {
}
};
- const handleSecurityFinish = async (values: { sessionTimeout: number }) => {
+ const handleSecurityFinish = async (values: {
+ sessionTimeout: number;
+ sessionIdleTimeout: number;
+ }) => {
if (!canWriteSettings || securityInFlightRef.current) return;
securityInFlightRef.current = true;
setSavingSecurity(true);
try {
- if (await mergeAndSave({ sessionTimeout: values.sessionTimeout })) {
+ if (
+ await mergeAndSave({
+ sessionTimeout: values.sessionTimeout,
+ sessionIdleTimeout: values.sessionIdleTimeout,
+ })
+ ) {
message.success(t('settings.saveSuccess'));
}
} finally {
@@ -317,6 +330,28 @@ export const GeneralSettingsTab = () => {
</Space.Compact>
</Form.Item>
+ <Form.Item
+ label={t('settings.sessionIdleTimeout')}
+ extra={t('settings.sessionIdleTimeoutHelp')}
+ style={{ marginBottom: 16 }}
+ >
+ <Space.Compact>
+ <Form.Item
+ name="sessionIdleTimeout"
+ noStyle
+ rules={[{ required: true, message:
t('settings.sessionIdleTimeoutRequired') }]}
+ >
+ <InputNumber min={0} max={1440} style={{ width: 120 }} />
+ </Form.Item>
+ <Input
+ aria-label={t('settings.sessionIdleTimeoutUnit')}
+ readOnly
+ value={t('settings.minutes')}
+ style={{ width: 64 }}
+ />
+ </Space.Compact>
+ </Form.Item>
+
<Form.Item style={{ marginBottom: 16 }}>
<Button
type="primary"
diff --git a/web/src/pages/settings/__tests__/GeneralSettingsTab.test.tsx
b/web/src/pages/settings/__tests__/GeneralSettingsTab.test.tsx
index 1ebb42c44..9fd25504a 100644
--- a/web/src/pages/settings/__tests__/GeneralSettingsTab.test.tsx
+++ b/web/src/pages/settings/__tests__/GeneralSettingsTab.test.tsx
@@ -95,11 +95,62 @@ describe('GeneralSettingsTab', () => {
await waitFor(() => expect(saveGeneralSettings).toHaveBeenCalledTimes(1));
});
- it('keeps the session timeout field bound when displaying its unit', async
() => {
+ it('keeps the session timeout fields bound when displaying their unit',
async () => {
renderTab();
- expect(await screen.findByDisplayValue('30')).toBeInTheDocument();
+ // The absolute timeout comes from the stored settings; the idle deadline
falls back to the
+ // server default, which is off rather than a window an upgrade silently
starts enforcing.
+ const timeoutInputs = await screen.findAllByDisplayValue('30');
+ expect(timeoutInputs).toHaveLength(1);
+ expect(document.getElementById('sessionIdleTimeout')).toHaveValue('0');
expect(screen.getByLabelText('会话超时单位')).toHaveValue('分钟');
+ expect(screen.getByLabelText('会话空闲超时单位')).toHaveValue('分钟');
+ });
+
+ it('saves the session idle timeout from the security form', async () => {
+ vi.mocked(saveGeneralSettings).mockResolvedValue();
+ vi.mocked(getGeneralSettings).mockResolvedValue({
+ theme: 'system',
+ compact: false,
+ desktopNotify: false,
+ notifySound: false,
+ sessionTimeout: 30,
+ sessionIdleTimeout: 15,
+ requireLogin: true,
+ llmProvider: 'openai',
+ apiKeyConfigured: false,
+ model: 'test-model',
+ baseUrl: 'https://example.test/v1',
+ });
+ renderTab();
+
+ const idleInput = await waitFor(() => {
+ const input = document.getElementById('sessionIdleTimeout') as
HTMLInputElement | null;
+ expect(input).not.toBeNull();
+ return input!;
+ });
+ expect(idleInput).toHaveValue('15');
+ fireEvent.change(idleInput, { target: { value: '45' } });
+
+ const saveButtons = await screen.findAllByRole('button', { name: '保存设置' });
+ fireEvent.submit(saveButtons[0].closest('form')!);
+
+ await waitFor(() =>
+ expect(saveGeneralSettings).toHaveBeenCalledWith(
+ expect.objectContaining({ sessionTimeout: 30, sessionIdleTimeout: 45
}),
+ ),
+ );
+ });
+
+ it('defaults the idle timeout to the server default when the field is
absent', async () => {
+ renderTab();
+
+ const idleInput = await waitFor(() => {
+ const input = document.getElementById('sessionIdleTimeout') as
HTMLInputElement | null;
+ expect(input).not.toBeNull();
+ return input!;
+ });
+ expect(idleInput).toHaveValue('0');
});
it('shows appearance preferences but keeps desktop notification options
hidden', async () => {
diff --git a/web/src/pages/studio/UserManagement.tsx
b/web/src/pages/studio/UserManagement.tsx
index f5149bf91..00fda6402 100644
--- a/web/src/pages/studio/UserManagement.tsx
+++ b/web/src/pages/studio/UserManagement.tsx
@@ -31,6 +31,7 @@ import {
Switch,
Table,
Tag,
+ Tooltip,
message,
} from 'antd';
import type { ColumnsType } from 'antd/es/table';
@@ -53,6 +54,8 @@ import {
listStudioUserSessions,
listStudioUsers,
resetStudioUserPassword,
+ revokeAllStudioUserSessions,
+ revokeStudioSession,
revokeStudioUserSessions,
setStudioUserEnabled,
type StudioUser,
@@ -139,6 +142,7 @@ const UserManagementPage = () => {
const [createOpen, setCreateOpen] = useState(false);
const [passwordTarget, setPasswordTarget] = useState<StudioUser |
null>(null);
const [userExporting, setUserExporting] = useState(false);
+ const [revokingAllSessions, setRevokingAllSessions] = useState(false);
const [mutatingUserIds, setMutatingUserIds] = useState<Set<number>>(() =>
new Set());
const [refreshVersion, setRefreshVersion] = useState(0);
const [createForm] = Form.useForm<CreateFormValues>();
@@ -367,7 +371,40 @@ const UserManagementPage = () => {
t('userMgmt.revokeFailed'),
);
+ // Shares the per-user in-flight guard with revoke-all, so the two can never
overlap.
+ const revokeSession = (record: StudioUser, sessionId: number) =>
+ runUserMutation(
+ record.id,
+ async () => {
+ await revokeStudioSession(sessionId);
+ message.success(t('userMgmt.sessionRevoked', { id: sessionId }));
+ await loadSessionDetails(record);
+ await loadUsers();
+ },
+ t('userMgmt.revokeFailed'),
+ );
+
const openCreateUserModal = () => setCreateOpen(true);
+
+ // The endpoint spares this operator's own sessions, so the incident
responder stays
+ // logged in; everyone else has to sign in again.
+ const handleRevokeAllSessions = async () => {
+ if (revokingAllSessions) return;
+ setRevokingAllSessions(true);
+ try {
+ const revokedCount = await revokeAllStudioUserSessions();
+ message.success(t('userMgmt.revokedAllCount', { count: revokedCount }));
+ if (sessionModalUser) {
+ setSessionDrawerUser(null);
+ }
+ await loadUsers();
+ } catch {
+ message.error(t('userMgmt.revokeAllFailed'));
+ } finally {
+ setRevokingAllSessions(false);
+ }
+ };
+
const handleExportUsers = useCallback(async () => {
if (!admin) return;
setUserExporting(true);
@@ -431,6 +468,39 @@ const UserManagementPage = () => {
ellipsis: true,
render: dateTime,
},
+ {
+ title: t('userMgmt.clientOrigin'),
+ dataIndex: 'clientIp',
+ width: 160,
+ ellipsis: true,
+ render: (value: string | null | undefined, record) =>
+ value ? (
+ <Tooltip title={record.userAgent ?? value} placement="topLeft">
+ <span>{value}</span>
+ </Tooltip>
+ ) : (
+ '-'
+ ),
+ },
+ {
+ title: t('common.actions'),
+ key: 'actions',
+ width: 96,
+ render: (_, record) =>
+ sessionModalUser && !mutatingUserIds.has(sessionModalUser.id) ? (
+ <Popconfirm
+ title={t('userMgmt.revokeSessionConfirm', { id: record.id })}
+ okText={t('userMgmt.revokeSession')}
+ cancelText={t('common.cancel')}
+ okButtonProps={{ danger: true }}
+ onConfirm={() => void revokeSession(sessionModalUser, record.id)}
+ >
+ <Button size="small" danger icon={<SignOut size={14} />}>
+ {t('userMgmt.revokeSession')}
+ </Button>
+ </Popconfirm>
+ ) : null,
+ },
];
// Declared widths total 1116px, which stays inside the usable content width
of a normal
// 1440px viewport (220px Sider plus page and Card padding), so the table
does not show a
@@ -614,7 +684,24 @@ const UserManagementPage = () => {
/>
)}
{admin && sessionOverview && (
- <Card title={t('userMgmt.sessionOverview')} style={{ marginBottom: 16
}}>
+ <Card
+ title={t('userMgmt.sessionOverview')}
+ extra={
+ <Popconfirm
+ title={t('userMgmt.revokeAllConfirmTitle')}
+ description={t('userMgmt.revokeAllConfirmDescription')}
+ okText={t('userMgmt.revokeAllSessions')}
+ cancelText={t('common.cancel')}
+ okButtonProps={{ danger: true }}
+ onConfirm={() => void handleRevokeAllSessions()}
+ >
+ <Button danger icon={<SignOut size={14} />}
loading={revokingAllSessions}>
+ {t('userMgmt.revokeAllSessions')}
+ </Button>
+ </Popconfirm>
+ }
+ style={{ marginBottom: 16 }}
+ >
<Flex gap={32} wrap>
<Statistic
title={t('userMgmt.activeSessions')}
diff --git a/web/src/pages/studio/__tests__/UserManagement.test.tsx
b/web/src/pages/studio/__tests__/UserManagement.test.tsx
index 1d2fc8a54..36ebd0b81 100644
--- a/web/src/pages/studio/__tests__/UserManagement.test.tsx
+++ b/web/src/pages/studio/__tests__/UserManagement.test.tsx
@@ -25,6 +25,8 @@ import {
listAllStudioUsers as downloadStudioUsers,
listStudioUserSessions,
listStudioUsers,
+ revokeAllStudioUserSessions,
+ revokeStudioSession,
revokeStudioUserSessions,
setStudioUserEnabled,
type StudioUser,
@@ -44,6 +46,8 @@ vi.mock('../../../api/studioUsers', () => ({
listStudioUserSessions: vi.fn(),
listStudioUsers: vi.fn(),
resetStudioUserPassword: vi.fn(),
+ revokeAllStudioUserSessions: vi.fn(),
+ revokeStudioSession: vi.fn(),
revokeStudioUserSessions: vi.fn(),
setStudioUserEnabled: vi.fn(),
}));
@@ -92,6 +96,8 @@ const sessionDetails: StudioUserSessionDetail[] = [
idleSeconds: 60,
expiringSoon: true,
stale: false,
+ clientIp: '203.0.113.7',
+ userAgent: 'Mozilla/5.0 (Macintosh) StudioTestAgent/1.0',
},
{
id: 20,
@@ -103,6 +109,7 @@ const sessionDetails: StudioUserSessionDetail[] = [
idleSeconds: 1500,
expiringSoon: false,
stale: true,
+ // No attribution: a session created before the columns existed.
},
];
@@ -305,6 +312,9 @@ describe('UserManagementPage', () => {
expect(within(drawer).getByText('5分钟')).toBeInTheDocument();
expect(within(drawer).getByText('1分钟')).toBeInTheDocument();
expect(within(drawer).queryByText(/token/i)).not.toBeInTheDocument();
+ // The attributed session shows its origin; the legacy row without
attribution renders a dash.
+ expect(within(drawer).getByText('203.0.113.7')).toBeInTheDocument();
+ expect(within(drawer).getAllByText('-').length).toBeGreaterThan(0);
});
it('revokes sessions after row confirmation', async () => {
@@ -361,6 +371,56 @@ describe('UserManagementPage', () => {
expect(screen.queryByText('operator')).not.toBeInTheDocument();
});
+ it('revokes every other session after confirming the global action', async
() => {
+ vi.mocked(revokeAllStudioUserSessions).mockResolvedValue(6);
+ const user = userEvent.setup({ pointerEventsCheck: 0 });
+ renderPage();
+
+ await screen.findByText('operator');
+ await user.click(screen.getByRole('button', { name: '注销全部会话' }));
+ await screen.findByText('注销其他全部用户的活跃会话?');
+ expect(
+ screen.getByText('所有用户(你自己除外)都会被退出登录并需要重新登录。此操作用于疑似凭据泄露时的事件响应。'),
+ ).toBeInTheDocument();
+ await waitFor(() =>
expect(document.querySelector('.ant-popover')).toBeTruthy());
+ const popover = document.querySelector('.ant-popover') as HTMLElement;
+ await user.click(within(popover).getByRole('button', { name: /注\s*销/ }));
+
+ await waitFor(() =>
expect(revokeAllStudioUserSessions).toHaveBeenCalledTimes(1));
+ // The table and the overview both reload with the post-revocation state.
+ expect(listStudioUsers).toHaveBeenCalledTimes(2);
+ await screen.findByText('已注销 6 个活跃会话(不含你自己的)');
+ });
+
+ it('revokes a single session from the drawer without touching the others',
async () => {
+ vi.mocked(listStudioUserSessions)
+ .mockResolvedValueOnce(sessionDetails)
+ .mockResolvedValueOnce([sessionDetails[1]]);
+ vi.mocked(revokeStudioSession).mockResolvedValue();
+ const user = userEvent.setup({ pointerEventsCheck: 0 });
+ renderPage();
+
+ await screen.findByText('operator');
+ await user.click(screen.getByRole('button', { name: '会话' }));
+ const drawer = await screen.findByRole('dialog', { name: 'operator 的会话' });
+ expect(within(drawer).getByText('19')).toBeInTheDocument();
+
+ const rowButtons = within(drawer).getAllByRole('button', { name: '注销会话' });
+ expect(rowButtons).toHaveLength(2);
+ await user.click(rowButtons[0]);
+ await screen.findByText('注销会话 #19?该用户的其他会话不受影响。');
+ await waitFor(() =>
expect(document.querySelector('.ant-popover')).toBeTruthy());
+ const popover = document.querySelector('.ant-popover') as HTMLElement;
+ await user.click(within(popover).getByRole('button', { name: /注\s*销/ }));
+
+ await waitFor(() => expect(revokeStudioSession).toHaveBeenCalledWith(19));
+ // The drawer reloads with the survivor only.
+ await waitFor(() =>
expect(listStudioUserSessions).toHaveBeenCalledTimes(2));
+ expect(within(drawer).getByText('20')).toBeInTheDocument();
+ expect(within(drawer).queryByText('19')).not.toBeInTheDocument();
+
+ });
+
it('renders session timestamps as UTC values in the viewer timezone', async
() => {
// The user/session APIs serialize UTC LocalDateTime values without an
offset, so
// the table and drawer must not parse them as browser-local wall times.
Pin the