Skip to content
Merged
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
Original file line number Diff line number Diff line change
Expand Up @@ -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
*/
Expand All @@ -57,10 +61,17 @@ public class AuthFilter implements GlobalFilter, Ordered {
public Mono<Void> 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");
Expand All @@ -77,16 +88,16 @@ public Mono<Void> 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);
}

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -42,4 +42,9 @@ public class UserResponse {
* 认证模式
*/
private String authMode; // "SSO" | "JWT" | "NONE"

/**
* 是否强制要求登录(由 datamate.jwt.enable 控制)
*/
private Boolean requireLogin;
}
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down Expand Up @@ -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";

Expand Down Expand Up @@ -109,32 +113,32 @@ public ResponseEntity<Response<LoginResponse>> register(@Valid @RequestBody Regi
*/
@GetMapping("/me")
public Response<UserResponse> getCurrentUser(ServerHttpRequest request) {
log.info("=== /api/user/me called ===");
log.debug("=== /api/user/me called ===");

// 优先检查 SSO 模式(从 cookies 读取 OMS token)
MultiValueMap<String, HttpCookie> 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());
}
Expand All @@ -144,6 +148,7 @@ public Response<UserResponse> getCurrentUser(ServerHttpRequest request) {
.groupId(groupId)
.authenticated(true)
.authMode("SSO")
.requireLogin(true) // SSO 模式始终要求登录
.build());
} else {
log.warn("OMS service returned null username");
Expand All @@ -160,20 +165,26 @@ public Response<UserResponse> 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());
}
}
4 changes: 2 additions & 2 deletions frontend/src/components/ErrorBoundary.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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);
}
Expand Down Expand Up @@ -187,7 +187,7 @@ export function withErrorBoundary(
Component: React.ComponentType
): React.ComponentType {
return (props) => (
<ErrorBoundary showDetails={process.env.NODE_ENV === "development"}>
<ErrorBoundary showDetails={import.meta.env.DEV}>
<Component {...props} />
</ErrorBoundary>
);
Expand Down
67 changes: 54 additions & 13 deletions frontend/src/main.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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() {
Expand Down Expand Up @@ -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<string, string> = { 'Content-Type': 'application/json' };
const token = getAuthToken();
if (token) {
Expand All @@ -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 {
// 网络错误等,尝试从缓存读取
Expand All @@ -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<boolean> {
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;
}
Expand All @@ -131,7 +172,7 @@ async function bootstrap() {
}

const root = createRoot(container);

root.render(
<StrictMode>
<Provider store={store}>
Expand Down
20 changes: 8 additions & 12 deletions frontend/src/pages/Layout/Header.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -17,6 +17,7 @@ interface UserResponse {
groupId?: string;
authenticated: boolean;
authMode: 'SSO' | 'JWT' | 'NONE';
requireLogin?: boolean; // 是否强制要求登录(由 DATAMATE_JWT_ENABLE 控制)
}

function loginUsingPost(data: any) {
Expand All @@ -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();
Expand Down Expand Up @@ -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) {
Expand Down
Loading
Loading