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 6dc79f9c feat: add configurable studio login protection (#533)
6dc79f9c is described below
commit 6dc79f9c9f22b0b6a5e3fb7f60deb143b8ec0534
Author: aias00 <[email protected]>
AuthorDate: Tue Jul 28 05:24:50 2026 -0700
feat: add configurable studio login protection (#533)
* fix: align frontend API success response handling (#429)
* ci: add build workflow and PR review skill (#430)
* fix: connect K8s certificate page to APIs (#440)
* feat: extend translation keys & add useLanguage compatible alias in
LangContext (#433)
* fix: validate audit query and cleanup parameters (#442)
* chore: fix Dockerfile missing style/ copy and standardize pr-review skill
(#444)
- Add COPY style ./style to server/Dockerfile so Maven checkstyle can
find style/rmq_checkstyle.xml during docker compose build
- Rewrite pr-review SKILL.md with an 8-stage pipeline checklist,
Dockerfile pre-flight fix step, and [Studio] title format spec
* feat: add centralized ThemeContext and useTheme hook for dark mode
management (#435)
* fix: preserve SSE framing for AI chat (#438)
* feat: add Prometheus range query adapter (#432)
Add a real Prometheus /api/v1/query_range adapter as the foundation for
observability (#431): configurable base URL, timeouts, Basic/Bearer auth, error
mapping, and tests.
* feat: implement login page, auth & AI modules, simplify theme management
(#473)
Add login page with full auth workflow, Auth/AI API modules, Zustand state,
and simplified theme management.
* feat: add Ops page (NameServer management, VIPChannel, TLS) (#474)
Add Ops management page for NameServer address management, VIP channel
toggle, and TLS switch.
* feat: add Producer page (#475)
Add a page to query producer client connections by Topic and Producer Group.
* feat: add AlertManagement page for alert rule operations (#476)
Add alert rule management page: parse/render Prometheus AlertManager YAML
rules with search, toggle, and CRUD.
* feat: add SslSettings page for SSL/TLS configuration management (#477)
Add SSL/TLS configuration page: SSL toggle, TLS version,
KeyStore/TrustStore, client auth mode.
* feat: add broker cluster overview page (#479)
Add Broker Cluster overview page with tab-based NameServer/Broker/Proxy
management and cluster stats.
* feat: add consumer group management page (#480)
Add consumer group management page.
* feat: add lite topic management page (#481)
Add lite topic management page.
* feat: add Proxy management page (#482)
Add Proxy management page (address list, status, TPS, memory, CPU, uptime).
* feat: add LLM Settings configuration page (#483)
Add LLM settings configuration page (provider, apiKey, model, temperature,
etc.).
* feat: connect frontend pages to backend APIs and align contracts (#445)
Consolidated API-integration work
(#445,#446,#447,#448,#449,#450,#451,#452,#453,#454,#458,#459,#460,#461,#463).
* feat: add service capabilities and interaction enhancements (#462)
Consolidated service/interaction work
(#462,#468,#469,#464,#470,#471,#472,#488).
* feat: add preferences persistence, accessibility and env config (#465)
Consolidated preferences/a11y/config work (#465,#466,#485,#486,#487,#484).
* fix: include remaining consolidated frontend changes (#490)
topicService topic API contract, authStore persisted session, vite env
config.
* fix: align login response contract (#492)
Align frontend LoginResponse type with backend LoginVO contract: change
flat username/role to nested user.username/admin structure.
* fix: use runtime DNS resolver for Nginx API proxy (#494)
Fix Nginx API proxy failure caused by hardcoded DNS resolver by switching
to container runtime dynamic resolution.
* feat: enhance i18n support with labelKey-based translations (#495)
Migrate hardcoded Chinese UI text to i18n labelKey pattern, add ~280
translation entries (zh+en), fix duplicate key compilation errors.
* test: fix frontend baseline assertions (#498)
Fix test assertions to align with backend PageResult contract and current
i18n labels.
* fix: initialize deploy helpers before validation (#499)
Move logging/error helper initialization before config validation in
deploy.sh to fix err() undefined when REMOTE_HOST is missing.
* ci: build frontend Docker image (#500)
Add frontend Docker image build to CI workflow to validate Dockerfile/nginx
deployment path.
* fix: load producer topics from Studio API response (#501)
Fix Producer page topic list to read from Studio API new format
(data[].name) with backward compatibility for legacy topicList format.
* fix: generate data source keys on creation (#509)
Fix ConcurrentHashMap NPE when creating data sources with null key by
auto-generating UUID in service layer.
* feat: add producer connection endpoint (#510)
Add producer connection query endpoint with topic/producerGroup filtering,
returning backward-compatible connectionSet format.
* fix: avoid returning General Settings API key (#512)
Fix security issue where General Settings endpoint returned API key in
plaintext. Replace with apiKeyConfigured boolean flag and support explicit key
clearing.
* feat: add catalog-driven read-only tools (#514)
Introduce versioned YAML tool catalog with JSON Schema validation, SHA-256
digest, deep immutability, and two read-only L1 tool handlers (rmq.cluster.list
/ rmq.capabilities).
* feat: add LiteTopic backend endpoints (#502)
Add backend stub endpoints for LiteTopic page: list, session details, TTL
extension, quota, and capability check.
* feat: add ops backend endpoints (#504)
Add ops page backend endpoints: NameServer address management, VIP Channel,
and TLS switches.
* feat: add proxy address endpoints (#505)
Add proxy address list and add-address endpoints supporting Studio frontend
form submission.
* feat: add LLM settings endpoints (#506)
Add LLM settings backend endpoints: config read/write, connectivity test,
and model listing.
* feat: add alert rules YAML endpoint (#507)
Add alert rules Prometheus YAML export endpoint with default RocketMQ alert
template fallback.
* fix: ACL page API integration (#516)
Wire ACL management page to backend API, add update endpoints for ACL rules
and users with proper validation.
* fix: sort queried messages by store time (#519)
Sort message query results by storeTime in descending order so latest
messages appear first.
* feat: add consumer stack diagnostics API (#521)
Add consumer thread stack diagnostics endpoint (GET
/api/groups/{name}/instances/{clientId}/stack) with Provider/Stub/Service
layering.
* feat: add configurable studio login protection
* fix: fail closed when login users are missing
* [Studio] Require configured login users
---------
Co-authored-by: btlqql <[email protected]>
Co-authored-by: lizhimins <[email protected]>
Co-authored-by: zhaohai <[email protected]>
Co-authored-by: terrancesli <[email protected]>
Co-authored-by: wizcraft_kris
<[email protected]>
Co-authored-by: Loyal-Young <[email protected]>
Co-authored-by: majialong <[email protected]>
Co-authored-by: yx9o <[email protected]>
Co-authored-by: PiliLily <[email protected]>
---
README.md | 5 ++
README_zh.md | 5 ++
deploy/.env.example | 12 +++
deploy/README.md | 29 +++++++
deploy/deploy.sh | 7 ++
deploy/docker-compose.yml | 8 ++
.../rocketmq/studio/auth/AuthController.java | 7 +-
.../rocketmq/studio/auth/AuthInterceptor.java | 69 +++++++++++++++
.../{AuthController.java => AuthProperties.java} | 43 ++++++----
.../apache/rocketmq/studio/auth/AuthService.java | 78 ++++++++++++++---
.../{AuthController.java => AuthWebConfig.java} | 28 +++---
server/src/main/resources/application.yml | 6 ++
.../rocketmq/studio/auth/AuthControllerTest.java | 9 +-
.../rocketmq/studio/auth/AuthInterceptorTest.java | 91 ++++++++++++++++++++
.../rocketmq/studio/auth/AuthServiceTest.java | 99 +++++++++++++++++++++-
15 files changed, 443 insertions(+), 53 deletions(-)
diff --git a/README.md b/README.md
index b4260ebb..1c28da26 100644
--- a/README.md
+++ b/README.md
@@ -18,6 +18,11 @@ Visit **http://127.0.0.1:6789** after startup.
**RocketMQ ports:** NameServer 9876, Broker 10911, Proxy Remoting 8080, Proxy
gRPC 8081
+To enable login protection for a shared environment, copy
`deploy/.env.example` to
+`deploy/.env`, set `STUDIO_AUTH_LOGIN_REQUIRED=true`, and configure
+`STUDIO_AUTH_ADMIN_USERNAME` / `STUDIO_AUTH_ADMIN_PASSWORD`. The login
endpoint only accepts
+configured users; disabling login protection only skips API interception for
local development.
+
## Features
| Module | Capabilities |
diff --git a/README_zh.md b/README_zh.md
index a2a9319a..ff759aac 100644
--- a/README_zh.md
+++ b/README_zh.md
@@ -18,6 +18,11 @@ cd deploy && docker compose up -d --build
**RocketMQ 服务端端口:** NameServer 9876、Broker 10911、Proxy Remoting 8080、Proxy
gRPC 8081
+共享环境可复制 `deploy/.env.example` 为 `deploy/.env`,设置
+`STUDIO_AUTH_LOGIN_REQUIRED=true`,并配置 `STUDIO_AUTH_ADMIN_USERNAME` /
+`STUDIO_AUTH_ADMIN_PASSWORD` 开启登录保护。登录接口仅接受已配置用户;
+关闭登录保护只会跳过本地开发场景下的 API 拦截。
+
## 功能概览
| 模块 | 能力 |
diff --git a/deploy/.env.example b/deploy/.env.example
index 73438d01..a86b89f1 100644
--- a/deploy/.env.example
+++ b/deploy/.env.example
@@ -6,3 +6,15 @@ REMOTE_PATH=/opt/rocketmq-studio
# Frontend public port
PUBLIC_PORT=6789
+
+# Login protection is disabled by default for local development.
+# Set STUDIO_AUTH_LOGIN_REQUIRED=true and provide credentials in shared
environments.
+STUDIO_AUTH_LOGIN_REQUIRED=false
+STUDIO_AUTH_ADMIN_USERNAME=admin
+STUDIO_AUTH_ADMIN_PASSWORD=change-me
+
+# Optional Prometheus-compatible data source for the built-in metrics proxy.
+STUDIO_METRICS_PROMETHEUS_BASE_URL=
+STUDIO_METRICS_PROMETHEUS_USERNAME=
+STUDIO_METRICS_PROMETHEUS_PASSWORD=
+STUDIO_METRICS_PROMETHEUS_BEARER_TOKEN=
diff --git a/deploy/README.md b/deploy/README.md
index d8b3c460..a7a306d4 100644
--- a/deploy/README.md
+++ b/deploy/README.md
@@ -21,6 +21,35 @@ REMOTE_PATH=/opt/rocketmq-studio
PUBLIC_PORT=8080
```
+## 本地 Docker Compose
+
+复制示例配置后启动:
+
+```bash
+cp deploy/.env.example deploy/.env
+cd deploy && docker compose up -d --build
+```
+
+默认访问地址为 `http://127.0.0.1:6789`。
+
+## 开启登录保护
+
+`studio.auth.login-required` 默认为 `false`,便于本地开发和演示环境直接访问。共享环境建议在
+`deploy/.env` 中开启登录保护并设置管理员账号:
+
+```env
+STUDIO_AUTH_LOGIN_REQUIRED=true
+STUDIO_AUTH_ADMIN_USERNAME=admin
+STUDIO_AUTH_ADMIN_PASSWORD=change-me
+```
+
+开启后,`/api/auth/login` 使用 JSON request body 接收用户名和密码,密码不会出现在 URL 查询
+参数中。登录成功后前端会把返回的 token 作为 `Authorization: Bearer <token>` 发送给后续
+`/api/**` 请求;未携带有效 token 的请求会返回 `401 Unauthorized`。
+
+`/api/auth/login` 始终只接受已配置用户;如未配置有效用户名和密码,后端会拒绝登录以避免误签发 token。
+`studio.auth.login-required=false` 仅用于本地开发场景跳过 `/api/**` 拦截。
+
## 前置条件
- 本地安装 Docker
diff --git a/deploy/deploy.sh b/deploy/deploy.sh
index 754335e9..bc53747e 100755
--- a/deploy/deploy.sh
+++ b/deploy/deploy.sh
@@ -111,6 +111,13 @@ deploy_remote() {
--network $NETWORK \
--restart unless-stopped \
-p 8888:8888 \
+ -e STUDIO_AUTH_LOGIN_REQUIRED=\"${STUDIO_AUTH_LOGIN_REQUIRED:-false}\"
\
+ -e STUDIO_AUTH_ADMIN_USERNAME=\"${STUDIO_AUTH_ADMIN_USERNAME:-}\" \
+ -e STUDIO_AUTH_ADMIN_PASSWORD=\"${STUDIO_AUTH_ADMIN_PASSWORD:-}\" \
+ -e
STUDIO_METRICS_PROMETHEUS_BASE_URL=\"${STUDIO_METRICS_PROMETHEUS_BASE_URL:-}\" \
+ -e
STUDIO_METRICS_PROMETHEUS_USERNAME=\"${STUDIO_METRICS_PROMETHEUS_USERNAME:-}\" \
+ -e
STUDIO_METRICS_PROMETHEUS_PASSWORD=\"${STUDIO_METRICS_PROMETHEUS_PASSWORD:-}\" \
+ -e
STUDIO_METRICS_PROMETHEUS_BEARER_TOKEN=\"${STUDIO_METRICS_PROMETHEUS_BEARER_TOKEN:-}\"
\
rocketmq-server:latest
"
log "rocketmq-server 已启动"
diff --git a/deploy/docker-compose.yml b/deploy/docker-compose.yml
index 1fe68bd3..a707a007 100644
--- a/deploy/docker-compose.yml
+++ b/deploy/docker-compose.yml
@@ -8,6 +8,14 @@ services:
image: rocketmq-server:latest
container_name: rocketmq-server
restart: unless-stopped
+ environment:
+ STUDIO_AUTH_LOGIN_REQUIRED: ${STUDIO_AUTH_LOGIN_REQUIRED:-false}
+ STUDIO_AUTH_ADMIN_USERNAME: ${STUDIO_AUTH_ADMIN_USERNAME:-}
+ STUDIO_AUTH_ADMIN_PASSWORD: ${STUDIO_AUTH_ADMIN_PASSWORD:-}
+ STUDIO_METRICS_PROMETHEUS_BASE_URL:
${STUDIO_METRICS_PROMETHEUS_BASE_URL:-}
+ STUDIO_METRICS_PROMETHEUS_USERNAME:
${STUDIO_METRICS_PROMETHEUS_USERNAME:-}
+ STUDIO_METRICS_PROMETHEUS_PASSWORD:
${STUDIO_METRICS_PROMETHEUS_PASSWORD:-}
+ STUDIO_METRICS_PROMETHEUS_BEARER_TOKEN:
${STUDIO_METRICS_PROMETHEUS_BEARER_TOKEN:-}
expose:
- "8888"
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 17ec5b0d..4389a744 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
@@ -19,8 +19,10 @@ package org.apache.rocketmq.studio.auth;
import org.apache.rocketmq.studio.common.domain.Result;
import lombok.RequiredArgsConstructor;
+import org.springframework.http.HttpHeaders;
import org.springframework.web.bind.annotation.PostMapping;
import org.springframework.web.bind.annotation.RequestBody;
+import org.springframework.web.bind.annotation.RequestHeader;
import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.bind.annotation.RestController;
@@ -37,8 +39,9 @@ public class AuthController {
}
@PostMapping("/logout")
- public Result<Void> logout() {
- authService.logout();
+ public Result<Void> logout(@RequestHeader(value =
HttpHeaders.AUTHORIZATION, required = false)
+ String authorization) {
+ authService.logout(authorization);
return Result.ok();
}
}
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
new file mode 100644
index 00000000..43f51861
--- /dev/null
+++ b/server/src/main/java/org/apache/rocketmq/studio/auth/AuthInterceptor.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.auth;
+
+import jakarta.servlet.http.HttpServletRequest;
+import jakarta.servlet.http.HttpServletResponse;
+import lombok.RequiredArgsConstructor;
+import org.springframework.http.HttpHeaders;
+import org.springframework.http.HttpStatus;
+import org.springframework.http.MediaType;
+import org.springframework.web.servlet.HandlerInterceptor;
+
+@RequiredArgsConstructor
+public class AuthInterceptor implements HandlerInterceptor {
+
+ private final AuthProperties authProperties;
+ private final AuthService authService;
+
+ @Override
+ public boolean preHandle(HttpServletRequest request, HttpServletResponse
response,
+ Object handler) throws Exception {
+ if (!authProperties.isLoginRequired() ||
isPublicPath(requestPath(request))) {
+ return true;
+ }
+ if
(authService.isAuthenticated(request.getHeader(HttpHeaders.AUTHORIZATION))) {
+ return true;
+ }
+
+ response.setStatus(HttpStatus.UNAUTHORIZED.value());
+ response.setContentType(MediaType.APPLICATION_JSON_VALUE);
+
response.getWriter().write("{\"code\":401,\"message\":\"Unauthorized\",\"data\":null}");
+ return false;
+ }
+
+ private boolean isPublicPath(String path) {
+ return path.equals("/api/auth/login")
+ || path.startsWith("/api-docs")
+ || path.startsWith("/swagger-ui")
+ || path.startsWith("/actuator/health");
+ }
+
+ private String requestPath(HttpServletRequest request) {
+ String servletPath = request.getServletPath();
+ if (servletPath != null && !servletPath.isBlank()) {
+ return servletPath;
+ }
+ String contextPath = request.getContextPath();
+ String requestUri = request.getRequestURI();
+ if (contextPath != null && !contextPath.isBlank() &&
requestUri.startsWith(contextPath)) {
+ return requestUri.substring(contextPath.length());
+ }
+ return requestUri;
+ }
+}
diff --git
a/server/src/main/java/org/apache/rocketmq/studio/auth/AuthController.java
b/server/src/main/java/org/apache/rocketmq/studio/auth/AuthProperties.java
similarity index 50%
copy from
server/src/main/java/org/apache/rocketmq/studio/auth/AuthController.java
copy to server/src/main/java/org/apache/rocketmq/studio/auth/AuthProperties.java
index 17ec5b0d..ae084983 100644
--- a/server/src/main/java/org/apache/rocketmq/studio/auth/AuthController.java
+++ b/server/src/main/java/org/apache/rocketmq/studio/auth/AuthProperties.java
@@ -17,28 +17,35 @@
package org.apache.rocketmq.studio.auth;
-import org.apache.rocketmq.studio.common.domain.Result;
-import lombok.RequiredArgsConstructor;
-import org.springframework.web.bind.annotation.PostMapping;
-import org.springframework.web.bind.annotation.RequestBody;
-import org.springframework.web.bind.annotation.RequestMapping;
-import org.springframework.web.bind.annotation.RestController;
+import lombok.Getter;
+import lombok.Setter;
+import org.springframework.boot.context.properties.ConfigurationProperties;
+import org.springframework.stereotype.Component;
+import org.springframework.util.StringUtils;
-@RestController
-@RequestMapping("/api/auth")
-@RequiredArgsConstructor
-public class AuthController {
+import java.util.ArrayList;
+import java.util.List;
- private final AuthService authService;
+@Getter
+@Setter
+@Component
+@ConfigurationProperties(prefix = "studio.auth")
+public class AuthProperties {
+ private boolean loginRequired;
+ private List<User> users = new ArrayList<>();
- @PostMapping("/login")
- public Result<LoginVO> login(@RequestBody LoginDTO request) {
- return Result.ok(authService.login(request));
+ public List<User> configuredUsers() {
+ return users.stream()
+ .filter(user -> StringUtils.hasText(user.getUsername()))
+ .filter(user -> StringUtils.hasText(user.getPassword()))
+ .toList();
}
- @PostMapping("/logout")
- public Result<Void> logout() {
- authService.logout();
- return Result.ok();
+ @Getter
+ @Setter
+ public static class User {
+ private String username;
+ private String password;
+ private boolean admin;
}
}
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 ad831d40..5c083225 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,15 +18,26 @@
package org.apache.rocketmq.studio.auth;
import org.apache.rocketmq.studio.common.exception.BusinessException;
+import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import org.springframework.stereotype.Service;
+import java.util.Map;
+import java.util.Optional;
+import java.util.concurrent.ConcurrentHashMap;
import java.util.UUID;
@Slf4j
@Service
+@RequiredArgsConstructor
public class AuthService {
+ private static final int TOKEN_TTL_SECONDS = 86400;
+ private static final String TOKEN_PREFIX = "Bearer ";
+
+ private final AuthProperties authProperties;
+ private final Map<String, AuthSession> activeTokens = new
ConcurrentHashMap<>();
+
public LoginVO login(LoginDTO request) {
log.info("Login attempt for user: {}", request.getUsername());
@@ -37,24 +48,71 @@ public class AuthService {
throw new BusinessException(400, "Password is required");
}
- // Mock authentication — accept any non-empty credentials
- String token = "mock-jwt-" + UUID.randomUUID();
- boolean isAdmin = "admin".equals(request.getUsername());
+ LoginVO.UserInfo user = authenticate(request);
+ String token = "studio-jwt-" + UUID.randomUUID();
+ activeTokens.put(token, new AuthSession(user,
System.currentTimeMillis()
+ + TOKEN_TTL_SECONDS * 1000L));
LoginVO response = LoginVO.builder()
.token(token)
- .expiresIn(86400)
- .user(LoginVO.UserInfo.builder()
- .username(request.getUsername())
- .admin(isAdmin)
- .build())
+ .expiresIn(TOKEN_TTL_SECONDS)
+ .user(user)
.build();
- log.info("User {} logged in successfully, admin={}",
request.getUsername(), isAdmin);
+ log.info("User {} logged in successfully, admin={}",
user.getUsername(), user.isAdmin());
return response;
}
- public void logout() {
+ public boolean isAuthenticated(String authorization) {
+ Optional<String> token = tokenFromAuthorization(authorization);
+ if (token.isEmpty()) {
+ return false;
+ }
+ AuthSession session = activeTokens.get(token.get());
+ if (session == null) {
+ return false;
+ }
+ if (session.expiresAtMillis() <= System.currentTimeMillis()) {
+ activeTokens.remove(token.get());
+ return false;
+ }
+ return true;
+ }
+
+ public void logout(String authorization) {
+ tokenFromAuthorization(authorization).ifPresent(activeTokens::remove);
log.info("User logged out");
}
+
+ private LoginVO.UserInfo authenticate(LoginDTO request) {
+ var configuredUsers = authProperties.configuredUsers();
+ if (configuredUsers.isEmpty()) {
+ throw new BusinessException(503, "No valid login users are
configured");
+ }
+
+ return configuredUsers.stream()
+ .filter(user ->
user.getUsername().equals(request.getUsername()))
+ .filter(user ->
user.getPassword().equals(request.getPassword()))
+ .findFirst()
+ .map(user -> userInfo(user.getUsername(), user.isAdmin()))
+ .orElseThrow(() -> new BusinessException(401, "Invalid
username or password"));
+ }
+
+ private LoginVO.UserInfo userInfo(String username, boolean admin) {
+ return LoginVO.UserInfo.builder()
+ .username(username)
+ .admin(admin)
+ .build();
+ }
+
+ private Optional<String> tokenFromAuthorization(String authorization) {
+ if (authorization == null || !authorization.startsWith(TOKEN_PREFIX)) {
+ return Optional.empty();
+ }
+ String token = authorization.substring(TOKEN_PREFIX.length()).trim();
+ return token.isBlank() ? Optional.empty() : Optional.of(token);
+ }
+
+ private record AuthSession(LoginVO.UserInfo user, long expiresAtMillis) {
+ }
}
diff --git
a/server/src/main/java/org/apache/rocketmq/studio/auth/AuthController.java
b/server/src/main/java/org/apache/rocketmq/studio/auth/AuthWebConfig.java
similarity index 58%
copy from
server/src/main/java/org/apache/rocketmq/studio/auth/AuthController.java
copy to server/src/main/java/org/apache/rocketmq/studio/auth/AuthWebConfig.java
index 17ec5b0d..c89d0485 100644
--- a/server/src/main/java/org/apache/rocketmq/studio/auth/AuthController.java
+++ b/server/src/main/java/org/apache/rocketmq/studio/auth/AuthWebConfig.java
@@ -17,28 +17,22 @@
package org.apache.rocketmq.studio.auth;
-import org.apache.rocketmq.studio.common.domain.Result;
import lombok.RequiredArgsConstructor;
-import org.springframework.web.bind.annotation.PostMapping;
-import org.springframework.web.bind.annotation.RequestBody;
-import org.springframework.web.bind.annotation.RequestMapping;
-import org.springframework.web.bind.annotation.RestController;
+import org.springframework.boot.autoconfigure.condition.ConditionalOnProperty;
+import org.springframework.context.annotation.Configuration;
+import org.springframework.web.servlet.config.annotation.InterceptorRegistry;
+import org.springframework.web.servlet.config.annotation.WebMvcConfigurer;
-@RestController
-@RequestMapping("/api/auth")
+@Configuration
@RequiredArgsConstructor
-public class AuthController {
+@ConditionalOnProperty(prefix = "studio.auth", name = "login-required",
havingValue = "true")
+public class AuthWebConfig implements WebMvcConfigurer {
+ private final AuthProperties authProperties;
private final AuthService authService;
- @PostMapping("/login")
- public Result<LoginVO> login(@RequestBody LoginDTO request) {
- return Result.ok(authService.login(request));
- }
-
- @PostMapping("/logout")
- public Result<Void> logout() {
- authService.logout();
- return Result.ok();
+ @Override
+ public void addInterceptors(InterceptorRegistry registry) {
+ registry.addInterceptor(new AuthInterceptor(authProperties,
authService)).addPathPatterns("/api/**");
}
}
diff --git a/server/src/main/resources/application.yml
b/server/src/main/resources/application.yml
index a213118d..131f0f27 100644
--- a/server/src/main/resources/application.yml
+++ b/server/src/main/resources/application.yml
@@ -14,6 +14,12 @@ springdoc:
path: /swagger-ui.html
studio:
+ auth:
+ login-required: ${STUDIO_AUTH_LOGIN_REQUIRED:false}
+ users:
+ - username: ${STUDIO_AUTH_ADMIN_USERNAME:}
+ password: ${STUDIO_AUTH_ADMIN_PASSWORD:}
+ admin: true
metrics:
prometheus:
base-url: ${STUDIO_METRICS_PROMETHEUS_BASE_URL:}
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 22d143eb..0b140a74 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
@@ -23,10 +23,12 @@ import
org.springframework.beans.factory.annotation.Autowired;
import
org.springframework.boot.test.autoconfigure.web.servlet.AutoConfigureMockMvc;
import org.springframework.boot.test.autoconfigure.web.servlet.WebMvcTest;
import org.springframework.boot.test.mock.mockito.MockBean;
+import org.springframework.http.HttpHeaders;
import org.springframework.http.MediaType;
import org.springframework.test.web.servlet.MockMvc;
import static org.mockito.ArgumentMatchers.any;
+import static org.mockito.ArgumentMatchers.eq;
import static org.mockito.Mockito.doNothing;
import static org.mockito.Mockito.verify;
import static org.mockito.Mockito.when;
@@ -103,13 +105,14 @@ class AuthControllerTest {
@Test
void logoutShouldReturnSuccess() throws Exception {
- doNothing().when(authService).logout();
+ doNothing().when(authService).logout("Bearer token-1");
- mockMvc.perform(post("/api/auth/logout"))
+ mockMvc.perform(post("/api/auth/logout")
+ .header(HttpHeaders.AUTHORIZATION, "Bearer token-1"))
.andExpect(status().isOk())
.andExpect(jsonPath("$.code").value(200))
.andExpect(jsonPath("$.message").value("success"));
- verify(authService).logout();
+ verify(authService).logout(eq("Bearer token-1"));
}
}
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
new file mode 100644
index 00000000..9c9d0e66
--- /dev/null
+++
b/server/src/test/java/org/apache/rocketmq/studio/auth/AuthInterceptorTest.java
@@ -0,0 +1,91 @@
+/*
+ * 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.junit.jupiter.api.Test;
+import org.springframework.http.HttpHeaders;
+import org.springframework.mock.web.MockHttpServletRequest;
+import org.springframework.mock.web.MockHttpServletResponse;
+
+import java.util.List;
+
+import static org.assertj.core.api.Assertions.assertThat;
+
+class AuthInterceptorTest {
+
+ @Test
+ void shouldAllowRequestsWhenLoginIsDisabled() throws Exception {
+ AuthProperties properties = new AuthProperties();
+ AuthInterceptor interceptor = new AuthInterceptor(properties, new
AuthService(properties));
+ MockHttpServletRequest request = new MockHttpServletRequest("GET",
"/api/clusters");
+
+ boolean allowed = interceptor.preHandle(request, new
MockHttpServletResponse(), new Object());
+
+ assertThat(allowed).isTrue();
+ }
+
+ @Test
+ void shouldRejectProtectedApiWithoutTokenWhenLoginIsEnabled() throws
Exception {
+ AuthProperties properties = new AuthProperties();
+ properties.setLoginRequired(true);
+ AuthInterceptor interceptor = new AuthInterceptor(properties, new
AuthService(properties));
+ MockHttpServletRequest request = new MockHttpServletRequest("GET",
"/api/clusters");
+ MockHttpServletResponse response = new MockHttpServletResponse();
+
+ boolean allowed = interceptor.preHandle(request, response, new
Object());
+
+ assertThat(allowed).isFalse();
+ assertThat(response.getStatus()).isEqualTo(401);
+ assertThat(response.getContentAsString()).contains("Unauthorized");
+ }
+
+ @Test
+ void shouldAllowProtectedApiWithActiveTokenWhenLoginIsEnabled() throws
Exception {
+ AuthProperties properties = new AuthProperties();
+ properties.setLoginRequired(true);
+ AuthProperties.User user = new AuthProperties.User();
+ user.setUsername("admin");
+ user.setPassword("secret");
+ user.setAdmin(true);
+ properties.setUsers(List.of(user));
+ AuthService authService = new AuthService(properties);
+ AuthInterceptor interceptor = new AuthInterceptor(properties,
authService);
+ LoginDTO login = new LoginDTO();
+ login.setUsername("admin");
+ login.setPassword("secret");
+ String token = authService.login(login).getToken();
+ MockHttpServletRequest request = new MockHttpServletRequest("GET",
"/api/clusters");
+ request.addHeader(HttpHeaders.AUTHORIZATION, "Bearer " + token);
+
+ boolean allowed = interceptor.preHandle(request, new
MockHttpServletResponse(), new Object());
+
+ assertThat(allowed).isTrue();
+ }
+
+ @Test
+ void shouldAllowLoginEndpointWhenLoginIsEnabled() throws Exception {
+ AuthProperties properties = new AuthProperties();
+ properties.setLoginRequired(true);
+ AuthInterceptor interceptor = new AuthInterceptor(properties, new
AuthService(properties));
+ MockHttpServletRequest request = new MockHttpServletRequest("POST",
"/api/auth/login");
+
+ boolean allowed = interceptor.preHandle(request, new
MockHttpServletResponse(), new Object());
+
+ assertThat(allowed).isTrue();
+ }
+}
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 934e3429..5880e5ba 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
@@ -23,6 +23,8 @@ import org.junit.jupiter.api.Test;
import org.junit.jupiter.api.extension.ExtendWith;
import org.mockito.junit.jupiter.MockitoExtension;
+import java.util.List;
+
import static org.assertj.core.api.Assertions.assertThat;
import static org.assertj.core.api.Assertions.assertThatThrownBy;
@@ -30,14 +32,20 @@ import static
org.assertj.core.api.Assertions.assertThatThrownBy;
class AuthServiceTest {
private AuthService authService;
+ private AuthProperties authProperties;
@BeforeEach
void setUp() {
- authService = new AuthService();
+ authProperties = new AuthProperties();
+ authService = new AuthService(authProperties);
}
@Test
void loginShouldReturnTokenForValidCredentials() {
+ 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");
@@ -45,15 +53,21 @@ class AuthServiceTest {
LoginVO response = authService.login(request);
assertThat(response).isNotNull();
- assertThat(response.getToken()).startsWith("mock-jwt-");
+ assertThat(response.getToken()).startsWith("studio-jwt-");
assertThat(response.getExpiresIn()).isEqualTo(86400);
assertThat(response.getUser()).isNotNull();
assertThat(response.getUser().getUsername()).isEqualTo("testuser");
assertThat(response.getUser().isAdmin()).isFalse();
+ assertThat(authService.isAuthenticated("Bearer " +
response.getToken())).isTrue();
}
@Test
void loginShouldReturnAdminFlagForAdminUser() {
+ AuthProperties.User user = new AuthProperties.User();
+ user.setUsername("admin");
+ user.setPassword("adminpass");
+ user.setAdmin(true);
+ authProperties.setUsers(List.of(user));
LoginDTO request = new LoginDTO();
request.setUsername("admin");
request.setPassword("adminpass");
@@ -64,6 +78,85 @@ class AuthServiceTest {
assertThat(response.getUser().isAdmin()).isTrue();
}
+ @Test
+ void loginShouldUseConfiguredUsersWhenPresent() {
+ AuthProperties.User user = new AuthProperties.User();
+ user.setUsername("ops");
+ user.setPassword("secret");
+ user.setAdmin(true);
+ authProperties.setUsers(List.of(user));
+
+ LoginDTO request = new LoginDTO();
+ request.setUsername("ops");
+ request.setPassword("secret");
+
+ LoginVO response = authService.login(request);
+
+ assertThat(response.getUser().getUsername()).isEqualTo("ops");
+ assertThat(response.getUser().isAdmin()).isTrue();
+ }
+
+ @Test
+ void loginShouldRejectInvalidConfiguredUserPassword() {
+ AuthProperties.User user = new AuthProperties.User();
+ user.setUsername("ops");
+ user.setPassword("secret");
+ authProperties.setUsers(List.of(user));
+
+ LoginDTO request = new LoginDTO();
+ request.setUsername("ops");
+ request.setPassword("wrong");
+
+ assertThatThrownBy(() -> authService.login(request))
+ .isInstanceOf(BusinessException.class)
+ .hasMessage("Invalid username or password");
+ }
+
+ @Test
+ void loginShouldRejectUnknownConfiguredUser() {
+ AuthProperties.User user = new AuthProperties.User();
+ user.setUsername("ops");
+ user.setPassword("secret");
+ authProperties.setUsers(List.of(user));
+
+ LoginDTO request = new LoginDTO();
+ request.setUsername("admin");
+ request.setPassword("secret");
+
+ assertThatThrownBy(() -> authService.login(request))
+ .isInstanceOf(BusinessException.class)
+ .hasMessage("Invalid username or password")
+ .satisfies(ex -> assertThat(((BusinessException)
ex).getCode()).isEqualTo(401));
+ }
+
+ @Test
+ void loginShouldRejectWithoutConfiguredUsers() {
+ LoginDTO request = new LoginDTO();
+ request.setUsername("admin");
+ request.setPassword("secret");
+
+ assertThatThrownBy(() -> authService.login(request))
+ .isInstanceOf(BusinessException.class)
+ .hasMessage("No valid login users are configured")
+ .satisfies(ex -> assertThat(((BusinessException)
ex).getCode()).isEqualTo(503));
+ }
+
+ @Test
+ void logoutShouldRevokeActiveToken() {
+ 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);
+
+ authService.logout("Bearer " + response.getToken());
+
+ assertThat(authService.isAuthenticated("Bearer " +
response.getToken())).isFalse();
+ }
+
@Test
void loginShouldThrowWhenUsernameIsNull() {
LoginDTO request = new LoginDTO();
@@ -110,6 +203,6 @@ class AuthServiceTest {
@Test
void logoutShouldCompleteWithoutError() {
- authService.logout();
+ authService.logout(null);
}
}