Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 4 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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 |
Expand Down
4 changes: 4 additions & 0 deletions README_zh.md
Original file line number Diff line number Diff line change
Expand Up @@ -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` 开启登录保护。

## 功能概览

| 模块 | 能力 |
Expand Down
12 changes: 12 additions & 0 deletions deploy/.env.example
Original file line number Diff line number Diff line change
Expand Up @@ -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=
29 changes: 29 additions & 0 deletions deploy/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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`。

开启登录保护后必须至少配置一个有效用户;如未配置有效用户名和密码,后端会拒绝登录以避免共享环境误开放。
本地开发兼容模式仅在 `studio.auth.login-required=false` 时保留。

## 前置条件

- 本地安装 Docker
Expand Down
7 changes: 7 additions & 0 deletions deploy/deploy.sh
Original file line number Diff line number Diff line change
Expand Up @@ -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 已启动"
Expand Down
8 changes: 8 additions & 0 deletions deploy/docker-compose.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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"

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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;

Expand All @@ -37,8 +39,9 @@ public Result<LoginVO> login(@RequestBody LoginDTO request) {
}

@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();
}
}
69 changes: 69 additions & 0 deletions server/src/main/java/com/rocketmq/studio/auth/AuthInterceptor.java
Original file line number Diff line number Diff line change
@@ -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;
}
}
51 changes: 51 additions & 0 deletions server/src/main/java/com/rocketmq/studio/auth/AuthProperties.java
Original file line number Diff line number Diff line change
@@ -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<User> users = new ArrayList<>();

public List<User> 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;
}
}
81 changes: 71 additions & 10 deletions server/src/main/java/com/rocketmq/studio/auth/AuthService.java
Original file line number Diff line number Diff line change
Expand Up @@ -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<String, AuthSession> activeTokens = new ConcurrentHashMap<>();

public LoginVO login(LoginDTO request) {
log.info("Login attempt for user: {}", request.getUsername());

Expand All @@ -37,24 +48,74 @@ 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<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()) {
if (authProperties.isLoginRequired()) {
throw new BusinessException(503, "Login is enabled but no valid users are configured");
}
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<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) {
}
}
38 changes: 38 additions & 0 deletions server/src/main/java/com/rocketmq/studio/auth/AuthWebConfig.java
Original file line number Diff line number Diff line change
@@ -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/**");
}
}
Loading
Loading