fix: harden gateway auth and credential handling

This commit is contained in:
wjh
2026-10-09 15:47:56 +08:00
parent 00a9dcaf2f
commit 12920d698a
19 changed files with 1168 additions and 250 deletions
@@ -1,18 +1,25 @@
package com.iot.gateway.mcp;
import com.fasterxml.jackson.databind.ObjectMapper;
import com.iot.gateway.common.BizException;
import com.iot.gateway.oauth.GatewaySecurityContext;
import io.swagger.v3.oas.annotations.Operation;
import io.swagger.v3.oas.annotations.tags.Tag;
import jakarta.servlet.http.HttpServletResponse;
import java.io.IOException;
import java.util.Collections;
import java.util.HashMap;
import java.util.List;
import java.util.Map;
import java.util.UUID;
import java.util.concurrent.ConcurrentHashMap;
import lombok.AllArgsConstructor;
import lombok.Builder;
import lombok.Data;
import lombok.NoArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.http.HttpStatus;
import org.springframework.http.MediaType;
import org.springframework.http.ResponseEntity;
import org.springframework.web.bind.annotation.GetMapping;
@@ -34,31 +41,78 @@ public class McpSseController {
private final ObjectMapper objectMapper = new ObjectMapper();
/** 维护 SSE 活跃会话 */
/** 维护 SSE 活跃网络通道 */
private final Map<String, SseEmitter> sessionMap = new ConcurrentHashMap<>();
/** 维护 MCP 会话与租户/客户端身份的安全绑定,杜绝凭 sessionId 伪造跨租户请求 */
private final Map<String, McpSessionInfo> sessionInfoMap = new ConcurrentHashMap<>();
@Data
@Builder
@NoArgsConstructor
@AllArgsConstructor
public static class McpSessionInfo {
private String sessionId;
private String clientId;
private Long tenantId;
private List<String> scopes;
private boolean admin;
private long createdAt;
}
@Operation(summary = "建立 MCP SSE 长连接端点 (用于 Cursor/Antigravity/Agent 客户端)")
@GetMapping(value = "/sse", produces = MediaType.TEXT_EVENT_STREAM_VALUE)
public SseEmitter connectSse(HttpServletResponse response) {
public SseEmitter connectSse(
@RequestParam(required = false) Long tenantId,
HttpServletResponse response) {
// 严格认证:必须具备合法鉴权凭据与租户上下文,严禁匿名或跨租户访问
GatewaySecurityContext ctx = GatewaySecurityContext.get();
if (ctx == null) {
throw new BizException(401, "MCP 连接建立拒绝: 未提供有效的访问凭据,请携带 Bearer 令牌或在 URL 附加 ?access_token=");
}
Long effectiveTenantId = ctx.getTenantId();
if (effectiveTenantId == null) {
if (ctx.isAdmin() && tenantId != null) {
effectiveTenantId = tenantId;
} else {
throw new BizException(401, "MCP 连接建立拒绝: 缺少有效的租户授权上下文");
}
}
response.setHeader("Cache-Control", "no-cache");
response.setHeader("X-Accel-Buffering", "no");
String sessionId = UUID.randomUUID().toString().replace("-", "");
SseEmitter emitter = new SseEmitter(24 * 60 * 60 * 1000L); // 24小时超长连接保活
McpSessionInfo sessionInfo = McpSessionInfo.builder()
.sessionId(sessionId)
.clientId(ctx.getClientId())
.tenantId(effectiveTenantId)
.scopes(ctx.getScopes() != null ? ctx.getScopes() : Collections.emptyList())
.admin(ctx.isAdmin())
.createdAt(System.currentTimeMillis())
.build();
sessionMap.put(sessionId, emitter);
sessionInfoMap.put(sessionId, sessionInfo);
emitter.onCompletion(() -> {
log.info("MCP SSE 会话完成: sessionId={}", sessionId);
sessionMap.remove(sessionId);
sessionInfoMap.remove(sessionId);
});
emitter.onTimeout(() -> {
log.info("MCP SSE 会话超时: sessionId={}", sessionId);
sessionMap.remove(sessionId);
sessionInfoMap.remove(sessionId);
});
emitter.onError(e -> {
log.warn("MCP SSE 会话异常: sessionId={}, err={}", sessionId, e.getMessage());
sessionMap.remove(sessionId);
sessionInfoMap.remove(sessionId);
});
try {
@@ -66,7 +120,8 @@ public class McpSseController {
emitter.send(SseEmitter.event()
.name("endpoint")
.data("/mcp/messages?sessionId=" + sessionId));
log.info("MCP SSE 客户端握手连接成功: sessionId={}", sessionId);
log.info("MCP SSE 客户端握手建立成功: sessionId={}, clientId={}, tenantId={}",
sessionId, ctx.getClientId(), effectiveTenantId);
} catch (IOException e) {
log.error("发送 MCP endpoint 事件失败: {}", e.getMessage());
}
@@ -92,6 +147,45 @@ public class McpSseController {
log.info("收到 MCP JSON-RPC 请求: method={}, id={}, sessionId={}", method, id, sessionId);
// 会话与安全身份解析
McpSessionInfo sessionInfo = sessionId != null ? sessionInfoMap.get(sessionId) : null;
GatewaySecurityContext currentAuth = GatewaySecurityContext.get();
String effectiveClientId;
Long effectiveTenantId;
List<String> effectiveScopes;
boolean effectiveIsAdmin;
if (sessionInfo != null) {
// 会话已绑定,若请求同时带了 Token,核验两者租户是否一致
if (currentAuth != null && currentAuth.getTenantId() != null
&& !currentAuth.isAdmin()
&& !currentAuth.getTenantId().equals(sessionInfo.getTenantId())) {
rpcResponse.put("error", Map.of(
"code", -32003,
"message", "安全拦截: 传入的认证凭据租户与当前 MCP 会话租户不一致"
));
return ResponseEntity.status(HttpStatus.FORBIDDEN).body(rpcResponse);
}
effectiveClientId = sessionInfo.getClientId();
effectiveTenantId = sessionInfo.getTenantId();
effectiveScopes = sessionInfo.getScopes();
effectiveIsAdmin = sessionInfo.isAdmin();
} else if (currentAuth != null) {
// 无 session 绑定但具备有效 Bearer Token
effectiveClientId = currentAuth.getClientId();
effectiveTenantId = currentAuth.getTenantId();
effectiveScopes = currentAuth.getScopes();
effectiveIsAdmin = currentAuth.isAdmin();
} else {
// 既无有效 sessionId 也无 Token,直接拒绝
rpcResponse.put("error", Map.of(
"code", -32001,
"message", "未授权访问: 缺少合法的 MCP 会话或 Bearer 访问凭据"
));
return ResponseEntity.status(HttpStatus.UNAUTHORIZED).body(rpcResponse);
}
if ("initialize".equalsIgnoreCase(method)) {
// 握手响应
Map<String, Object> serverInfo = Map.of(
@@ -119,10 +213,9 @@ public class McpSseController {
@SuppressWarnings("unchecked")
Map<String, Object> arguments = params != null ? (Map<String, Object>) params.get("arguments") : Map.of();
Long tenantId = GatewaySecurityContext.getTenantIdOrDefault(1L);
try {
Map<String, Object> toolResult = toolRegistry.executeTool(toolName, arguments, tenantId);
Map<String, Object> toolResult = toolRegistry.executeTool(
toolName, arguments, effectiveTenantId, effectiveScopes, effectiveIsAdmin);
String contentJson = objectMapper.writeValueAsString(toolResult);
Map<String, Object> textContent = Map.of(
@@ -130,11 +223,19 @@ public class McpSseController {
"text", contentJson
);
rpcResponse.put("result", Map.of("content", List.of(textContent)));
} catch (Exception e) {
log.error("执行 MCP 工具异常: ", e);
} catch (BizException be) {
log.warn("执行 MCP 工具业务失败: tool={}, tenantId={}, code={}, msg={}",
toolName, effectiveTenantId, be.getCode(), be.getMessage());
Map<String, Object> errContent = Map.of(
"type", "text",
"text", "{\"error\": \"执行失败: " + e.getMessage() + "\"}"
"text", "{\"error\": \"" + be.getMessage() + "\", \"code\": " + be.getCode() + "}"
);
rpcResponse.put("result", Map.of("content", List.of(errContent), "isError", true));
} catch (Exception e) {
log.error("执行 MCP 工具异常: tool={}, tenantId={}", toolName, effectiveTenantId, e);
Map<String, Object> errContent = Map.of(
"type", "text",
"text", "{\"error\": \"网关内部执行异常: " + e.getMessage() + "\"}"
);
rpcResponse.put("result", Map.of("content", List.of(errContent), "isError", true));
}
@@ -154,7 +255,7 @@ public class McpSseController {
.name("message")
.data(objectMapper.writeValueAsString(rpcResponse)));
} catch (Exception e) {
log.warn("向 SSE 会话推送消息失败: {}", e.getMessage());
log.warn("向 SSE 会话推送消息失败: sessionId={}, err={}", sessionId, e.getMessage());
}
}
@@ -2,12 +2,14 @@ package com.iot.gateway.mcp;
import com.fasterxml.jackson.databind.JsonNode;
import com.iot.gateway.common.BackendInternalClient;
import com.iot.gateway.common.BizException;
import java.util.ArrayList;
import java.util.HashMap;
import java.util.List;
import java.util.Map;
import lombok.extern.slf4j.Slf4j;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.beans.factory.annotation.Value;
import org.springframework.stereotype.Component;
@Slf4j
@@ -17,13 +19,19 @@ public class McpToolRegistry {
@Autowired
private BackendInternalClient internalClient;
/**
* 演示/开发模式开关。生产环境下必须为 false,避免将后端故障误报为“成功”或演示响应
*/
@Value("${gateway.mock.enabled:false}")
private boolean mockEnabled;
public List<Map<String, Object>> getToolDefinitions() {
List<Map<String, Object>> tools = new ArrayList<>();
// 1. iot_device_list
Map<String, Object> t1 = new HashMap<>();
t1.put("name", "iot_device_list");
t1.put("description", "查询当前租户名下的所有工业物联网设备台账与实时运行状态(在线/报警/停机)。");
t1.put("description", "查询当前租户名下的所有工业物联网设备台账与实时运行状态(在线/报警/停机)。所需权限范围: device.read");
Map<String, Object> s1 = new HashMap<>();
s1.put("type", "object");
Map<String, Object> p1 = new HashMap<>();
@@ -37,7 +45,7 @@ public class McpToolRegistry {
// 2. iot_telemetry_latest
Map<String, Object> t2 = new HashMap<>();
t2.put("name", "iot_telemetry_latest");
t2.put("description", "召测指定工业设备的最新实时点位遥测数据(注塑机温度、注塑压力、螺杆转速、能耗电流等)。");
t2.put("description", "召测指定工业设备的最新实时点位遥测数据(注塑机温度、注塑压力、螺杆转速、能耗电流等)。所需权限范围: telemetry.read");
Map<String, Object> s2 = new HashMap<>();
s2.put("type", "object");
Map<String, Object> p2 = new HashMap<>();
@@ -50,12 +58,12 @@ public class McpToolRegistry {
// 3. iot_device_control
Map<String, Object> t3 = new HashMap<>();
t3.put("name", "iot_device_control");
t3.put("description", "向边缘物联设备安全下发反写控制指令(如设定注塑温度、设备启停控制、工艺参数微调)。");
t3.put("description", "向边缘物联设备安全下发反写控制指令(如设定注塑温度、设备启停控制、工艺参数微调)。所需权限范围: device.control");
Map<String, Object> s3 = new HashMap<>();
s3.put("type", "object");
Map<String, Object> p3 = new HashMap<>();
p3.put("deviceId", Map.of("type", "string", "description", "目标设备编码"));
p3.put("command", Map.of("type", "string", "description", "指令动作: start/stop/set_param"));
p3.put("command", Map.of("type", "string", "description", "指令动作: start/stop/reboot/set_param"));
p3.put("params", Map.of("type", "object", "description", "指令参数键值对"));
s3.put("properties", p3);
s3.put("required", List.of("deviceId", "command"));
@@ -65,7 +73,7 @@ public class McpToolRegistry {
// 4. iot_alarm_query
Map<String, Object> t4 = new HashMap<>();
t4.put("name", "iot_alarm_query");
t4.put("description", "按设备与时间范围检索未处理的设备故障与历史严重告警记录。");
t4.put("description", "按设备与时间范围检索未处理的设备故障与历史严重告警记录。所需权限范围: alarm.read");
Map<String, Object> s4 = new HashMap<>();
s4.put("type", "object");
Map<String, Object> p4 = new HashMap<>();
@@ -78,8 +86,20 @@ public class McpToolRegistry {
return tools;
}
public Map<String, Object> executeTool(String toolName, Map<String, Object> args, Long tenantId) {
log.info("执行 MCP 工具调用: tool={}, tenantId={}, args={}", toolName, tenantId, args);
/**
* 执行 MCP 工具调用,严格校验租户上下文与授权范围 Scope
*/
public Map<String, Object> executeTool(String toolName, Map<String, Object> args, Long tenantId, List<String> scopes, boolean isAdmin) {
log.info("执行 MCP 工具调用: tool={}, tenantId={}, args={}, scopes={}, isAdmin={}",
toolName, tenantId, args, scopes, isAdmin);
// 1. 严格租户隔离校验
if (tenantId == null) {
throw new BizException(401, "MCP 工具执行拒绝: 缺少有效的租户上下文,严禁匿名或跨租户执行");
}
// 2. 细粒度 Scope 校验
checkToolScope(toolName, scopes, isAdmin);
Map<String, Object> result = new HashMap<>();
@@ -103,22 +123,40 @@ public class McpToolRegistry {
return result;
}
// 兜底降级响应
// 非 mock 模式下直接明确报错
if (!mockEnabled) {
String errMsg = resp != null && resp.has("message") ? resp.get("message").asText() : "核心物联后端服务不可用或查询超时";
throw new BizException(502, "召测设备列表失败: " + errMsg);
}
// 开发演示兜底
result.put("tenantId", tenantId);
result.put("isMock", true);
result.put("total", 2);
result.put("devices", List.of(
Map.of("deviceCode", "DEV20261009001", "deviceName", "1号注塑机 (海天MA1200)", "online", 1),
Map.of("deviceCode", "DEV20261009002", "deviceName", "2号注塑机 (海天MA2000)", "online", 0)
));
} else if ("iot_telemetry_latest".equals(toolName)) {
String deviceId = args != null && args.containsKey("deviceId") ? String.valueOf(args.get("deviceId")) : "DEV20261009001";
String deviceId = args != null && args.containsKey("deviceId") ? String.valueOf(args.get("deviceId")) : "";
if (deviceId.isEmpty()) {
throw new BizException(400, "缺少必填参数: deviceId");
}
JsonNode resp = internalClient.get("/api/internal/open/telemetry/latest", tenantId, Map.of("deviceId", deviceId));
if (resp != null && resp.has("code") && resp.get("code").asInt() == 200 && resp.has("data")) {
result.put("tenantId", tenantId);
result.put("deviceId", deviceId);
result.put("data", resp.get("data"));
return result;
}
if (!mockEnabled) {
String errMsg = resp != null && resp.has("message") ? resp.get("message").asText() : "后端遥测接口不可用或设备无数据";
throw new BizException(502, "召测设备遥测失败: " + errMsg);
}
result.put("isMock", true);
result.put("deviceId", deviceId);
result.put("timestamp", System.currentTimeMillis());
result.put("telemetry", Map.of(
@@ -128,17 +166,26 @@ public class McpToolRegistry {
"screw_speed_rpm", 85.0
));
} else if ("iot_device_control".equals(toolName)) {
// 控制指令下发:绝不能在后端失败时误报“EXECUTED”
JsonNode resp = internalClient.post("/api/internal/open/commands", tenantId, args);
if (resp != null && resp.has("code") && resp.get("code").asInt() == 200 && resp.has("data")) {
result.put("tenantId", tenantId);
result.put("result", resp.get("data"));
result.put("success", true);
result.put("status", "EXECUTED");
result.put("data", resp.get("data"));
return result;
}
result.put("success", true);
result.put("deviceId", args != null ? args.get("deviceId") : "");
result.put("status", "EXECUTED");
result.put("message", "控制指令已成功通过网关下发");
String errMsg = resp != null && resp.has("message") ? resp.get("message").asText() : "后端指令网关通信超时或未响应";
if (!mockEnabled) {
log.error("下发设备控制指令失败: tool={}, tenantId={}, args={}, err={}", toolName, tenantId, args, errMsg);
throw new BizException(502, "控制指令执行失败: " + errMsg);
}
result.put("isMock", true);
result.put("success", false);
result.put("status", "MOCK_MODE_REFUSED");
result.put("message", "当前为开发演示模式,已拒绝向物理设备真实下发控制指令: " + errMsg);
} else if ("iot_alarm_query".equals(toolName)) {
Map<String, String> params = new HashMap<>();
if (args != null && args.containsKey("deviceId")) params.put("deviceId", String.valueOf(args.get("deviceId")));
@@ -151,13 +198,50 @@ public class McpToolRegistry {
return result;
}
if (!mockEnabled) {
String errMsg = resp != null && resp.has("message") ? resp.get("message").asText() : "后端告警接口不可用";
throw new BizException(502, "查询告警失败: " + errMsg);
}
result.put("isMock", true);
result.put("alarms", List.of(
Map.of("alarmId", "ALM-2026100901", "deviceId", "DEV20261009001", "severity", 3, "title", "模具温度异常过高")
));
} else {
result.put("error", "未知的工具名称: " + toolName);
throw new BizException(400, "未知的 MCP 工具名称: " + toolName);
}
return result;
}
private void checkToolScope(String toolName, List<String> scopes, boolean isAdmin) {
if (isAdmin) return;
String requiredScope;
switch (toolName) {
case "iot_device_list" -> requiredScope = "device.read";
case "iot_telemetry_latest" -> requiredScope = "telemetry.read";
case "iot_device_control" -> requiredScope = "device.control";
case "iot_alarm_query" -> requiredScope = "alarm.read";
default -> requiredScope = "all";
}
if (scopes == null || scopes.isEmpty()) {
throw new BizException(403, "权限不足: 客户端未具备任何授权范围,无法调用 [" + toolName + "]");
}
String normReq = requiredScope.replace(":", ".");
boolean match = false;
for (String s : scopes) {
String norm = s.trim().replace(":", ".");
if (norm.equals(normReq) || norm.equals("all") || norm.equals("*")) {
match = true;
break;
}
}
if (!match) {
throw new BizException(403, "权限不足: 调用工具 [" + toolName + "] 需要具备 [" + requiredScope + "] 授权范围");
}
}
}