From c87ce15a999c713571060d75c3414546d07210b1 Mon Sep 17 00:00:00 2001 From: MoeexT Date: Wed, 1 Apr 2026 10:09:37 +0800 Subject: [PATCH] fix: loging popup when enable jwt --- .../gateway/common/filter/AuthFilter.java | 29 +++++--- .../gateway/interfaces/dto/UserResponse.java | 5 ++ .../interfaces/rest/UserController.java | 27 +++++--- frontend/src/components/ErrorBoundary.tsx | 4 +- frontend/src/main.tsx | 67 +++++++++++++++---- frontend/src/pages/Layout/Header.tsx | 20 +++--- frontend/src/utils/request.ts | 23 +++---- 7 files changed, 119 insertions(+), 56 deletions(-) diff --git a/backend/api-gateway/src/main/java/com/datamate/gateway/common/filter/AuthFilter.java b/backend/api-gateway/src/main/java/com/datamate/gateway/common/filter/AuthFilter.java index b1d6cb9b2..44bbdcb27 100644 --- a/backend/api-gateway/src/main/java/com/datamate/gateway/common/filter/AuthFilter.java +++ b/backend/api-gateway/src/main/java/com/datamate/gateway/common/filter/AuthFilter.java @@ -26,15 +26,19 @@ /** * 用户数据隔离过滤器 * - * 支持两种认证模式: - * 1. SSO 模式:从 OmsAuthFilter 添加的 X-User-Name header 中提取用户信息 - * 2. JWT 模式:从 Authorization Bearer Token 中提取用户信息 - * - * 无论哪种模式,最终都会添加 User header 供下游服务隔离用户数据 + * 支持两种场景: + * 1. 商业场景(SSO):OmsAuthFilter 已添加 X-User-Name header,直接使用 + * 2. 独立场景(可选登录): + * - DATAMATE_JWT_ENABLED=true:必须登录,验证 JWT token 并添加 User header + * - DATAMATE_JWT_ENABLED=false:允许匿名访问,不添加 User header * * 优先级:SSO > JWT * Order: 2 (低于 OmsAuthFilter 的 Order=1) * + * 环境变量: + * - OMS_AUTH_ENABLED:是否启用 OmsAuthFilter(商业场景) + * - DATAMATE_JWT_ENABLE:独立场景下是否要求用户登录 + * * @author songyongtan * @date 2026-03-30 */ @@ -57,10 +61,17 @@ public class AuthFilter implements GlobalFilter, Ordered { public Mono filter(ServerWebExchange exchange, GatewayFilterChain chain) { ServerHttpRequest request = exchange.getRequest(); String path = request.getURI().getPath(); + + // 公开接口:直接放行 if (path.equals("/api/user/login") || path.equals("/api/user/signup")) { return chain.filter(exchange); } + // 内部接口:/api/user/me 内部会自行验证 SSO 或 JWT,直接放行 + if (path.equals("/api/user/me")) { + return chain.filter(exchange); + } + try { // 优先检查 SSO 模式(OmsAuthFilter 已添加的 header) String ssoUser = request.getHeaders().getFirst("X-User-Name"); @@ -77,16 +88,16 @@ public Mono filter(ServerWebExchange exchange, GatewayFilterChain chain) { return chain.filter(mutatedExchange); } - // 检查 JWT 模式 + // 独立场景:根据 DATAMATE_JWT_ENABLE 决定是否要求登录 if (!jwtEnable) { - log.debug("JWT is disabled, passing request without user header"); + log.debug("JWT authentication is not required, passing request without user header"); return chain.filter(exchange); } - // JWT 模式:验证 Token + // JWT 模式:必须登录,验证 Token String authHeader = request.getHeaders().getFirst(AUTH_HEADER); if (authHeader == null || !authHeader.startsWith(TOKEN_PREFIX)) { - log.warn("JWT enabled but no valid Authorization header found"); + log.warn("JWT authentication is required but no valid Authorization header found"); return sendUnauthorizedResponse(exchange); } diff --git a/backend/api-gateway/src/main/java/com/datamate/gateway/interfaces/dto/UserResponse.java b/backend/api-gateway/src/main/java/com/datamate/gateway/interfaces/dto/UserResponse.java index 16d6f3079..0bb191ddf 100644 --- a/backend/api-gateway/src/main/java/com/datamate/gateway/interfaces/dto/UserResponse.java +++ b/backend/api-gateway/src/main/java/com/datamate/gateway/interfaces/dto/UserResponse.java @@ -42,4 +42,9 @@ public class UserResponse { * 认证模式 */ private String authMode; // "SSO" | "JWT" | "NONE" + + /** + * 是否强制要求登录(由 datamate.jwt.enable 控制) + */ + private Boolean requireLogin; } diff --git a/backend/api-gateway/src/main/java/com/datamate/gateway/interfaces/rest/UserController.java b/backend/api-gateway/src/main/java/com/datamate/gateway/interfaces/rest/UserController.java index 79afd82ca..3d11ac85a 100644 --- a/backend/api-gateway/src/main/java/com/datamate/gateway/interfaces/rest/UserController.java +++ b/backend/api-gateway/src/main/java/com/datamate/gateway/interfaces/rest/UserController.java @@ -15,6 +15,7 @@ import lombok.RequiredArgsConstructor; import lombok.extern.slf4j.Slf4j; import org.apache.commons.lang3.StringUtils; +import org.springframework.beans.factory.annotation.Value; import org.springframework.http.HttpStatus; import org.springframework.http.ResponseEntity; import org.springframework.http.server.reactive.ServerHttpRequest; @@ -43,6 +44,9 @@ public class UserController { private final OmsService omsService; private final OmsExtensionService omsExtensionService; + @Value("${datamate.jwt.enable:false}") + private Boolean jwtEnable; + private static final String AUTH_TOKEN_KEY = "__Host-X-Auth-Token"; private static final String CSRF_TOKEN_KEY = "__Host-X-Csrf-Token"; @@ -109,32 +113,32 @@ public ResponseEntity> register(@Valid @RequestBody Regi */ @GetMapping("/me") public Response getCurrentUser(ServerHttpRequest request) { - log.info("=== /api/user/me called ==="); + log.debug("=== /api/user/me called ==="); // 优先检查 SSO 模式(从 cookies 读取 OMS token) MultiValueMap cookies = request.getCookies(); String authToken = getToken(cookies, AUTH_TOKEN_KEY); String csrfToken = getToken(cookies, CSRF_TOKEN_KEY); - log.info("Cookies present - __Host-X-Auth-Token: {}, __Host-X-Csrf-Token: {}", + log.debug("Cookies present - __Host-X-Auth-Token: {}, __Host-X-Csrf-Token: {}", StringUtils.isNotBlank(authToken), StringUtils.isNotBlank(csrfToken)); if (StringUtils.isNotBlank(authToken)) { try { // 获取真实 IP String realIp = getRealIp(request); - log.info("Calling OMS service with realIp: {}", realIp); + log.debug("Calling OMS service with realIp: {}", realIp); // 调用 OMS 服务验证 String username = omsService.getUserNameFromOms(authToken, csrfToken, realIp); if (StringUtils.isNotBlank(username)) { - log.info("SSO mode: user={}", username); + log.info("SSO authentication successful: user={}", username); // 获取用户组 ID(可能为 null) String groupId = null; try { groupId = omsExtensionService.getUserGroupId(username); - log.info("User groupId: {}", groupId); + log.debug("User groupId: {}", groupId); } catch (Exception e) { log.warn("Failed to get user group ID: {}", e.getMessage()); } @@ -144,6 +148,7 @@ public Response getCurrentUser(ServerHttpRequest request) { .groupId(groupId) .authenticated(true) .authMode("SSO") + .requireLogin(true) // SSO 模式始终要求登录 .build()); } else { log.warn("OMS service returned null username"); @@ -160,20 +165,26 @@ public Response getCurrentUser(ServerHttpRequest request) { String username = userService.validateToken(token); if (StringUtils.isNotBlank(username)) { - log.info("JWT mode: user={}", username); + log.info("JWT authentication successful: user={}", username); return Response.ok(UserResponse.builder() .username(username) .authenticated(true) .authMode("JWT") + .requireLogin(true) // 已登录 .build()); + } else { + log.warn("JWT token validation failed"); } } - // 未登录 - log.debug("User not authenticated"); + // 未登录:检查是否强制要求登录 + boolean requireLogin = Boolean.TRUE.equals(jwtEnable); + log.debug("User not authenticated, requireLogin={}, jwtEnable={}", requireLogin, jwtEnable); + return Response.ok(UserResponse.builder() .authenticated(false) .authMode("NONE") + .requireLogin(requireLogin) // 关键字段:告诉前端是否需要登录 .build()); } } diff --git a/frontend/src/components/ErrorBoundary.tsx b/frontend/src/components/ErrorBoundary.tsx index 3c2602d36..6fc9223f1 100644 --- a/frontend/src/components/ErrorBoundary.tsx +++ b/frontend/src/components/ErrorBoundary.tsx @@ -63,7 +63,7 @@ export default class ErrorBoundary extends Component< this.logErrorToService(error, errorInfo); // 开发环境下在控制台显示详细错误 - if (process.env.NODE_ENV === "development") { + if (import.meta.env.DEV) { console.error("ErrorBoundary 捕获到错误:", error); console.error("错误详情:", errorInfo); } @@ -187,7 +187,7 @@ export function withErrorBoundary( Component: React.ComponentType ): React.ComponentType { return (props) => ( - + ); diff --git a/frontend/src/main.tsx b/frontend/src/main.tsx index 064858055..6fe167976 100644 --- a/frontend/src/main.tsx +++ b/frontend/src/main.tsx @@ -11,6 +11,7 @@ import { Provider } from "react-redux"; import theme from "./theme"; import {errorConfigStore} from "@/utils/errorConfigStore.ts"; import { setCachedHomePageUrl, getCachedHomePageUrl } from "@/utils/systemParam"; +import { setRequireLoginMode } from "@/utils/request"; import "@/i18n"; function showLoadingUI() { @@ -64,11 +65,23 @@ function getAuthToken(): string | null { * 在任何渲染之前检查系统参数 sys.home.page.url,若已配置则立即跳转,确保无闪烁。 * 使用原始 fetch 但携带 JWT token,避免已登录用户仍收到 401。 */ -async function checkHomePageRedirect(): Promise<{ redirected: boolean; authNeeded: boolean }> { +async function checkHomePageRedirect(requireLogin: boolean): Promise<{ redirected: boolean; authNeeded: boolean }> { if (window.location.pathname !== '/') { return { redirected: false, authNeeded: false }; } + // 如果需要登录,检查是否有缓存的登录页URL + if (requireLogin) { + const cachedUrl = getCachedHomePageUrl(); + if (cachedUrl) { + window.location.replace(cachedUrl); + return { redirected: true, authNeeded: false }; + } + // 需要登录且没有缓存URL,显示登录框 + return { redirected: false, authNeeded: true }; + } + + // 不需要登录时,检查自定义首页 const headers: Record = { 'Content-Type': 'application/json' }; const token = getAuthToken(); if (token) { @@ -91,15 +104,6 @@ async function checkHomePageRedirect(): Promise<{ redirected: boolean; authNeede } // 参数存在但值为空 → 管理员已清除,清掉缓存 setCachedHomePageUrl(null); - } else if (response.status === 401) { - // 未登录,尝试从缓存读取 - const cachedUrl = getCachedHomePageUrl(); - if (cachedUrl) { - window.location.replace(cachedUrl); - return { redirected: true, authNeeded: false }; - } - // 未登录且无缓存,需要弹出登录框 - return { redirected: false, authNeeded: true }; } } catch { // 网络错误等,尝试从缓存读取 @@ -109,15 +113,52 @@ async function checkHomePageRedirect(): Promise<{ redirected: boolean; authNeede return { redirected: true, authNeeded: false }; } } + return { redirected: false, authNeeded: false }; } +/** + * 检查是否需要登录(从后端获取配置) + * 这个检查应该在应用启动时总是执行,无论当前路径是什么 + */ +async function checkRequireLoginMode(): Promise { + try { + const userResponse = await fetch('/api/user/me', { + method: 'GET', + credentials: 'include', + headers: { 'Content-Type': 'application/json' }, + }); + + if (userResponse.ok) { + const userResult = await userResponse.json(); + const requireLogin = userResult?.data?.requireLogin ?? false; + // 设置全局标记,供 request.ts 使用 + setRequireLoginMode(requireLogin); + return requireLogin; + } else if (userResponse.status === 401) { + // /api/user/me 本身返回 401,说明需要登录 + setRequireLoginMode(true); + return true; + } + } catch (e) { + console.error('[bootstrap] Failed to check login requirement:', e); + } + + // 默认不需要登录 + setRequireLoginMode(false); + return false; +} + async function bootstrap() { const container = document.getElementById("root"); if (!container) return; - // 在任何 UI 渲染之前检查自定义首页重定向 - const { redirected, authNeeded } = await checkHomePageRedirect(); + // 首先检查是否需要登录(无论当前路径是什么) + const requireLogin = await checkRequireLoginMode(); + + // 然后检查自定义首页重定向(只在根路径时) + const { redirected, authNeeded } = await checkHomePageRedirect(requireLogin); + if (redirected) { return; } @@ -131,7 +172,7 @@ async function bootstrap() { } const root = createRoot(container); - + root.render( diff --git a/frontend/src/pages/Layout/Header.tsx b/frontend/src/pages/Layout/Header.tsx index 9271977dc..ef81d5c03 100644 --- a/frontend/src/pages/Layout/Header.tsx +++ b/frontend/src/pages/Layout/Header.tsx @@ -17,6 +17,7 @@ interface UserResponse { groupId?: string; authenticated: boolean; authMode: 'SSO' | 'JWT' | 'NONE'; + requireLogin?: boolean; // 是否强制要求登录(由 DATAMATE_JWT_ENABLE 控制) } function loginUsingPost(data: any) { @@ -32,8 +33,8 @@ function getCurrentUser() { } // ME 登录 URL(根据实际环境修改) -const ME_LOGIN_URL = process.env.VITE_ME_LOGIN_URL || 'https://modelengine.com/login'; -const OMS_LOGOUT_URL = process.env.VITE_OMS_LOGOUT_URL || 'https://oms-service/logout'; +const ME_LOGIN_URL = import.meta.env.VITE_ME_LOGIN_URL || 'https://modelengine.com/login'; +const OMS_LOGOUT_URL = import.meta.env.VITE_OMS_LOGOUT_URL || 'https://oms-service/logout'; export function Header() { const { t } = useTranslation(); @@ -154,18 +155,13 @@ export function Header() { setCurrentUser(response.data); setAuthMode(response.data.authMode); - // 如果未登录,根据模式处理 + // 如果未登录,根据 requireLogin 决定是否弹出登录框 if (!response.data.authenticated) { - if (isSSOAvailable()) { - // SSO 模式:自动跳转到 ME 登录 - console.log('SSO mode detected, redirecting to ME login...'); - // 不自动跳转,等待用户点击登录按钮 - } else { - // JWT 模式:保持未登录状态 - console.log('JWT mode, waiting for user to login'); + if (response.data.requireLogin) { + // 强制要求登录:弹出登录框 + window.dispatchEvent(new CustomEvent('show-login')); } - } else { - console.log(`User authenticated via ${response.data.authMode}:`, response.data.username); + // 不强制登录:允许匿名访问 } } } catch (error) { diff --git a/frontend/src/utils/request.ts b/frontend/src/utils/request.ts index f76c115e8..1159f812f 100644 --- a/frontend/src/utils/request.ts +++ b/frontend/src/utils/request.ts @@ -566,24 +566,30 @@ const AUTH_ERR_CODES = [401, '401', 'common.401', 'common.0401']; // --- 辅助函数:防抖处理登录失效 --- let isRelogging = false; +// 全局标记:是否需要登录(从 /api/user/me 获取) +let requireLoginMode = false; + +// 设置是否需要登录 +export function setRequireLoginMode(value: boolean) { + requireLoginMode = value; +} const handleLoginRedirect = () => { - console.log('[Auth] handleLoginRedirect called, isRelogging:', isRelogging); + // 如果不需要登录,直接返回 + if (!requireLoginMode) { + return; + } if (isRelogging) { - console.log('[Auth] Skipping - already relogging'); return; } isRelogging = true; localStorage.removeItem('session'); - - console.log('[Auth] Dispatching show-login event'); window.dispatchEvent(new CustomEvent('show-login')); setTimeout(() => { isRelogging = false; - console.log('[Auth] Reset isRelogging flag'); }, 3000); }; @@ -594,9 +600,6 @@ request.addResponseInterceptor(async (response, config) => { } const { status } = response; - console.log('[API Interceptor] Response status:', status, 'URL:', config?.url); - - // ------------------ 修改重点开始 ------------------ let resData: {}; @@ -605,11 +608,9 @@ request.addResponseInterceptor(async (response, config) => { // 关键点 2: 必须用 .clone(),因为流只能读一次。读了克隆的,原版 response 还能留给外面用 // 关键点 3: 必须 await,因为读取流是异步的 resData = await response.clone().json(); - console.log('[API Interceptor] Response data:', resData); } catch (e) { // 如果后端返回的不是 JSON (比如 404 HTML 页面,或者空字符串),json() 会报错 // 这里捕获异常,保证 resData 至少是个空对象,不会导致后面取值 crash - console.warn('[API Interceptor] 响应体不是有效的JSON:', e); resData = {}; } @@ -617,7 +618,6 @@ request.addResponseInterceptor(async (response, config) => { // 优先取后端 body 里的 business code,没有则取 HTTP status const code = resData.code ?? status; const codeStr = String(code); - console.log('[API Interceptor] Extracted code:', code, 'codeStr:', codeStr); // 3. 判断成功 (根据你的后端约定:200/0 为成功) // 如果是成功状态,直接返回 response,不拦截 @@ -643,7 +643,6 @@ request.addResponseInterceptor(async (response, config) => { // 7. 处理 Token 过期 / 未登录 const isAuthError = AUTH_ERR_CODES.includes(code) || AUTH_ERR_CODES.includes(codeStr); - console.log('[API Interceptor] Is auth error?', isAuthError, 'AUTH_ERR_CODES:', AUTH_ERR_CODES); if (isAuthError) { handleLoginRedirect(); }