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);
     }
 }

Reply via email to