From 286486d53d37ebe01724cf7b05c4b46b1abd539c Mon Sep 17 00:00:00 2001 From: liuhy Date: Fri, 24 Jul 2026 00:43:57 -0700 Subject: [PATCH 1/2] feat: add configurable studio login protection --- README.md | 4 + README_zh.md | 4 + deploy/.env.example | 12 +++ deploy/README.md | 28 +++++++ deploy/deploy.sh | 7 ++ deploy/docker-compose.yml | 8 ++ .../rocketmq/studio/auth/AuthController.java | 7 +- .../rocketmq/studio/auth/AuthInterceptor.java | 69 +++++++++++++++ .../rocketmq/studio/auth/AuthProperties.java | 51 +++++++++++ .../com/rocketmq/studio/auth/AuthService.java | 78 ++++++++++++++--- .../rocketmq/studio/auth/AuthWebConfig.java | 38 +++++++++ server/src/main/resources/application.yml | 6 ++ .../studio/auth/AuthControllerTest.java | 9 +- .../studio/auth/AuthInterceptorTest.java | 84 +++++++++++++++++++ .../rocketmq/studio/auth/AuthServiceTest.java | 57 ++++++++++++- 15 files changed, 444 insertions(+), 18 deletions(-) create mode 100644 server/src/main/java/com/rocketmq/studio/auth/AuthInterceptor.java create mode 100644 server/src/main/java/com/rocketmq/studio/auth/AuthProperties.java create mode 100644 server/src/main/java/com/rocketmq/studio/auth/AuthWebConfig.java create mode 100644 server/src/test/java/com/rocketmq/studio/auth/AuthInterceptorTest.java diff --git a/README.md b/README.md index b4260ebb..68829107 100644 --- a/README.md +++ b/README.md @@ -18,6 +18,10 @@ 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`. + ## Features | Module | Capabilities | diff --git a/README_zh.md b/README_zh.md index a2a9319a..177da203 100644 --- a/README_zh.md +++ b/README_zh.md @@ -18,6 +18,10 @@ 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` 开启登录保护。 + ## 功能概览 | 模块 | 能力 | 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..bbb4fd7b 100644 --- a/deploy/README.md +++ b/deploy/README.md @@ -21,6 +21,34 @@ 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 ` 发送给后续 +`/api/**` 请求;未携带有效 token 的请求会返回 `401 Unauthorized`。 + +没有配置有效用户时,后端保留本地开发兼容模式:任意非空用户名和密码可登录,`admin` 用户会被标记为管理员。 + ## 前置条件 - 本地安装 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/com/rocketmq/studio/auth/AuthController.java b/server/src/main/java/com/rocketmq/studio/auth/AuthController.java index 8b4f6f10..b7c8621c 100644 --- a/server/src/main/java/com/rocketmq/studio/auth/AuthController.java +++ b/server/src/main/java/com/rocketmq/studio/auth/AuthController.java @@ -19,8 +19,10 @@ import com.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 Result login(@RequestBody LoginDTO request) { } @PostMapping("/logout") - public Result logout() { - authService.logout(); + public Result logout(@RequestHeader(value = HttpHeaders.AUTHORIZATION, required = false) + String authorization) { + authService.logout(authorization); return Result.ok(); } } diff --git a/server/src/main/java/com/rocketmq/studio/auth/AuthInterceptor.java b/server/src/main/java/com/rocketmq/studio/auth/AuthInterceptor.java new file mode 100644 index 00000000..abb1c034 --- /dev/null +++ b/server/src/main/java/com/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 com.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/com/rocketmq/studio/auth/AuthProperties.java b/server/src/main/java/com/rocketmq/studio/auth/AuthProperties.java new file mode 100644 index 00000000..40d656c0 --- /dev/null +++ b/server/src/main/java/com/rocketmq/studio/auth/AuthProperties.java @@ -0,0 +1,51 @@ +/* + * 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 com.rocketmq.studio.auth; + +import lombok.Getter; +import lombok.Setter; +import org.springframework.boot.context.properties.ConfigurationProperties; +import org.springframework.stereotype.Component; +import org.springframework.util.StringUtils; + +import java.util.ArrayList; +import java.util.List; + +@Getter +@Setter +@Component +@ConfigurationProperties(prefix = "studio.auth") +public class AuthProperties { + private boolean loginRequired; + private List users = new ArrayList<>(); + + public List configuredUsers() { + return users.stream() + .filter(user -> StringUtils.hasText(user.getUsername())) + .filter(user -> StringUtils.hasText(user.getPassword())) + .toList(); + } + + @Getter + @Setter + public static class User { + private String username; + private String password; + private boolean admin; + } +} diff --git a/server/src/main/java/com/rocketmq/studio/auth/AuthService.java b/server/src/main/java/com/rocketmq/studio/auth/AuthService.java index 0591cefe..1e36ef7c 100644 --- a/server/src/main/java/com/rocketmq/studio/auth/AuthService.java +++ b/server/src/main/java/com/rocketmq/studio/auth/AuthService.java @@ -18,15 +18,26 @@ package com.rocketmq.studio.auth; import com.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 activeTokens = new ConcurrentHashMap<>(); + public LoginVO login(LoginDTO request) { log.info("Login attempt for user: {}", request.getUsername()); @@ -37,24 +48,71 @@ public LoginVO login(LoginDTO request) { 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 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()) { + return userInfo(request.getUsername(), "admin".equals(request.getUsername())); + } + + 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 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/com/rocketmq/studio/auth/AuthWebConfig.java b/server/src/main/java/com/rocketmq/studio/auth/AuthWebConfig.java new file mode 100644 index 00000000..fee9a54e --- /dev/null +++ b/server/src/main/java/com/rocketmq/studio/auth/AuthWebConfig.java @@ -0,0 +1,38 @@ +/* + * 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 com.rocketmq.studio.auth; + +import lombok.RequiredArgsConstructor; +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; + +@Configuration +@RequiredArgsConstructor +@ConditionalOnProperty(prefix = "studio.auth", name = "login-required", havingValue = "true") +public class AuthWebConfig implements WebMvcConfigurer { + + private final AuthProperties authProperties; + private final AuthService authService; + + @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/com/rocketmq/studio/auth/AuthControllerTest.java b/server/src/test/java/com/rocketmq/studio/auth/AuthControllerTest.java index b4fe26fa..ccd2f051 100644 --- a/server/src/test/java/com/rocketmq/studio/auth/AuthControllerTest.java +++ b/server/src/test/java/com/rocketmq/studio/auth/AuthControllerTest.java @@ -23,10 +23,12 @@ 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 @@ void loginShouldReturnAdminUserWhenAdminLogsIn() throws Exception { @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/com/rocketmq/studio/auth/AuthInterceptorTest.java b/server/src/test/java/com/rocketmq/studio/auth/AuthInterceptorTest.java new file mode 100644 index 00000000..e3f8273f --- /dev/null +++ b/server/src/test/java/com/rocketmq/studio/auth/AuthInterceptorTest.java @@ -0,0 +1,84 @@ +/* + * 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 com.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 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); + 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/com/rocketmq/studio/auth/AuthServiceTest.java b/server/src/test/java/com/rocketmq/studio/auth/AuthServiceTest.java index 14525729..ea58dc7e 100644 --- a/server/src/test/java/com/rocketmq/studio/auth/AuthServiceTest.java +++ b/server/src/test/java/com/rocketmq/studio/auth/AuthServiceTest.java @@ -23,6 +23,8 @@ 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,10 +32,12 @@ class AuthServiceTest { private AuthService authService; + private AuthProperties authProperties; @BeforeEach void setUp() { - authService = new AuthService(); + authProperties = new AuthProperties(); + authService = new AuthService(authProperties); } @Test @@ -45,11 +49,12 @@ void loginShouldReturnTokenForValidCredentials() { 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 @@ -64,6 +69,52 @@ void loginShouldReturnAdminFlagForAdminUser() { 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 logoutShouldRevokeActiveToken() { + 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 +161,6 @@ void loginShouldThrowWhenPasswordIsBlank() { @Test void logoutShouldCompleteWithoutError() { - authService.logout(); + authService.logout(null); } } From 582b4099c0246beaf0b9e0d2c63bd749587e2da5 Mon Sep 17 00:00:00 2001 From: liuhy Date: Mon, 27 Jul 2026 00:32:29 -0700 Subject: [PATCH 2/2] fix: fail closed when login users are missing --- deploy/README.md | 3 ++- .../java/com/rocketmq/studio/auth/AuthService.java | 3 +++ .../rocketmq/studio/auth/AuthInterceptorTest.java | 7 +++++++ .../com/rocketmq/studio/auth/AuthServiceTest.java | 13 +++++++++++++ 4 files changed, 25 insertions(+), 1 deletion(-) diff --git a/deploy/README.md b/deploy/README.md index bbb4fd7b..c7368eac 100644 --- a/deploy/README.md +++ b/deploy/README.md @@ -47,7 +47,8 @@ STUDIO_AUTH_ADMIN_PASSWORD=change-me 参数中。登录成功后前端会把返回的 token 作为 `Authorization: Bearer ` 发送给后续 `/api/**` 请求;未携带有效 token 的请求会返回 `401 Unauthorized`。 -没有配置有效用户时,后端保留本地开发兼容模式:任意非空用户名和密码可登录,`admin` 用户会被标记为管理员。 +开启登录保护后必须至少配置一个有效用户;如未配置有效用户名和密码,后端会拒绝登录以避免共享环境误开放。 +本地开发兼容模式仅在 `studio.auth.login-required=false` 时保留。 ## 前置条件 diff --git a/server/src/main/java/com/rocketmq/studio/auth/AuthService.java b/server/src/main/java/com/rocketmq/studio/auth/AuthService.java index 1e36ef7c..26f17b1a 100644 --- a/server/src/main/java/com/rocketmq/studio/auth/AuthService.java +++ b/server/src/main/java/com/rocketmq/studio/auth/AuthService.java @@ -87,6 +87,9 @@ public void logout(String authorization) { private LoginVO.UserInfo authenticate(LoginDTO request) { var configuredUsers = authProperties.configuredUsers(); if (configuredUsers.isEmpty()) { + if (authProperties.isLoginRequired()) { + throw new BusinessException(503, "Login is enabled but no valid users are configured"); + } return userInfo(request.getUsername(), "admin".equals(request.getUsername())); } diff --git a/server/src/test/java/com/rocketmq/studio/auth/AuthInterceptorTest.java b/server/src/test/java/com/rocketmq/studio/auth/AuthInterceptorTest.java index e3f8273f..c5300a70 100644 --- a/server/src/test/java/com/rocketmq/studio/auth/AuthInterceptorTest.java +++ b/server/src/test/java/com/rocketmq/studio/auth/AuthInterceptorTest.java @@ -22,6 +22,8 @@ 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 { @@ -56,6 +58,11 @@ void shouldRejectProtectedApiWithoutTokenWhenLoginIsEnabled() throws Exception { 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(); diff --git a/server/src/test/java/com/rocketmq/studio/auth/AuthServiceTest.java b/server/src/test/java/com/rocketmq/studio/auth/AuthServiceTest.java index ea58dc7e..da5122df 100644 --- a/server/src/test/java/com/rocketmq/studio/auth/AuthServiceTest.java +++ b/server/src/test/java/com/rocketmq/studio/auth/AuthServiceTest.java @@ -103,6 +103,19 @@ void loginShouldRejectInvalidConfiguredUserPassword() { .hasMessage("Invalid username or password"); } + @Test + void loginShouldRejectWhenLoginIsRequiredWithoutConfiguredUsers() { + authProperties.setLoginRequired(true); + LoginDTO request = new LoginDTO(); + request.setUsername("admin"); + request.setPassword("secret"); + + assertThatThrownBy(() -> authService.login(request)) + .isInstanceOf(BusinessException.class) + .hasMessage("Login is enabled but no valid users are configured") + .satisfies(ex -> assertThat(((BusinessException) ex).getCode()).isEqualTo(503)); + } + @Test void logoutShouldRevokeActiveToken() { LoginDTO request = new LoginDTO();