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:
2026-02-19 13:03:42 +08:00
parent f12e4abd83
commit 444f8cd015
11 changed files with 293 additions and 17 deletions

2
.gitignore vendored
View File

@@ -190,5 +190,3 @@ Thumbs.db
# Milvus
deployment/docker/milvus/volumes/
# Local documentation
docs/knowledge-graph/

View File

@@ -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

View File

@@ -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;

View File

@@ -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 {

View File

@@ -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));
}
}

View File

@@ -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/**");
}
}

View File

@@ -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")

View File

@@ -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:
# 数据管理服务地址

View File

@@ -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\"");
}
}

View File

@@ -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

View File

@@ -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