From b32fb37519789c29549af193caa65acd63a957e9 Mon Sep 17 00:00:00 2001 From: Rui <1685901819@qq.com> Date: Wed, 29 Jul 2026 15:43:14 +0800 Subject: [PATCH] fix: honor configurable login protection in frontend routes --- .../rocketmq/studio/auth/AuthController.java | 16 +++ .../rocketmq/studio/auth/AuthInterceptor.java | 1 + .../rocketmq/studio/auth/AuthStatusVO.java | 32 +++++ .../studio/auth/AuthControllerTest.java | 41 ++++++ .../studio/auth/AuthInterceptorTest.java | 12 ++ web/src/App.test.tsx | 102 ++++++++++++++ web/src/App.tsx | 128 ++++++++++++++---- web/src/api/auth.test.ts | 9 +- web/src/api/auth.ts | 10 ++ web/src/i18n/translations.ts | 5 + 10 files changed, 327 insertions(+), 29 deletions(-) create mode 100644 server/src/main/java/org/apache/rocketmq/studio/auth/AuthStatusVO.java create mode 100644 web/src/App.test.tsx 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 4389a744..d5398dbd 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,7 +19,10 @@ import org.apache.rocketmq.studio.common.domain.Result; import lombok.RequiredArgsConstructor; +import org.springframework.http.CacheControl; import org.springframework.http.HttpHeaders; +import org.springframework.http.ResponseEntity; +import org.springframework.web.bind.annotation.GetMapping; import org.springframework.web.bind.annotation.PostMapping; import org.springframework.web.bind.annotation.RequestBody; import org.springframework.web.bind.annotation.RequestHeader; @@ -32,6 +35,19 @@ public class AuthController { private final AuthService authService; + private final AuthProperties authProperties; + + @GetMapping("/status") + public ResponseEntity> status( + @RequestHeader(value = HttpHeaders.AUTHORIZATION, required = false) String authorization) { + AuthStatusVO status = AuthStatusVO.builder() + .loginRequired(authProperties.isLoginRequired()) + .authenticated(authService.isAuthenticated(authorization)) + .build(); + return ResponseEntity.ok() + .cacheControl(CacheControl.noStore()) + .body(Result.ok(status)); + } @PostMapping("/login") public Result login(@RequestBody LoginDTO request) { 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 index 43f51861..93ce5442 100644 --- a/server/src/main/java/org/apache/rocketmq/studio/auth/AuthInterceptor.java +++ b/server/src/main/java/org/apache/rocketmq/studio/auth/AuthInterceptor.java @@ -49,6 +49,7 @@ public boolean preHandle(HttpServletRequest request, HttpServletResponse respons private boolean isPublicPath(String path) { return path.equals("/api/auth/login") + || path.equals("/api/auth/status") || path.startsWith("/api-docs") || path.startsWith("/swagger-ui") || path.startsWith("/actuator/health"); diff --git a/server/src/main/java/org/apache/rocketmq/studio/auth/AuthStatusVO.java b/server/src/main/java/org/apache/rocketmq/studio/auth/AuthStatusVO.java new file mode 100644 index 00000000..c766754e --- /dev/null +++ b/server/src/main/java/org/apache/rocketmq/studio/auth/AuthStatusVO.java @@ -0,0 +1,32 @@ +/* + * Licensed to the Apache Software Foundation (ASF) under one or more + * contributor license agreements. See the NOTICE file distributed with + * this work for additional information regarding copyright ownership. + * The ASF licenses this file to You under the Apache License, Version 2.0 + * (the "License"); you may not use this file except in compliance with + * the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package org.apache.rocketmq.studio.auth; + +import lombok.AllArgsConstructor; +import lombok.Builder; +import lombok.Data; +import lombok.NoArgsConstructor; + +@Data +@Builder +@NoArgsConstructor +@AllArgsConstructor +public class AuthStatusVO { + private boolean loginRequired; + private boolean authenticated; +} 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 0b140a74..9d0a5f16 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 @@ -32,7 +32,9 @@ import static org.mockito.Mockito.doNothing; import static org.mockito.Mockito.verify; import static org.mockito.Mockito.when; +import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.get; import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.post; +import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.header; import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.jsonPath; import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status; @@ -46,9 +48,48 @@ class AuthControllerTest { @MockBean private AuthService authService; + @MockBean + private AuthProperties authProperties; + @Autowired private ObjectMapper objectMapper; + @Test + void statusShouldReportDisabledLoginProtection() throws Exception { + when(authProperties.isLoginRequired()).thenReturn(false); + when(authService.isAuthenticated(null)).thenReturn(false); + + mockMvc.perform(get("/api/auth/status")) + .andExpect(status().isOk()) + .andExpect(header().string(HttpHeaders.CACHE_CONTROL, "no-store")) + .andExpect(jsonPath("$.code").value(200)) + .andExpect(jsonPath("$.data.loginRequired").value(false)) + .andExpect(jsonPath("$.data.authenticated").value(false)); + } + + @Test + void statusShouldReportUnauthenticatedWhenTokenIsMissing() throws Exception { + when(authProperties.isLoginRequired()).thenReturn(true); + when(authService.isAuthenticated(null)).thenReturn(false); + + mockMvc.perform(get("/api/auth/status")) + .andExpect(status().isOk()) + .andExpect(jsonPath("$.data.loginRequired").value(true)) + .andExpect(jsonPath("$.data.authenticated").value(false)); + } + + @Test + void statusShouldReportAuthenticatedForActiveToken() throws Exception { + when(authProperties.isLoginRequired()).thenReturn(true); + when(authService.isAuthenticated("Bearer token-1")).thenReturn(true); + + mockMvc.perform(get("/api/auth/status") + .header(HttpHeaders.AUTHORIZATION, "Bearer token-1")) + .andExpect(status().isOk()) + .andExpect(jsonPath("$.data.loginRequired").value(true)) + .andExpect(jsonPath("$.data.authenticated").value(true)); + } + @Test void loginShouldReturnTokenOnValidRequest() throws Exception { LoginVO mockResponse = LoginVO.builder() 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 index 9c9d0e66..5aa924ba 100644 --- a/server/src/test/java/org/apache/rocketmq/studio/auth/AuthInterceptorTest.java +++ b/server/src/test/java/org/apache/rocketmq/studio/auth/AuthInterceptorTest.java @@ -88,4 +88,16 @@ void shouldAllowLoginEndpointWhenLoginIsEnabled() throws Exception { assertThat(allowed).isTrue(); } + + @Test + void shouldAllowAuthStatusEndpointWhenLoginIsEnabled() throws Exception { + AuthProperties properties = new AuthProperties(); + properties.setLoginRequired(true); + AuthInterceptor interceptor = new AuthInterceptor(properties, new AuthService(properties)); + MockHttpServletRequest request = new MockHttpServletRequest("GET", "/api/auth/status"); + + boolean allowed = interceptor.preHandle(request, new MockHttpServletResponse(), new Object()); + + assertThat(allowed).isTrue(); + } } diff --git a/web/src/App.test.tsx b/web/src/App.test.tsx new file mode 100644 index 00000000..4ac35351 --- /dev/null +++ b/web/src/App.test.tsx @@ -0,0 +1,102 @@ +/* + * 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. + */ + +import { cleanup, fireEvent, render, screen, waitFor } from '@testing-library/react'; +import { MemoryRouter, Route, Routes } from 'react-router-dom'; +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; +import { getAuthStatus } from './api/auth'; +import { AuthGate } from './App'; +import { LangProvider } from './i18n/LangContext'; + +vi.mock('./api/auth', async (importOriginal) => { + const actual = await importOriginal(); + return { ...actual, getAuthStatus: vi.fn() }; +}); + +vi.mock('./config', () => ({ API_BASE_URL: '/api', USE_MOCK: false })); + +const mockedGetAuthStatus = vi.mocked(getAuthStatus); + +function renderGate() { + return render( + + + + login page} /> + }> + protected content} /> + + + + , + ); +} + +describe('AuthGate', () => { + beforeEach(() => { + mockedGetAuthStatus.mockReset(); + localStorage.setItem('token', 'stale-token'); + localStorage.setItem('rocketmq-studio-user', 'admin'); + }); + + afterEach(() => { + cleanup(); + localStorage.clear(); + }); + + it('allows protected routes when login protection is disabled', async () => { + mockedGetAuthStatus.mockResolvedValue({ loginRequired: false, authenticated: false }); + + renderGate(); + + expect(await screen.findByText('protected content')).toBeInTheDocument(); + }); + + it('allows protected routes for an authenticated session', async () => { + mockedGetAuthStatus.mockResolvedValue({ loginRequired: true, authenticated: true }); + + renderGate(); + + expect(await screen.findByText('protected content')).toBeInTheDocument(); + }); + + it('clears an invalid session and redirects to login', async () => { + mockedGetAuthStatus.mockResolvedValue({ loginRequired: true, authenticated: false }); + + renderGate(); + + expect(await screen.findByText('login page')).toBeInTheDocument(); + expect(localStorage.getItem('token')).toBeNull(); + expect(localStorage.getItem('rocketmq-studio-user')).toBeNull(); + }); + + it('fails closed and retries the status check', async () => { + mockedGetAuthStatus + .mockRejectedValueOnce(new Error('network unavailable')) + .mockResolvedValueOnce({ loginRequired: false, authenticated: false }); + + renderGate(); + + expect(await screen.findByText('无法验证登录状态')).toBeInTheDocument(); + expect(screen.queryByText('protected content')).not.toBeInTheDocument(); + + fireEvent.click(screen.getByRole('button', { name: /重\s*试/ })); + + await waitFor(() => expect(mockedGetAuthStatus).toHaveBeenCalledTimes(2)); + expect(await screen.findByText('protected content')).toBeInTheDocument(); + }); +}); diff --git a/web/src/App.tsx b/web/src/App.tsx index 593295ac..627fceb7 100644 --- a/web/src/App.tsx +++ b/web/src/App.tsx @@ -15,7 +15,13 @@ * limitations under the License. */ -import { Routes, Route, Navigate } from 'react-router-dom'; +import { useCallback, useEffect, useState } from 'react'; +import { Button, Result, Spin } from 'antd'; +import { Routes, Route, Navigate, Outlet } from 'react-router-dom'; +import { getAuthStatus } from './api/auth'; +import { USE_MOCK } from './config'; +import { useLang } from './i18n/LangContext'; +import useAuthStore from './stores/authStore'; import MainLayout from './layouts/MainLayout'; import HomePage from './pages/home'; import InstancePage from './pages/instance'; @@ -44,37 +50,103 @@ import ProducerPage from './pages/studio/Producer'; import OpsPage from './pages/studio/Ops'; import LoginPage from './pages/login'; +type AuthGateState = 'checking' | 'allowed' | 'denied' | 'error'; + +export function AuthGate() { + const { t } = useLang(); + const clearAuth = useAuthStore((state) => state.logout); + const [gateState, setGateState] = useState(USE_MOCK ? 'allowed' : 'checking'); + const [attempt, setAttempt] = useState(0); + + useEffect(() => { + if (USE_MOCK) return; + + let cancelled = false; + void getAuthStatus() + .then((status) => { + if (cancelled) return; + if (!status.loginRequired || status.authenticated) { + setGateState('allowed'); + return; + } + clearAuth(); + setGateState('denied'); + }) + .catch(() => { + if (!cancelled) setGateState('error'); + }); + + return () => { + cancelled = true; + }; + }, [attempt, clearAuth]); + + const retry = useCallback(() => { + setGateState('checking'); + setAttempt((current) => current + 1); + }, []); + + if (gateState === 'checking') { + return ( +
+ +
+ ); + } + if (gateState === 'denied') return ; + if (gateState === 'error') { + return ( + + {t('common.retry')} + + } + /> + ); + } + return ; +} + function App() { return ( } /> - }> - } /> - } /> - } /> - } /> - } /> - } /> - } /> - } /> - } /> - } /> - } /> - } /> - } /> - } /> - } /> - } /> - } /> - } /> - } /> - } /> - } /> - } /> - } /> - } /> - } /> - } /> + }> + }> + } /> + } /> + } /> + } /> + } /> + } /> + } /> + } /> + } /> + } /> + } /> + } /> + } /> + } /> + } /> + } /> + } /> + } /> + } /> + } /> + } /> + } /> + } /> + } /> + } /> + } /> + ); diff --git a/web/src/api/auth.test.ts b/web/src/api/auth.test.ts index c0fcc8fd..3adfd5f7 100644 --- a/web/src/api/auth.test.ts +++ b/web/src/api/auth.test.ts @@ -18,7 +18,7 @@ import MockAdapter from 'axios-mock-adapter'; import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; import client from './client'; -import { login, logout } from './auth'; +import { getAuthStatus, login, logout } from './auth'; const mock = new MockAdapter(client); @@ -37,6 +37,13 @@ describe('Auth API', () => { vi.unstubAllGlobals(); }); + it('status should return the login requirement and session state', async () => { + const authStatus = { loginRequired: true, authenticated: false }; + mock.onGet('/auth/status').reply(200, { data: authStatus }); + + await expect(getAuthStatus()).resolves.toEqual(authStatus); + }); + it('login should post credentials and return token data', async () => { const mockResponse = { token: 'jwt-token-123', diff --git a/web/src/api/auth.ts b/web/src/api/auth.ts index 5abeb807..0c3a8f07 100644 --- a/web/src/api/auth.ts +++ b/web/src/api/auth.ts @@ -32,7 +32,17 @@ export interface LoginResponse { }; } +export interface AuthStatus { + loginRequired: boolean; + authenticated: boolean; +} + // ─── Auth ─────────────────────────────────────────────────────── +export async function getAuthStatus() { + const res = await client.get<{ data: AuthStatus }>('/auth/status'); + return res.data.data; +} + export async function login(username: string, password: string) { const res = await client.post<{ data: LoginResponse }>('/auth/login', { username, password }); return res.data.data; diff --git a/web/src/i18n/translations.ts b/web/src/i18n/translations.ts index 54625d24..47bde378 100644 --- a/web/src/i18n/translations.ts +++ b/web/src/i18n/translations.ts @@ -70,6 +70,7 @@ const translations: Record> = { 'common.liveRefresh': { zh: '实时刷新', en: 'Live Refresh' }, 'common.yes': { zh: '是', en: 'Yes' }, 'common.no': { zh: '否', en: 'No' }, + 'common.retry': { zh: '重试', en: 'Retry' }, // ─── Dashboard ─── 'dashboard.title': { zh: '监控面板', en: 'Dashboard' }, @@ -641,6 +642,10 @@ const translations: Record> = { 'login.success': { zh: '登录成功', en: 'Login successful' }, 'login.failed': { zh: '登录失败', en: 'Login failed' }, 'login.welcome': { zh: '欢迎使用 RocketMQ 仪表盘', en: 'Welcome to RocketMQ Dashboard' }, + 'login.statusCheckFailed': { + zh: '无法验证登录状态', + en: 'Unable to verify login status', + }, // ─── Ops ─── 'ops.title': { zh: '运维', en: 'Ops' },