You've already forked DataMate
fix: 修复知识图谱模块 P0/P1/P2/P3 问题
【P0 - 安全风险修复】 - InternalTokenInterceptor: fail-open → fail-closed - 未配置 token 时直接拒绝(401) - 仅 dev/test 环境可显式跳过校验 - KnowledgeGraphProperties: 新增 skipTokenCheck 配置项 - application-knowledgegraph.yml: 新增 skip-token-check 配置 【P1 - 文档版本控制】 - .gitignore: 移除 docs/knowledge-graph/ 忽略规则 - schema 文档现已纳入版本控制 【P2 - 代码质量改进】 - InternalTokenInterceptor: 错误响应改为 Response.error() 格式 - 新增 InternalTokenInterceptorTest.java(7 个测试用例) - fail-closed 行为验证 - token 校验逻辑验证 - 错误响应格式验证 【P3 - 文档一致性】 - README.md: 相对链接改为显式 GitHub 链接 【验证结果】 - 编译通过 - 198 个测试全部通过(0 failures)
This commit is contained in:
2
.gitignore
vendored
2
.gitignore
vendored
@@ -190,5 +190,3 @@ Thumbs.db
|
||||
|
||||
# Milvus
|
||||
deployment/docker/milvus/volumes/
|
||||
# Local documentation
|
||||
docs/knowledge-graph/
|
||||
|
||||
@@ -110,9 +110,9 @@ Thank you for your interest in this project! We warmly welcome contributions fro
|
||||
bug reports, suggesting new features, or directly participating in code development, all forms of help make the project
|
||||
better.
|
||||
|
||||
• 📮 [GitHub Issues](../../issues): Submit bugs or feature suggestions.
|
||||
• 📮 [GitHub Issues](https://github.com/ModelEngine-Group/DataMate/issues): Submit bugs or feature suggestions.
|
||||
|
||||
• 🔧 [GitHub Pull Requests](../../pulls): Contribute code improvements.
|
||||
• 🔧 [GitHub Pull Requests](https://github.com/ModelEngine-Group/DataMate/pulls): Contribute code improvements.
|
||||
|
||||
## 📄 License
|
||||
|
||||
|
||||
@@ -22,7 +22,8 @@ public enum KnowledgeGraphErrorCode implements ErrorCode {
|
||||
SYNC_FAILED("knowledge_graph.0009", "数据同步失败"),
|
||||
EMPTY_SNAPSHOT_PURGE_BLOCKED("knowledge_graph.0010", "空快照保护:上游返回空列表,已阻止 purge 操作"),
|
||||
SCHEMA_INIT_FAILED("knowledge_graph.0011", "图谱 Schema 初始化失败"),
|
||||
INSECURE_DEFAULT_CREDENTIALS("knowledge_graph.0012", "检测到默认凭据,生产环境禁止使用默认密码");
|
||||
INSECURE_DEFAULT_CREDENTIALS("knowledge_graph.0012", "检测到默认凭据,生产环境禁止使用默认密码"),
|
||||
UNAUTHORIZED_INTERNAL_CALL("knowledge_graph.0013", "内部调用未授权:X-Internal-Token 校验失败");
|
||||
|
||||
private final String code;
|
||||
private final String message;
|
||||
|
||||
@@ -25,6 +25,24 @@ public class KnowledgeGraphProperties {
|
||||
/** 同步相关配置 */
|
||||
private Sync sync = new Sync();
|
||||
|
||||
/** 安全相关配置 */
|
||||
private Security security = new Security();
|
||||
|
||||
@Data
|
||||
public static class Security {
|
||||
|
||||
/** 内部服务调用 Token,用于校验 sync 端点的 X-Internal-Token 请求头 */
|
||||
private String internalToken;
|
||||
|
||||
/**
|
||||
* 是否跳过内部 Token 校验(默认 false,即 fail-closed)。
|
||||
* <p>
|
||||
* 仅允许在 dev/test 环境显式设置为 true 以跳过校验。
|
||||
* 生产环境必须保持 false 并配置 {@code internal-token}。
|
||||
*/
|
||||
private boolean skipTokenCheck = false;
|
||||
}
|
||||
|
||||
@Data
|
||||
public static class Sync {
|
||||
|
||||
|
||||
@@ -0,0 +1,74 @@
|
||||
package com.datamate.knowledgegraph.infrastructure.security;
|
||||
|
||||
import com.datamate.common.infrastructure.common.Response;
|
||||
import com.datamate.knowledgegraph.infrastructure.exception.KnowledgeGraphErrorCode;
|
||||
import com.datamate.knowledgegraph.infrastructure.neo4j.KnowledgeGraphProperties;
|
||||
import com.fasterxml.jackson.databind.ObjectMapper;
|
||||
import jakarta.servlet.http.HttpServletRequest;
|
||||
import jakarta.servlet.http.HttpServletResponse;
|
||||
import lombok.RequiredArgsConstructor;
|
||||
import org.slf4j.Logger;
|
||||
import org.slf4j.LoggerFactory;
|
||||
import org.springframework.http.MediaType;
|
||||
import org.springframework.stereotype.Component;
|
||||
import org.springframework.util.StringUtils;
|
||||
import org.springframework.web.servlet.HandlerInterceptor;
|
||||
|
||||
import java.io.IOException;
|
||||
|
||||
/**
|
||||
* 内部服务调用 Token 校验拦截器。
|
||||
* <p>
|
||||
* 验证 {@code X-Internal-Token} 请求头,保护 sync 端点仅供内部服务/定时任务调用。
|
||||
* <p>
|
||||
* <strong>安全策略(fail-closed)</strong>:
|
||||
* <ul>
|
||||
* <li>Token 未配置且 {@code skip-token-check=false}(默认)时,直接拒绝请求</li>
|
||||
* <li>仅当 dev/test 环境显式设置 {@code skip-token-check=true} 时,才跳过校验</li>
|
||||
* </ul>
|
||||
*/
|
||||
@Component
|
||||
@RequiredArgsConstructor
|
||||
public class InternalTokenInterceptor implements HandlerInterceptor {
|
||||
|
||||
private static final Logger log = LoggerFactory.getLogger(InternalTokenInterceptor.class);
|
||||
private static final String HEADER_INTERNAL_TOKEN = "X-Internal-Token";
|
||||
|
||||
private final KnowledgeGraphProperties properties;
|
||||
private final ObjectMapper objectMapper;
|
||||
|
||||
@Override
|
||||
public boolean preHandle(HttpServletRequest request, HttpServletResponse response, Object handler)
|
||||
throws IOException {
|
||||
KnowledgeGraphProperties.Security security = properties.getSecurity();
|
||||
String configuredToken = security.getInternalToken();
|
||||
|
||||
if (!StringUtils.hasText(configuredToken)) {
|
||||
if (security.isSkipTokenCheck()) {
|
||||
log.warn("内部调用 Token 未配置且 skip-token-check=true,跳过校验(仅限 dev/test 环境)。");
|
||||
return true;
|
||||
}
|
||||
log.error("内部调用 Token 未配置且 skip-token-check=false(fail-closed),拒绝请求。"
|
||||
+ "请设置 KG_INTERNAL_TOKEN 环境变量或在 dev/test 环境启用 skip-token-check。");
|
||||
writeErrorResponse(response);
|
||||
return false;
|
||||
}
|
||||
|
||||
String requestToken = request.getHeader(HEADER_INTERNAL_TOKEN);
|
||||
|
||||
if (!configuredToken.equals(requestToken)) {
|
||||
writeErrorResponse(response);
|
||||
return false;
|
||||
}
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
private void writeErrorResponse(HttpServletResponse response) throws IOException {
|
||||
Response<?> errorBody = Response.error(KnowledgeGraphErrorCode.UNAUTHORIZED_INTERNAL_CALL);
|
||||
response.setStatus(HttpServletResponse.SC_UNAUTHORIZED);
|
||||
response.setContentType(MediaType.APPLICATION_JSON_VALUE);
|
||||
response.setCharacterEncoding("UTF-8");
|
||||
response.getWriter().write(objectMapper.writeValueAsString(errorBody));
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,22 @@
|
||||
package com.datamate.knowledgegraph.infrastructure.security;
|
||||
|
||||
import lombok.RequiredArgsConstructor;
|
||||
import org.springframework.context.annotation.Configuration;
|
||||
import org.springframework.web.servlet.config.annotation.InterceptorRegistry;
|
||||
import org.springframework.web.servlet.config.annotation.WebMvcConfigurer;
|
||||
|
||||
/**
|
||||
* 注册 {@link InternalTokenInterceptor},仅拦截 sync 端点。
|
||||
*/
|
||||
@Configuration
|
||||
@RequiredArgsConstructor
|
||||
public class InternalTokenWebMvcConfigurer implements WebMvcConfigurer {
|
||||
|
||||
private final InternalTokenInterceptor internalTokenInterceptor;
|
||||
|
||||
@Override
|
||||
public void addInterceptors(InterceptorRegistry registry) {
|
||||
registry.addInterceptor(internalTokenInterceptor)
|
||||
.addPathPatterns("/knowledge-graph/*/sync/**");
|
||||
}
|
||||
}
|
||||
@@ -23,10 +23,13 @@ import java.util.List;
|
||||
* 提供手动触发 MySQL → Neo4j 同步的 REST 端点。
|
||||
* 生产环境中也可通过定时任务自动触发。
|
||||
* <p>
|
||||
* <b>安全说明</b>:本接口仅供内部服务调用(API Gateway / 定时任务),
|
||||
* 外部请求必须经 API Gateway 鉴权后转发。
|
||||
* 生产环境建议通过 mTLS 或内部 JWT 进一步加固服务间认证。
|
||||
* 当前通过 {@code X-Internal-Token} 请求头进行简单的内部调用校验。
|
||||
* <b>安全架构</b>:
|
||||
* <ul>
|
||||
* <li>外部请求 → API Gateway (JWT 校验) → X-User-* headers → 后端服务</li>
|
||||
* <li>内部调用 → X-Internal-Token header → {@code InternalTokenInterceptor} 校验 → sync 端点</li>
|
||||
* </ul>
|
||||
* Token 校验由 {@code InternalTokenInterceptor} 拦截器统一实现,
|
||||
* 对 {@code /knowledge-graph/{graphId}/sync/} 路径前缀自动生效。
|
||||
*/
|
||||
@RestController
|
||||
@RequestMapping("/knowledge-graph/{graphId}/sync")
|
||||
|
||||
@@ -23,6 +23,14 @@ datamate:
|
||||
max-nodes-per-query: ${KG_MAX_NODES:500}
|
||||
# 批量导入批次大小
|
||||
import-batch-size: ${KG_IMPORT_BATCH_SIZE:100}
|
||||
# 安全配置
|
||||
security:
|
||||
# 内部服务调用 Token(用于 sync 端点的 X-Internal-Token 校验)
|
||||
# 生产环境务必通过 KG_INTERNAL_TOKEN 环境变量设置,否则 sync 端点将拒绝所有请求(fail-closed)
|
||||
internal-token: ${KG_INTERNAL_TOKEN:}
|
||||
# 是否跳过 Token 校验(默认 false = fail-closed)
|
||||
# 仅在 dev/test 环境显式设置为 true 以跳过校验
|
||||
skip-token-check: ${KG_SKIP_TOKEN_CHECK:false}
|
||||
# MySQL → Neo4j 同步配置
|
||||
sync:
|
||||
# 数据管理服务地址
|
||||
|
||||
@@ -0,0 +1,152 @@
|
||||
package com.datamate.knowledgegraph.infrastructure.security;
|
||||
|
||||
import com.datamate.knowledgegraph.infrastructure.neo4j.KnowledgeGraphProperties;
|
||||
import com.fasterxml.jackson.databind.ObjectMapper;
|
||||
import jakarta.servlet.http.HttpServletResponse;
|
||||
import org.junit.jupiter.api.BeforeEach;
|
||||
import org.junit.jupiter.api.Test;
|
||||
import org.junit.jupiter.api.extension.ExtendWith;
|
||||
import org.mockito.junit.jupiter.MockitoExtension;
|
||||
import org.springframework.mock.web.MockHttpServletRequest;
|
||||
import org.springframework.mock.web.MockHttpServletResponse;
|
||||
|
||||
import static org.assertj.core.api.Assertions.assertThat;
|
||||
|
||||
@ExtendWith(MockitoExtension.class)
|
||||
class InternalTokenInterceptorTest {
|
||||
|
||||
private static final String VALID_TOKEN = "test-secret-token";
|
||||
|
||||
private KnowledgeGraphProperties properties;
|
||||
private InternalTokenInterceptor interceptor;
|
||||
|
||||
@BeforeEach
|
||||
void setUp() {
|
||||
properties = new KnowledgeGraphProperties();
|
||||
interceptor = new InternalTokenInterceptor(properties, new ObjectMapper());
|
||||
}
|
||||
|
||||
// -----------------------------------------------------------------------
|
||||
// fail-closed:Token 未配置 + skipTokenCheck=false → 拒绝
|
||||
// -----------------------------------------------------------------------
|
||||
|
||||
@Test
|
||||
void tokenNotConfigured_skipFalse_rejects() throws Exception {
|
||||
properties.getSecurity().setInternalToken(null);
|
||||
properties.getSecurity().setSkipTokenCheck(false);
|
||||
|
||||
MockHttpServletRequest request = new MockHttpServletRequest();
|
||||
MockHttpServletResponse response = new MockHttpServletResponse();
|
||||
|
||||
boolean result = interceptor.preHandle(request, response, new Object());
|
||||
|
||||
assertThat(result).isFalse();
|
||||
assertThat(response.getStatus()).isEqualTo(HttpServletResponse.SC_UNAUTHORIZED);
|
||||
assertThat(response.getContentAsString()).contains("knowledge_graph.0013");
|
||||
}
|
||||
|
||||
@Test
|
||||
void tokenEmpty_skipFalse_rejects() throws Exception {
|
||||
properties.getSecurity().setInternalToken("");
|
||||
properties.getSecurity().setSkipTokenCheck(false);
|
||||
|
||||
MockHttpServletRequest request = new MockHttpServletRequest();
|
||||
MockHttpServletResponse response = new MockHttpServletResponse();
|
||||
|
||||
boolean result = interceptor.preHandle(request, response, new Object());
|
||||
|
||||
assertThat(result).isFalse();
|
||||
assertThat(response.getStatus()).isEqualTo(HttpServletResponse.SC_UNAUTHORIZED);
|
||||
}
|
||||
|
||||
// -----------------------------------------------------------------------
|
||||
// dev/test 放行:Token 未配置 + skipTokenCheck=true → 放行
|
||||
// -----------------------------------------------------------------------
|
||||
|
||||
@Test
|
||||
void tokenNotConfigured_skipTrue_allows() throws Exception {
|
||||
properties.getSecurity().setInternalToken(null);
|
||||
properties.getSecurity().setSkipTokenCheck(true);
|
||||
|
||||
MockHttpServletRequest request = new MockHttpServletRequest();
|
||||
MockHttpServletResponse response = new MockHttpServletResponse();
|
||||
|
||||
boolean result = interceptor.preHandle(request, response, new Object());
|
||||
|
||||
assertThat(result).isTrue();
|
||||
assertThat(response.getStatus()).isEqualTo(HttpServletResponse.SC_OK);
|
||||
}
|
||||
|
||||
// -----------------------------------------------------------------------
|
||||
// 正常校验:Token 已配置 + 请求头匹配 → 放行
|
||||
// -----------------------------------------------------------------------
|
||||
|
||||
@Test
|
||||
void validToken_allows() throws Exception {
|
||||
properties.getSecurity().setInternalToken(VALID_TOKEN);
|
||||
|
||||
MockHttpServletRequest request = new MockHttpServletRequest();
|
||||
request.addHeader("X-Internal-Token", VALID_TOKEN);
|
||||
MockHttpServletResponse response = new MockHttpServletResponse();
|
||||
|
||||
boolean result = interceptor.preHandle(request, response, new Object());
|
||||
|
||||
assertThat(result).isTrue();
|
||||
assertThat(response.getStatus()).isEqualTo(HttpServletResponse.SC_OK);
|
||||
}
|
||||
|
||||
// -----------------------------------------------------------------------
|
||||
// 401:Token 已配置 + 请求头不匹配 → 拒绝
|
||||
// -----------------------------------------------------------------------
|
||||
|
||||
@Test
|
||||
void invalidToken_rejects() throws Exception {
|
||||
properties.getSecurity().setInternalToken(VALID_TOKEN);
|
||||
|
||||
MockHttpServletRequest request = new MockHttpServletRequest();
|
||||
request.addHeader("X-Internal-Token", "wrong-token");
|
||||
MockHttpServletResponse response = new MockHttpServletResponse();
|
||||
|
||||
boolean result = interceptor.preHandle(request, response, new Object());
|
||||
|
||||
assertThat(result).isFalse();
|
||||
assertThat(response.getStatus()).isEqualTo(HttpServletResponse.SC_UNAUTHORIZED);
|
||||
assertThat(response.getContentType()).startsWith("application/json");
|
||||
assertThat(response.getContentAsString()).contains("knowledge_graph.0013");
|
||||
}
|
||||
|
||||
@Test
|
||||
void missingTokenHeader_rejects() throws Exception {
|
||||
properties.getSecurity().setInternalToken(VALID_TOKEN);
|
||||
|
||||
MockHttpServletRequest request = new MockHttpServletRequest();
|
||||
// No X-Internal-Token header
|
||||
MockHttpServletResponse response = new MockHttpServletResponse();
|
||||
|
||||
boolean result = interceptor.preHandle(request, response, new Object());
|
||||
|
||||
assertThat(result).isFalse();
|
||||
assertThat(response.getStatus()).isEqualTo(HttpServletResponse.SC_UNAUTHORIZED);
|
||||
}
|
||||
|
||||
// -----------------------------------------------------------------------
|
||||
// 错误响应格式:应使用 Response 体系
|
||||
// -----------------------------------------------------------------------
|
||||
|
||||
@Test
|
||||
void errorResponse_usesResponseFormat() throws Exception {
|
||||
properties.getSecurity().setInternalToken(VALID_TOKEN);
|
||||
|
||||
MockHttpServletRequest request = new MockHttpServletRequest();
|
||||
request.addHeader("X-Internal-Token", "wrong");
|
||||
MockHttpServletResponse response = new MockHttpServletResponse();
|
||||
|
||||
interceptor.preHandle(request, response, new Object());
|
||||
|
||||
String body = response.getContentAsString();
|
||||
assertThat(body).contains("\"code\"");
|
||||
assertThat(body).contains("\"message\"");
|
||||
// Response.error() 包含 data 字段(值为 null)
|
||||
assertThat(body).contains("\"data\"");
|
||||
}
|
||||
}
|
||||
@@ -7,8 +7,14 @@ import org.springframework.security.config.annotation.web.configuration.EnableWe
|
||||
import org.springframework.security.web.SecurityFilterChain;
|
||||
|
||||
/**
|
||||
* 安全配置 - 暂时禁用所有认证
|
||||
* 开发阶段使用,生产环境需要启用认证
|
||||
* Spring Security 配置。
|
||||
* <p>
|
||||
* 安全架构采用双层防护:
|
||||
* <ul>
|
||||
* <li><b>Gateway 层</b>:API Gateway 负责 JWT 校验,通过后透传 X-User-* headers 到后端服务</li>
|
||||
* <li><b>服务层</b>:内部 sync 端点通过 {@code InternalTokenInterceptor} 校验 X-Internal-Token</li>
|
||||
* </ul>
|
||||
* 当前 SecurityFilterChain 配置为 permitAll,HTTP 级别的访问控制由 Gateway 和业务拦截器共同完成。
|
||||
*/
|
||||
@Configuration
|
||||
@EnableWebSecurity
|
||||
|
||||
@@ -3,12 +3,6 @@ spring:
|
||||
application:
|
||||
name: datamate
|
||||
|
||||
# 暂时排除Spring Security自动配置(开发阶段使用)
|
||||
autoconfigure:
|
||||
exclude:
|
||||
- org.springframework.boot.autoconfigure.security.servlet.SecurityAutoConfiguration
|
||||
- org.springframework.boot.autoconfigure.security.servlet.UserDetailsServiceAutoConfiguration
|
||||
|
||||
# 数据源配置
|
||||
datasource:
|
||||
driver-class-name: com.mysql.cj.jdbc.Driver
|
||||
|
||||
Reference in New Issue
Block a user