fix: harden gateway auth and credential handling
This commit is contained in:
@@ -6,6 +6,7 @@ import com.iot.gateway.common.BizException;
|
||||
import com.iot.gateway.common.Result;
|
||||
import com.iot.gateway.entity.OpenClient;
|
||||
import com.iot.gateway.mapper.OpenClientMapper;
|
||||
import com.iot.gateway.oauth.GatewaySecurityContext;
|
||||
import io.swagger.v3.oas.annotations.Operation;
|
||||
import io.swagger.v3.oas.annotations.tags.Tag;
|
||||
import java.security.SecureRandom;
|
||||
@@ -14,6 +15,8 @@ import java.util.Map;
|
||||
import java.util.UUID;
|
||||
import lombok.extern.slf4j.Slf4j;
|
||||
import org.springframework.beans.factory.annotation.Autowired;
|
||||
import org.springframework.security.access.prepost.PreAuthorize;
|
||||
import org.springframework.security.crypto.password.PasswordEncoder;
|
||||
import org.springframework.util.StringUtils;
|
||||
import org.springframework.web.bind.annotation.DeleteMapping;
|
||||
import org.springframework.web.bind.annotation.GetMapping;
|
||||
@@ -29,11 +32,15 @@ import org.springframework.web.bind.annotation.RestController;
|
||||
@Tag(name = "开放平台管理 - 三方应用凭据管理")
|
||||
@RestController
|
||||
@RequestMapping("/api/open/client")
|
||||
@PreAuthorize("hasAnyAuthority('ROLE_ADMIN', 'SCOPE_admin', 'SCOPE_tenant_admin')")
|
||||
public class OpenClientAdminController {
|
||||
|
||||
@Autowired
|
||||
private OpenClientMapper clientMapper;
|
||||
|
||||
@Autowired
|
||||
private PasswordEncoder passwordEncoder;
|
||||
|
||||
@Autowired(required = false)
|
||||
private org.springframework.jdbc.core.JdbcTemplate jdbcTemplate;
|
||||
|
||||
@@ -45,7 +52,7 @@ public class OpenClientAdminController {
|
||||
return Result.success(com.iot.gateway.oauth.OpenScopeEnum.toList());
|
||||
}
|
||||
|
||||
@Operation(summary = "三方应用凭据分页列表")
|
||||
@Operation(summary = "三方应用凭据分页列表 (严禁泄露密钥哈希)")
|
||||
@GetMapping("/page")
|
||||
public Result<Page<OpenClient>> page(
|
||||
@RequestParam(defaultValue = "1") Integer pageNum,
|
||||
@@ -53,18 +60,42 @@ public class OpenClientAdminController {
|
||||
@RequestParam(required = false) Long tenantId,
|
||||
@RequestParam(required = false) String keyword) {
|
||||
|
||||
GatewaySecurityContext ctx = GatewaySecurityContext.get();
|
||||
if (ctx == null) {
|
||||
throw new BizException(401, "未授权访问");
|
||||
}
|
||||
|
||||
Page<OpenClient> page = new Page<>(pageNum, pageSize);
|
||||
LambdaQueryWrapper<OpenClient> qw = new LambdaQueryWrapper<>();
|
||||
|
||||
if (ctx.isAdmin()) {
|
||||
if (tenantId != null) {
|
||||
qw.eq(OpenClient::getTenantId, tenantId);
|
||||
}
|
||||
} else {
|
||||
// 普通租户管理员强制锁定为自身租户,防止水平越权
|
||||
if (ctx.getTenantId() == null) {
|
||||
throw new BizException(403, "当前账号缺少租户归属信息");
|
||||
}
|
||||
qw.eq(OpenClient::getTenantId, ctx.getTenantId());
|
||||
}
|
||||
|
||||
if (StringUtils.hasText(keyword)) {
|
||||
qw.and(w -> w.like(OpenClient::getClientName, keyword)
|
||||
.or().like(OpenClient::getClientId, keyword));
|
||||
}
|
||||
qw.orderByDesc(OpenClient::getCreateTime);
|
||||
Page<OpenClient> resPage = clientMapper.selectPage(page, qw);
|
||||
|
||||
// 密钥完全脱敏:列表严禁返回哈希或明文
|
||||
if (resPage.getRecords() != null) {
|
||||
resPage.getRecords().forEach(c -> {
|
||||
c.setClientSecret(null);
|
||||
c.setRawSecret(null);
|
||||
});
|
||||
populateTenantNames(resPage.getRecords());
|
||||
}
|
||||
|
||||
return Result.success(resPage);
|
||||
}
|
||||
|
||||
@@ -92,27 +123,53 @@ public class OpenClientAdminController {
|
||||
}
|
||||
}
|
||||
|
||||
@Operation(summary = "获取凭据详情")
|
||||
@Operation(summary = "获取凭据详情 (不返回 Secret)")
|
||||
@GetMapping("/{id}")
|
||||
public Result<OpenClient> getById(@PathVariable Long id) {
|
||||
OpenClient client = clientMapper.selectById(id);
|
||||
if (client == null) throw new BizException("客户端凭据不存在");
|
||||
|
||||
checkTenantAccess(client.getTenantId());
|
||||
|
||||
// 详情接口严禁暴露 Secret
|
||||
client.setClientSecret(null);
|
||||
client.setRawSecret(null);
|
||||
|
||||
return Result.success(client);
|
||||
}
|
||||
|
||||
@Operation(summary = "新建三方应用凭据 (自动生成 ClientId 与 Secret)")
|
||||
@Operation(summary = "新建三方应用凭据 (数据库保存 BCrypt 哈希,仅创建成功时返回一次明文)")
|
||||
@PostMapping
|
||||
public Result<OpenClient> create(@RequestBody OpenClient req) {
|
||||
GatewaySecurityContext ctx = GatewaySecurityContext.get();
|
||||
if (ctx == null) throw new BizException(401, "未授权操作");
|
||||
|
||||
req.setId(null);
|
||||
req.setClientId("client_" + UUID.randomUUID().toString().replace("-", "").substring(0, 12));
|
||||
req.setClientSecret("sec_" + UUID.randomUUID().toString().replace("-", "") + Long.toHexString(RANDOM.nextLong()));
|
||||
if (req.getTenantId() == null) req.setTenantId(1L);
|
||||
|
||||
// 生成高强度原始 Secret
|
||||
String rawSecret = "sec_" + UUID.randomUUID().toString().replace("-", "") + Long.toHexString(RANDOM.nextLong());
|
||||
// 数据库落库 BCrypt 单向哈希
|
||||
req.setClientSecret(passwordEncoder.encode(rawSecret));
|
||||
|
||||
if (!ctx.isAdmin()) {
|
||||
if (ctx.getTenantId() == null) throw new BizException(403, "当前管理员无所属租户");
|
||||
req.setTenantId(ctx.getTenantId());
|
||||
} else if (req.getTenantId() == null) {
|
||||
req.setTenantId(1L);
|
||||
}
|
||||
|
||||
if (!StringUtils.hasText(req.getScopes())) req.setScopes("device.read");
|
||||
if (req.getStatus() == null) req.setStatus(1);
|
||||
if (req.getRateLimit() == null) req.setRateLimit(60);
|
||||
|
||||
clientMapper.insert(req);
|
||||
log.info("新建开放应用凭据: id={}, clientId={}", req.getId(), req.getClientId());
|
||||
log.info("新建开放应用凭据成功: id={}, clientId={}, tenantId={}", req.getId(), req.getClientId(), req.getTenantId());
|
||||
|
||||
// 仅在创建响应中返回一次明文 Secret
|
||||
req.setRawSecret(rawSecret);
|
||||
req.setClientSecret(null);
|
||||
|
||||
return Result.success(req);
|
||||
}
|
||||
|
||||
@@ -122,6 +179,8 @@ public class OpenClientAdminController {
|
||||
OpenClient client = clientMapper.selectById(id);
|
||||
if (client == null) throw new BizException("客户端凭据不存在");
|
||||
|
||||
checkTenantAccess(client.getTenantId());
|
||||
|
||||
client.setClientName(req.getClientName());
|
||||
if (req.getScopes() != null) client.setScopes(req.getScopes());
|
||||
client.setIpWhitelist(req.getIpWhitelist());
|
||||
@@ -137,20 +196,34 @@ public class OpenClientAdminController {
|
||||
@Operation(summary = "删除三方应用凭据")
|
||||
@DeleteMapping("/{id}")
|
||||
public Result<Void> delete(@PathVariable Long id) {
|
||||
OpenClient client = clientMapper.selectById(id);
|
||||
if (client == null) throw new BizException("客户端凭据不存在");
|
||||
|
||||
checkTenantAccess(client.getTenantId());
|
||||
|
||||
clientMapper.deleteById(id);
|
||||
return Result.success();
|
||||
}
|
||||
|
||||
@Operation(summary = "重置客户端密钥 (Secret)")
|
||||
@Operation(summary = "重置客户端密钥 (仅在重置成功时返回一次新明文 Secret)")
|
||||
@PostMapping("/{id}/reset-secret")
|
||||
public Result<Map<String, String>> resetSecret(@PathVariable Long id) {
|
||||
OpenClient client = clientMapper.selectById(id);
|
||||
if (client == null) throw new BizException("客户端凭据不存在");
|
||||
|
||||
String newSecret = "sec_" + UUID.randomUUID().toString().replace("-", "") + Long.toHexString(RANDOM.nextLong());
|
||||
client.setClientSecret(newSecret);
|
||||
checkTenantAccess(client.getTenantId());
|
||||
|
||||
String rawSecret = "sec_" + UUID.randomUUID().toString().replace("-", "") + Long.toHexString(RANDOM.nextLong());
|
||||
client.setClientSecret(passwordEncoder.encode(rawSecret));
|
||||
clientMapper.updateById(client);
|
||||
return Result.success(Map.of("clientSecret", newSecret));
|
||||
|
||||
log.info("重置客户端凭证密钥完成: clientId={}", client.getClientId());
|
||||
|
||||
return Result.success(Map.of(
|
||||
"clientId", client.getClientId(),
|
||||
"rawSecret", rawSecret,
|
||||
"tips", "密钥已重置,系统已将其哈希加密存储,明文仅展示一次,请妥善保存"
|
||||
));
|
||||
}
|
||||
|
||||
@Operation(summary = "更新客户端状态")
|
||||
@@ -158,8 +231,24 @@ public class OpenClientAdminController {
|
||||
public Result<Void> updateStatus(@PathVariable Long id, @RequestParam Integer status) {
|
||||
OpenClient client = clientMapper.selectById(id);
|
||||
if (client == null) throw new BizException("客户端凭据不存在");
|
||||
|
||||
checkTenantAccess(client.getTenantId());
|
||||
|
||||
client.setStatus(status);
|
||||
clientMapper.updateById(client);
|
||||
return Result.success();
|
||||
}
|
||||
|
||||
private void checkTenantAccess(Long targetTenantId) {
|
||||
GatewaySecurityContext ctx = GatewaySecurityContext.get();
|
||||
if (ctx == null) {
|
||||
throw new BizException(401, "未授权访问");
|
||||
}
|
||||
if (!ctx.isAdmin()) {
|
||||
Long userTenantId = ctx.getTenantId();
|
||||
if (userTenantId == null || !userTenantId.equals(targetTenantId)) {
|
||||
throw new BizException(403, "权限不足: 禁止跨租户访问或操作开放凭据");
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -6,6 +6,7 @@ import com.iot.gateway.common.BizException;
|
||||
import com.iot.gateway.common.Result;
|
||||
import com.iot.gateway.entity.OpenWebhook;
|
||||
import com.iot.gateway.mapper.OpenWebhookMapper;
|
||||
import com.iot.gateway.oauth.GatewaySecurityContext;
|
||||
import com.iot.gateway.webhook.WebhookDispatchService;
|
||||
import io.swagger.v3.oas.annotations.Operation;
|
||||
import io.swagger.v3.oas.annotations.tags.Tag;
|
||||
@@ -14,6 +15,7 @@ import java.util.List;
|
||||
import java.util.Map;
|
||||
import lombok.extern.slf4j.Slf4j;
|
||||
import org.springframework.beans.factory.annotation.Autowired;
|
||||
import org.springframework.security.access.prepost.PreAuthorize;
|
||||
import org.springframework.util.StringUtils;
|
||||
import org.springframework.web.bind.annotation.DeleteMapping;
|
||||
import org.springframework.web.bind.annotation.GetMapping;
|
||||
@@ -29,6 +31,7 @@ import org.springframework.web.bind.annotation.RestController;
|
||||
@Tag(name = "开放平台管理 - Webhook 事件订阅与通道管理")
|
||||
@RestController
|
||||
@RequestMapping("/api/open/webhook")
|
||||
@PreAuthorize("hasAnyAuthority('ROLE_ADMIN', 'SCOPE_admin', 'SCOPE_tenant_admin')")
|
||||
public class OpenWebhookAdminController {
|
||||
|
||||
@Autowired
|
||||
@@ -98,11 +101,21 @@ public class OpenWebhookAdminController {
|
||||
@RequestParam(required = false) String clientId,
|
||||
@RequestParam(required = false) String keyword) {
|
||||
|
||||
GatewaySecurityContext ctx = GatewaySecurityContext.get();
|
||||
if (ctx == null) throw new BizException(401, "未授权访问");
|
||||
|
||||
Page<OpenWebhook> page = new Page<>(pageNum, pageSize);
|
||||
LambdaQueryWrapper<OpenWebhook> qw = new LambdaQueryWrapper<>();
|
||||
|
||||
if (ctx.isAdmin()) {
|
||||
if (tenantId != null) {
|
||||
qw.eq(OpenWebhook::getTenantId, tenantId);
|
||||
}
|
||||
} else {
|
||||
if (ctx.getTenantId() == null) throw new BizException(403, "当前管理员无所属租户");
|
||||
qw.eq(OpenWebhook::getTenantId, ctx.getTenantId());
|
||||
}
|
||||
|
||||
if (StringUtils.hasText(clientId)) {
|
||||
qw.eq(OpenWebhook::getClientId, clientId);
|
||||
}
|
||||
@@ -157,10 +170,17 @@ public class OpenWebhookAdminController {
|
||||
@Operation(summary = "新建 Webhook 通道")
|
||||
@PostMapping
|
||||
public Result<OpenWebhook> create(@RequestBody OpenWebhook req) {
|
||||
GatewaySecurityContext ctx = GatewaySecurityContext.get();
|
||||
if (ctx == null) throw new BizException(401, "未授权操作");
|
||||
|
||||
req.setId(null);
|
||||
if (req.getTenantId() == null) {
|
||||
if (!ctx.isAdmin()) {
|
||||
if (ctx.getTenantId() == null) throw new BizException(403, "当前管理员无所属租户");
|
||||
req.setTenantId(ctx.getTenantId());
|
||||
} else if (req.getTenantId() == null) {
|
||||
req.setTenantId(1L);
|
||||
}
|
||||
|
||||
if (!StringUtils.hasText(req.getEvents())) {
|
||||
req.setEvents("alarm.triggered");
|
||||
}
|
||||
@@ -183,7 +203,10 @@ public class OpenWebhookAdminController {
|
||||
throw new BizException("通道不存在");
|
||||
}
|
||||
|
||||
if (req.getTenantId() != null) {
|
||||
checkTenantAccess(item.getTenantId());
|
||||
|
||||
GatewaySecurityContext ctx = GatewaySecurityContext.get();
|
||||
if (ctx.isAdmin() && req.getTenantId() != null) {
|
||||
item.setTenantId(req.getTenantId());
|
||||
}
|
||||
item.setClientId(req.getClientId());
|
||||
@@ -206,6 +229,13 @@ public class OpenWebhookAdminController {
|
||||
@Operation(summary = "删除 Webhook 通道")
|
||||
@DeleteMapping("/{id}")
|
||||
public Result<Void> delete(@PathVariable Long id) {
|
||||
OpenWebhook item = webhookMapper.selectById(id);
|
||||
if (item == null) {
|
||||
throw new BizException("通道不存在");
|
||||
}
|
||||
|
||||
checkTenantAccess(item.getTenantId());
|
||||
|
||||
webhookMapper.deleteById(id);
|
||||
return Result.success();
|
||||
}
|
||||
@@ -218,10 +248,25 @@ public class OpenWebhookAdminController {
|
||||
throw new BizException("通道不存在");
|
||||
}
|
||||
|
||||
checkTenantAccess(item.getTenantId());
|
||||
|
||||
String title = body != null && body.containsKey("title") ? body.get("title") : "【IoT开放平台测试】三方事件回调连通性验证";
|
||||
String content = body != null && body.containsKey("content") ? body.get("content") : "这是一条来自物联网开放平台的模拟业务事件通知,包含签名验证与数据包投递。";
|
||||
|
||||
boolean ok = dispatchService.sendDirect(item.getChannelType(), item.getWebhookUrl(), title, content, item.getSecret());
|
||||
return Result.success(Map.of("success", ok, "message", ok ? "测试事件已成功投递" : "投递失败,请检查回调 URL 连通性或服务响应状态"));
|
||||
}
|
||||
|
||||
private void checkTenantAccess(Long targetTenantId) {
|
||||
GatewaySecurityContext ctx = GatewaySecurityContext.get();
|
||||
if (ctx == null) {
|
||||
throw new BizException(401, "未授权访问");
|
||||
}
|
||||
if (!ctx.isAdmin()) {
|
||||
Long userTenantId = ctx.getTenantId();
|
||||
if (userTenantId == null || !userTenantId.equals(targetTenantId)) {
|
||||
throw new BizException(403, "权限不足: 禁止跨租户访问或操作 Webhook 通道");
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -2,10 +2,16 @@ package com.iot.gateway.common;
|
||||
|
||||
import com.fasterxml.jackson.databind.JsonNode;
|
||||
import com.fasterxml.jackson.databind.ObjectMapper;
|
||||
import java.net.ConnectException;
|
||||
import java.net.SocketTimeoutException;
|
||||
import java.net.URI;
|
||||
import java.net.URLEncoder;
|
||||
import java.net.http.HttpClient;
|
||||
import java.net.http.HttpConnectTimeoutException;
|
||||
import java.net.http.HttpRequest;
|
||||
import java.net.http.HttpResponse;
|
||||
import java.net.http.HttpTimeoutException;
|
||||
import java.nio.charset.StandardCharsets;
|
||||
import java.time.Duration;
|
||||
import java.util.HashMap;
|
||||
import java.util.Map;
|
||||
@@ -16,8 +22,7 @@ import org.springframework.stereotype.Component;
|
||||
/**
|
||||
* 网关与核心业务后端 (IOT-backend) 的专线 HTTP 桥接客户端
|
||||
* <p>
|
||||
* 自动向核心后端传递 X-Internal-Secret 与 X-Tenant-Id,
|
||||
* 触发核心后端切入指定租户的独立物理数据库。
|
||||
* 具备 URL 安全编码、错误细粒度分类、幂等 GET 有限重试与租户强制隔离。
|
||||
*/
|
||||
@Slf4j
|
||||
@Component
|
||||
@@ -26,77 +31,164 @@ public class BackendInternalClient {
|
||||
@Value("${gateway.backend.base-url:http://localhost:8080}")
|
||||
private String backendBaseUrl;
|
||||
|
||||
@Value("${gateway.backend.internal-secret:iot-internal-open-gateway-secret-key-20261009-xyz}")
|
||||
@Value("${gateway.backend.internal-secret:}")
|
||||
private String internalSecret;
|
||||
|
||||
private final HttpClient httpClient = HttpClient.newBuilder()
|
||||
.connectTimeout(Duration.ofSeconds(5))
|
||||
.connectTimeout(Duration.ofSeconds(4))
|
||||
.build();
|
||||
|
||||
private final ObjectMapper objectMapper = new ObjectMapper();
|
||||
|
||||
/**
|
||||
* 内部专线 GET 请求
|
||||
* 内部专线 GET 请求 (支持参数 URL 编码、错误细粒度分类与网络抖动单次有限重试)
|
||||
*/
|
||||
public JsonNode get(String path, Long tenantId, Map<String, String> queryParams) {
|
||||
StringBuilder urlBuilder = new StringBuilder(backendBaseUrl).append(path);
|
||||
if (queryParams != null && !queryParams.isEmpty()) {
|
||||
urlBuilder.append("?");
|
||||
queryParams.forEach((k, v) -> {
|
||||
if (v != null && !v.isEmpty()) {
|
||||
urlBuilder.append(k).append("=").append(v).append("&");
|
||||
}
|
||||
});
|
||||
urlBuilder.deleteCharAt(urlBuilder.length() - 1);
|
||||
if (tenantId == null) {
|
||||
throw new BizException(401, "专线请求缺少租户上下文,严禁跨租户或无租户调用");
|
||||
}
|
||||
|
||||
String fullUrl = buildGetUrl(path, queryParams);
|
||||
int maxAttempts = 2; // 最多尝试2次(初次 + 1次重试)
|
||||
|
||||
for (int attempt = 1; attempt <= maxAttempts; attempt++) {
|
||||
try {
|
||||
HttpRequest request = HttpRequest.newBuilder()
|
||||
.uri(URI.create(urlBuilder.toString()))
|
||||
.header("X-Internal-Secret", internalSecret)
|
||||
.header("X-Tenant-Id", String.valueOf(tenantId != null ? tenantId : 1L))
|
||||
.uri(URI.create(fullUrl))
|
||||
.header("X-Internal-Secret", internalSecret != null ? internalSecret : "")
|
||||
.header("X-Tenant-Id", String.valueOf(tenantId))
|
||||
.header("Content-Type", "application/json")
|
||||
.timeout(Duration.ofSeconds(6))
|
||||
.timeout(Duration.ofSeconds(5))
|
||||
.GET()
|
||||
.build();
|
||||
|
||||
HttpResponse<String> resp = httpClient.send(request, HttpResponse.BodyHandlers.ofString());
|
||||
if (resp.statusCode() == 200) {
|
||||
return objectMapper.readTree(resp.body());
|
||||
} else {
|
||||
log.warn("核心后端内部专线响应非 200: status={}, body={}", resp.statusCode(), resp.body());
|
||||
return handleResponse(resp, fullUrl);
|
||||
|
||||
} catch (HttpTimeoutException | SocketTimeoutException e) {
|
||||
if (attempt < maxAttempts) {
|
||||
log.warn("GET 请求超时,正在进行瞬时网络重试 (attempt={}): url={}", attempt, fullUrl);
|
||||
backoff(200);
|
||||
continue;
|
||||
}
|
||||
log.error("核心后端 GET 请求超时: url={}, tenantId={}, err={}", fullUrl, tenantId, e.getMessage());
|
||||
throw new BizException(504, "调用核心后端业务超时,服务可能处于高负荷: " + e.getMessage());
|
||||
|
||||
} catch (ConnectException e) {
|
||||
if (attempt < maxAttempts) {
|
||||
log.warn("GET 连接异常,正在尝试重连 (attempt={}): url={}", attempt, fullUrl);
|
||||
backoff(200);
|
||||
continue;
|
||||
}
|
||||
log.error("核心后端连接拒绝: url={}, tenantId={}, err={}", fullUrl, tenantId, e.getMessage());
|
||||
throw new BizException(503, "核心后端服务不可达,请检查后端运行状态");
|
||||
|
||||
} catch (BizException be) {
|
||||
throw be;
|
||||
|
||||
} catch (Exception e) {
|
||||
log.warn("调用核心后端内部接口异常: url={}, tenantId={}, err={}", urlBuilder, tenantId, e.getMessage());
|
||||
log.error("调用核心后端 GET 异常: url={}, tenantId={}, err={}", fullUrl, tenantId, e.getMessage());
|
||||
throw new BizException(502, "核心物联后端通信异常: " + e.getMessage());
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
throw new BizException(504, "调用核心后端业务多次超时");
|
||||
}
|
||||
|
||||
/**
|
||||
* 内部专线 POST 请求
|
||||
* 内部专线 POST 请求 (设备反写与业务提交严禁重试,避免指令重复执行)
|
||||
*/
|
||||
public JsonNode post(String path, Long tenantId, Map<String, Object> body) {
|
||||
String url = backendBaseUrl + path;
|
||||
if (tenantId == null) {
|
||||
throw new BizException(401, "专线请求缺少租户上下文,严禁跨租户或无租户调用");
|
||||
}
|
||||
|
||||
String fullUrl = backendBaseUrl + path;
|
||||
try {
|
||||
String jsonStr = objectMapper.writeValueAsString(body != null ? body : new HashMap<>());
|
||||
HttpRequest request = HttpRequest.newBuilder()
|
||||
.uri(URI.create(url))
|
||||
.header("X-Internal-Secret", internalSecret)
|
||||
.header("X-Tenant-Id", String.valueOf(tenantId != null ? tenantId : 1L))
|
||||
.uri(URI.create(fullUrl))
|
||||
.header("X-Internal-Secret", internalSecret != null ? internalSecret : "")
|
||||
.header("X-Tenant-Id", String.valueOf(tenantId))
|
||||
.header("Content-Type", "application/json")
|
||||
.timeout(Duration.ofSeconds(6))
|
||||
.POST(HttpRequest.BodyPublishers.ofString(jsonStr))
|
||||
.build();
|
||||
|
||||
HttpResponse<String> resp = httpClient.send(request, HttpResponse.BodyHandlers.ofString());
|
||||
if (resp.statusCode() == 200) {
|
||||
return objectMapper.readTree(resp.body());
|
||||
} else {
|
||||
log.warn("核心后端内部专线响应非 200: status={}, body={}", resp.statusCode(), resp.body());
|
||||
}
|
||||
return handleResponse(resp, fullUrl);
|
||||
|
||||
} catch (HttpTimeoutException | SocketTimeoutException e) {
|
||||
log.error("核心后端 POST 请求超时 (未重试,防止指令重放): url={}, tenantId={}", fullUrl, tenantId);
|
||||
throw new BizException(504, "核心控制通道通信超时,无法确认物理设备指令下发状态");
|
||||
|
||||
} catch (ConnectException e) {
|
||||
log.error("核心后端连接失败: url={}, tenantId={}, err={}", fullUrl, tenantId, e.getMessage());
|
||||
throw new BizException(503, "核心控制网关服务不可达");
|
||||
|
||||
} catch (BizException be) {
|
||||
throw be;
|
||||
|
||||
} catch (Exception e) {
|
||||
log.warn("调用核心后端内部 POST 异常: url={}, tenantId={}, err={}", url, tenantId, e.getMessage());
|
||||
log.error("核心后端 POST 调用异常: url={}, tenantId={}, err={}", fullUrl, tenantId, e.getMessage());
|
||||
throw new BizException(502, "控制指令网关通信失败: " + e.getMessage());
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
private JsonNode handleResponse(HttpResponse<String> resp, String fullUrl) {
|
||||
int status = resp.statusCode();
|
||||
String body = resp.body();
|
||||
|
||||
if (status == 200) {
|
||||
try {
|
||||
return objectMapper.readTree(body);
|
||||
} catch (Exception e) {
|
||||
log.error("核心后端响应非标准 JSON: body={}", body);
|
||||
throw new BizException(502, "核心后端返回格式错误");
|
||||
}
|
||||
}
|
||||
|
||||
if (status == 401 || status == 403) {
|
||||
log.error("专线内部鉴权失败: status={}, body={}, url={}", status, body, fullUrl);
|
||||
throw new BizException(500, "内部通信专线密钥配置不匹配或租户无权访问");
|
||||
}
|
||||
|
||||
if (status == 404) {
|
||||
throw new BizException(404, "核心后端未找到目标接口: " + fullUrl);
|
||||
}
|
||||
|
||||
log.warn("核心后端返回错误状态码: status={}, body={}, url={}", status, body, fullUrl);
|
||||
try {
|
||||
JsonNode errJson = objectMapper.readTree(body);
|
||||
if (errJson.has("message")) {
|
||||
throw new BizException(502, errJson.get("message").asText());
|
||||
}
|
||||
} catch (BizException be) {
|
||||
throw be;
|
||||
} catch (Exception ignored) {}
|
||||
|
||||
throw new BizException(502, "核心后端服务返回异常代码: HTTP " + status);
|
||||
}
|
||||
|
||||
private String buildGetUrl(String path, Map<String, String> queryParams) {
|
||||
StringBuilder urlBuilder = new StringBuilder(backendBaseUrl).append(path);
|
||||
if (queryParams != null && !queryParams.isEmpty()) {
|
||||
urlBuilder.append("?");
|
||||
for (Map.Entry<String, String> entry : queryParams.entrySet()) {
|
||||
if (entry.getValue() != null) {
|
||||
String k = URLEncoder.encode(entry.getKey(), StandardCharsets.UTF_8);
|
||||
String v = URLEncoder.encode(entry.getValue(), StandardCharsets.UTF_8);
|
||||
urlBuilder.append(k).append("=").append(v).append("&");
|
||||
}
|
||||
}
|
||||
urlBuilder.deleteCharAt(urlBuilder.length() - 1);
|
||||
}
|
||||
return urlBuilder.toString();
|
||||
}
|
||||
|
||||
private void backoff(long ms) {
|
||||
try {
|
||||
Thread.sleep(ms);
|
||||
} catch (InterruptedException ignored) {}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -4,11 +4,13 @@ import jakarta.servlet.http.HttpServletRequest;
|
||||
import java.net.InetAddress;
|
||||
import java.net.UnknownHostException;
|
||||
import java.util.Arrays;
|
||||
import java.util.List;
|
||||
import java.util.concurrent.CopyOnWriteArrayList;
|
||||
import lombok.extern.slf4j.Slf4j;
|
||||
import org.springframework.util.StringUtils;
|
||||
|
||||
/**
|
||||
* IP 获取与白名单校验工具类
|
||||
* IP 获取与白名单校验工具类 (具备可信反向代理校验防御)
|
||||
*/
|
||||
@Slf4j
|
||||
public class IpUtils {
|
||||
@@ -18,13 +20,47 @@ public class IpUtils {
|
||||
private static final String LOCALHOST_IPV6 = "0:0:0:0:0:0:0:1";
|
||||
|
||||
/**
|
||||
* 获取客户端真实 IP(兼容多级 Nginx / 反向代理 / 负载均衡)
|
||||
* 默认信任的内网/本地反向代理 IP 网段 (如本地 Nginx / 宿主机代理)
|
||||
*/
|
||||
private static final List<String> TRUSTED_PROXIES = new CopyOnWriteArrayList<>(Arrays.asList(
|
||||
"127.0.0.1",
|
||||
"::1",
|
||||
"0:0:0:0:0:0:0:1",
|
||||
"10.0.0.0/8",
|
||||
"172.16.0.0/12",
|
||||
"192.168.0.0/16"
|
||||
));
|
||||
|
||||
public static void setTrustedProxies(String proxiesConfig) {
|
||||
if (StringUtils.hasText(proxiesConfig)) {
|
||||
TRUSTED_PROXIES.clear();
|
||||
for (String p : proxiesConfig.split("[,;\\s]+")) {
|
||||
if (StringUtils.hasText(p)) {
|
||||
TRUSTED_PROXIES.add(p.trim());
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取客户端真实 IP(仅在直接对端 RemoteAddr 属于可信反向代理时,才信任反代传递的 IP 头)
|
||||
*/
|
||||
public static String getClientIp(HttpServletRequest request) {
|
||||
if (request == null) {
|
||||
return UNKNOWN;
|
||||
}
|
||||
|
||||
String remoteAddr = request.getRemoteAddr();
|
||||
if (LOCALHOST_IPV6.equals(remoteAddr)) {
|
||||
remoteAddr = LOCALHOST_IPV4;
|
||||
}
|
||||
|
||||
// 核心安全防伪造:如果直连来源不是受信任的反向代理,直接使用 remoteAddr,严禁采信伪造的 X-Forwarded-For 等头部
|
||||
if (!isTrustedProxy(remoteAddr)) {
|
||||
return remoteAddr != null ? remoteAddr.trim() : UNKNOWN;
|
||||
}
|
||||
|
||||
// 仅在确认对端为受信任反向代理的前提下,提取代理传递的源 IP
|
||||
String ip = request.getHeader("X-Forwarded-For");
|
||||
if (isInvalidIp(ip)) {
|
||||
ip = request.getHeader("X-Real-IP");
|
||||
@@ -36,13 +72,7 @@ public class IpUtils {
|
||||
ip = request.getHeader("WL-Proxy-Client-IP");
|
||||
}
|
||||
if (isInvalidIp(ip)) {
|
||||
ip = request.getHeader("HTTP_CLIENT_IP");
|
||||
}
|
||||
if (isInvalidIp(ip)) {
|
||||
ip = request.getHeader("HTTP_X_FORWARDED_FOR");
|
||||
}
|
||||
if (isInvalidIp(ip)) {
|
||||
ip = request.getRemoteAddr();
|
||||
ip = remoteAddr;
|
||||
}
|
||||
|
||||
// 多级反向代理会有多个逗号分隔的 IP,首个为客户端真实源 IP
|
||||
@@ -63,19 +93,27 @@ public class IpUtils {
|
||||
return ip != null ? ip.trim() : UNKNOWN;
|
||||
}
|
||||
|
||||
private static boolean isTrustedProxy(String ip) {
|
||||
if (!StringUtils.hasText(ip) || UNKNOWN.equalsIgnoreCase(ip)) {
|
||||
return false;
|
||||
}
|
||||
String normalized = LOCALHOST_IPV6.equals(ip.trim()) ? LOCALHOST_IPV4 : ip.trim();
|
||||
for (String trustedPattern : TRUSTED_PROXIES) {
|
||||
if (matchIp(normalized, trustedPattern)) {
|
||||
return true;
|
||||
}
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
private static boolean isInvalidIp(String ip) {
|
||||
return !StringUtils.hasText(ip) || UNKNOWN.equalsIgnoreCase(ip.trim());
|
||||
}
|
||||
|
||||
/**
|
||||
* 校验客户端 IP 是否匹配白名单
|
||||
*
|
||||
* @param clientIp 客户端请求真实 IP
|
||||
* @param whitelist 白名单配置(支持英文逗号、分号、中文逗号、换行分隔;留空表示不限制;支持精确 IP、CIDR 网段如 192.168.1.0/24、通配符如 192.168.1.*)
|
||||
* @return true 允许通过,false 拦截
|
||||
*/
|
||||
public static boolean isAllowed(String clientIp, String whitelist) {
|
||||
// 白名单为空则不限制
|
||||
if (!StringUtils.hasText(whitelist)) {
|
||||
return true;
|
||||
}
|
||||
@@ -83,10 +121,8 @@ public class IpUtils {
|
||||
return false;
|
||||
}
|
||||
|
||||
// 统一规范化 localhost
|
||||
String normalizedClientIp = LOCALHOST_IPV6.equals(clientIp.trim()) ? LOCALHOST_IPV4 : clientIp.trim();
|
||||
|
||||
// 支持多种常见分隔符
|
||||
String[] rules = whitelist.split("[,;,\\n\\r]+");
|
||||
for (String rule : rules) {
|
||||
String pattern = rule.trim();
|
||||
@@ -99,75 +135,67 @@ public class IpUtils {
|
||||
}
|
||||
|
||||
/**
|
||||
* 单条规则匹配
|
||||
* IP 规则匹配:支持精确 IP、通配符(192.168.1.*)、CIDR 网段(192.168.1.0/24)
|
||||
*/
|
||||
private static boolean matchIp(String clientIp, String pattern) {
|
||||
if ("*".equals(pattern)) {
|
||||
return true;
|
||||
}
|
||||
// 规范化 localhost
|
||||
if (("localhost".equalsIgnoreCase(pattern) || LOCALHOST_IPV6.equals(pattern)) && LOCALHOST_IPV4.equals(clientIp)) {
|
||||
return true;
|
||||
}
|
||||
// 1. 精确匹配
|
||||
if (pattern.equalsIgnoreCase(clientIp)) {
|
||||
return true;
|
||||
}
|
||||
// 2. 通配符匹配 (如 192.168.1.* -> 转换为 CIDR 192.168.1.0/24 逻辑)
|
||||
if (pattern.contains("*")) {
|
||||
String regex = pattern.replace(".", "\\.").replace("*", ".*");
|
||||
return clientIp.matches(regex);
|
||||
}
|
||||
// 3. CIDR 网段匹配 (如 192.168.1.0/24)
|
||||
if (pattern.contains("/")) {
|
||||
return isInCidrRange(clientIp, pattern);
|
||||
}
|
||||
|
||||
public static boolean matchIp(String ip, String pattern) {
|
||||
if (ip == null || pattern == null) {
|
||||
return false;
|
||||
}
|
||||
|
||||
/**
|
||||
* 校验 IPv4 是否在 CIDR 网段内
|
||||
*/
|
||||
private static boolean isInCidrRange(String ipStr, String cidr) {
|
||||
pattern = pattern.trim();
|
||||
ip = ip.trim();
|
||||
|
||||
if (LOCALHOST_IPV6.equals(ip)) ip = LOCALHOST_IPV4;
|
||||
if (LOCALHOST_IPV6.equals(pattern)) pattern = LOCALHOST_IPV4;
|
||||
|
||||
if ("*".equals(pattern) || "0.0.0.0/0".equals(pattern)) {
|
||||
return true;
|
||||
}
|
||||
|
||||
if (pattern.contains("/")) {
|
||||
return matchCidr(ip, pattern);
|
||||
}
|
||||
|
||||
if (pattern.contains("*")) {
|
||||
return matchWildcard(ip, pattern);
|
||||
}
|
||||
|
||||
return ip.equalsIgnoreCase(pattern);
|
||||
}
|
||||
|
||||
private static boolean matchWildcard(String ip, String pattern) {
|
||||
String regex = pattern.replace(".", "\\.").replace("*", ".*");
|
||||
return ip.matches(regex);
|
||||
}
|
||||
|
||||
private static boolean matchCidr(String ip, String cidr) {
|
||||
try {
|
||||
String[] parts = cidr.split("/");
|
||||
if (parts.length != 2) {
|
||||
return false;
|
||||
}
|
||||
int maskBits = Integer.parseInt(parts[1].trim());
|
||||
if (maskBits < 0 || maskBits > 32) {
|
||||
return false;
|
||||
if (parts.length != 2) return false;
|
||||
|
||||
String network = parts[0];
|
||||
int prefixLength = Integer.parseInt(parts[1]);
|
||||
|
||||
byte[] ipBytes = InetAddress.getByName(ip).getAddress();
|
||||
byte[] netBytes = InetAddress.getByName(network).getAddress();
|
||||
|
||||
if (ipBytes.length != netBytes.length) return false;
|
||||
|
||||
int byteCount = prefixLength / 8;
|
||||
int bitCount = prefixLength % 8;
|
||||
|
||||
for (int i = 0; i < byteCount; i++) {
|
||||
if (ipBytes[i] != netBytes[i]) return false;
|
||||
}
|
||||
|
||||
InetAddress targetAddr = InetAddress.getByName(ipStr);
|
||||
InetAddress subnetAddr = InetAddress.getByName(parts[0].trim());
|
||||
|
||||
byte[] targetBytes = targetAddr.getAddress();
|
||||
byte[] subnetBytes = subnetAddr.getAddress();
|
||||
|
||||
if (targetBytes.length != subnetBytes.length) {
|
||||
return false;
|
||||
}
|
||||
|
||||
int fullBytes = maskBits / 8;
|
||||
for (int i = 0; i < fullBytes; i++) {
|
||||
if (targetBytes[i] != subnetBytes[i]) {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
int remainingBits = maskBits % 8;
|
||||
if (remainingBits > 0 && fullBytes < targetBytes.length) {
|
||||
int mask = (0xFF << (8 - remainingBits)) & 0xFF;
|
||||
if ((targetBytes[fullBytes] & mask) != (subnetBytes[fullBytes] & mask)) {
|
||||
return false;
|
||||
}
|
||||
if (bitCount > 0 && byteCount < ipBytes.length) {
|
||||
int mask = (0xFF << (8 - bitCount)) & 0xFF;
|
||||
return (ipBytes[byteCount] & mask) == (netBytes[byteCount] & mask);
|
||||
}
|
||||
|
||||
return true;
|
||||
} catch (UnknownHostException | NumberFormatException e) {
|
||||
log.warn("CIDR 网段解析校验异常: ip={}, cidr={}, error={}", ipStr, cidr, e.getMessage());
|
||||
log.warn("CIDR 解析错误: ip={}, cidr={}, err={}", ip, cidr, e.getMessage());
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,9 +1,11 @@
|
||||
package com.iot.gateway.entity;
|
||||
|
||||
import com.baomidou.mybatisplus.annotation.IdType;
|
||||
import com.baomidou.mybatisplus.annotation.TableField;
|
||||
import com.baomidou.mybatisplus.annotation.TableId;
|
||||
import com.baomidou.mybatisplus.annotation.TableName;
|
||||
import com.fasterxml.jackson.annotation.JsonFormat;
|
||||
import com.fasterxml.jackson.annotation.JsonProperty;
|
||||
import java.io.Serializable;
|
||||
import java.time.LocalDateTime;
|
||||
import lombok.Data;
|
||||
@@ -16,11 +18,23 @@ public class OpenClient implements Serializable {
|
||||
private Long id;
|
||||
|
||||
private String clientId;
|
||||
|
||||
/**
|
||||
* 客户端密钥哈希(数据库存储 BCrypt 哈希,序列化对外输出时默认忽略,防止密钥泄露)
|
||||
*/
|
||||
@JsonProperty(access = JsonProperty.Access.WRITE_ONLY)
|
||||
private String clientSecret;
|
||||
|
||||
/**
|
||||
* 仅在创建或重置密钥时返回一次的明文密钥
|
||||
*/
|
||||
@TableField(exist = false)
|
||||
private String rawSecret;
|
||||
|
||||
private String clientName;
|
||||
private Long tenantId;
|
||||
|
||||
@com.baomidou.mybatisplus.annotation.TableField(exist = false)
|
||||
@TableField(exist = false)
|
||||
private String tenantName;
|
||||
|
||||
private String scopes;
|
||||
|
||||
@@ -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 + "] 授权范围");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -19,6 +19,8 @@ public class GatewaySecurityContext {
|
||||
private Long tenantId;
|
||||
private List<String> scopes;
|
||||
|
||||
private boolean admin;
|
||||
|
||||
public static void set(GatewaySecurityContext context) {
|
||||
CONTEXT_HOLDER.set(context);
|
||||
}
|
||||
@@ -31,13 +33,32 @@ public class GatewaySecurityContext {
|
||||
CONTEXT_HOLDER.remove();
|
||||
}
|
||||
|
||||
public static Long getTenantIdOrDefault(Long defaultVal) {
|
||||
/**
|
||||
* 严格获取当前已认证租户 ID,未认证或缺少租户时抛出异常,杜绝默认兜底到租户 1
|
||||
*/
|
||||
public static Long requireTenantId() {
|
||||
GatewaySecurityContext ctx = get();
|
||||
return (ctx != null && ctx.getTenantId() != null) ? ctx.getTenantId() : defaultVal;
|
||||
if (ctx == null || ctx.getTenantId() == null) {
|
||||
throw new com.iot.gateway.common.BizException(401, "未识别到合法租户授权上下文,请先完成认证");
|
||||
}
|
||||
return ctx.getTenantId();
|
||||
}
|
||||
|
||||
public static Long getTenantIdOrNull() {
|
||||
GatewaySecurityContext ctx = get();
|
||||
return ctx != null ? ctx.getTenantId() : null;
|
||||
}
|
||||
|
||||
public boolean hasScope(String requiredScope) {
|
||||
if (admin) return true;
|
||||
if (scopes == null || scopes.isEmpty()) return false;
|
||||
return scopes.contains(requiredScope) || scopes.contains("all") || scopes.contains("*");
|
||||
String normalized = requiredScope != null ? requiredScope.replace(":", ".") : "";
|
||||
for (String s : scopes) {
|
||||
String norm = s.trim().replace(":", ".");
|
||||
if (norm.equals(normalized) || norm.equals("all") || norm.equals("*")) {
|
||||
return true;
|
||||
}
|
||||
}
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -13,22 +13,33 @@ import java.util.Map;
|
||||
import lombok.extern.slf4j.Slf4j;
|
||||
import org.springframework.beans.factory.annotation.Value;
|
||||
import org.springframework.stereotype.Component;
|
||||
import org.springframework.util.StringUtils;
|
||||
|
||||
@Slf4j
|
||||
@Component
|
||||
public class JwtTokenProvider {
|
||||
|
||||
@Value("${gateway.oauth.jwt-secret:iot-gateway-oauth2-super-secret-key-666888-abcdefghijklmnopqrstuvwxyz}")
|
||||
@Value("${gateway.oauth.jwt-secret:}")
|
||||
private String jwtSecret;
|
||||
|
||||
@Value("${gateway.oauth.token-expire-seconds:7200}")
|
||||
private long tokenExpireSeconds;
|
||||
|
||||
private Key getSigningKey() {
|
||||
if (!StringUtils.hasText(jwtSecret)) {
|
||||
throw new IllegalStateException("安全配置缺失: 未注入 gateway.oauth.jwt-secret 环境变量,严禁启动空私钥运行");
|
||||
}
|
||||
return Keys.hmacShaKeyFor(jwtSecret.getBytes(StandardCharsets.UTF_8));
|
||||
}
|
||||
|
||||
public String generateToken(OpenClient client) {
|
||||
return generateToken(client, client.getScopes());
|
||||
}
|
||||
|
||||
/**
|
||||
* 生成支持 Scope 收窄的访问令牌
|
||||
*/
|
||||
public String generateToken(OpenClient client, String effectiveScopes) {
|
||||
long nowMillis = System.currentTimeMillis();
|
||||
Date now = new Date(nowMillis);
|
||||
Date exp = new Date(nowMillis + tokenExpireSeconds * 1000);
|
||||
@@ -36,8 +47,9 @@ public class JwtTokenProvider {
|
||||
Map<String, Object> claims = new HashMap<>();
|
||||
claims.put("tenant_id", client.getTenantId());
|
||||
claims.put("client_name", client.getClientName());
|
||||
claims.put("scopes", client.getScopes());
|
||||
claims.put("scopes", effectiveScopes != null ? effectiveScopes : client.getScopes());
|
||||
claims.put("ip_whitelist", client.getIpWhitelist());
|
||||
claims.put("rate_limit", client.getRateLimit() != null ? client.getRateLimit() : 60);
|
||||
|
||||
return Jwts.builder()
|
||||
.setClaims(claims)
|
||||
|
||||
@@ -1,16 +1,19 @@
|
||||
package com.iot.gateway.oauth;
|
||||
|
||||
import com.iot.gateway.common.IpUtils;
|
||||
import io.jsonwebtoken.Claims;
|
||||
import jakarta.servlet.FilterChain;
|
||||
import jakarta.servlet.ServletException;
|
||||
import jakarta.servlet.http.HttpServletRequest;
|
||||
import jakarta.servlet.http.HttpServletResponse;
|
||||
import java.io.IOException;
|
||||
import java.util.ArrayList;
|
||||
import java.util.Arrays;
|
||||
import java.util.Collections;
|
||||
import java.util.List;
|
||||
import lombok.extern.slf4j.Slf4j;
|
||||
import org.springframework.beans.factory.annotation.Autowired;
|
||||
import org.springframework.beans.factory.annotation.Value;
|
||||
import org.springframework.security.authentication.UsernamePasswordAuthenticationToken;
|
||||
import org.springframework.security.core.authority.SimpleGrantedAuthority;
|
||||
import org.springframework.security.core.context.SecurityContextHolder;
|
||||
@@ -25,14 +28,104 @@ public class OAuth2AuthenticationFilter extends OncePerRequestFilter {
|
||||
@Autowired
|
||||
private JwtTokenProvider tokenProvider;
|
||||
|
||||
@Value("${gateway.backend.internal-secret:}")
|
||||
private String internalSecret;
|
||||
|
||||
@Value("${gateway.admin.secret:}")
|
||||
private String adminSecret;
|
||||
|
||||
@Override
|
||||
protected void doFilterInternal(HttpServletRequest request, HttpServletResponse response, FilterChain filterChain)
|
||||
throws ServletException, IOException {
|
||||
|
||||
// 1. 检查内部通道通信密钥 (例如核心后端调用网关,或特权管理脚本调用)
|
||||
String requestInternalSecret = request.getHeader("X-Internal-Secret");
|
||||
String requestAdminSecret = request.getHeader("X-Gateway-Admin-Secret");
|
||||
|
||||
if ((StringUtils.hasText(internalSecret) && internalSecret.equals(requestInternalSecret)) ||
|
||||
(StringUtils.hasText(adminSecret) && adminSecret.equals(requestAdminSecret))) {
|
||||
|
||||
String headerTenant = request.getHeader("X-Tenant-Id");
|
||||
Long tenantId = null;
|
||||
if (StringUtils.hasText(headerTenant)) {
|
||||
try {
|
||||
tenantId = Long.parseLong(headerTenant);
|
||||
} catch (NumberFormatException ignored) {}
|
||||
}
|
||||
|
||||
GatewaySecurityContext context = GatewaySecurityContext.builder()
|
||||
.clientId("internal_admin")
|
||||
.clientName("系统特权管理员")
|
||||
.tenantId(tenantId)
|
||||
.scopes(List.of("admin", "all", "device.read", "device.control", "telemetry.read", "alarm.read"))
|
||||
.admin(true)
|
||||
.build();
|
||||
GatewaySecurityContext.set(context);
|
||||
|
||||
List<SimpleGrantedAuthority> authorities = List.of(
|
||||
new SimpleGrantedAuthority("ROLE_ADMIN"),
|
||||
new SimpleGrantedAuthority("SCOPE_admin"),
|
||||
new SimpleGrantedAuthority("SCOPE_all")
|
||||
);
|
||||
UsernamePasswordAuthenticationToken auth =
|
||||
new UsernamePasswordAuthenticationToken("internal_admin", null, authorities);
|
||||
SecurityContextHolder.getContext().setAuthentication(auth);
|
||||
|
||||
try {
|
||||
filterChain.doFilter(request, response);
|
||||
} finally {
|
||||
GatewaySecurityContext.clear();
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
// 2. 解析 Bearer Token
|
||||
String token = resolveToken(request);
|
||||
if (StringUtils.hasText(token) && tokenProvider.validateToken(token)) {
|
||||
Claims claims = tokenProvider.parseToken(token);
|
||||
if (claims != null) {
|
||||
// 判断是后端用户登录凭据还是开放客户端凭据
|
||||
Object uidObj = claims.get("uid");
|
||||
Object userTypeObj = claims.get("type");
|
||||
|
||||
if (uidObj != null || userTypeObj != null) {
|
||||
// 核心业务后端签发的管理员或租户用户 JWT (来自 Web 前端控制台)
|
||||
String username = claims.getSubject();
|
||||
String userType = userTypeObj != null ? String.valueOf(userTypeObj) : "";
|
||||
Long tenantId = null;
|
||||
Object tidObj = claims.get("tenantId");
|
||||
if (tidObj != null) {
|
||||
try {
|
||||
tenantId = Long.parseLong(String.valueOf(tidObj));
|
||||
} catch (Exception ignored) {}
|
||||
}
|
||||
|
||||
boolean isPlatformAdmin = "PLATFORM".equalsIgnoreCase(userType);
|
||||
List<SimpleGrantedAuthority> authorities = new ArrayList<>();
|
||||
authorities.add(new SimpleGrantedAuthority("ROLE_USER"));
|
||||
if (isPlatformAdmin) {
|
||||
authorities.add(new SimpleGrantedAuthority("ROLE_ADMIN"));
|
||||
authorities.add(new SimpleGrantedAuthority("SCOPE_admin"));
|
||||
authorities.add(new SimpleGrantedAuthority("SCOPE_all"));
|
||||
} else {
|
||||
authorities.add(new SimpleGrantedAuthority("SCOPE_tenant_admin"));
|
||||
authorities.add(new SimpleGrantedAuthority("SCOPE_admin")); // 租户管理员亦可管理自己租户的客户端
|
||||
}
|
||||
|
||||
GatewaySecurityContext context = GatewaySecurityContext.builder()
|
||||
.clientId(username)
|
||||
.clientName(claims.get("nickname", String.class))
|
||||
.tenantId(tenantId)
|
||||
.scopes(List.of("admin", "all"))
|
||||
.admin(isPlatformAdmin)
|
||||
.build();
|
||||
GatewaySecurityContext.set(context);
|
||||
|
||||
UsernamePasswordAuthenticationToken auth =
|
||||
new UsernamePasswordAuthenticationToken(username, null, authorities);
|
||||
SecurityContextHolder.getContext().setAuthentication(auth);
|
||||
} else {
|
||||
// 三方应用 OAuth 2.0 客户端凭证 Token
|
||||
String clientId = claims.getSubject();
|
||||
Long tenantId = claims.get("tenant_id", Long.class);
|
||||
String clientName = claims.get("client_name", String.class);
|
||||
@@ -41,8 +134,8 @@ public class OAuth2AuthenticationFilter extends OncePerRequestFilter {
|
||||
|
||||
// 校验客户端当前请求 IP 是否在白名单内
|
||||
if (StringUtils.hasText(ipWhitelist)) {
|
||||
String clientIp = com.iot.gateway.common.IpUtils.getClientIp(request);
|
||||
if (!com.iot.gateway.common.IpUtils.isAllowed(clientIp, ipWhitelist)) {
|
||||
String clientIp = IpUtils.getClientIp(request);
|
||||
if (!IpUtils.isAllowed(clientIp, ipWhitelist)) {
|
||||
log.warn("OAuth 2.0 令牌访问拒绝: 客户端 IP [{}] 不在白名单 [{}] 内, clientId={}",
|
||||
clientIp, ipWhitelist, clientId);
|
||||
response.setStatus(HttpServletResponse.SC_FORBIDDEN);
|
||||
@@ -56,23 +149,29 @@ public class OAuth2AuthenticationFilter extends OncePerRequestFilter {
|
||||
? Arrays.asList(scopesStr.split(","))
|
||||
: Collections.emptyList();
|
||||
|
||||
// 严禁无租户凭据被默认落入租户 1
|
||||
GatewaySecurityContext context = GatewaySecurityContext.builder()
|
||||
.clientId(clientId)
|
||||
.tenantId(tenantId != null ? tenantId : 1L)
|
||||
.tenantId(tenantId)
|
||||
.clientName(clientName)
|
||||
.scopes(scopes)
|
||||
.admin(false)
|
||||
.build();
|
||||
GatewaySecurityContext.set(context);
|
||||
|
||||
List<SimpleGrantedAuthority> authorities = scopes.stream()
|
||||
.map(s -> new SimpleGrantedAuthority("SCOPE_" + s.trim()))
|
||||
.toList();
|
||||
List<SimpleGrantedAuthority> authorities = new ArrayList<>();
|
||||
authorities.add(new SimpleGrantedAuthority("ROLE_CLIENT"));
|
||||
for (String s : scopes) {
|
||||
String norm = s.trim().replace(":", ".");
|
||||
authorities.add(new SimpleGrantedAuthority("SCOPE_" + norm));
|
||||
}
|
||||
|
||||
UsernamePasswordAuthenticationToken auth =
|
||||
new UsernamePasswordAuthenticationToken(clientId, null, authorities);
|
||||
SecurityContextHolder.getContext().setAuthentication(auth);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
try {
|
||||
filterChain.doFilter(request, response);
|
||||
@@ -86,8 +185,11 @@ public class OAuth2AuthenticationFilter extends OncePerRequestFilter {
|
||||
if (StringUtils.hasText(bearer) && bearer.startsWith("Bearer ")) {
|
||||
return bearer.substring(7).trim();
|
||||
}
|
||||
// 允许通过 ?token= 查询参数传递 (供 SSE / EventSource 连接时携带)
|
||||
String queryToken = request.getParameter("token");
|
||||
// 允许通过 ?token= 或 ?access_token= 查询参数传递 (供 SSE / EventSource 连接时携带)
|
||||
String queryToken = request.getParameter("access_token");
|
||||
if (!StringUtils.hasText(queryToken)) {
|
||||
queryToken = request.getParameter("token");
|
||||
}
|
||||
if (StringUtils.hasText(queryToken)) {
|
||||
return queryToken.trim();
|
||||
}
|
||||
|
||||
@@ -1,19 +1,26 @@
|
||||
package com.iot.gateway.oauth;
|
||||
|
||||
import com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper;
|
||||
import com.fasterxml.jackson.databind.JsonNode;
|
||||
import com.fasterxml.jackson.databind.ObjectMapper;
|
||||
import com.iot.gateway.common.IpUtils;
|
||||
import com.iot.gateway.entity.OpenClient;
|
||||
import com.iot.gateway.mapper.OpenClientMapper;
|
||||
import io.swagger.v3.oas.annotations.Operation;
|
||||
import io.swagger.v3.oas.annotations.tags.Tag;
|
||||
import java.time.LocalDateTime;
|
||||
import java.util.Arrays;
|
||||
import java.util.Collections;
|
||||
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.http.HttpStatus;
|
||||
import org.springframework.http.ResponseEntity;
|
||||
import org.springframework.security.crypto.password.PasswordEncoder;
|
||||
import org.springframework.util.StringUtils;
|
||||
import org.springframework.web.bind.annotation.PostMapping;
|
||||
import org.springframework.web.bind.annotation.RequestBody;
|
||||
import org.springframework.web.bind.annotation.RequestMapping;
|
||||
import org.springframework.web.bind.annotation.RequestParam;
|
||||
import org.springframework.web.bind.annotation.RestController;
|
||||
@@ -30,20 +37,41 @@ public class OAuth2TokenController {
|
||||
@Autowired
|
||||
private JwtTokenProvider jwtTokenProvider;
|
||||
|
||||
@Operation(summary = "客户端凭据模式换取访问令牌 (Client Credentials Grant)")
|
||||
@Autowired
|
||||
private PasswordEncoder passwordEncoder;
|
||||
|
||||
private final ObjectMapper objectMapper = new ObjectMapper();
|
||||
|
||||
@Operation(summary = "客户端凭据模式换取访问令牌 (支持 form-urlencoded 与 application/json)")
|
||||
@PostMapping(value = "/token")
|
||||
public ResponseEntity<Map<String, Object>> issueToken(
|
||||
@RequestParam(value = "grant_type", required = false) String formGrantType,
|
||||
@RequestParam(value = "client_id", required = false) String formClientId,
|
||||
@RequestParam(value = "client_secret", required = false) String formClientSecret,
|
||||
@RequestParam(value = "scope", required = false) String formScope,
|
||||
@RequestBody(required = false) Map<String, String> jsonBody,
|
||||
jakarta.servlet.http.HttpServletRequest request) {
|
||||
|
||||
String grantType = formGrantType != null ? formGrantType : (jsonBody != null ? jsonBody.get("grant_type") : null);
|
||||
String clientId = formClientId != null ? formClientId : (jsonBody != null ? jsonBody.get("client_id") : null);
|
||||
String clientSecret = formClientSecret != null ? formClientSecret : (jsonBody != null ? jsonBody.get("client_secret") : null);
|
||||
String scope = formScope != null ? formScope : (jsonBody != null ? jsonBody.get("scope") : null);
|
||||
String grantType = formGrantType;
|
||||
String clientId = formClientId;
|
||||
String clientSecret = formClientSecret;
|
||||
String scope = formScope;
|
||||
|
||||
// 兼容自适应解析 application/json 请求载荷
|
||||
String contentType = request.getContentType();
|
||||
if ((!StringUtils.hasText(clientId) || !StringUtils.hasText(clientSecret))
|
||||
&& contentType != null && contentType.toLowerCase().contains("application/json")) {
|
||||
try {
|
||||
JsonNode root = objectMapper.readTree(request.getInputStream());
|
||||
if (root != null) {
|
||||
if (grantType == null && root.has("grant_type")) grantType = root.get("grant_type").asText();
|
||||
if (clientId == null && root.has("client_id")) clientId = root.get("client_id").asText();
|
||||
if (clientSecret == null && root.has("client_secret")) clientSecret = root.get("client_secret").asText();
|
||||
if (scope == null && root.has("scope")) scope = root.get("scope").asText();
|
||||
}
|
||||
} catch (Exception e) {
|
||||
log.warn("解析 JSON 格式 OAuth 请求体异常: {}", e.getMessage());
|
||||
}
|
||||
}
|
||||
|
||||
Map<String, Object> error = new HashMap<>();
|
||||
|
||||
@@ -53,16 +81,36 @@ public class OAuth2TokenController {
|
||||
return ResponseEntity.status(HttpStatus.BAD_REQUEST).body(error);
|
||||
}
|
||||
|
||||
if (clientId == null || clientSecret == null) {
|
||||
if (!StringUtils.hasText(clientId) || !StringUtils.hasText(clientSecret)) {
|
||||
error.put("error", "invalid_client");
|
||||
error.put("error_description", "client_id 或 client_secret 不能为空");
|
||||
return ResponseEntity.status(HttpStatus.UNAUTHORIZED).body(error);
|
||||
}
|
||||
|
||||
OpenClient client = clientMapper.selectOne(new LambdaQueryWrapper<OpenClient>()
|
||||
.eq(OpenClient::getClientId, clientId));
|
||||
.eq(OpenClient::getClientId, clientId.trim()));
|
||||
|
||||
if (client == null || !client.getClientSecret().equals(clientSecret)) {
|
||||
if (client == null) {
|
||||
error.put("error", "invalid_client");
|
||||
error.put("error_description", "客户端标识或密钥错误");
|
||||
return ResponseEntity.status(HttpStatus.UNAUTHORIZED).body(error);
|
||||
}
|
||||
|
||||
// 密钥比对:优先支持 BCrypt 哈希比对;兼顾历史明文并自动平滑升级为 BCrypt 哈希
|
||||
boolean secretMatch = false;
|
||||
String storedSecret = client.getClientSecret();
|
||||
if (storedSecret != null) {
|
||||
if (storedSecret.startsWith("$2a$") || storedSecret.startsWith("$2b$")) {
|
||||
secretMatch = passwordEncoder.matches(clientSecret, storedSecret);
|
||||
} else if (storedSecret.equals(clientSecret)) {
|
||||
secretMatch = true;
|
||||
client.setClientSecret(passwordEncoder.encode(clientSecret));
|
||||
clientMapper.updateById(client);
|
||||
log.info("检测到明文密钥成功验证,已自动无感升级为 BCrypt 哈希: clientId={}", clientId);
|
||||
}
|
||||
}
|
||||
|
||||
if (!secretMatch) {
|
||||
error.put("error", "invalid_client");
|
||||
error.put("error_description", "客户端标识或密钥错误");
|
||||
return ResponseEntity.status(HttpStatus.UNAUTHORIZED).body(error);
|
||||
@@ -80,9 +128,9 @@ public class OAuth2TokenController {
|
||||
return ResponseEntity.status(HttpStatus.FORBIDDEN).body(error);
|
||||
}
|
||||
|
||||
// 校验 IP 白名单
|
||||
String clientIp = com.iot.gateway.common.IpUtils.getClientIp(request);
|
||||
if (!com.iot.gateway.common.IpUtils.isAllowed(clientIp, client.getIpWhitelist())) {
|
||||
// 校验客户端来源 IP 白名单(基于可信反向代理验证)
|
||||
String clientIp = IpUtils.getClientIp(request);
|
||||
if (!IpUtils.isAllowed(clientIp, client.getIpWhitelist())) {
|
||||
log.warn("OAuth 2.0 客户端 IP 白名单拦截: clientId={}, clientIp={}, whitelist={}",
|
||||
clientId, clientIp, client.getIpWhitelist());
|
||||
error.put("error", "unauthorized_client");
|
||||
@@ -90,14 +138,38 @@ public class OAuth2TokenController {
|
||||
return ResponseEntity.status(HttpStatus.FORBIDDEN).body(error);
|
||||
}
|
||||
|
||||
String accessToken = jwtTokenProvider.generateToken(client);
|
||||
log.info("OAuth 2.0 成功颁发令牌: clientId={}, clientIp={}, tenantId={}", clientId, clientIp, client.getTenantId());
|
||||
// 验证 Scope 并支持合法收窄 (Scope Narrowing)
|
||||
String effectiveScopes = client.getScopes();
|
||||
if (StringUtils.hasText(scope)) {
|
||||
List<String> clientAllowed = StringUtils.hasText(client.getScopes())
|
||||
? Arrays.asList(client.getScopes().split("[,;\\s]+"))
|
||||
: Collections.emptyList();
|
||||
List<String> requested = Arrays.asList(scope.split("[,;\\s]+"));
|
||||
|
||||
for (String reqScope : requested) {
|
||||
String reqNorm = reqScope.trim().replace(":", ".");
|
||||
boolean allowed = clientAllowed.stream().anyMatch(ca -> {
|
||||
String caNorm = ca.trim().replace(":", ".");
|
||||
return caNorm.equals(reqNorm) || caNorm.equals("all") || caNorm.equals("*");
|
||||
});
|
||||
if (!allowed) {
|
||||
error.put("error", "invalid_scope");
|
||||
error.put("error_description", "请求的作用域 [" + reqScope + "] 超出该客户端的允许授权范围");
|
||||
return ResponseEntity.status(HttpStatus.BAD_REQUEST).body(error);
|
||||
}
|
||||
}
|
||||
effectiveScopes = String.join(",", requested);
|
||||
}
|
||||
|
||||
String accessToken = jwtTokenProvider.generateToken(client, effectiveScopes);
|
||||
log.info("OAuth 2.0 成功颁发令牌: clientId={}, clientIp={}, tenantId={}, scopes={}",
|
||||
clientId, clientIp, client.getTenantId(), effectiveScopes);
|
||||
|
||||
Map<String, Object> response = new HashMap<>();
|
||||
response.put("access_token", accessToken);
|
||||
response.put("token_type", "Bearer");
|
||||
response.put("expires_in", jwtTokenProvider.getTokenExpireSeconds());
|
||||
response.put("scope", client.getScopes());
|
||||
response.put("scope", effectiveScopes);
|
||||
response.put("tenant_id", client.getTenantId());
|
||||
response.put("client_name", client.getClientName());
|
||||
|
||||
|
||||
@@ -0,0 +1,118 @@
|
||||
package com.iot.gateway.oauth;
|
||||
|
||||
import com.fasterxml.jackson.databind.ObjectMapper;
|
||||
import com.iot.gateway.common.IpUtils;
|
||||
import io.jsonwebtoken.Claims;
|
||||
import jakarta.servlet.FilterChain;
|
||||
import jakarta.servlet.ServletException;
|
||||
import jakarta.servlet.http.HttpServletRequest;
|
||||
import jakarta.servlet.http.HttpServletResponse;
|
||||
import java.io.IOException;
|
||||
import java.util.Map;
|
||||
import java.util.concurrent.ConcurrentHashMap;
|
||||
import java.util.concurrent.atomic.AtomicInteger;
|
||||
import lombok.extern.slf4j.Slf4j;
|
||||
import org.springframework.beans.factory.annotation.Autowired;
|
||||
import org.springframework.core.annotation.Order;
|
||||
import org.springframework.stereotype.Component;
|
||||
import org.springframework.util.StringUtils;
|
||||
import org.springframework.web.filter.OncePerRequestFilter;
|
||||
|
||||
/**
|
||||
* 客户端调用频率限流 (Rate Limit) 与核心调用审计拦截器
|
||||
*/
|
||||
@Slf4j
|
||||
@Component
|
||||
@Order(10)
|
||||
public class OpenClientRateLimitFilter extends OncePerRequestFilter {
|
||||
|
||||
private final ObjectMapper objectMapper = new ObjectMapper();
|
||||
|
||||
/** 客户端 1 分钟滑动窗口计数器缓存: key = clientId + ":" + (epochMinute) */
|
||||
private final Map<String, AtomicInteger> counterMap = new ConcurrentHashMap<>();
|
||||
|
||||
@Autowired
|
||||
private JwtTokenProvider jwtTokenProvider;
|
||||
|
||||
@Override
|
||||
protected void doFilterInternal(HttpServletRequest request, HttpServletResponse response, FilterChain filterChain)
|
||||
throws ServletException, IOException {
|
||||
|
||||
long startTime = System.currentTimeMillis();
|
||||
GatewaySecurityContext ctx = GatewaySecurityContext.get();
|
||||
|
||||
// 仅对外部 OAuth 客户端进行 rateLimit 校验与审计 (特权内部通道 internal_admin 不受限)
|
||||
if (ctx != null && StringUtils.hasText(ctx.getClientId()) && !ctx.isAdmin()) {
|
||||
String clientId = ctx.getClientId();
|
||||
int rateLimit = resolveRateLimit(request);
|
||||
|
||||
long currentMinute = System.currentTimeMillis() / 60000;
|
||||
String counterKey = clientId + ":" + currentMinute;
|
||||
|
||||
AtomicInteger counter = counterMap.computeIfAbsent(counterKey, k -> new AtomicInteger(0));
|
||||
int currentCount = counter.incrementAndGet();
|
||||
|
||||
// 定期清理过期分钟的计数器,防止内存泄漏
|
||||
if (counterMap.size() > 5000) {
|
||||
counterMap.keySet().removeIf(k -> {
|
||||
String[] parts = k.split(":");
|
||||
if (parts.length == 2) {
|
||||
try {
|
||||
long min = Long.parseLong(parts[1]);
|
||||
return min < currentMinute;
|
||||
} catch (NumberFormatException ignored) {}
|
||||
}
|
||||
return false;
|
||||
});
|
||||
}
|
||||
|
||||
if (currentCount > rateLimit) {
|
||||
log.warn("OAuth 2.0 客户端调用超限触发拦截 (429): clientId={}, currentCount={}, rateLimit={}/min, path={}",
|
||||
clientId, currentCount, rateLimit, request.getRequestURI());
|
||||
|
||||
response.setStatus(429); // 429 Too Many Requests
|
||||
response.setHeader("Retry-After", "60");
|
||||
response.setHeader("X-RateLimit-Limit", String.valueOf(rateLimit));
|
||||
response.setHeader("X-RateLimit-Remaining", "0");
|
||||
response.setContentType("application/json;charset=UTF-8");
|
||||
response.getWriter().write(objectMapper.writeValueAsString(Map.of(
|
||||
"code", 429,
|
||||
"success", false,
|
||||
"message", "客户端请求频率超出配额限制 (限额: " + rateLimit + " 次/分钟),请稍后重试"
|
||||
)));
|
||||
return;
|
||||
}
|
||||
|
||||
response.setHeader("X-RateLimit-Limit", String.valueOf(rateLimit));
|
||||
response.setHeader("X-RateLimit-Remaining", String.valueOf(Math.max(0, rateLimit - currentCount)));
|
||||
}
|
||||
|
||||
try {
|
||||
filterChain.doFilter(request, response);
|
||||
} finally {
|
||||
if (ctx != null && StringUtils.hasText(ctx.getClientId())) {
|
||||
long cost = System.currentTimeMillis() - startTime;
|
||||
String clientIp = IpUtils.getClientIp(request);
|
||||
// 记录关键调用审计日志
|
||||
log.info("[API 审计日志] client={}, tenant={}, ip={}, method={}, uri={}, status={}, cost={}ms",
|
||||
ctx.getClientId(), ctx.getTenantId(), clientIp, request.getMethod(),
|
||||
request.getRequestURI(), response.getStatus(), cost);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private int resolveRateLimit(HttpServletRequest request) {
|
||||
String authHeader = request.getHeader("Authorization");
|
||||
if (StringUtils.hasText(authHeader) && authHeader.startsWith("Bearer ")) {
|
||||
String token = authHeader.substring(7).trim();
|
||||
Claims claims = jwtTokenProvider.parseToken(token);
|
||||
if (claims != null && claims.containsKey("rate_limit")) {
|
||||
Object rl = claims.get("rate_limit");
|
||||
if (rl instanceof Number) {
|
||||
return ((Number) rl).intValue();
|
||||
}
|
||||
}
|
||||
}
|
||||
return 60; // 默认每分钟 60 次
|
||||
}
|
||||
}
|
||||
@@ -8,6 +8,8 @@ import org.springframework.security.config.annotation.method.configuration.Enabl
|
||||
import org.springframework.security.config.annotation.web.builders.HttpSecurity;
|
||||
import org.springframework.security.config.annotation.web.configuration.EnableWebSecurity;
|
||||
import org.springframework.security.config.http.SessionCreationPolicy;
|
||||
import org.springframework.security.crypto.bcrypt.BCryptPasswordEncoder;
|
||||
import org.springframework.security.crypto.password.PasswordEncoder;
|
||||
import org.springframework.security.web.SecurityFilterChain;
|
||||
import org.springframework.security.web.authentication.UsernamePasswordAuthenticationFilter;
|
||||
|
||||
@@ -19,26 +21,48 @@ public class SecurityConfig {
|
||||
@Autowired
|
||||
private OAuth2AuthenticationFilter oauth2Filter;
|
||||
|
||||
@Autowired
|
||||
private OpenClientRateLimitFilter rateLimitFilter;
|
||||
|
||||
@Bean
|
||||
public PasswordEncoder passwordEncoder() {
|
||||
return new BCryptPasswordEncoder();
|
||||
}
|
||||
|
||||
@Bean
|
||||
public SecurityFilterChain filterChain(HttpSecurity http) throws Exception {
|
||||
http.csrf(csrf -> csrf.disable())
|
||||
.cors(Customizer.withDefaults())
|
||||
.sessionManagement(sm -> sm.sessionCreationPolicy(SessionCreationPolicy.STATELESS))
|
||||
.authorizeHttpRequests(auth -> auth
|
||||
// 1. 公开端点 (仅限 Token 申请、网关健康探针与 OpenAPI 接口文档)
|
||||
.requestMatchers(
|
||||
"/oauth2/**",
|
||||
"/open/gateway/**",
|
||||
"/mcp/**",
|
||||
"/api/**",
|
||||
"/actuator/**",
|
||||
"/oauth2/token",
|
||||
"/open/gateway/health",
|
||||
"/open/gateway/catalog",
|
||||
"/actuator/health",
|
||||
"/v3/api-docs/**",
|
||||
"/swagger-ui/**",
|
||||
"/swagger-ui.html",
|
||||
"/error"
|
||||
).permitAll()
|
||||
|
||||
// 2. 开放平台管理后台端点 (必须具备管理员权限,严禁匿名或外部三方调用)
|
||||
.requestMatchers("/api/open/**").hasAnyAuthority(
|
||||
"ROLE_ADMIN", "SCOPE_admin", "SCOPE_all", "SCOPE_tenant_admin"
|
||||
)
|
||||
|
||||
// 3. MCP 智能体长连接与消息端点 (必须通过 OAuth 2.0 客户端或管理员认证)
|
||||
.requestMatchers("/mcp/**").authenticated()
|
||||
|
||||
// 4. OpenAPI 业务接口 (必须通过 OAuth 2.0 Bearer 认证)
|
||||
.requestMatchers("/open/v1/**").authenticated()
|
||||
.anyRequest().permitAll())
|
||||
.addFilterBefore(oauth2Filter, UsernamePasswordAuthenticationFilter.class);
|
||||
|
||||
// 其他任意未显式列出的路径默认全阻断
|
||||
.anyRequest().authenticated()
|
||||
)
|
||||
.addFilterBefore(oauth2Filter, UsernamePasswordAuthenticationFilter.class)
|
||||
.addFilterAfter(rateLimitFilter, OAuth2AuthenticationFilter.class);
|
||||
|
||||
return http.build();
|
||||
}
|
||||
|
||||
@@ -1,12 +1,18 @@
|
||||
package com.iot.gateway.openapi;
|
||||
|
||||
import com.fasterxml.jackson.databind.JsonNode;
|
||||
import com.iot.gateway.common.BackendInternalClient;
|
||||
import com.iot.gateway.common.BizException;
|
||||
import com.iot.gateway.common.Result;
|
||||
import com.iot.gateway.oauth.GatewaySecurityContext;
|
||||
import io.swagger.v3.oas.annotations.Operation;
|
||||
import io.swagger.v3.oas.annotations.tags.Tag;
|
||||
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.security.access.prepost.PreAuthorize;
|
||||
import org.springframework.web.bind.annotation.GetMapping;
|
||||
import org.springframework.web.bind.annotation.PostMapping;
|
||||
@@ -21,6 +27,12 @@ import org.springframework.web.bind.annotation.RestController;
|
||||
@RequestMapping("/open/v1/alarms")
|
||||
public class OpenAlarmController {
|
||||
|
||||
@Autowired
|
||||
private BackendInternalClient internalClient;
|
||||
|
||||
@Value("${gateway.mock.enabled:false}")
|
||||
private boolean mockEnabled;
|
||||
|
||||
@Operation(summary = "按租户与设备检索未确认/历史告警")
|
||||
@GetMapping
|
||||
@PreAuthorize("hasAuthority('SCOPE_alarm.read') or hasAuthority('SCOPE_all')")
|
||||
@@ -28,9 +40,23 @@ public class OpenAlarmController {
|
||||
@RequestParam(required = false) String deviceId,
|
||||
@RequestParam(required = false) Integer severity) {
|
||||
|
||||
Long tenantId = GatewaySecurityContext.getTenantIdOrDefault(1L);
|
||||
Long tenantId = GatewaySecurityContext.requireTenantId();
|
||||
log.info("三方系统查询告警列表: tenantId={}, deviceId={}, severity={}", tenantId, deviceId, severity);
|
||||
|
||||
Map<String, String> params = new HashMap<>();
|
||||
if (deviceId != null) params.put("deviceId", deviceId);
|
||||
if (severity != null) params.put("severity", String.valueOf(severity));
|
||||
|
||||
JsonNode resp = internalClient.get("/api/internal/open/alarms", tenantId, params);
|
||||
if (resp != null && resp.has("code") && resp.get("code").asInt() == 200 && resp.has("data")) {
|
||||
return Result.success(Map.of("tenantId", tenantId, "alarms", resp.get("data")));
|
||||
}
|
||||
|
||||
if (!mockEnabled) {
|
||||
String errMsg = resp != null && resp.has("message") ? resp.get("message").asText() : "核心物联后端不可用或查询告警失败";
|
||||
throw new BizException(502, "查询告警失败: " + errMsg);
|
||||
}
|
||||
|
||||
List<Map<String, Object>> alarms = List.of(
|
||||
Map.of("alarmId", "ALM-9821", "deviceId", "DEV-ZSJ-002", "severity", 3,
|
||||
"title", "注塑模具温度异常过高 (当前 265℃)", "createTime", "2026-10-09 08:15:20"),
|
||||
@@ -38,14 +64,15 @@ public class OpenAlarmController {
|
||||
"title", "射胶压力超限预警", "createTime", "2026-10-09 08:22:10")
|
||||
);
|
||||
|
||||
return Result.success(Map.of("tenantId", tenantId, "total", alarms.size(), "list", alarms));
|
||||
return Result.success(Map.of("tenantId", tenantId, "isMock", true, "total", alarms.size(), "list", alarms));
|
||||
}
|
||||
|
||||
@Operation(summary = "三方系统确认告警回执")
|
||||
@PostMapping("/ack")
|
||||
@PreAuthorize("hasAuthority('SCOPE_alarm.read') or hasAuthority('SCOPE_all')")
|
||||
public Result<Map<String, Object>> ackAlarm(@RequestBody Map<String, Object> req) {
|
||||
Long tenantId = GatewaySecurityContext.requireTenantId();
|
||||
String alarmId = String.valueOf(req.get("alarmId"));
|
||||
return Result.success(Map.of("alarmId", alarmId, "status", "ACKNOWLEDGED", "ackTime", System.currentTimeMillis()));
|
||||
return Result.success(Map.of("alarmId", alarmId, "tenantId", tenantId, "status", "ACKNOWLEDGED", "ackTime", System.currentTimeMillis()));
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,11 +1,16 @@
|
||||
package com.iot.gateway.openapi;
|
||||
|
||||
import com.fasterxml.jackson.databind.JsonNode;
|
||||
import com.iot.gateway.common.BackendInternalClient;
|
||||
import com.iot.gateway.common.BizException;
|
||||
import com.iot.gateway.common.Result;
|
||||
import com.iot.gateway.oauth.GatewaySecurityContext;
|
||||
import io.swagger.v3.oas.annotations.Operation;
|
||||
import io.swagger.v3.oas.annotations.tags.Tag;
|
||||
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.security.access.prepost.PreAuthorize;
|
||||
import org.springframework.web.bind.annotation.PostMapping;
|
||||
import org.springframework.web.bind.annotation.RequestBody;
|
||||
@@ -18,23 +23,47 @@ import org.springframework.web.bind.annotation.RestController;
|
||||
@RequestMapping("/open/v1/commands")
|
||||
public class OpenCommandController {
|
||||
|
||||
@Operation(summary = "下发设备控制与工艺参数反写指令 (MQTT桥接)")
|
||||
@Autowired
|
||||
private BackendInternalClient internalClient;
|
||||
|
||||
@Value("${gateway.mock.enabled:false}")
|
||||
private boolean mockEnabled;
|
||||
|
||||
@Operation(summary = "下发设备控制与工艺参数反写指令 (真实专线桥接,严禁伪报已执行)")
|
||||
@PostMapping
|
||||
@PreAuthorize("hasAuthority('SCOPE_device.control') or hasAuthority('SCOPE_all')")
|
||||
public Result<Map<String, Object>> sendCommand(@RequestBody Map<String, Object> req) {
|
||||
Long tenantId = GatewaySecurityContext.getTenantIdOrDefault(1L);
|
||||
Long tenantId = GatewaySecurityContext.requireTenantId();
|
||||
String deviceId = String.valueOf(req.get("deviceId"));
|
||||
String command = String.valueOf(req.get("command"));
|
||||
|
||||
log.info("三方系统下发设备反写指令: tenantId={}, deviceId={}, command={}", tenantId, deviceId, command);
|
||||
|
||||
JsonNode resp = internalClient.post("/api/internal/open/commands", tenantId, req);
|
||||
if (resp != null && resp.has("code") && resp.get("code").asInt() == 200 && resp.has("data")) {
|
||||
return Result.success(Map.of(
|
||||
"success", true,
|
||||
"commandId", "CMD-" + System.currentTimeMillis(),
|
||||
"deviceId", deviceId,
|
||||
"command", command,
|
||||
"status", "DELIVERED",
|
||||
"message", "指令已成功路由至对应租户的边缘网关执行"
|
||||
"status", "EXECUTED",
|
||||
"data", resp.get("data")
|
||||
));
|
||||
}
|
||||
|
||||
String errMsg = resp != null && resp.has("message") ? resp.get("message").asText() : "核心控制网关通信超时或未响应";
|
||||
if (!mockEnabled) {
|
||||
log.error("下发设备控制指令失败 (拒绝伪报成功): tenantId={}, deviceId={}, err={}", tenantId, deviceId, errMsg);
|
||||
throw new BizException(502, "控制指令执行失败: " + errMsg);
|
||||
}
|
||||
|
||||
return Result.success(Map.of(
|
||||
"success", false,
|
||||
"isMock", true,
|
||||
"deviceId", deviceId,
|
||||
"command", command,
|
||||
"status", "MOCK_MODE_REFUSED",
|
||||
"message", "当前为开发演示环境,已拒绝向物理设备下发控制指令: " + errMsg
|
||||
));
|
||||
}
|
||||
}
|
||||
|
||||
@@ -2,6 +2,7 @@ package com.iot.gateway.openapi;
|
||||
|
||||
import com.fasterxml.jackson.databind.JsonNode;
|
||||
import com.iot.gateway.common.BackendInternalClient;
|
||||
import com.iot.gateway.common.BizException;
|
||||
import com.iot.gateway.common.Result;
|
||||
import com.iot.gateway.oauth.GatewaySecurityContext;
|
||||
import io.swagger.v3.oas.annotations.Operation;
|
||||
@@ -11,6 +12,7 @@ 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.security.access.prepost.PreAuthorize;
|
||||
import org.springframework.web.bind.annotation.GetMapping;
|
||||
import org.springframework.web.bind.annotation.PathVariable;
|
||||
@@ -27,6 +29,9 @@ public class OpenDeviceController {
|
||||
@Autowired
|
||||
private BackendInternalClient internalClient;
|
||||
|
||||
@Value("${gateway.mock.enabled:false}")
|
||||
private boolean mockEnabled;
|
||||
|
||||
@Operation(summary = "查询当前租户名下设备列表")
|
||||
@GetMapping
|
||||
@PreAuthorize("hasAuthority('SCOPE_device.read') or hasAuthority('SCOPE_all')")
|
||||
@@ -35,7 +40,8 @@ public class OpenDeviceController {
|
||||
@RequestParam(required = false, defaultValue = "20") Integer pageSize,
|
||||
@RequestParam(required = false) String status) {
|
||||
|
||||
Long tenantId = GatewaySecurityContext.getTenantIdOrDefault(1L);
|
||||
// 严格获取当前已认证租户 ID,未认证或缺少租户时抛出 401 异常,严禁默认落到租户 1
|
||||
Long tenantId = GatewaySecurityContext.requireTenantId();
|
||||
log.info("三方系统查询设备列表: tenantId={}, status={}", tenantId, status);
|
||||
|
||||
Map<String, String> params = new HashMap<>();
|
||||
@@ -58,9 +64,15 @@ public class OpenDeviceController {
|
||||
));
|
||||
}
|
||||
|
||||
// 兜底返回
|
||||
if (!mockEnabled) {
|
||||
String errMsg = resp != null && resp.has("message") ? resp.get("message").asText() : "核心物联后端不可用或请求超时";
|
||||
throw new BizException(502, "召测设备列表失败: " + errMsg);
|
||||
}
|
||||
|
||||
// 仅在显式配置 mockEnabled=true 时允许返回演示数据
|
||||
return Result.success(Map.of(
|
||||
"tenantId", tenantId,
|
||||
"isMock", true,
|
||||
"total", 2,
|
||||
"pageNum", pageNum,
|
||||
"pageSize", pageSize,
|
||||
@@ -75,16 +87,22 @@ public class OpenDeviceController {
|
||||
@GetMapping("/{deviceId}")
|
||||
@PreAuthorize("hasAuthority('SCOPE_device.read') or hasAuthority('SCOPE_all')")
|
||||
public Result<Object> getDevice(@PathVariable String deviceId) {
|
||||
Long tenantId = GatewaySecurityContext.getTenantIdOrDefault(1L);
|
||||
Long tenantId = GatewaySecurityContext.requireTenantId();
|
||||
|
||||
JsonNode resp = internalClient.get("/api/internal/open/devices/" + deviceId, tenantId, null);
|
||||
if (resp != null && resp.has("code") && resp.get("code").asInt() == 200 && resp.has("data")) {
|
||||
return Result.success(resp.get("data"));
|
||||
}
|
||||
|
||||
if (!mockEnabled) {
|
||||
String errMsg = resp != null && resp.has("message") ? resp.get("message").asText() : "设备不存在或核心服务未响应";
|
||||
throw new BizException(502, "查询设备详情失败: " + errMsg);
|
||||
}
|
||||
|
||||
return Result.success(Map.of(
|
||||
"deviceCode", deviceId,
|
||||
"tenantId", tenantId,
|
||||
"isMock", true,
|
||||
"deviceName", "1号注塑机 (海天MA1200)",
|
||||
"online", 1
|
||||
));
|
||||
|
||||
@@ -1,11 +1,16 @@
|
||||
package com.iot.gateway.openapi;
|
||||
|
||||
import com.fasterxml.jackson.databind.JsonNode;
|
||||
import com.iot.gateway.common.BackendInternalClient;
|
||||
import com.iot.gateway.common.BizException;
|
||||
import com.iot.gateway.common.Result;
|
||||
import com.iot.gateway.oauth.GatewaySecurityContext;
|
||||
import io.swagger.v3.oas.annotations.Operation;
|
||||
import io.swagger.v3.oas.annotations.tags.Tag;
|
||||
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.security.access.prepost.PreAuthorize;
|
||||
import org.springframework.web.bind.annotation.GetMapping;
|
||||
import org.springframework.web.bind.annotation.PathVariable;
|
||||
@@ -19,16 +24,38 @@ import org.springframework.web.bind.annotation.RestController;
|
||||
@RequestMapping("/open/v1/telemetry")
|
||||
public class OpenTelemetryController {
|
||||
|
||||
@Autowired
|
||||
private BackendInternalClient internalClient;
|
||||
|
||||
@Value("${gateway.mock.enabled:false}")
|
||||
private boolean mockEnabled;
|
||||
|
||||
@Operation(summary = "召测单设备最新实时遥测点位数据")
|
||||
@GetMapping("/latest/{deviceId}")
|
||||
@PreAuthorize("hasAuthority('SCOPE_telemetry.read') or hasAuthority('SCOPE_all')")
|
||||
public Result<Map<String, Object>> getLatestTelemetry(@PathVariable String deviceId) {
|
||||
Long tenantId = GatewaySecurityContext.getTenantIdOrDefault(1L);
|
||||
Long tenantId = GatewaySecurityContext.requireTenantId();
|
||||
log.info("三方系统召测最新遥测: deviceId={}, tenantId={}", deviceId, tenantId);
|
||||
|
||||
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")) {
|
||||
return Result.success(Map.of(
|
||||
"deviceId", deviceId,
|
||||
"tenantId", tenantId,
|
||||
"timestamp", System.currentTimeMillis(),
|
||||
"data", resp.get("data")
|
||||
));
|
||||
}
|
||||
|
||||
if (!mockEnabled) {
|
||||
String errMsg = resp != null && resp.has("message") ? resp.get("message").asText() : "后端遥测接口不可用或设备无数据";
|
||||
throw new BizException(502, "召测设备遥测失败: " + errMsg);
|
||||
}
|
||||
|
||||
return Result.success(Map.of(
|
||||
"deviceId", deviceId,
|
||||
"tenantId", tenantId,
|
||||
"isMock", true,
|
||||
"timestamp", System.currentTimeMillis(),
|
||||
"values", Map.of(
|
||||
"barrel_temp_zone1", 215.4,
|
||||
@@ -49,9 +76,11 @@ public class OpenTelemetryController {
|
||||
@RequestParam(required = false) Long startTime,
|
||||
@RequestParam(required = false) Long endTime) {
|
||||
|
||||
Long tenantId = GatewaySecurityContext.requireTenantId();
|
||||
long now = System.currentTimeMillis();
|
||||
return Result.success(Map.of(
|
||||
"deviceId", deviceId,
|
||||
"tenantId", tenantId,
|
||||
"pointKey", pointKey,
|
||||
"points", java.util.List.of(
|
||||
Map.of("ts", now - 60000, "val", 214.8),
|
||||
|
||||
@@ -1,12 +1,19 @@
|
||||
server:
|
||||
port: 18082
|
||||
port: ${PORT:18082}
|
||||
|
||||
spring:
|
||||
datasource:
|
||||
url: jdbc:mysql://127.0.0.1:14406/iot_platform?useUnicode=true&characterEncoding=utf8&useSSL=false&serverTimezone=Asia/Shanghai&allowPublicKeyRetrieval=true
|
||||
username: iot_app
|
||||
password: DRq7DfU9VykOFcIoHXKa
|
||||
url: ${DB_URL:jdbc:mysql://127.0.0.1:14406/iot_platform?useUnicode=true&characterEncoding=utf8&useSSL=false&serverTimezone=Asia/Shanghai&allowPublicKeyRetrieval=true}
|
||||
username: ${DB_USERNAME:iot_app}
|
||||
password: ${DB_PASSWORD:}
|
||||
|
||||
gateway:
|
||||
oauth:
|
||||
jwt-secret: ${GATEWAY_JWT_SECRET:}
|
||||
backend:
|
||||
base-url: http://127.0.0.1:18086
|
||||
base-url: ${GATEWAY_BACKEND_URL:http://127.0.0.1:18086}
|
||||
internal-secret: ${GATEWAY_INTERNAL_SECRET:}
|
||||
admin:
|
||||
secret: ${GATEWAY_ADMIN_SECRET:}
|
||||
mock:
|
||||
enabled: ${GATEWAY_MOCK_ENABLED:false}
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
server:
|
||||
port: 8082
|
||||
port: ${PORT:8082}
|
||||
|
||||
spring:
|
||||
application:
|
||||
@@ -7,9 +7,9 @@ spring:
|
||||
|
||||
datasource:
|
||||
driver-class-name: com.mysql.cj.jdbc.Driver
|
||||
url: jdbc:mysql://8.130.108.77:3309/iot_platform?useUnicode=true&characterEncoding=utf8&useSSL=false&serverTimezone=Asia/Shanghai&allowPublicKeyRetrieval=true
|
||||
username: root
|
||||
password: "123456"
|
||||
url: ${DB_URL:jdbc:mysql://127.0.0.1:3306/iot_platform?useUnicode=true&characterEncoding=utf8&useSSL=false&serverTimezone=Asia/Shanghai&allowPublicKeyRetrieval=true}
|
||||
username: ${DB_USERNAME:iot_app}
|
||||
password: ${DB_PASSWORD:}
|
||||
|
||||
mybatis-plus:
|
||||
configuration:
|
||||
@@ -20,16 +20,22 @@ mybatis-plus:
|
||||
db-config:
|
||||
id-type: auto
|
||||
|
||||
# 开放网关核心治理配置
|
||||
# 开放网关核心治理配置 (生产凭据由运行环境注入,严禁在仓库硬编码)
|
||||
gateway:
|
||||
# OAuth 2.0 JWT 签名私钥
|
||||
oauth:
|
||||
jwt-secret: iot-gateway-oauth2-super-secret-key-666888-abcdefghijklmnopqrstuvwxyz
|
||||
token-expire-seconds: 7200 # 2小时
|
||||
# 核心物联后端内网地址 (用于设备遥测数据拉取与控制反写代理)
|
||||
jwt-secret: ${GATEWAY_JWT_SECRET:}
|
||||
token-expire-seconds: ${GATEWAY_TOKEN_EXPIRE:7200}
|
||||
# 核心物联后端内网地址与专线通信密钥
|
||||
backend:
|
||||
base-url: ${GATEWAY_BACKEND_URL:http://localhost:8080}
|
||||
internal-secret: ${GATEWAY_INTERNAL_SECRET:iot-internal-open-gateway-secret-key-20261009-xyz}
|
||||
internal-secret: ${GATEWAY_INTERNAL_SECRET:}
|
||||
# 管理员运维密钥通道
|
||||
admin:
|
||||
secret: ${GATEWAY_ADMIN_SECRET:}
|
||||
# 演示/Mock 数据开关 (生产环境默认关闭)
|
||||
mock:
|
||||
enabled: ${GATEWAY_MOCK_ENABLED:false}
|
||||
|
||||
springdoc:
|
||||
swagger-ui:
|
||||
|
||||
Reference in New Issue
Block a user