fix: harden gateway auth and credential handling

This commit is contained in:
wjh
2026-10-09 15:47:56 +08:00
parent 00a9dcaf2f
commit 12920d698a
19 changed files with 1168 additions and 250 deletions
@@ -6,6 +6,7 @@ import com.iot.gateway.common.BizException;
import com.iot.gateway.common.Result; import com.iot.gateway.common.Result;
import com.iot.gateway.entity.OpenClient; import com.iot.gateway.entity.OpenClient;
import com.iot.gateway.mapper.OpenClientMapper; 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.Operation;
import io.swagger.v3.oas.annotations.tags.Tag; import io.swagger.v3.oas.annotations.tags.Tag;
import java.security.SecureRandom; import java.security.SecureRandom;
@@ -14,6 +15,8 @@ import java.util.Map;
import java.util.UUID; import java.util.UUID;
import lombok.extern.slf4j.Slf4j; import lombok.extern.slf4j.Slf4j;
import org.springframework.beans.factory.annotation.Autowired; 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.util.StringUtils;
import org.springframework.web.bind.annotation.DeleteMapping; import org.springframework.web.bind.annotation.DeleteMapping;
import org.springframework.web.bind.annotation.GetMapping; import org.springframework.web.bind.annotation.GetMapping;
@@ -29,11 +32,15 @@ import org.springframework.web.bind.annotation.RestController;
@Tag(name = "开放平台管理 - 三方应用凭据管理") @Tag(name = "开放平台管理 - 三方应用凭据管理")
@RestController @RestController
@RequestMapping("/api/open/client") @RequestMapping("/api/open/client")
@PreAuthorize("hasAnyAuthority('ROLE_ADMIN', 'SCOPE_admin', 'SCOPE_tenant_admin')")
public class OpenClientAdminController { public class OpenClientAdminController {
@Autowired @Autowired
private OpenClientMapper clientMapper; private OpenClientMapper clientMapper;
@Autowired
private PasswordEncoder passwordEncoder;
@Autowired(required = false) @Autowired(required = false)
private org.springframework.jdbc.core.JdbcTemplate jdbcTemplate; private org.springframework.jdbc.core.JdbcTemplate jdbcTemplate;
@@ -45,7 +52,7 @@ public class OpenClientAdminController {
return Result.success(com.iot.gateway.oauth.OpenScopeEnum.toList()); return Result.success(com.iot.gateway.oauth.OpenScopeEnum.toList());
} }
@Operation(summary = "三方应用凭据分页列表") @Operation(summary = "三方应用凭据分页列表 (严禁泄露密钥哈希)")
@GetMapping("/page") @GetMapping("/page")
public Result<Page<OpenClient>> page( public Result<Page<OpenClient>> page(
@RequestParam(defaultValue = "1") Integer pageNum, @RequestParam(defaultValue = "1") Integer pageNum,
@@ -53,18 +60,42 @@ public class OpenClientAdminController {
@RequestParam(required = false) Long tenantId, @RequestParam(required = false) Long tenantId,
@RequestParam(required = false) String keyword) { @RequestParam(required = false) String keyword) {
GatewaySecurityContext ctx = GatewaySecurityContext.get();
if (ctx == null) {
throw new BizException(401, "未授权访问");
}
Page<OpenClient> page = new Page<>(pageNum, pageSize); Page<OpenClient> page = new Page<>(pageNum, pageSize);
LambdaQueryWrapper<OpenClient> qw = new LambdaQueryWrapper<>(); LambdaQueryWrapper<OpenClient> qw = new LambdaQueryWrapper<>();
if (tenantId != null) {
qw.eq(OpenClient::getTenantId, tenantId); 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)) { if (StringUtils.hasText(keyword)) {
qw.and(w -> w.like(OpenClient::getClientName, keyword) qw.and(w -> w.like(OpenClient::getClientName, keyword)
.or().like(OpenClient::getClientId, keyword)); .or().like(OpenClient::getClientId, keyword));
} }
qw.orderByDesc(OpenClient::getCreateTime); qw.orderByDesc(OpenClient::getCreateTime);
Page<OpenClient> resPage = clientMapper.selectPage(page, qw); Page<OpenClient> resPage = clientMapper.selectPage(page, qw);
populateTenantNames(resPage.getRecords());
// 密钥完全脱敏:列表严禁返回哈希或明文
if (resPage.getRecords() != null) {
resPage.getRecords().forEach(c -> {
c.setClientSecret(null);
c.setRawSecret(null);
});
populateTenantNames(resPage.getRecords());
}
return Result.success(resPage); return Result.success(resPage);
} }
@@ -92,27 +123,53 @@ public class OpenClientAdminController {
} }
} }
@Operation(summary = "获取凭据详情") @Operation(summary = "获取凭据详情 (不返回 Secret)")
@GetMapping("/{id}") @GetMapping("/{id}")
public Result<OpenClient> getById(@PathVariable Long id) { public Result<OpenClient> getById(@PathVariable Long id) {
OpenClient client = clientMapper.selectById(id); OpenClient client = clientMapper.selectById(id);
if (client == null) throw new BizException("客户端凭据不存在"); if (client == null) throw new BizException("客户端凭据不存在");
checkTenantAccess(client.getTenantId());
// 详情接口严禁暴露 Secret
client.setClientSecret(null);
client.setRawSecret(null);
return Result.success(client); return Result.success(client);
} }
@Operation(summary = "新建三方应用凭据 (自动生成 ClientId 与 Secret)") @Operation(summary = "新建三方应用凭据 (数据库保存 BCrypt 哈希,仅创建成功时返回一次明文)")
@PostMapping @PostMapping
public Result<OpenClient> create(@RequestBody OpenClient req) { public Result<OpenClient> create(@RequestBody OpenClient req) {
GatewaySecurityContext ctx = GatewaySecurityContext.get();
if (ctx == null) throw new BizException(401, "未授权操作");
req.setId(null); req.setId(null);
req.setClientId("client_" + UUID.randomUUID().toString().replace("-", "").substring(0, 12)); 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 (!StringUtils.hasText(req.getScopes())) req.setScopes("device.read");
if (req.getStatus() == null) req.setStatus(1); if (req.getStatus() == null) req.setStatus(1);
if (req.getRateLimit() == null) req.setRateLimit(60); if (req.getRateLimit() == null) req.setRateLimit(60);
clientMapper.insert(req); 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); return Result.success(req);
} }
@@ -122,6 +179,8 @@ public class OpenClientAdminController {
OpenClient client = clientMapper.selectById(id); OpenClient client = clientMapper.selectById(id);
if (client == null) throw new BizException("客户端凭据不存在"); if (client == null) throw new BizException("客户端凭据不存在");
checkTenantAccess(client.getTenantId());
client.setClientName(req.getClientName()); client.setClientName(req.getClientName());
if (req.getScopes() != null) client.setScopes(req.getScopes()); if (req.getScopes() != null) client.setScopes(req.getScopes());
client.setIpWhitelist(req.getIpWhitelist()); client.setIpWhitelist(req.getIpWhitelist());
@@ -137,20 +196,34 @@ public class OpenClientAdminController {
@Operation(summary = "删除三方应用凭据") @Operation(summary = "删除三方应用凭据")
@DeleteMapping("/{id}") @DeleteMapping("/{id}")
public Result<Void> delete(@PathVariable Long 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); clientMapper.deleteById(id);
return Result.success(); return Result.success();
} }
@Operation(summary = "重置客户端密钥 (Secret)") @Operation(summary = "重置客户端密钥 (仅在重置成功时返回一次新明文 Secret)")
@PostMapping("/{id}/reset-secret") @PostMapping("/{id}/reset-secret")
public Result<Map<String, String>> resetSecret(@PathVariable Long id) { public Result<Map<String, String>> resetSecret(@PathVariable Long id) {
OpenClient client = clientMapper.selectById(id); OpenClient client = clientMapper.selectById(id);
if (client == null) throw new BizException("客户端凭据不存在"); if (client == null) throw new BizException("客户端凭据不存在");
String newSecret = "sec_" + UUID.randomUUID().toString().replace("-", "") + Long.toHexString(RANDOM.nextLong()); checkTenantAccess(client.getTenantId());
client.setClientSecret(newSecret);
String rawSecret = "sec_" + UUID.randomUUID().toString().replace("-", "") + Long.toHexString(RANDOM.nextLong());
client.setClientSecret(passwordEncoder.encode(rawSecret));
clientMapper.updateById(client); 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 = "更新客户端状态") @Operation(summary = "更新客户端状态")
@@ -158,8 +231,24 @@ public class OpenClientAdminController {
public Result<Void> updateStatus(@PathVariable Long id, @RequestParam Integer status) { public Result<Void> updateStatus(@PathVariable Long id, @RequestParam Integer status) {
OpenClient client = clientMapper.selectById(id); OpenClient client = clientMapper.selectById(id);
if (client == null) throw new BizException("客户端凭据不存在"); if (client == null) throw new BizException("客户端凭据不存在");
checkTenantAccess(client.getTenantId());
client.setStatus(status); client.setStatus(status);
clientMapper.updateById(client); clientMapper.updateById(client);
return Result.success(); 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.common.Result;
import com.iot.gateway.entity.OpenWebhook; import com.iot.gateway.entity.OpenWebhook;
import com.iot.gateway.mapper.OpenWebhookMapper; import com.iot.gateway.mapper.OpenWebhookMapper;
import com.iot.gateway.oauth.GatewaySecurityContext;
import com.iot.gateway.webhook.WebhookDispatchService; import com.iot.gateway.webhook.WebhookDispatchService;
import io.swagger.v3.oas.annotations.Operation; import io.swagger.v3.oas.annotations.Operation;
import io.swagger.v3.oas.annotations.tags.Tag; import io.swagger.v3.oas.annotations.tags.Tag;
@@ -14,6 +15,7 @@ import java.util.List;
import java.util.Map; import java.util.Map;
import lombok.extern.slf4j.Slf4j; import lombok.extern.slf4j.Slf4j;
import org.springframework.beans.factory.annotation.Autowired; import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.security.access.prepost.PreAuthorize;
import org.springframework.util.StringUtils; import org.springframework.util.StringUtils;
import org.springframework.web.bind.annotation.DeleteMapping; import org.springframework.web.bind.annotation.DeleteMapping;
import org.springframework.web.bind.annotation.GetMapping; import org.springframework.web.bind.annotation.GetMapping;
@@ -29,6 +31,7 @@ import org.springframework.web.bind.annotation.RestController;
@Tag(name = "开放平台管理 - Webhook 事件订阅与通道管理") @Tag(name = "开放平台管理 - Webhook 事件订阅与通道管理")
@RestController @RestController
@RequestMapping("/api/open/webhook") @RequestMapping("/api/open/webhook")
@PreAuthorize("hasAnyAuthority('ROLE_ADMIN', 'SCOPE_admin', 'SCOPE_tenant_admin')")
public class OpenWebhookAdminController { public class OpenWebhookAdminController {
@Autowired @Autowired
@@ -98,11 +101,21 @@ public class OpenWebhookAdminController {
@RequestParam(required = false) String clientId, @RequestParam(required = false) String clientId,
@RequestParam(required = false) String keyword) { @RequestParam(required = false) String keyword) {
GatewaySecurityContext ctx = GatewaySecurityContext.get();
if (ctx == null) throw new BizException(401, "未授权访问");
Page<OpenWebhook> page = new Page<>(pageNum, pageSize); Page<OpenWebhook> page = new Page<>(pageNum, pageSize);
LambdaQueryWrapper<OpenWebhook> qw = new LambdaQueryWrapper<>(); LambdaQueryWrapper<OpenWebhook> qw = new LambdaQueryWrapper<>();
if (tenantId != null) {
qw.eq(OpenWebhook::getTenantId, tenantId); 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)) { if (StringUtils.hasText(clientId)) {
qw.eq(OpenWebhook::getClientId, clientId); qw.eq(OpenWebhook::getClientId, clientId);
} }
@@ -157,10 +170,17 @@ public class OpenWebhookAdminController {
@Operation(summary = "新建 Webhook 通道") @Operation(summary = "新建 Webhook 通道")
@PostMapping @PostMapping
public Result<OpenWebhook> create(@RequestBody OpenWebhook req) { public Result<OpenWebhook> create(@RequestBody OpenWebhook req) {
GatewaySecurityContext ctx = GatewaySecurityContext.get();
if (ctx == null) throw new BizException(401, "未授权操作");
req.setId(null); 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); req.setTenantId(1L);
} }
if (!StringUtils.hasText(req.getEvents())) { if (!StringUtils.hasText(req.getEvents())) {
req.setEvents("alarm.triggered"); req.setEvents("alarm.triggered");
} }
@@ -183,7 +203,10 @@ public class OpenWebhookAdminController {
throw new BizException("通道不存在"); 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.setTenantId(req.getTenantId());
} }
item.setClientId(req.getClientId()); item.setClientId(req.getClientId());
@@ -206,6 +229,13 @@ public class OpenWebhookAdminController {
@Operation(summary = "删除 Webhook 通道") @Operation(summary = "删除 Webhook 通道")
@DeleteMapping("/{id}") @DeleteMapping("/{id}")
public Result<Void> delete(@PathVariable Long 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); webhookMapper.deleteById(id);
return Result.success(); return Result.success();
} }
@@ -218,10 +248,25 @@ public class OpenWebhookAdminController {
throw new BizException("通道不存在"); throw new BizException("通道不存在");
} }
checkTenantAccess(item.getTenantId());
String title = body != null && body.containsKey("title") ? body.get("title") : "【IoT开放平台测试】三方事件回调连通性验证"; String title = body != null && body.containsKey("title") ? body.get("title") : "【IoT开放平台测试】三方事件回调连通性验证";
String content = body != null && body.containsKey("content") ? body.get("content") : "这是一条来自物联网开放平台的模拟业务事件通知,包含签名验证与数据包投递。"; String content = body != null && body.containsKey("content") ? body.get("content") : "这是一条来自物联网开放平台的模拟业务事件通知,包含签名验证与数据包投递。";
boolean ok = dispatchService.sendDirect(item.getChannelType(), item.getWebhookUrl(), title, content, item.getSecret()); boolean ok = dispatchService.sendDirect(item.getChannelType(), item.getWebhookUrl(), title, content, item.getSecret());
return Result.success(Map.of("success", ok, "message", ok ? "测试事件已成功投递" : "投递失败,请检查回调 URL 连通性或服务响应状态")); 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.JsonNode;
import com.fasterxml.jackson.databind.ObjectMapper; import com.fasterxml.jackson.databind.ObjectMapper;
import java.net.ConnectException;
import java.net.SocketTimeoutException;
import java.net.URI; import java.net.URI;
import java.net.URLEncoder;
import java.net.http.HttpClient; import java.net.http.HttpClient;
import java.net.http.HttpConnectTimeoutException;
import java.net.http.HttpRequest; import java.net.http.HttpRequest;
import java.net.http.HttpResponse; import java.net.http.HttpResponse;
import java.net.http.HttpTimeoutException;
import java.nio.charset.StandardCharsets;
import java.time.Duration; import java.time.Duration;
import java.util.HashMap; import java.util.HashMap;
import java.util.Map; import java.util.Map;
@@ -16,8 +22,7 @@ import org.springframework.stereotype.Component;
/** /**
* 网关与核心业务后端 (IOT-backend) 的专线 HTTP 桥接客户端 * 网关与核心业务后端 (IOT-backend) 的专线 HTTP 桥接客户端
* <p> * <p>
* 自动向核心后端传递 X-Internal-Secret 与 X-Tenant-Id, * 具备 URL 安全编码、错误细粒度分类、幂等 GET 有限重试与租户强制隔离。
* 触发核心后端切入指定租户的独立物理数据库。
*/ */
@Slf4j @Slf4j
@Component @Component
@@ -26,77 +31,164 @@ public class BackendInternalClient {
@Value("${gateway.backend.base-url:http://localhost:8080}") @Value("${gateway.backend.base-url:http://localhost:8080}")
private String backendBaseUrl; 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 String internalSecret;
private final HttpClient httpClient = HttpClient.newBuilder() private final HttpClient httpClient = HttpClient.newBuilder()
.connectTimeout(Duration.ofSeconds(5)) .connectTimeout(Duration.ofSeconds(4))
.build(); .build();
private final ObjectMapper objectMapper = new ObjectMapper(); private final ObjectMapper objectMapper = new ObjectMapper();
/** /**
* 内部专线 GET 请求 * 内部专线 GET 请求 (支持参数 URL 编码、错误细粒度分类与网络抖动单次有限重试)
*/ */
public JsonNode get(String path, Long tenantId, Map<String, String> queryParams) { public JsonNode get(String path, Long tenantId, Map<String, String> queryParams) {
StringBuilder urlBuilder = new StringBuilder(backendBaseUrl).append(path); if (tenantId == null) {
if (queryParams != null && !queryParams.isEmpty()) { throw new BizException(401, "专线请求缺少租户上下文,严禁跨租户或无租户调用");
urlBuilder.append("?"); }
queryParams.forEach((k, v) -> {
if (v != null && !v.isEmpty()) { String fullUrl = buildGetUrl(path, queryParams);
urlBuilder.append(k).append("=").append(v).append("&"); int maxAttempts = 2; // 最多尝试2次(初次 + 1次重试)
for (int attempt = 1; attempt <= maxAttempts; attempt++) {
try {
HttpRequest request = HttpRequest.newBuilder()
.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(5))
.GET()
.build();
HttpResponse<String> resp = httpClient.send(request, HttpResponse.BodyHandlers.ofString());
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());
urlBuilder.deleteCharAt(urlBuilder.length() - 1); throw new BizException(504, "调用核心后端业务超时,服务可能处于高负荷: " + e.getMessage());
}
try { } catch (ConnectException e) {
HttpRequest request = HttpRequest.newBuilder() if (attempt < maxAttempts) {
.uri(URI.create(urlBuilder.toString())) log.warn("GET 连接异常,正在尝试重连 (attempt={}): url={}", attempt, fullUrl);
.header("X-Internal-Secret", internalSecret) backoff(200);
.header("X-Tenant-Id", String.valueOf(tenantId != null ? tenantId : 1L)) continue;
.header("Content-Type", "application/json") }
.timeout(Duration.ofSeconds(6)) log.error("核心后端连接拒绝: url={}, tenantId={}, err={}", fullUrl, tenantId, e.getMessage());
.GET() throw new BizException(503, "核心后端服务不可达,请检查后端运行状态");
.build();
HttpResponse<String> resp = httpClient.send(request, HttpResponse.BodyHandlers.ofString()); } catch (BizException be) {
if (resp.statusCode() == 200) { throw be;
return objectMapper.readTree(resp.body());
} else { } catch (Exception e) {
log.warn("核心后端内部专线响应非 200: status={}, body={}", resp.statusCode(), resp.body()); log.error("调用核心后端 GET 异常: url={}, tenantId={}, err={}", fullUrl, tenantId, e.getMessage());
throw new BizException(502, "核心物联后端通信异常: " + e.getMessage());
} }
} catch (Exception e) {
log.warn("调用核心后端内部接口异常: url={}, tenantId={}, err={}", urlBuilder, tenantId, e.getMessage());
} }
return null;
throw new BizException(504, "调用核心后端业务多次超时");
} }
/** /**
* 内部专线 POST 请求 * 内部专线 POST 请求 (设备反写与业务提交严禁重试,避免指令重复执行)
*/ */
public JsonNode post(String path, Long tenantId, Map<String, Object> body) { 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 { try {
String jsonStr = objectMapper.writeValueAsString(body != null ? body : new HashMap<>()); String jsonStr = objectMapper.writeValueAsString(body != null ? body : new HashMap<>());
HttpRequest request = HttpRequest.newBuilder() HttpRequest request = HttpRequest.newBuilder()
.uri(URI.create(url)) .uri(URI.create(fullUrl))
.header("X-Internal-Secret", internalSecret) .header("X-Internal-Secret", internalSecret != null ? internalSecret : "")
.header("X-Tenant-Id", String.valueOf(tenantId != null ? tenantId : 1L)) .header("X-Tenant-Id", String.valueOf(tenantId))
.header("Content-Type", "application/json") .header("Content-Type", "application/json")
.timeout(Duration.ofSeconds(6)) .timeout(Duration.ofSeconds(6))
.POST(HttpRequest.BodyPublishers.ofString(jsonStr)) .POST(HttpRequest.BodyPublishers.ofString(jsonStr))
.build(); .build();
HttpResponse<String> resp = httpClient.send(request, HttpResponse.BodyHandlers.ofString()); HttpResponse<String> resp = httpClient.send(request, HttpResponse.BodyHandlers.ofString());
if (resp.statusCode() == 200) { return handleResponse(resp, fullUrl);
return objectMapper.readTree(resp.body());
} else { } catch (HttpTimeoutException | SocketTimeoutException e) {
log.warn("核心后端内部专线响应非 200: status={}, body={}", resp.statusCode(), resp.body()); 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) { } 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) {}
} }
} }
+100 -72
View File
@@ -4,11 +4,13 @@ import jakarta.servlet.http.HttpServletRequest;
import java.net.InetAddress; import java.net.InetAddress;
import java.net.UnknownHostException; import java.net.UnknownHostException;
import java.util.Arrays; import java.util.Arrays;
import java.util.List;
import java.util.concurrent.CopyOnWriteArrayList;
import lombok.extern.slf4j.Slf4j; import lombok.extern.slf4j.Slf4j;
import org.springframework.util.StringUtils; import org.springframework.util.StringUtils;
/** /**
* IP 获取与白名单校验工具类 * IP 获取与白名单校验工具类 (具备可信反向代理校验防御)
*/ */
@Slf4j @Slf4j
public class IpUtils { public class IpUtils {
@@ -18,13 +20,47 @@ public class IpUtils {
private static final String LOCALHOST_IPV6 = "0:0:0:0:0:0:0:1"; 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) { public static String getClientIp(HttpServletRequest request) {
if (request == null) { if (request == null) {
return UNKNOWN; 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"); String ip = request.getHeader("X-Forwarded-For");
if (isInvalidIp(ip)) { if (isInvalidIp(ip)) {
ip = request.getHeader("X-Real-IP"); ip = request.getHeader("X-Real-IP");
@@ -36,13 +72,7 @@ public class IpUtils {
ip = request.getHeader("WL-Proxy-Client-IP"); ip = request.getHeader("WL-Proxy-Client-IP");
} }
if (isInvalidIp(ip)) { if (isInvalidIp(ip)) {
ip = request.getHeader("HTTP_CLIENT_IP"); ip = remoteAddr;
}
if (isInvalidIp(ip)) {
ip = request.getHeader("HTTP_X_FORWARDED_FOR");
}
if (isInvalidIp(ip)) {
ip = request.getRemoteAddr();
} }
// 多级反向代理会有多个逗号分隔的 IP,首个为客户端真实源 IP // 多级反向代理会有多个逗号分隔的 IP,首个为客户端真实源 IP
@@ -63,19 +93,27 @@ public class IpUtils {
return ip != null ? ip.trim() : UNKNOWN; 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) { private static boolean isInvalidIp(String ip) {
return !StringUtils.hasText(ip) || UNKNOWN.equalsIgnoreCase(ip.trim()); return !StringUtils.hasText(ip) || UNKNOWN.equalsIgnoreCase(ip.trim());
} }
/** /**
* 校验客户端 IP 是否匹配白名单 * 校验客户端 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) { public static boolean isAllowed(String clientIp, String whitelist) {
// 白名单为空则不限制
if (!StringUtils.hasText(whitelist)) { if (!StringUtils.hasText(whitelist)) {
return true; return true;
} }
@@ -83,10 +121,8 @@ public class IpUtils {
return false; return false;
} }
// 统一规范化 localhost
String normalizedClientIp = LOCALHOST_IPV6.equals(clientIp.trim()) ? LOCALHOST_IPV4 : clientIp.trim(); String normalizedClientIp = LOCALHOST_IPV6.equals(clientIp.trim()) ? LOCALHOST_IPV4 : clientIp.trim();
// 支持多种常见分隔符
String[] rules = whitelist.split("[,;,\\n\\r]+"); String[] rules = whitelist.split("[,;,\\n\\r]+");
for (String rule : rules) { for (String rule : rules) {
String pattern = rule.trim(); 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) { public static boolean matchIp(String ip, String pattern) {
if ("*".equals(pattern)) { if (ip == null || pattern == null) {
return true; return false;
}
// 规范化 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);
} }
return false; 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) {
* 校验 IPv4 是否在 CIDR 网段内 String regex = pattern.replace(".", "\\.").replace("*", ".*");
*/ return ip.matches(regex);
private static boolean isInCidrRange(String ipStr, String cidr) { }
private static boolean matchCidr(String ip, String cidr) {
try { try {
String[] parts = cidr.split("/"); String[] parts = cidr.split("/");
if (parts.length != 2) { if (parts.length != 2) return false;
return false;
} String network = parts[0];
int maskBits = Integer.parseInt(parts[1].trim()); int prefixLength = Integer.parseInt(parts[1]);
if (maskBits < 0 || maskBits > 32) {
return false; 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); if (bitCount > 0 && byteCount < ipBytes.length) {
InetAddress subnetAddr = InetAddress.getByName(parts[0].trim()); int mask = (0xFF << (8 - bitCount)) & 0xFF;
return (ipBytes[byteCount] & mask) == (netBytes[byteCount] & mask);
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;
}
} }
return true; return true;
} catch (UnknownHostException | NumberFormatException e) { } 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; return false;
} }
} }
@@ -1,9 +1,11 @@
package com.iot.gateway.entity; package com.iot.gateway.entity;
import com.baomidou.mybatisplus.annotation.IdType; import com.baomidou.mybatisplus.annotation.IdType;
import com.baomidou.mybatisplus.annotation.TableField;
import com.baomidou.mybatisplus.annotation.TableId; import com.baomidou.mybatisplus.annotation.TableId;
import com.baomidou.mybatisplus.annotation.TableName; import com.baomidou.mybatisplus.annotation.TableName;
import com.fasterxml.jackson.annotation.JsonFormat; import com.fasterxml.jackson.annotation.JsonFormat;
import com.fasterxml.jackson.annotation.JsonProperty;
import java.io.Serializable; import java.io.Serializable;
import java.time.LocalDateTime; import java.time.LocalDateTime;
import lombok.Data; import lombok.Data;
@@ -16,11 +18,23 @@ public class OpenClient implements Serializable {
private Long id; private Long id;
private String clientId; private String clientId;
/**
* 客户端密钥哈希(数据库存储 BCrypt 哈希,序列化对外输出时默认忽略,防止密钥泄露)
*/
@JsonProperty(access = JsonProperty.Access.WRITE_ONLY)
private String clientSecret; private String clientSecret;
/**
* 仅在创建或重置密钥时返回一次的明文密钥
*/
@TableField(exist = false)
private String rawSecret;
private String clientName; private String clientName;
private Long tenantId; private Long tenantId;
@com.baomidou.mybatisplus.annotation.TableField(exist = false) @TableField(exist = false)
private String tenantName; private String tenantName;
private String scopes; private String scopes;
@@ -1,18 +1,25 @@
package com.iot.gateway.mcp; package com.iot.gateway.mcp;
import com.fasterxml.jackson.databind.ObjectMapper; import com.fasterxml.jackson.databind.ObjectMapper;
import com.iot.gateway.common.BizException;
import com.iot.gateway.oauth.GatewaySecurityContext; import com.iot.gateway.oauth.GatewaySecurityContext;
import io.swagger.v3.oas.annotations.Operation; import io.swagger.v3.oas.annotations.Operation;
import io.swagger.v3.oas.annotations.tags.Tag; import io.swagger.v3.oas.annotations.tags.Tag;
import jakarta.servlet.http.HttpServletResponse; import jakarta.servlet.http.HttpServletResponse;
import java.io.IOException; import java.io.IOException;
import java.util.Collections;
import java.util.HashMap; import java.util.HashMap;
import java.util.List; import java.util.List;
import java.util.Map; import java.util.Map;
import java.util.UUID; import java.util.UUID;
import java.util.concurrent.ConcurrentHashMap; import java.util.concurrent.ConcurrentHashMap;
import lombok.AllArgsConstructor;
import lombok.Builder;
import lombok.Data;
import lombok.NoArgsConstructor;
import lombok.extern.slf4j.Slf4j; import lombok.extern.slf4j.Slf4j;
import org.springframework.beans.factory.annotation.Autowired; import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.http.HttpStatus;
import org.springframework.http.MediaType; import org.springframework.http.MediaType;
import org.springframework.http.ResponseEntity; import org.springframework.http.ResponseEntity;
import org.springframework.web.bind.annotation.GetMapping; import org.springframework.web.bind.annotation.GetMapping;
@@ -34,31 +41,78 @@ public class McpSseController {
private final ObjectMapper objectMapper = new ObjectMapper(); private final ObjectMapper objectMapper = new ObjectMapper();
/** 维护 SSE 活跃会话 */ /** 维护 SSE 活跃网络通道 */
private final Map<String, SseEmitter> sessionMap = new ConcurrentHashMap<>(); 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 客户端)") @Operation(summary = "建立 MCP SSE 长连接端点 (用于 Cursor/Antigravity/Agent 客户端)")
@GetMapping(value = "/sse", produces = MediaType.TEXT_EVENT_STREAM_VALUE) @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("Cache-Control", "no-cache");
response.setHeader("X-Accel-Buffering", "no"); response.setHeader("X-Accel-Buffering", "no");
String sessionId = UUID.randomUUID().toString().replace("-", ""); String sessionId = UUID.randomUUID().toString().replace("-", "");
SseEmitter emitter = new SseEmitter(24 * 60 * 60 * 1000L); // 24小时超长连接保活 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); sessionMap.put(sessionId, emitter);
sessionInfoMap.put(sessionId, sessionInfo);
emitter.onCompletion(() -> { emitter.onCompletion(() -> {
log.info("MCP SSE 会话完成: sessionId={}", sessionId); log.info("MCP SSE 会话完成: sessionId={}", sessionId);
sessionMap.remove(sessionId); sessionMap.remove(sessionId);
sessionInfoMap.remove(sessionId);
}); });
emitter.onTimeout(() -> { emitter.onTimeout(() -> {
log.info("MCP SSE 会话超时: sessionId={}", sessionId); log.info("MCP SSE 会话超时: sessionId={}", sessionId);
sessionMap.remove(sessionId); sessionMap.remove(sessionId);
sessionInfoMap.remove(sessionId);
}); });
emitter.onError(e -> { emitter.onError(e -> {
log.warn("MCP SSE 会话异常: sessionId={}, err={}", sessionId, e.getMessage()); log.warn("MCP SSE 会话异常: sessionId={}, err={}", sessionId, e.getMessage());
sessionMap.remove(sessionId); sessionMap.remove(sessionId);
sessionInfoMap.remove(sessionId);
}); });
try { try {
@@ -66,7 +120,8 @@ public class McpSseController {
emitter.send(SseEmitter.event() emitter.send(SseEmitter.event()
.name("endpoint") .name("endpoint")
.data("/mcp/messages?sessionId=" + sessionId)); .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) { } catch (IOException e) {
log.error("发送 MCP endpoint 事件失败: {}", e.getMessage()); log.error("发送 MCP endpoint 事件失败: {}", e.getMessage());
} }
@@ -92,6 +147,45 @@ public class McpSseController {
log.info("收到 MCP JSON-RPC 请求: method={}, id={}, sessionId={}", method, id, sessionId); 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)) { if ("initialize".equalsIgnoreCase(method)) {
// 握手响应 // 握手响应
Map<String, Object> serverInfo = Map.of( Map<String, Object> serverInfo = Map.of(
@@ -119,10 +213,9 @@ public class McpSseController {
@SuppressWarnings("unchecked") @SuppressWarnings("unchecked")
Map<String, Object> arguments = params != null ? (Map<String, Object>) params.get("arguments") : Map.of(); Map<String, Object> arguments = params != null ? (Map<String, Object>) params.get("arguments") : Map.of();
Long tenantId = GatewaySecurityContext.getTenantIdOrDefault(1L);
try { 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); String contentJson = objectMapper.writeValueAsString(toolResult);
Map<String, Object> textContent = Map.of( Map<String, Object> textContent = Map.of(
@@ -130,11 +223,19 @@ public class McpSseController {
"text", contentJson "text", contentJson
); );
rpcResponse.put("result", Map.of("content", List.of(textContent))); rpcResponse.put("result", Map.of("content", List.of(textContent)));
} catch (Exception e) { } catch (BizException be) {
log.error("执行 MCP 工具异常: ", e); log.warn("执行 MCP 工具业务失败: tool={}, tenantId={}, code={}, msg={}",
toolName, effectiveTenantId, be.getCode(), be.getMessage());
Map<String, Object> errContent = Map.of( Map<String, Object> errContent = Map.of(
"type", "text", "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)); rpcResponse.put("result", Map.of("content", List.of(errContent), "isError", true));
} }
@@ -154,7 +255,7 @@ public class McpSseController {
.name("message") .name("message")
.data(objectMapper.writeValueAsString(rpcResponse))); .data(objectMapper.writeValueAsString(rpcResponse)));
} catch (Exception e) { } 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.fasterxml.jackson.databind.JsonNode;
import com.iot.gateway.common.BackendInternalClient; import com.iot.gateway.common.BackendInternalClient;
import com.iot.gateway.common.BizException;
import java.util.ArrayList; import java.util.ArrayList;
import java.util.HashMap; import java.util.HashMap;
import java.util.List; import java.util.List;
import java.util.Map; import java.util.Map;
import lombok.extern.slf4j.Slf4j; import lombok.extern.slf4j.Slf4j;
import org.springframework.beans.factory.annotation.Autowired; import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.beans.factory.annotation.Value;
import org.springframework.stereotype.Component; import org.springframework.stereotype.Component;
@Slf4j @Slf4j
@@ -17,13 +19,19 @@ public class McpToolRegistry {
@Autowired @Autowired
private BackendInternalClient internalClient; private BackendInternalClient internalClient;
/**
* 演示/开发模式开关。生产环境下必须为 false,避免将后端故障误报为“成功”或演示响应
*/
@Value("${gateway.mock.enabled:false}")
private boolean mockEnabled;
public List<Map<String, Object>> getToolDefinitions() { public List<Map<String, Object>> getToolDefinitions() {
List<Map<String, Object>> tools = new ArrayList<>(); List<Map<String, Object>> tools = new ArrayList<>();
// 1. iot_device_list // 1. iot_device_list
Map<String, Object> t1 = new HashMap<>(); Map<String, Object> t1 = new HashMap<>();
t1.put("name", "iot_device_list"); t1.put("name", "iot_device_list");
t1.put("description", "查询当前租户名下的所有工业物联网设备台账与实时运行状态(在线/报警/停机)。"); t1.put("description", "查询当前租户名下的所有工业物联网设备台账与实时运行状态(在线/报警/停机)。所需权限范围: device.read");
Map<String, Object> s1 = new HashMap<>(); Map<String, Object> s1 = new HashMap<>();
s1.put("type", "object"); s1.put("type", "object");
Map<String, Object> p1 = new HashMap<>(); Map<String, Object> p1 = new HashMap<>();
@@ -37,7 +45,7 @@ public class McpToolRegistry {
// 2. iot_telemetry_latest // 2. iot_telemetry_latest
Map<String, Object> t2 = new HashMap<>(); Map<String, Object> t2 = new HashMap<>();
t2.put("name", "iot_telemetry_latest"); t2.put("name", "iot_telemetry_latest");
t2.put("description", "召测指定工业设备的最新实时点位遥测数据(注塑机温度、注塑压力、螺杆转速、能耗电流等)。"); t2.put("description", "召测指定工业设备的最新实时点位遥测数据(注塑机温度、注塑压力、螺杆转速、能耗电流等)。所需权限范围: telemetry.read");
Map<String, Object> s2 = new HashMap<>(); Map<String, Object> s2 = new HashMap<>();
s2.put("type", "object"); s2.put("type", "object");
Map<String, Object> p2 = new HashMap<>(); Map<String, Object> p2 = new HashMap<>();
@@ -50,12 +58,12 @@ public class McpToolRegistry {
// 3. iot_device_control // 3. iot_device_control
Map<String, Object> t3 = new HashMap<>(); Map<String, Object> t3 = new HashMap<>();
t3.put("name", "iot_device_control"); t3.put("name", "iot_device_control");
t3.put("description", "向边缘物联设备安全下发反写控制指令(如设定注塑温度、设备启停控制、工艺参数微调)。"); t3.put("description", "向边缘物联设备安全下发反写控制指令(如设定注塑温度、设备启停控制、工艺参数微调)。所需权限范围: device.control");
Map<String, Object> s3 = new HashMap<>(); Map<String, Object> s3 = new HashMap<>();
s3.put("type", "object"); s3.put("type", "object");
Map<String, Object> p3 = new HashMap<>(); Map<String, Object> p3 = new HashMap<>();
p3.put("deviceId", Map.of("type", "string", "description", "目标设备编码")); 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", "指令参数键值对")); p3.put("params", Map.of("type", "object", "description", "指令参数键值对"));
s3.put("properties", p3); s3.put("properties", p3);
s3.put("required", List.of("deviceId", "command")); s3.put("required", List.of("deviceId", "command"));
@@ -65,7 +73,7 @@ public class McpToolRegistry {
// 4. iot_alarm_query // 4. iot_alarm_query
Map<String, Object> t4 = new HashMap<>(); Map<String, Object> t4 = new HashMap<>();
t4.put("name", "iot_alarm_query"); t4.put("name", "iot_alarm_query");
t4.put("description", "按设备与时间范围检索未处理的设备故障与历史严重告警记录。"); t4.put("description", "按设备与时间范围检索未处理的设备故障与历史严重告警记录。所需权限范围: alarm.read");
Map<String, Object> s4 = new HashMap<>(); Map<String, Object> s4 = new HashMap<>();
s4.put("type", "object"); s4.put("type", "object");
Map<String, Object> p4 = new HashMap<>(); Map<String, Object> p4 = new HashMap<>();
@@ -78,8 +86,20 @@ public class McpToolRegistry {
return tools; 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<>(); Map<String, Object> result = new HashMap<>();
@@ -103,22 +123,40 @@ public class McpToolRegistry {
return result; 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("tenantId", tenantId);
result.put("isMock", true);
result.put("total", 2); result.put("total", 2);
result.put("devices", List.of( result.put("devices", List.of(
Map.of("deviceCode", "DEV20261009001", "deviceName", "1号注塑机 (海天MA1200)", "online", 1), Map.of("deviceCode", "DEV20261009001", "deviceName", "1号注塑机 (海天MA1200)", "online", 1),
Map.of("deviceCode", "DEV20261009002", "deviceName", "2号注塑机 (海天MA2000)", "online", 0) Map.of("deviceCode", "DEV20261009002", "deviceName", "2号注塑机 (海天MA2000)", "online", 0)
)); ));
} else if ("iot_telemetry_latest".equals(toolName)) { } 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)); 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")) { if (resp != null && resp.has("code") && resp.get("code").asInt() == 200 && resp.has("data")) {
result.put("tenantId", tenantId); result.put("tenantId", tenantId);
result.put("deviceId", deviceId);
result.put("data", resp.get("data")); result.put("data", resp.get("data"));
return result; 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("deviceId", deviceId);
result.put("timestamp", System.currentTimeMillis()); result.put("timestamp", System.currentTimeMillis());
result.put("telemetry", Map.of( result.put("telemetry", Map.of(
@@ -128,17 +166,26 @@ public class McpToolRegistry {
"screw_speed_rpm", 85.0 "screw_speed_rpm", 85.0
)); ));
} else if ("iot_device_control".equals(toolName)) { } else if ("iot_device_control".equals(toolName)) {
// 控制指令下发:绝不能在后端失败时误报“EXECUTED”
JsonNode resp = internalClient.post("/api/internal/open/commands", tenantId, args); JsonNode resp = internalClient.post("/api/internal/open/commands", tenantId, args);
if (resp != null && resp.has("code") && resp.get("code").asInt() == 200 && resp.has("data")) { if (resp != null && resp.has("code") && resp.get("code").asInt() == 200 && resp.has("data")) {
result.put("tenantId", tenantId); 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; return result;
} }
result.put("success", true); String errMsg = resp != null && resp.has("message") ? resp.get("message").asText() : "后端指令网关通信超时或未响应";
result.put("deviceId", args != null ? args.get("deviceId") : ""); if (!mockEnabled) {
result.put("status", "EXECUTED"); log.error("下发设备控制指令失败: tool={}, tenantId={}, args={}, err={}", toolName, tenantId, args, errMsg);
result.put("message", "控制指令已成功通过网关下发"); 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)) { } else if ("iot_alarm_query".equals(toolName)) {
Map<String, String> params = new HashMap<>(); Map<String, String> params = new HashMap<>();
if (args != null && args.containsKey("deviceId")) params.put("deviceId", String.valueOf(args.get("deviceId"))); if (args != null && args.containsKey("deviceId")) params.put("deviceId", String.valueOf(args.get("deviceId")));
@@ -151,13 +198,50 @@ public class McpToolRegistry {
return result; 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( result.put("alarms", List.of(
Map.of("alarmId", "ALM-2026100901", "deviceId", "DEV20261009001", "severity", 3, "title", "模具温度异常过高") Map.of("alarmId", "ALM-2026100901", "deviceId", "DEV20261009001", "severity", 3, "title", "模具温度异常过高")
)); ));
} else { } else {
result.put("error", "未知的工具名称: " + toolName); throw new BizException(400, "未知的 MCP 工具名称: " + toolName);
} }
return result; 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 Long tenantId;
private List<String> scopes; private List<String> scopes;
private boolean admin;
public static void set(GatewaySecurityContext context) { public static void set(GatewaySecurityContext context) {
CONTEXT_HOLDER.set(context); CONTEXT_HOLDER.set(context);
} }
@@ -31,13 +33,32 @@ public class GatewaySecurityContext {
CONTEXT_HOLDER.remove(); CONTEXT_HOLDER.remove();
} }
public static Long getTenantIdOrDefault(Long defaultVal) { /**
* 严格获取当前已认证租户 ID,未认证或缺少租户时抛出异常,杜绝默认兜底到租户 1
*/
public static Long requireTenantId() {
GatewaySecurityContext ctx = get(); 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) { public boolean hasScope(String requiredScope) {
if (admin) return true;
if (scopes == null || scopes.isEmpty()) return false; 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 lombok.extern.slf4j.Slf4j;
import org.springframework.beans.factory.annotation.Value; import org.springframework.beans.factory.annotation.Value;
import org.springframework.stereotype.Component; import org.springframework.stereotype.Component;
import org.springframework.util.StringUtils;
@Slf4j @Slf4j
@Component @Component
public class JwtTokenProvider { public class JwtTokenProvider {
@Value("${gateway.oauth.jwt-secret:iot-gateway-oauth2-super-secret-key-666888-abcdefghijklmnopqrstuvwxyz}") @Value("${gateway.oauth.jwt-secret:}")
private String jwtSecret; private String jwtSecret;
@Value("${gateway.oauth.token-expire-seconds:7200}") @Value("${gateway.oauth.token-expire-seconds:7200}")
private long tokenExpireSeconds; private long tokenExpireSeconds;
private Key getSigningKey() { private Key getSigningKey() {
if (!StringUtils.hasText(jwtSecret)) {
throw new IllegalStateException("安全配置缺失: 未注入 gateway.oauth.jwt-secret 环境变量,严禁启动空私钥运行");
}
return Keys.hmacShaKeyFor(jwtSecret.getBytes(StandardCharsets.UTF_8)); return Keys.hmacShaKeyFor(jwtSecret.getBytes(StandardCharsets.UTF_8));
} }
public String generateToken(OpenClient client) { public String generateToken(OpenClient client) {
return generateToken(client, client.getScopes());
}
/**
* 生成支持 Scope 收窄的访问令牌
*/
public String generateToken(OpenClient client, String effectiveScopes) {
long nowMillis = System.currentTimeMillis(); long nowMillis = System.currentTimeMillis();
Date now = new Date(nowMillis); Date now = new Date(nowMillis);
Date exp = new Date(nowMillis + tokenExpireSeconds * 1000); Date exp = new Date(nowMillis + tokenExpireSeconds * 1000);
@@ -36,8 +47,9 @@ public class JwtTokenProvider {
Map<String, Object> claims = new HashMap<>(); Map<String, Object> claims = new HashMap<>();
claims.put("tenant_id", client.getTenantId()); claims.put("tenant_id", client.getTenantId());
claims.put("client_name", client.getClientName()); 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("ip_whitelist", client.getIpWhitelist());
claims.put("rate_limit", client.getRateLimit() != null ? client.getRateLimit() : 60);
return Jwts.builder() return Jwts.builder()
.setClaims(claims) .setClaims(claims)
@@ -1,16 +1,19 @@
package com.iot.gateway.oauth; package com.iot.gateway.oauth;
import com.iot.gateway.common.IpUtils;
import io.jsonwebtoken.Claims; import io.jsonwebtoken.Claims;
import jakarta.servlet.FilterChain; import jakarta.servlet.FilterChain;
import jakarta.servlet.ServletException; import jakarta.servlet.ServletException;
import jakarta.servlet.http.HttpServletRequest; import jakarta.servlet.http.HttpServletRequest;
import jakarta.servlet.http.HttpServletResponse; import jakarta.servlet.http.HttpServletResponse;
import java.io.IOException; import java.io.IOException;
import java.util.ArrayList;
import java.util.Arrays; import java.util.Arrays;
import java.util.Collections; import java.util.Collections;
import java.util.List; import java.util.List;
import lombok.extern.slf4j.Slf4j; import lombok.extern.slf4j.Slf4j;
import org.springframework.beans.factory.annotation.Autowired; import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.beans.factory.annotation.Value;
import org.springframework.security.authentication.UsernamePasswordAuthenticationToken; import org.springframework.security.authentication.UsernamePasswordAuthenticationToken;
import org.springframework.security.core.authority.SimpleGrantedAuthority; import org.springframework.security.core.authority.SimpleGrantedAuthority;
import org.springframework.security.core.context.SecurityContextHolder; import org.springframework.security.core.context.SecurityContextHolder;
@@ -25,52 +28,148 @@ public class OAuth2AuthenticationFilter extends OncePerRequestFilter {
@Autowired @Autowired
private JwtTokenProvider tokenProvider; private JwtTokenProvider tokenProvider;
@Value("${gateway.backend.internal-secret:}")
private String internalSecret;
@Value("${gateway.admin.secret:}")
private String adminSecret;
@Override @Override
protected void doFilterInternal(HttpServletRequest request, HttpServletResponse response, FilterChain filterChain) protected void doFilterInternal(HttpServletRequest request, HttpServletResponse response, FilterChain filterChain)
throws ServletException, IOException { 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); String token = resolveToken(request);
if (StringUtils.hasText(token) && tokenProvider.validateToken(token)) { if (StringUtils.hasText(token) && tokenProvider.validateToken(token)) {
Claims claims = tokenProvider.parseToken(token); Claims claims = tokenProvider.parseToken(token);
if (claims != null) { if (claims != null) {
String clientId = claims.getSubject(); // 判断是后端用户登录凭据还是开放客户端凭据
Long tenantId = claims.get("tenant_id", Long.class); Object uidObj = claims.get("uid");
String clientName = claims.get("client_name", String.class); Object userTypeObj = claims.get("type");
String scopesStr = claims.get("scopes", String.class);
String ipWhitelist = claims.get("ip_whitelist", String.class);
// 校验客户端当前请求 IP 是否在白名单内 if (uidObj != null || userTypeObj != null) {
if (StringUtils.hasText(ipWhitelist)) { // 核心业务后端签发的管理员或租户用户 JWT (来自 Web 前端控制台)
String clientIp = com.iot.gateway.common.IpUtils.getClientIp(request); String username = claims.getSubject();
if (!com.iot.gateway.common.IpUtils.isAllowed(clientIp, ipWhitelist)) { String userType = userTypeObj != null ? String.valueOf(userTypeObj) : "";
log.warn("OAuth 2.0 令牌访问拒绝: 客户端 IP [{}] 不在白名单 [{}] 内, clientId={}", Long tenantId = null;
clientIp, ipWhitelist, clientId); Object tidObj = claims.get("tenantId");
response.setStatus(HttpServletResponse.SC_FORBIDDEN); if (tidObj != null) {
response.setContentType("application/json;charset=UTF-8"); try {
response.getWriter().write("{\"code\":403,\"message\":\"客户端来源 IP [" + clientIp + "] 不在授权白名单内\",\"success\":false}"); tenantId = Long.parseLong(String.valueOf(tidObj));
return; } 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);
String scopesStr = claims.get("scopes", String.class);
String ipWhitelist = claims.get("ip_whitelist", String.class);
// 校验客户端当前请求 IP 是否在白名单内
if (StringUtils.hasText(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);
response.setContentType("application/json;charset=UTF-8");
response.getWriter().write("{\"code\":403,\"message\":\"客户端来源 IP [" + clientIp + "] 不在授权白名单内\",\"success\":false}");
return;
}
}
List<String> scopes = StringUtils.hasText(scopesStr)
? Arrays.asList(scopesStr.split(","))
: Collections.emptyList();
// 严禁无租户凭据被默认落入租户 1
GatewaySecurityContext context = GatewaySecurityContext.builder()
.clientId(clientId)
.tenantId(tenantId)
.clientName(clientName)
.scopes(scopes)
.admin(false)
.build();
GatewaySecurityContext.set(context);
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);
} }
List<String> scopes = StringUtils.hasText(scopesStr)
? Arrays.asList(scopesStr.split(","))
: Collections.emptyList();
GatewaySecurityContext context = GatewaySecurityContext.builder()
.clientId(clientId)
.tenantId(tenantId != null ? tenantId : 1L)
.clientName(clientName)
.scopes(scopes)
.build();
GatewaySecurityContext.set(context);
List<SimpleGrantedAuthority> authorities = scopes.stream()
.map(s -> new SimpleGrantedAuthority("SCOPE_" + s.trim()))
.toList();
UsernamePasswordAuthenticationToken auth =
new UsernamePasswordAuthenticationToken(clientId, null, authorities);
SecurityContextHolder.getContext().setAuthentication(auth);
} }
} }
@@ -86,8 +185,11 @@ public class OAuth2AuthenticationFilter extends OncePerRequestFilter {
if (StringUtils.hasText(bearer) && bearer.startsWith("Bearer ")) { if (StringUtils.hasText(bearer) && bearer.startsWith("Bearer ")) {
return bearer.substring(7).trim(); return bearer.substring(7).trim();
} }
// 允许通过 ?token= 查询参数传递 (供 SSE / EventSource 连接时携带) // 允许通过 ?token= 或 ?access_token= 查询参数传递 (供 SSE / EventSource 连接时携带)
String queryToken = request.getParameter("token"); String queryToken = request.getParameter("access_token");
if (!StringUtils.hasText(queryToken)) {
queryToken = request.getParameter("token");
}
if (StringUtils.hasText(queryToken)) { if (StringUtils.hasText(queryToken)) {
return queryToken.trim(); return queryToken.trim();
} }
@@ -1,19 +1,26 @@
package com.iot.gateway.oauth; package com.iot.gateway.oauth;
import com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper; 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.entity.OpenClient;
import com.iot.gateway.mapper.OpenClientMapper; import com.iot.gateway.mapper.OpenClientMapper;
import io.swagger.v3.oas.annotations.Operation; import io.swagger.v3.oas.annotations.Operation;
import io.swagger.v3.oas.annotations.tags.Tag; import io.swagger.v3.oas.annotations.tags.Tag;
import java.time.LocalDateTime; import java.time.LocalDateTime;
import java.util.Arrays;
import java.util.Collections;
import java.util.HashMap; import java.util.HashMap;
import java.util.List;
import java.util.Map; import java.util.Map;
import lombok.extern.slf4j.Slf4j; import lombok.extern.slf4j.Slf4j;
import org.springframework.beans.factory.annotation.Autowired; import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.http.HttpStatus; import org.springframework.http.HttpStatus;
import org.springframework.http.ResponseEntity; 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.PostMapping;
import org.springframework.web.bind.annotation.RequestBody;
import org.springframework.web.bind.annotation.RequestMapping; import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.bind.annotation.RequestParam; import org.springframework.web.bind.annotation.RequestParam;
import org.springframework.web.bind.annotation.RestController; import org.springframework.web.bind.annotation.RestController;
@@ -30,20 +37,41 @@ public class OAuth2TokenController {
@Autowired @Autowired
private JwtTokenProvider jwtTokenProvider; 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") @PostMapping(value = "/token")
public ResponseEntity<Map<String, Object>> issueToken( public ResponseEntity<Map<String, Object>> issueToken(
@RequestParam(value = "grant_type", required = false) String formGrantType, @RequestParam(value = "grant_type", required = false) String formGrantType,
@RequestParam(value = "client_id", required = false) String formClientId, @RequestParam(value = "client_id", required = false) String formClientId,
@RequestParam(value = "client_secret", required = false) String formClientSecret, @RequestParam(value = "client_secret", required = false) String formClientSecret,
@RequestParam(value = "scope", required = false) String formScope, @RequestParam(value = "scope", required = false) String formScope,
@RequestBody(required = false) Map<String, String> jsonBody,
jakarta.servlet.http.HttpServletRequest request) { jakarta.servlet.http.HttpServletRequest request) {
String grantType = formGrantType != null ? formGrantType : (jsonBody != null ? jsonBody.get("grant_type") : null); String grantType = formGrantType;
String clientId = formClientId != null ? formClientId : (jsonBody != null ? jsonBody.get("client_id") : null); String clientId = formClientId;
String clientSecret = formClientSecret != null ? formClientSecret : (jsonBody != null ? jsonBody.get("client_secret") : null); String clientSecret = formClientSecret;
String scope = formScope != null ? formScope : (jsonBody != null ? jsonBody.get("scope") : null); 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<>(); Map<String, Object> error = new HashMap<>();
@@ -53,16 +81,36 @@ public class OAuth2TokenController {
return ResponseEntity.status(HttpStatus.BAD_REQUEST).body(error); 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", "invalid_client");
error.put("error_description", "client_id 或 client_secret 不能为空"); error.put("error_description", "client_id 或 client_secret 不能为空");
return ResponseEntity.status(HttpStatus.UNAUTHORIZED).body(error); return ResponseEntity.status(HttpStatus.UNAUTHORIZED).body(error);
} }
OpenClient client = clientMapper.selectOne(new LambdaQueryWrapper<OpenClient>() 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", "invalid_client");
error.put("error_description", "客户端标识或密钥错误"); error.put("error_description", "客户端标识或密钥错误");
return ResponseEntity.status(HttpStatus.UNAUTHORIZED).body(error); return ResponseEntity.status(HttpStatus.UNAUTHORIZED).body(error);
@@ -80,9 +128,9 @@ public class OAuth2TokenController {
return ResponseEntity.status(HttpStatus.FORBIDDEN).body(error); return ResponseEntity.status(HttpStatus.FORBIDDEN).body(error);
} }
// 校验 IP 白名单 // 校验客户端来源 IP 白名单(基于可信反向代理验证)
String clientIp = com.iot.gateway.common.IpUtils.getClientIp(request); String clientIp = IpUtils.getClientIp(request);
if (!com.iot.gateway.common.IpUtils.isAllowed(clientIp, client.getIpWhitelist())) { if (!IpUtils.isAllowed(clientIp, client.getIpWhitelist())) {
log.warn("OAuth 2.0 客户端 IP 白名单拦截: clientId={}, clientIp={}, whitelist={}", log.warn("OAuth 2.0 客户端 IP 白名单拦截: clientId={}, clientIp={}, whitelist={}",
clientId, clientIp, client.getIpWhitelist()); clientId, clientIp, client.getIpWhitelist());
error.put("error", "unauthorized_client"); error.put("error", "unauthorized_client");
@@ -90,14 +138,38 @@ public class OAuth2TokenController {
return ResponseEntity.status(HttpStatus.FORBIDDEN).body(error); return ResponseEntity.status(HttpStatus.FORBIDDEN).body(error);
} }
String accessToken = jwtTokenProvider.generateToken(client); // 验证 Scope 并支持合法收窄 (Scope Narrowing)
log.info("OAuth 2.0 成功颁发令牌: clientId={}, clientIp={}, tenantId={}", clientId, clientIp, client.getTenantId()); 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<>(); Map<String, Object> response = new HashMap<>();
response.put("access_token", accessToken); response.put("access_token", accessToken);
response.put("token_type", "Bearer"); response.put("token_type", "Bearer");
response.put("expires_in", jwtTokenProvider.getTokenExpireSeconds()); response.put("expires_in", jwtTokenProvider.getTokenExpireSeconds());
response.put("scope", client.getScopes()); response.put("scope", effectiveScopes);
response.put("tenant_id", client.getTenantId()); response.put("tenant_id", client.getTenantId());
response.put("client_name", client.getClientName()); 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.builders.HttpSecurity;
import org.springframework.security.config.annotation.web.configuration.EnableWebSecurity; import org.springframework.security.config.annotation.web.configuration.EnableWebSecurity;
import org.springframework.security.config.http.SessionCreationPolicy; 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.SecurityFilterChain;
import org.springframework.security.web.authentication.UsernamePasswordAuthenticationFilter; import org.springframework.security.web.authentication.UsernamePasswordAuthenticationFilter;
@@ -19,26 +21,48 @@ public class SecurityConfig {
@Autowired @Autowired
private OAuth2AuthenticationFilter oauth2Filter; private OAuth2AuthenticationFilter oauth2Filter;
@Autowired
private OpenClientRateLimitFilter rateLimitFilter;
@Bean
public PasswordEncoder passwordEncoder() {
return new BCryptPasswordEncoder();
}
@Bean @Bean
public SecurityFilterChain filterChain(HttpSecurity http) throws Exception { public SecurityFilterChain filterChain(HttpSecurity http) throws Exception {
http.csrf(csrf -> csrf.disable()) http.csrf(csrf -> csrf.disable())
.cors(Customizer.withDefaults()) .cors(Customizer.withDefaults())
.sessionManagement(sm -> sm.sessionCreationPolicy(SessionCreationPolicy.STATELESS)) .sessionManagement(sm -> sm.sessionCreationPolicy(SessionCreationPolicy.STATELESS))
.authorizeHttpRequests(auth -> auth .authorizeHttpRequests(auth -> auth
// 1. 公开端点 (仅限 Token 申请、网关健康探针与 OpenAPI 接口文档)
.requestMatchers( .requestMatchers(
"/oauth2/**", "/oauth2/token",
"/open/gateway/**", "/open/gateway/health",
"/mcp/**", "/open/gateway/catalog",
"/api/**", "/actuator/health",
"/actuator/**",
"/v3/api-docs/**", "/v3/api-docs/**",
"/swagger-ui/**", "/swagger-ui/**",
"/swagger-ui.html", "/swagger-ui.html",
"/error" "/error"
).permitAll() ).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() .requestMatchers("/open/v1/**").authenticated()
.anyRequest().permitAll())
.addFilterBefore(oauth2Filter, UsernamePasswordAuthenticationFilter.class); // 其他任意未显式列出的路径默认全阻断
.anyRequest().authenticated()
)
.addFilterBefore(oauth2Filter, UsernamePasswordAuthenticationFilter.class)
.addFilterAfter(rateLimitFilter, OAuth2AuthenticationFilter.class);
return http.build(); return http.build();
} }
@@ -1,12 +1,18 @@
package com.iot.gateway.openapi; 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.common.Result;
import com.iot.gateway.oauth.GatewaySecurityContext; import com.iot.gateway.oauth.GatewaySecurityContext;
import io.swagger.v3.oas.annotations.Operation; import io.swagger.v3.oas.annotations.Operation;
import io.swagger.v3.oas.annotations.tags.Tag; import io.swagger.v3.oas.annotations.tags.Tag;
import java.util.HashMap;
import java.util.List; import java.util.List;
import java.util.Map; import java.util.Map;
import lombok.extern.slf4j.Slf4j; 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.security.access.prepost.PreAuthorize;
import org.springframework.web.bind.annotation.GetMapping; import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.PostMapping; import org.springframework.web.bind.annotation.PostMapping;
@@ -21,6 +27,12 @@ import org.springframework.web.bind.annotation.RestController;
@RequestMapping("/open/v1/alarms") @RequestMapping("/open/v1/alarms")
public class OpenAlarmController { public class OpenAlarmController {
@Autowired
private BackendInternalClient internalClient;
@Value("${gateway.mock.enabled:false}")
private boolean mockEnabled;
@Operation(summary = "按租户与设备检索未确认/历史告警") @Operation(summary = "按租户与设备检索未确认/历史告警")
@GetMapping @GetMapping
@PreAuthorize("hasAuthority('SCOPE_alarm.read') or hasAuthority('SCOPE_all')") @PreAuthorize("hasAuthority('SCOPE_alarm.read') or hasAuthority('SCOPE_all')")
@@ -28,9 +40,23 @@ public class OpenAlarmController {
@RequestParam(required = false) String deviceId, @RequestParam(required = false) String deviceId,
@RequestParam(required = false) Integer severity) { @RequestParam(required = false) Integer severity) {
Long tenantId = GatewaySecurityContext.getTenantIdOrDefault(1L); Long tenantId = GatewaySecurityContext.requireTenantId();
log.info("三方系统查询告警列表: tenantId={}, deviceId={}, severity={}", tenantId, deviceId, severity); 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( List<Map<String, Object>> alarms = List.of(
Map.of("alarmId", "ALM-9821", "deviceId", "DEV-ZSJ-002", "severity", 3, Map.of("alarmId", "ALM-9821", "deviceId", "DEV-ZSJ-002", "severity", 3,
"title", "注塑模具温度异常过高 (当前 265℃)", "createTime", "2026-10-09 08:15:20"), "title", "注塑模具温度异常过高 (当前 265℃)", "createTime", "2026-10-09 08:15:20"),
@@ -38,14 +64,15 @@ public class OpenAlarmController {
"title", "射胶压力超限预警", "createTime", "2026-10-09 08:22:10") "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 = "三方系统确认告警回执") @Operation(summary = "三方系统确认告警回执")
@PostMapping("/ack") @PostMapping("/ack")
@PreAuthorize("hasAuthority('SCOPE_alarm.read') or hasAuthority('SCOPE_all')") @PreAuthorize("hasAuthority('SCOPE_alarm.read') or hasAuthority('SCOPE_all')")
public Result<Map<String, Object>> ackAlarm(@RequestBody Map<String, Object> req) { public Result<Map<String, Object>> ackAlarm(@RequestBody Map<String, Object> req) {
Long tenantId = GatewaySecurityContext.requireTenantId();
String alarmId = String.valueOf(req.get("alarmId")); 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; 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.common.Result;
import com.iot.gateway.oauth.GatewaySecurityContext; import com.iot.gateway.oauth.GatewaySecurityContext;
import io.swagger.v3.oas.annotations.Operation; import io.swagger.v3.oas.annotations.Operation;
import io.swagger.v3.oas.annotations.tags.Tag; import io.swagger.v3.oas.annotations.tags.Tag;
import java.util.Map; import java.util.Map;
import lombok.extern.slf4j.Slf4j; 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.security.access.prepost.PreAuthorize;
import org.springframework.web.bind.annotation.PostMapping; import org.springframework.web.bind.annotation.PostMapping;
import org.springframework.web.bind.annotation.RequestBody; import org.springframework.web.bind.annotation.RequestBody;
@@ -18,23 +23,47 @@ import org.springframework.web.bind.annotation.RestController;
@RequestMapping("/open/v1/commands") @RequestMapping("/open/v1/commands")
public class OpenCommandController { public class OpenCommandController {
@Operation(summary = "下发设备控制与工艺参数反写指令 (MQTT桥接)") @Autowired
private BackendInternalClient internalClient;
@Value("${gateway.mock.enabled:false}")
private boolean mockEnabled;
@Operation(summary = "下发设备控制与工艺参数反写指令 (真实专线桥接,严禁伪报已执行)")
@PostMapping @PostMapping
@PreAuthorize("hasAuthority('SCOPE_device.control') or hasAuthority('SCOPE_all')") @PreAuthorize("hasAuthority('SCOPE_device.control') or hasAuthority('SCOPE_all')")
public Result<Map<String, Object>> sendCommand(@RequestBody Map<String, Object> req) { 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 deviceId = String.valueOf(req.get("deviceId"));
String command = String.valueOf(req.get("command")); String command = String.valueOf(req.get("command"));
log.info("三方系统下发设备反写指令: tenantId={}, deviceId={}, command={}", tenantId, deviceId, 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", "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( return Result.success(Map.of(
"success", true, "success", false,
"commandId", "CMD-" + System.currentTimeMillis(), "isMock", true,
"deviceId", deviceId, "deviceId", deviceId,
"command", command, "command", command,
"status", "DELIVERED", "status", "MOCK_MODE_REFUSED",
"message", "指令已成功路由至对应租户的边缘网关执行" "message", "当前为开发演示环境,已拒绝向物理设备下发控制指令: " + errMsg
)); ));
} }
} }
@@ -2,6 +2,7 @@ package com.iot.gateway.openapi;
import com.fasterxml.jackson.databind.JsonNode; import com.fasterxml.jackson.databind.JsonNode;
import com.iot.gateway.common.BackendInternalClient; import com.iot.gateway.common.BackendInternalClient;
import com.iot.gateway.common.BizException;
import com.iot.gateway.common.Result; import com.iot.gateway.common.Result;
import com.iot.gateway.oauth.GatewaySecurityContext; import com.iot.gateway.oauth.GatewaySecurityContext;
import io.swagger.v3.oas.annotations.Operation; import io.swagger.v3.oas.annotations.Operation;
@@ -11,6 +12,7 @@ import java.util.List;
import java.util.Map; import java.util.Map;
import lombok.extern.slf4j.Slf4j; import lombok.extern.slf4j.Slf4j;
import org.springframework.beans.factory.annotation.Autowired; import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.beans.factory.annotation.Value;
import org.springframework.security.access.prepost.PreAuthorize; import org.springframework.security.access.prepost.PreAuthorize;
import org.springframework.web.bind.annotation.GetMapping; import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.PathVariable; import org.springframework.web.bind.annotation.PathVariable;
@@ -27,6 +29,9 @@ public class OpenDeviceController {
@Autowired @Autowired
private BackendInternalClient internalClient; private BackendInternalClient internalClient;
@Value("${gateway.mock.enabled:false}")
private boolean mockEnabled;
@Operation(summary = "查询当前租户名下设备列表") @Operation(summary = "查询当前租户名下设备列表")
@GetMapping @GetMapping
@PreAuthorize("hasAuthority('SCOPE_device.read') or hasAuthority('SCOPE_all')") @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, defaultValue = "20") Integer pageSize,
@RequestParam(required = false) String status) { @RequestParam(required = false) String status) {
Long tenantId = GatewaySecurityContext.getTenantIdOrDefault(1L); // 严格获取当前已认证租户 ID,未认证或缺少租户时抛出 401 异常,严禁默认落到租户 1
Long tenantId = GatewaySecurityContext.requireTenantId();
log.info("三方系统查询设备列表: tenantId={}, status={}", tenantId, status); log.info("三方系统查询设备列表: tenantId={}, status={}", tenantId, status);
Map<String, String> params = new HashMap<>(); 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( return Result.success(Map.of(
"tenantId", tenantId, "tenantId", tenantId,
"isMock", true,
"total", 2, "total", 2,
"pageNum", pageNum, "pageNum", pageNum,
"pageSize", pageSize, "pageSize", pageSize,
@@ -75,16 +87,22 @@ public class OpenDeviceController {
@GetMapping("/{deviceId}") @GetMapping("/{deviceId}")
@PreAuthorize("hasAuthority('SCOPE_device.read') or hasAuthority('SCOPE_all')") @PreAuthorize("hasAuthority('SCOPE_device.read') or hasAuthority('SCOPE_all')")
public Result<Object> getDevice(@PathVariable String deviceId) { 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); 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")) { if (resp != null && resp.has("code") && resp.get("code").asInt() == 200 && resp.has("data")) {
return Result.success(resp.get("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( return Result.success(Map.of(
"deviceCode", deviceId, "deviceCode", deviceId,
"tenantId", tenantId, "tenantId", tenantId,
"isMock", true,
"deviceName", "1号注塑机 (海天MA1200)", "deviceName", "1号注塑机 (海天MA1200)",
"online", 1 "online", 1
)); ));
@@ -1,11 +1,16 @@
package com.iot.gateway.openapi; 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.common.Result;
import com.iot.gateway.oauth.GatewaySecurityContext; import com.iot.gateway.oauth.GatewaySecurityContext;
import io.swagger.v3.oas.annotations.Operation; import io.swagger.v3.oas.annotations.Operation;
import io.swagger.v3.oas.annotations.tags.Tag; import io.swagger.v3.oas.annotations.tags.Tag;
import java.util.Map; import java.util.Map;
import lombok.extern.slf4j.Slf4j; 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.security.access.prepost.PreAuthorize;
import org.springframework.web.bind.annotation.GetMapping; import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.PathVariable; import org.springframework.web.bind.annotation.PathVariable;
@@ -19,16 +24,38 @@ import org.springframework.web.bind.annotation.RestController;
@RequestMapping("/open/v1/telemetry") @RequestMapping("/open/v1/telemetry")
public class OpenTelemetryController { public class OpenTelemetryController {
@Autowired
private BackendInternalClient internalClient;
@Value("${gateway.mock.enabled:false}")
private boolean mockEnabled;
@Operation(summary = "召测单设备最新实时遥测点位数据") @Operation(summary = "召测单设备最新实时遥测点位数据")
@GetMapping("/latest/{deviceId}") @GetMapping("/latest/{deviceId}")
@PreAuthorize("hasAuthority('SCOPE_telemetry.read') or hasAuthority('SCOPE_all')") @PreAuthorize("hasAuthority('SCOPE_telemetry.read') or hasAuthority('SCOPE_all')")
public Result<Map<String, Object>> getLatestTelemetry(@PathVariable String deviceId) { public Result<Map<String, Object>> getLatestTelemetry(@PathVariable String deviceId) {
Long tenantId = GatewaySecurityContext.getTenantIdOrDefault(1L); Long tenantId = GatewaySecurityContext.requireTenantId();
log.info("三方系统召测最新遥测: deviceId={}, tenantId={}", deviceId, tenantId); 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( return Result.success(Map.of(
"deviceId", deviceId, "deviceId", deviceId,
"tenantId", tenantId, "tenantId", tenantId,
"isMock", true,
"timestamp", System.currentTimeMillis(), "timestamp", System.currentTimeMillis(),
"values", Map.of( "values", Map.of(
"barrel_temp_zone1", 215.4, "barrel_temp_zone1", 215.4,
@@ -49,9 +76,11 @@ public class OpenTelemetryController {
@RequestParam(required = false) Long startTime, @RequestParam(required = false) Long startTime,
@RequestParam(required = false) Long endTime) { @RequestParam(required = false) Long endTime) {
Long tenantId = GatewaySecurityContext.requireTenantId();
long now = System.currentTimeMillis(); long now = System.currentTimeMillis();
return Result.success(Map.of( return Result.success(Map.of(
"deviceId", deviceId, "deviceId", deviceId,
"tenantId", tenantId,
"pointKey", pointKey, "pointKey", pointKey,
"points", java.util.List.of( "points", java.util.List.of(
Map.of("ts", now - 60000, "val", 214.8), Map.of("ts", now - 60000, "val", 214.8),
+12 -5
View File
@@ -1,12 +1,19 @@
server: server:
port: 18082 port: ${PORT:18082}
spring: spring:
datasource: datasource:
url: jdbc:mysql://127.0.0.1:14406/iot_platform?useUnicode=true&characterEncoding=utf8&useSSL=false&serverTimezone=Asia/Shanghai&allowPublicKeyRetrieval=true url: ${DB_URL:jdbc:mysql://127.0.0.1:14406/iot_platform?useUnicode=true&characterEncoding=utf8&useSSL=false&serverTimezone=Asia/Shanghai&allowPublicKeyRetrieval=true}
username: iot_app username: ${DB_USERNAME:iot_app}
password: DRq7DfU9VykOFcIoHXKa password: ${DB_PASSWORD:}
gateway: gateway:
oauth:
jwt-secret: ${GATEWAY_JWT_SECRET:}
backend: 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}
+15 -9
View File
@@ -1,5 +1,5 @@
server: server:
port: 8082 port: ${PORT:8082}
spring: spring:
application: application:
@@ -7,9 +7,9 @@ spring:
datasource: datasource:
driver-class-name: com.mysql.cj.jdbc.Driver 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 url: ${DB_URL:jdbc:mysql://127.0.0.1:3306/iot_platform?useUnicode=true&characterEncoding=utf8&useSSL=false&serverTimezone=Asia/Shanghai&allowPublicKeyRetrieval=true}
username: root username: ${DB_USERNAME:iot_app}
password: "123456" password: ${DB_PASSWORD:}
mybatis-plus: mybatis-plus:
configuration: configuration:
@@ -20,16 +20,22 @@ mybatis-plus:
db-config: db-config:
id-type: auto id-type: auto
# 开放网关核心治理配置 # 开放网关核心治理配置 (生产凭据由运行环境注入,严禁在仓库硬编码)
gateway: gateway:
# OAuth 2.0 JWT 签名私钥 # OAuth 2.0 JWT 签名私钥
oauth: oauth:
jwt-secret: iot-gateway-oauth2-super-secret-key-666888-abcdefghijklmnopqrstuvwxyz jwt-secret: ${GATEWAY_JWT_SECRET:}
token-expire-seconds: 7200 # 2小时 token-expire-seconds: ${GATEWAY_TOKEN_EXPIRE:7200}
# 核心物联后端内网地址 (用于设备遥测数据拉取与控制反写代理) # 核心物联后端内网地址与专线通信密钥
backend: backend:
base-url: ${GATEWAY_BACKEND_URL:http://localhost:8080} 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: springdoc:
swagger-ui: swagger-ui: