diff --git a/src/main/java/com/iot/gateway/admin/OpenClientAdminController.java b/src/main/java/com/iot/gateway/admin/OpenClientAdminController.java index 98e023e..ffecb75 100644 --- a/src/main/java/com/iot/gateway/admin/OpenClientAdminController.java +++ b/src/main/java/com/iot/gateway/admin/OpenClientAdminController.java @@ -6,6 +6,7 @@ import com.iot.gateway.common.BizException; import com.iot.gateway.common.Result; import com.iot.gateway.entity.OpenClient; import com.iot.gateway.mapper.OpenClientMapper; +import com.iot.gateway.oauth.GatewaySecurityContext; import io.swagger.v3.oas.annotations.Operation; import io.swagger.v3.oas.annotations.tags.Tag; import java.security.SecureRandom; @@ -14,6 +15,8 @@ import java.util.Map; import java.util.UUID; import lombok.extern.slf4j.Slf4j; import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.security.access.prepost.PreAuthorize; +import org.springframework.security.crypto.password.PasswordEncoder; import org.springframework.util.StringUtils; import org.springframework.web.bind.annotation.DeleteMapping; import org.springframework.web.bind.annotation.GetMapping; @@ -29,11 +32,15 @@ import org.springframework.web.bind.annotation.RestController; @Tag(name = "开放平台管理 - 三方应用凭据管理") @RestController @RequestMapping("/api/open/client") +@PreAuthorize("hasAnyAuthority('ROLE_ADMIN', 'SCOPE_admin', 'SCOPE_tenant_admin')") public class OpenClientAdminController { @Autowired private OpenClientMapper clientMapper; + @Autowired + private PasswordEncoder passwordEncoder; + @Autowired(required = false) private org.springframework.jdbc.core.JdbcTemplate jdbcTemplate; @@ -45,7 +52,7 @@ public class OpenClientAdminController { return Result.success(com.iot.gateway.oauth.OpenScopeEnum.toList()); } - @Operation(summary = "三方应用凭据分页列表") + @Operation(summary = "三方应用凭据分页列表 (严禁泄露密钥哈希)") @GetMapping("/page") public Result> page( @RequestParam(defaultValue = "1") Integer pageNum, @@ -53,18 +60,42 @@ public class OpenClientAdminController { @RequestParam(required = false) Long tenantId, @RequestParam(required = false) String keyword) { + GatewaySecurityContext ctx = GatewaySecurityContext.get(); + if (ctx == null) { + throw new BizException(401, "未授权访问"); + } + Page page = new Page<>(pageNum, pageSize); LambdaQueryWrapper 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)) { qw.and(w -> w.like(OpenClient::getClientName, keyword) .or().like(OpenClient::getClientId, keyword)); } qw.orderByDesc(OpenClient::getCreateTime); Page 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); } @@ -92,27 +123,53 @@ public class OpenClientAdminController { } } - @Operation(summary = "获取凭据详情") + @Operation(summary = "获取凭据详情 (不返回 Secret)") @GetMapping("/{id}") public Result getById(@PathVariable Long id) { OpenClient client = clientMapper.selectById(id); if (client == null) throw new BizException("客户端凭据不存在"); + + checkTenantAccess(client.getTenantId()); + + // 详情接口严禁暴露 Secret + client.setClientSecret(null); + client.setRawSecret(null); + return Result.success(client); } - @Operation(summary = "新建三方应用凭据 (自动生成 ClientId 与 Secret)") + @Operation(summary = "新建三方应用凭据 (数据库保存 BCrypt 哈希,仅创建成功时返回一次明文)") @PostMapping public Result create(@RequestBody OpenClient req) { + GatewaySecurityContext ctx = GatewaySecurityContext.get(); + if (ctx == null) throw new BizException(401, "未授权操作"); + req.setId(null); req.setClientId("client_" + UUID.randomUUID().toString().replace("-", "").substring(0, 12)); - req.setClientSecret("sec_" + UUID.randomUUID().toString().replace("-", "") + Long.toHexString(RANDOM.nextLong())); - if (req.getTenantId() == null) req.setTenantId(1L); + + // 生成高强度原始 Secret + String rawSecret = "sec_" + UUID.randomUUID().toString().replace("-", "") + Long.toHexString(RANDOM.nextLong()); + // 数据库落库 BCrypt 单向哈希 + req.setClientSecret(passwordEncoder.encode(rawSecret)); + + if (!ctx.isAdmin()) { + if (ctx.getTenantId() == null) throw new BizException(403, "当前管理员无所属租户"); + req.setTenantId(ctx.getTenantId()); + } else if (req.getTenantId() == null) { + req.setTenantId(1L); + } + if (!StringUtils.hasText(req.getScopes())) req.setScopes("device.read"); if (req.getStatus() == null) req.setStatus(1); if (req.getRateLimit() == null) req.setRateLimit(60); clientMapper.insert(req); - log.info("新建开放应用凭据: id={}, clientId={}", req.getId(), req.getClientId()); + log.info("新建开放应用凭据成功: id={}, clientId={}, tenantId={}", req.getId(), req.getClientId(), req.getTenantId()); + + // 仅在创建响应中返回一次明文 Secret + req.setRawSecret(rawSecret); + req.setClientSecret(null); + return Result.success(req); } @@ -122,6 +179,8 @@ public class OpenClientAdminController { OpenClient client = clientMapper.selectById(id); if (client == null) throw new BizException("客户端凭据不存在"); + checkTenantAccess(client.getTenantId()); + client.setClientName(req.getClientName()); if (req.getScopes() != null) client.setScopes(req.getScopes()); client.setIpWhitelist(req.getIpWhitelist()); @@ -137,20 +196,34 @@ public class OpenClientAdminController { @Operation(summary = "删除三方应用凭据") @DeleteMapping("/{id}") public Result delete(@PathVariable Long id) { + OpenClient client = clientMapper.selectById(id); + if (client == null) throw new BizException("客户端凭据不存在"); + + checkTenantAccess(client.getTenantId()); + clientMapper.deleteById(id); return Result.success(); } - @Operation(summary = "重置客户端密钥 (Secret)") + @Operation(summary = "重置客户端密钥 (仅在重置成功时返回一次新明文 Secret)") @PostMapping("/{id}/reset-secret") public Result> resetSecret(@PathVariable Long id) { OpenClient client = clientMapper.selectById(id); if (client == null) throw new BizException("客户端凭据不存在"); - String newSecret = "sec_" + UUID.randomUUID().toString().replace("-", "") + Long.toHexString(RANDOM.nextLong()); - client.setClientSecret(newSecret); + checkTenantAccess(client.getTenantId()); + + String rawSecret = "sec_" + UUID.randomUUID().toString().replace("-", "") + Long.toHexString(RANDOM.nextLong()); + client.setClientSecret(passwordEncoder.encode(rawSecret)); clientMapper.updateById(client); - return Result.success(Map.of("clientSecret", newSecret)); + + log.info("重置客户端凭证密钥完成: clientId={}", client.getClientId()); + + return Result.success(Map.of( + "clientId", client.getClientId(), + "rawSecret", rawSecret, + "tips", "密钥已重置,系统已将其哈希加密存储,明文仅展示一次,请妥善保存" + )); } @Operation(summary = "更新客户端状态") @@ -158,8 +231,24 @@ public class OpenClientAdminController { public Result updateStatus(@PathVariable Long id, @RequestParam Integer status) { OpenClient client = clientMapper.selectById(id); if (client == null) throw new BizException("客户端凭据不存在"); + + checkTenantAccess(client.getTenantId()); + client.setStatus(status); clientMapper.updateById(client); return Result.success(); } + + private void checkTenantAccess(Long targetTenantId) { + GatewaySecurityContext ctx = GatewaySecurityContext.get(); + if (ctx == null) { + throw new BizException(401, "未授权访问"); + } + if (!ctx.isAdmin()) { + Long userTenantId = ctx.getTenantId(); + if (userTenantId == null || !userTenantId.equals(targetTenantId)) { + throw new BizException(403, "权限不足: 禁止跨租户访问或操作开放凭据"); + } + } + } } diff --git a/src/main/java/com/iot/gateway/admin/OpenWebhookAdminController.java b/src/main/java/com/iot/gateway/admin/OpenWebhookAdminController.java index 3241b38..895f37b 100644 --- a/src/main/java/com/iot/gateway/admin/OpenWebhookAdminController.java +++ b/src/main/java/com/iot/gateway/admin/OpenWebhookAdminController.java @@ -6,6 +6,7 @@ import com.iot.gateway.common.BizException; import com.iot.gateway.common.Result; import com.iot.gateway.entity.OpenWebhook; import com.iot.gateway.mapper.OpenWebhookMapper; +import com.iot.gateway.oauth.GatewaySecurityContext; import com.iot.gateway.webhook.WebhookDispatchService; import io.swagger.v3.oas.annotations.Operation; import io.swagger.v3.oas.annotations.tags.Tag; @@ -14,6 +15,7 @@ import java.util.List; import java.util.Map; import lombok.extern.slf4j.Slf4j; import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.security.access.prepost.PreAuthorize; import org.springframework.util.StringUtils; import org.springframework.web.bind.annotation.DeleteMapping; import org.springframework.web.bind.annotation.GetMapping; @@ -29,6 +31,7 @@ import org.springframework.web.bind.annotation.RestController; @Tag(name = "开放平台管理 - Webhook 事件订阅与通道管理") @RestController @RequestMapping("/api/open/webhook") +@PreAuthorize("hasAnyAuthority('ROLE_ADMIN', 'SCOPE_admin', 'SCOPE_tenant_admin')") public class OpenWebhookAdminController { @Autowired @@ -98,11 +101,21 @@ public class OpenWebhookAdminController { @RequestParam(required = false) String clientId, @RequestParam(required = false) String keyword) { + GatewaySecurityContext ctx = GatewaySecurityContext.get(); + if (ctx == null) throw new BizException(401, "未授权访问"); + Page page = new Page<>(pageNum, pageSize); LambdaQueryWrapper 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)) { qw.eq(OpenWebhook::getClientId, clientId); } @@ -157,10 +170,17 @@ public class OpenWebhookAdminController { @Operation(summary = "新建 Webhook 通道") @PostMapping public Result create(@RequestBody OpenWebhook req) { + GatewaySecurityContext ctx = GatewaySecurityContext.get(); + if (ctx == null) throw new BizException(401, "未授权操作"); + req.setId(null); - if (req.getTenantId() == null) { + if (!ctx.isAdmin()) { + if (ctx.getTenantId() == null) throw new BizException(403, "当前管理员无所属租户"); + req.setTenantId(ctx.getTenantId()); + } else if (req.getTenantId() == null) { req.setTenantId(1L); } + if (!StringUtils.hasText(req.getEvents())) { req.setEvents("alarm.triggered"); } @@ -183,7 +203,10 @@ public class OpenWebhookAdminController { throw new BizException("通道不存在"); } - if (req.getTenantId() != null) { + checkTenantAccess(item.getTenantId()); + + GatewaySecurityContext ctx = GatewaySecurityContext.get(); + if (ctx.isAdmin() && req.getTenantId() != null) { item.setTenantId(req.getTenantId()); } item.setClientId(req.getClientId()); @@ -206,6 +229,13 @@ public class OpenWebhookAdminController { @Operation(summary = "删除 Webhook 通道") @DeleteMapping("/{id}") public Result delete(@PathVariable Long id) { + OpenWebhook item = webhookMapper.selectById(id); + if (item == null) { + throw new BizException("通道不存在"); + } + + checkTenantAccess(item.getTenantId()); + webhookMapper.deleteById(id); return Result.success(); } @@ -218,10 +248,25 @@ public class OpenWebhookAdminController { throw new BizException("通道不存在"); } + checkTenantAccess(item.getTenantId()); + String title = body != null && body.containsKey("title") ? body.get("title") : "【IoT开放平台测试】三方事件回调连通性验证"; String content = body != null && body.containsKey("content") ? body.get("content") : "这是一条来自物联网开放平台的模拟业务事件通知,包含签名验证与数据包投递。"; boolean ok = dispatchService.sendDirect(item.getChannelType(), item.getWebhookUrl(), title, content, item.getSecret()); return Result.success(Map.of("success", ok, "message", ok ? "测试事件已成功投递" : "投递失败,请检查回调 URL 连通性或服务响应状态")); } + + private void checkTenantAccess(Long targetTenantId) { + GatewaySecurityContext ctx = GatewaySecurityContext.get(); + if (ctx == null) { + throw new BizException(401, "未授权访问"); + } + if (!ctx.isAdmin()) { + Long userTenantId = ctx.getTenantId(); + if (userTenantId == null || !userTenantId.equals(targetTenantId)) { + throw new BizException(403, "权限不足: 禁止跨租户访问或操作 Webhook 通道"); + } + } + } } diff --git a/src/main/java/com/iot/gateway/common/BackendInternalClient.java b/src/main/java/com/iot/gateway/common/BackendInternalClient.java index 244f815..2d44453 100644 --- a/src/main/java/com/iot/gateway/common/BackendInternalClient.java +++ b/src/main/java/com/iot/gateway/common/BackendInternalClient.java @@ -2,10 +2,16 @@ package com.iot.gateway.common; import com.fasterxml.jackson.databind.JsonNode; import com.fasterxml.jackson.databind.ObjectMapper; +import java.net.ConnectException; +import java.net.SocketTimeoutException; import java.net.URI; +import java.net.URLEncoder; import java.net.http.HttpClient; +import java.net.http.HttpConnectTimeoutException; import java.net.http.HttpRequest; import java.net.http.HttpResponse; +import java.net.http.HttpTimeoutException; +import java.nio.charset.StandardCharsets; import java.time.Duration; import java.util.HashMap; import java.util.Map; @@ -16,8 +22,7 @@ import org.springframework.stereotype.Component; /** * 网关与核心业务后端 (IOT-backend) 的专线 HTTP 桥接客户端 *

- * 自动向核心后端传递 X-Internal-Secret 与 X-Tenant-Id, - * 触发核心后端切入指定租户的独立物理数据库。 + * 具备 URL 安全编码、错误细粒度分类、幂等 GET 有限重试与租户强制隔离。 */ @Slf4j @Component @@ -26,77 +31,164 @@ public class BackendInternalClient { @Value("${gateway.backend.base-url:http://localhost:8080}") private String backendBaseUrl; - @Value("${gateway.backend.internal-secret:iot-internal-open-gateway-secret-key-20261009-xyz}") + @Value("${gateway.backend.internal-secret:}") private String internalSecret; private final HttpClient httpClient = HttpClient.newBuilder() - .connectTimeout(Duration.ofSeconds(5)) + .connectTimeout(Duration.ofSeconds(4)) .build(); private final ObjectMapper objectMapper = new ObjectMapper(); /** - * 内部专线 GET 请求 + * 内部专线 GET 请求 (支持参数 URL 编码、错误细粒度分类与网络抖动单次有限重试) */ public JsonNode get(String path, Long tenantId, Map queryParams) { - StringBuilder urlBuilder = new StringBuilder(backendBaseUrl).append(path); - if (queryParams != null && !queryParams.isEmpty()) { - urlBuilder.append("?"); - queryParams.forEach((k, v) -> { - if (v != null && !v.isEmpty()) { - urlBuilder.append(k).append("=").append(v).append("&"); + if (tenantId == null) { + throw new BizException(401, "专线请求缺少租户上下文,严禁跨租户或无租户调用"); + } + + String fullUrl = buildGetUrl(path, queryParams); + int maxAttempts = 2; // 最多尝试2次(初次 + 1次重试) + + for (int attempt = 1; attempt <= maxAttempts; attempt++) { + try { + HttpRequest request = HttpRequest.newBuilder() + .uri(URI.create(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 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; } - }); - urlBuilder.deleteCharAt(urlBuilder.length() - 1); - } + log.error("核心后端 GET 请求超时: url={}, tenantId={}, err={}", fullUrl, tenantId, e.getMessage()); + throw new BizException(504, "调用核心后端业务超时,服务可能处于高负荷: " + e.getMessage()); - try { - HttpRequest request = HttpRequest.newBuilder() - .uri(URI.create(urlBuilder.toString())) - .header("X-Internal-Secret", internalSecret) - .header("X-Tenant-Id", String.valueOf(tenantId != null ? tenantId : 1L)) - .header("Content-Type", "application/json") - .timeout(Duration.ofSeconds(6)) - .GET() - .build(); + } catch (ConnectException e) { + if (attempt < maxAttempts) { + log.warn("GET 连接异常,正在尝试重连 (attempt={}): url={}", attempt, fullUrl); + backoff(200); + continue; + } + log.error("核心后端连接拒绝: url={}, tenantId={}, err={}", fullUrl, tenantId, e.getMessage()); + throw new BizException(503, "核心后端服务不可达,请检查后端运行状态"); - HttpResponse resp = httpClient.send(request, HttpResponse.BodyHandlers.ofString()); - if (resp.statusCode() == 200) { - return objectMapper.readTree(resp.body()); - } else { - log.warn("核心后端内部专线响应非 200: status={}, body={}", resp.statusCode(), resp.body()); + } catch (BizException be) { + throw be; + + } catch (Exception e) { + 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 body) { - String url = backendBaseUrl + path; + if (tenantId == null) { + throw new BizException(401, "专线请求缺少租户上下文,严禁跨租户或无租户调用"); + } + + String fullUrl = backendBaseUrl + path; try { String jsonStr = objectMapper.writeValueAsString(body != null ? body : new HashMap<>()); HttpRequest request = HttpRequest.newBuilder() - .uri(URI.create(url)) - .header("X-Internal-Secret", internalSecret) - .header("X-Tenant-Id", String.valueOf(tenantId != null ? tenantId : 1L)) + .uri(URI.create(fullUrl)) + .header("X-Internal-Secret", internalSecret != null ? internalSecret : "") + .header("X-Tenant-Id", String.valueOf(tenantId)) .header("Content-Type", "application/json") .timeout(Duration.ofSeconds(6)) .POST(HttpRequest.BodyPublishers.ofString(jsonStr)) .build(); HttpResponse resp = httpClient.send(request, HttpResponse.BodyHandlers.ofString()); - if (resp.statusCode() == 200) { - return objectMapper.readTree(resp.body()); - } else { - log.warn("核心后端内部专线响应非 200: status={}, body={}", resp.statusCode(), resp.body()); - } + return handleResponse(resp, fullUrl); + + } catch (HttpTimeoutException | SocketTimeoutException e) { + log.error("核心后端 POST 请求超时 (未重试,防止指令重放): url={}, tenantId={}", fullUrl, tenantId); + throw new BizException(504, "核心控制通道通信超时,无法确认物理设备指令下发状态"); + + } catch (ConnectException e) { + log.error("核心后端连接失败: url={}, tenantId={}, err={}", fullUrl, tenantId, e.getMessage()); + throw new BizException(503, "核心控制网关服务不可达"); + + } catch (BizException be) { + throw be; + } catch (Exception e) { - log.warn("调用核心后端内部 POST 异常: url={}, tenantId={}, err={}", url, tenantId, e.getMessage()); + log.error("核心后端 POST 调用异常: url={}, tenantId={}, err={}", fullUrl, tenantId, e.getMessage()); + throw new BizException(502, "控制指令网关通信失败: " + e.getMessage()); } - return null; + } + + private JsonNode handleResponse(HttpResponse 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 queryParams) { + StringBuilder urlBuilder = new StringBuilder(backendBaseUrl).append(path); + if (queryParams != null && !queryParams.isEmpty()) { + urlBuilder.append("?"); + for (Map.Entry 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) {} } } diff --git a/src/main/java/com/iot/gateway/common/IpUtils.java b/src/main/java/com/iot/gateway/common/IpUtils.java index 5f4a522..aa4f7cf 100644 --- a/src/main/java/com/iot/gateway/common/IpUtils.java +++ b/src/main/java/com/iot/gateway/common/IpUtils.java @@ -4,11 +4,13 @@ import jakarta.servlet.http.HttpServletRequest; import java.net.InetAddress; import java.net.UnknownHostException; import java.util.Arrays; +import java.util.List; +import java.util.concurrent.CopyOnWriteArrayList; import lombok.extern.slf4j.Slf4j; import org.springframework.util.StringUtils; /** - * IP 获取与白名单校验工具类 + * IP 获取与白名单校验工具类 (具备可信反向代理校验防御) */ @Slf4j public class IpUtils { @@ -18,13 +20,47 @@ public class IpUtils { private static final String LOCALHOST_IPV6 = "0:0:0:0:0:0:0:1"; /** - * 获取客户端真实 IP(兼容多级 Nginx / 反向代理 / 负载均衡) + * 默认信任的内网/本地反向代理 IP 网段 (如本地 Nginx / 宿主机代理) + */ + private static final List TRUSTED_PROXIES = new CopyOnWriteArrayList<>(Arrays.asList( + "127.0.0.1", + "::1", + "0:0:0:0:0:0:0:1", + "10.0.0.0/8", + "172.16.0.0/12", + "192.168.0.0/16" + )); + + public static void setTrustedProxies(String proxiesConfig) { + if (StringUtils.hasText(proxiesConfig)) { + TRUSTED_PROXIES.clear(); + for (String p : proxiesConfig.split("[,;\\s]+")) { + if (StringUtils.hasText(p)) { + TRUSTED_PROXIES.add(p.trim()); + } + } + } + } + + /** + * 获取客户端真实 IP(仅在直接对端 RemoteAddr 属于可信反向代理时,才信任反代传递的 IP 头) */ public static String getClientIp(HttpServletRequest request) { if (request == null) { return UNKNOWN; } + String remoteAddr = request.getRemoteAddr(); + if (LOCALHOST_IPV6.equals(remoteAddr)) { + remoteAddr = LOCALHOST_IPV4; + } + + // 核心安全防伪造:如果直连来源不是受信任的反向代理,直接使用 remoteAddr,严禁采信伪造的 X-Forwarded-For 等头部 + if (!isTrustedProxy(remoteAddr)) { + return remoteAddr != null ? remoteAddr.trim() : UNKNOWN; + } + + // 仅在确认对端为受信任反向代理的前提下,提取代理传递的源 IP String ip = request.getHeader("X-Forwarded-For"); if (isInvalidIp(ip)) { ip = request.getHeader("X-Real-IP"); @@ -36,13 +72,7 @@ public class IpUtils { ip = request.getHeader("WL-Proxy-Client-IP"); } if (isInvalidIp(ip)) { - ip = request.getHeader("HTTP_CLIENT_IP"); - } - if (isInvalidIp(ip)) { - ip = request.getHeader("HTTP_X_FORWARDED_FOR"); - } - if (isInvalidIp(ip)) { - ip = request.getRemoteAddr(); + ip = remoteAddr; } // 多级反向代理会有多个逗号分隔的 IP,首个为客户端真实源 IP @@ -63,19 +93,27 @@ public class IpUtils { return ip != null ? ip.trim() : UNKNOWN; } + private static boolean isTrustedProxy(String ip) { + if (!StringUtils.hasText(ip) || UNKNOWN.equalsIgnoreCase(ip)) { + return false; + } + String normalized = LOCALHOST_IPV6.equals(ip.trim()) ? LOCALHOST_IPV4 : ip.trim(); + for (String trustedPattern : TRUSTED_PROXIES) { + if (matchIp(normalized, trustedPattern)) { + return true; + } + } + return false; + } + private static boolean isInvalidIp(String ip) { return !StringUtils.hasText(ip) || UNKNOWN.equalsIgnoreCase(ip.trim()); } /** * 校验客户端 IP 是否匹配白名单 - * - * @param clientIp 客户端请求真实 IP - * @param whitelist 白名单配置(支持英文逗号、分号、中文逗号、换行分隔;留空表示不限制;支持精确 IP、CIDR 网段如 192.168.1.0/24、通配符如 192.168.1.*) - * @return true 允许通过,false 拦截 */ public static boolean isAllowed(String clientIp, String whitelist) { - // 白名单为空则不限制 if (!StringUtils.hasText(whitelist)) { return true; } @@ -83,10 +121,8 @@ public class IpUtils { return false; } - // 统一规范化 localhost String normalizedClientIp = LOCALHOST_IPV6.equals(clientIp.trim()) ? LOCALHOST_IPV4 : clientIp.trim(); - // 支持多种常见分隔符 String[] rules = whitelist.split("[,;,\\n\\r]+"); for (String rule : rules) { String pattern = rule.trim(); @@ -99,75 +135,67 @@ public class IpUtils { } /** - * 单条规则匹配 + * IP 规则匹配:支持精确 IP、通配符(192.168.1.*)、CIDR 网段(192.168.1.0/24) */ - private static boolean matchIp(String clientIp, String pattern) { - if ("*".equals(pattern)) { - return true; - } - // 规范化 localhost - if (("localhost".equalsIgnoreCase(pattern) || LOCALHOST_IPV6.equals(pattern)) && LOCALHOST_IPV4.equals(clientIp)) { - return true; - } - // 1. 精确匹配 - if (pattern.equalsIgnoreCase(clientIp)) { - return true; - } - // 2. 通配符匹配 (如 192.168.1.* -> 转换为 CIDR 192.168.1.0/24 逻辑) - if (pattern.contains("*")) { - String regex = pattern.replace(".", "\\.").replace("*", ".*"); - return clientIp.matches(regex); - } - // 3. CIDR 网段匹配 (如 192.168.1.0/24) - if (pattern.contains("/")) { - return isInCidrRange(clientIp, pattern); + public static boolean matchIp(String ip, String pattern) { + if (ip == null || pattern == null) { + return false; } - 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); } - /** - * 校验 IPv4 是否在 CIDR 网段内 - */ - private static boolean isInCidrRange(String ipStr, String cidr) { + private static boolean matchWildcard(String ip, String pattern) { + String regex = pattern.replace(".", "\\.").replace("*", ".*"); + return ip.matches(regex); + } + + private static boolean matchCidr(String ip, String cidr) { try { String[] parts = cidr.split("/"); - if (parts.length != 2) { - return false; - } - int maskBits = Integer.parseInt(parts[1].trim()); - if (maskBits < 0 || maskBits > 32) { - return false; + if (parts.length != 2) return false; + + String network = parts[0]; + int prefixLength = Integer.parseInt(parts[1]); + + byte[] ipBytes = InetAddress.getByName(ip).getAddress(); + byte[] netBytes = InetAddress.getByName(network).getAddress(); + + if (ipBytes.length != netBytes.length) return false; + + int byteCount = prefixLength / 8; + int bitCount = prefixLength % 8; + + for (int i = 0; i < byteCount; i++) { + if (ipBytes[i] != netBytes[i]) return false; } - InetAddress targetAddr = InetAddress.getByName(ipStr); - InetAddress subnetAddr = InetAddress.getByName(parts[0].trim()); - - byte[] targetBytes = targetAddr.getAddress(); - byte[] subnetBytes = subnetAddr.getAddress(); - - if (targetBytes.length != subnetBytes.length) { - return false; - } - - int fullBytes = maskBits / 8; - for (int i = 0; i < fullBytes; i++) { - if (targetBytes[i] != subnetBytes[i]) { - return false; - } - } - - int remainingBits = maskBits % 8; - if (remainingBits > 0 && fullBytes < targetBytes.length) { - int mask = (0xFF << (8 - remainingBits)) & 0xFF; - if ((targetBytes[fullBytes] & mask) != (subnetBytes[fullBytes] & mask)) { - return false; - } + if (bitCount > 0 && byteCount < ipBytes.length) { + int mask = (0xFF << (8 - bitCount)) & 0xFF; + return (ipBytes[byteCount] & mask) == (netBytes[byteCount] & mask); } return true; } catch (UnknownHostException | NumberFormatException e) { - log.warn("CIDR 网段解析校验异常: ip={}, cidr={}, error={}", ipStr, cidr, e.getMessage()); + log.warn("CIDR 解析错误: ip={}, cidr={}, err={}", ip, cidr, e.getMessage()); return false; } } diff --git a/src/main/java/com/iot/gateway/entity/OpenClient.java b/src/main/java/com/iot/gateway/entity/OpenClient.java index ecba6bc..272397b 100644 --- a/src/main/java/com/iot/gateway/entity/OpenClient.java +++ b/src/main/java/com/iot/gateway/entity/OpenClient.java @@ -1,9 +1,11 @@ package com.iot.gateway.entity; import com.baomidou.mybatisplus.annotation.IdType; +import com.baomidou.mybatisplus.annotation.TableField; import com.baomidou.mybatisplus.annotation.TableId; import com.baomidou.mybatisplus.annotation.TableName; import com.fasterxml.jackson.annotation.JsonFormat; +import com.fasterxml.jackson.annotation.JsonProperty; import java.io.Serializable; import java.time.LocalDateTime; import lombok.Data; @@ -16,11 +18,23 @@ public class OpenClient implements Serializable { private Long id; private String clientId; + + /** + * 客户端密钥哈希(数据库存储 BCrypt 哈希,序列化对外输出时默认忽略,防止密钥泄露) + */ + @JsonProperty(access = JsonProperty.Access.WRITE_ONLY) private String clientSecret; + + /** + * 仅在创建或重置密钥时返回一次的明文密钥 + */ + @TableField(exist = false) + private String rawSecret; + private String clientName; private Long tenantId; - @com.baomidou.mybatisplus.annotation.TableField(exist = false) + @TableField(exist = false) private String tenantName; private String scopes; diff --git a/src/main/java/com/iot/gateway/mcp/McpSseController.java b/src/main/java/com/iot/gateway/mcp/McpSseController.java index ca8b557..fd4f511 100644 --- a/src/main/java/com/iot/gateway/mcp/McpSseController.java +++ b/src/main/java/com/iot/gateway/mcp/McpSseController.java @@ -1,18 +1,25 @@ package com.iot.gateway.mcp; import com.fasterxml.jackson.databind.ObjectMapper; +import com.iot.gateway.common.BizException; import com.iot.gateway.oauth.GatewaySecurityContext; import io.swagger.v3.oas.annotations.Operation; import io.swagger.v3.oas.annotations.tags.Tag; import jakarta.servlet.http.HttpServletResponse; import java.io.IOException; +import java.util.Collections; import java.util.HashMap; import java.util.List; import java.util.Map; import java.util.UUID; import java.util.concurrent.ConcurrentHashMap; +import lombok.AllArgsConstructor; +import lombok.Builder; +import lombok.Data; +import lombok.NoArgsConstructor; import lombok.extern.slf4j.Slf4j; import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.http.HttpStatus; import org.springframework.http.MediaType; import org.springframework.http.ResponseEntity; import org.springframework.web.bind.annotation.GetMapping; @@ -34,31 +41,78 @@ public class McpSseController { private final ObjectMapper objectMapper = new ObjectMapper(); - /** 维护 SSE 活跃会话 */ + /** 维护 SSE 活跃网络通道 */ private final Map sessionMap = new ConcurrentHashMap<>(); + /** 维护 MCP 会话与租户/客户端身份的安全绑定,杜绝凭 sessionId 伪造跨租户请求 */ + private final Map sessionInfoMap = new ConcurrentHashMap<>(); + + @Data + @Builder + @NoArgsConstructor + @AllArgsConstructor + public static class McpSessionInfo { + private String sessionId; + private String clientId; + private Long tenantId; + private List scopes; + private boolean admin; + private long createdAt; + } + @Operation(summary = "建立 MCP SSE 长连接端点 (用于 Cursor/Antigravity/Agent 客户端)") @GetMapping(value = "/sse", produces = MediaType.TEXT_EVENT_STREAM_VALUE) - public SseEmitter connectSse(HttpServletResponse response) { + public SseEmitter connectSse( + @RequestParam(required = false) Long tenantId, + HttpServletResponse response) { + + // 严格认证:必须具备合法鉴权凭据与租户上下文,严禁匿名或跨租户访问 + GatewaySecurityContext ctx = GatewaySecurityContext.get(); + if (ctx == null) { + throw new BizException(401, "MCP 连接建立拒绝: 未提供有效的访问凭据,请携带 Bearer 令牌或在 URL 附加 ?access_token="); + } + + Long effectiveTenantId = ctx.getTenantId(); + if (effectiveTenantId == null) { + if (ctx.isAdmin() && tenantId != null) { + effectiveTenantId = tenantId; + } else { + throw new BizException(401, "MCP 连接建立拒绝: 缺少有效的租户授权上下文"); + } + } + response.setHeader("Cache-Control", "no-cache"); response.setHeader("X-Accel-Buffering", "no"); String sessionId = UUID.randomUUID().toString().replace("-", ""); SseEmitter emitter = new SseEmitter(24 * 60 * 60 * 1000L); // 24小时超长连接保活 + McpSessionInfo sessionInfo = McpSessionInfo.builder() + .sessionId(sessionId) + .clientId(ctx.getClientId()) + .tenantId(effectiveTenantId) + .scopes(ctx.getScopes() != null ? ctx.getScopes() : Collections.emptyList()) + .admin(ctx.isAdmin()) + .createdAt(System.currentTimeMillis()) + .build(); + sessionMap.put(sessionId, emitter); + sessionInfoMap.put(sessionId, sessionInfo); emitter.onCompletion(() -> { log.info("MCP SSE 会话完成: sessionId={}", sessionId); sessionMap.remove(sessionId); + sessionInfoMap.remove(sessionId); }); emitter.onTimeout(() -> { log.info("MCP SSE 会话超时: sessionId={}", sessionId); sessionMap.remove(sessionId); + sessionInfoMap.remove(sessionId); }); emitter.onError(e -> { log.warn("MCP SSE 会话异常: sessionId={}, err={}", sessionId, e.getMessage()); sessionMap.remove(sessionId); + sessionInfoMap.remove(sessionId); }); try { @@ -66,7 +120,8 @@ public class McpSseController { emitter.send(SseEmitter.event() .name("endpoint") .data("/mcp/messages?sessionId=" + sessionId)); - log.info("MCP SSE 客户端握手连接成功: sessionId={}", sessionId); + log.info("MCP SSE 客户端握手建立成功: sessionId={}, clientId={}, tenantId={}", + sessionId, ctx.getClientId(), effectiveTenantId); } catch (IOException e) { log.error("发送 MCP endpoint 事件失败: {}", e.getMessage()); } @@ -92,6 +147,45 @@ public class McpSseController { log.info("收到 MCP JSON-RPC 请求: method={}, id={}, sessionId={}", method, id, sessionId); + // 会话与安全身份解析 + McpSessionInfo sessionInfo = sessionId != null ? sessionInfoMap.get(sessionId) : null; + GatewaySecurityContext currentAuth = GatewaySecurityContext.get(); + + String effectiveClientId; + Long effectiveTenantId; + List effectiveScopes; + boolean effectiveIsAdmin; + + if (sessionInfo != null) { + // 会话已绑定,若请求同时带了 Token,核验两者租户是否一致 + if (currentAuth != null && currentAuth.getTenantId() != null + && !currentAuth.isAdmin() + && !currentAuth.getTenantId().equals(sessionInfo.getTenantId())) { + rpcResponse.put("error", Map.of( + "code", -32003, + "message", "安全拦截: 传入的认证凭据租户与当前 MCP 会话租户不一致" + )); + return ResponseEntity.status(HttpStatus.FORBIDDEN).body(rpcResponse); + } + effectiveClientId = sessionInfo.getClientId(); + effectiveTenantId = sessionInfo.getTenantId(); + effectiveScopes = sessionInfo.getScopes(); + effectiveIsAdmin = sessionInfo.isAdmin(); + } else if (currentAuth != null) { + // 无 session 绑定但具备有效 Bearer Token + effectiveClientId = currentAuth.getClientId(); + effectiveTenantId = currentAuth.getTenantId(); + effectiveScopes = currentAuth.getScopes(); + effectiveIsAdmin = currentAuth.isAdmin(); + } else { + // 既无有效 sessionId 也无 Token,直接拒绝 + rpcResponse.put("error", Map.of( + "code", -32001, + "message", "未授权访问: 缺少合法的 MCP 会话或 Bearer 访问凭据" + )); + return ResponseEntity.status(HttpStatus.UNAUTHORIZED).body(rpcResponse); + } + if ("initialize".equalsIgnoreCase(method)) { // 握手响应 Map serverInfo = Map.of( @@ -119,10 +213,9 @@ public class McpSseController { @SuppressWarnings("unchecked") Map arguments = params != null ? (Map) params.get("arguments") : Map.of(); - Long tenantId = GatewaySecurityContext.getTenantIdOrDefault(1L); - try { - Map toolResult = toolRegistry.executeTool(toolName, arguments, tenantId); + Map toolResult = toolRegistry.executeTool( + toolName, arguments, effectiveTenantId, effectiveScopes, effectiveIsAdmin); String contentJson = objectMapper.writeValueAsString(toolResult); Map textContent = Map.of( @@ -130,11 +223,19 @@ public class McpSseController { "text", contentJson ); rpcResponse.put("result", Map.of("content", List.of(textContent))); - } catch (Exception e) { - log.error("执行 MCP 工具异常: ", e); + } catch (BizException be) { + log.warn("执行 MCP 工具业务失败: tool={}, tenantId={}, code={}, msg={}", + toolName, effectiveTenantId, be.getCode(), be.getMessage()); Map errContent = Map.of( "type", "text", - "text", "{\"error\": \"执行失败: " + e.getMessage() + "\"}" + "text", "{\"error\": \"" + be.getMessage() + "\", \"code\": " + be.getCode() + "}" + ); + rpcResponse.put("result", Map.of("content", List.of(errContent), "isError", true)); + } catch (Exception e) { + log.error("执行 MCP 工具异常: tool={}, tenantId={}", toolName, effectiveTenantId, e); + Map errContent = Map.of( + "type", "text", + "text", "{\"error\": \"网关内部执行异常: " + e.getMessage() + "\"}" ); rpcResponse.put("result", Map.of("content", List.of(errContent), "isError", true)); } @@ -154,7 +255,7 @@ public class McpSseController { .name("message") .data(objectMapper.writeValueAsString(rpcResponse))); } catch (Exception e) { - log.warn("向 SSE 会话推送消息失败: {}", e.getMessage()); + log.warn("向 SSE 会话推送消息失败: sessionId={}, err={}", sessionId, e.getMessage()); } } diff --git a/src/main/java/com/iot/gateway/mcp/McpToolRegistry.java b/src/main/java/com/iot/gateway/mcp/McpToolRegistry.java index 79a90ed..6f14d60 100644 --- a/src/main/java/com/iot/gateway/mcp/McpToolRegistry.java +++ b/src/main/java/com/iot/gateway/mcp/McpToolRegistry.java @@ -2,12 +2,14 @@ package com.iot.gateway.mcp; import com.fasterxml.jackson.databind.JsonNode; import com.iot.gateway.common.BackendInternalClient; +import com.iot.gateway.common.BizException; import java.util.ArrayList; import java.util.HashMap; import java.util.List; import java.util.Map; import lombok.extern.slf4j.Slf4j; import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.beans.factory.annotation.Value; import org.springframework.stereotype.Component; @Slf4j @@ -17,13 +19,19 @@ public class McpToolRegistry { @Autowired private BackendInternalClient internalClient; + /** + * 演示/开发模式开关。生产环境下必须为 false,避免将后端故障误报为“成功”或演示响应 + */ + @Value("${gateway.mock.enabled:false}") + private boolean mockEnabled; + public List> getToolDefinitions() { List> tools = new ArrayList<>(); // 1. iot_device_list Map t1 = new HashMap<>(); t1.put("name", "iot_device_list"); - t1.put("description", "查询当前租户名下的所有工业物联网设备台账与实时运行状态(在线/报警/停机)。"); + t1.put("description", "查询当前租户名下的所有工业物联网设备台账与实时运行状态(在线/报警/停机)。所需权限范围: device.read"); Map s1 = new HashMap<>(); s1.put("type", "object"); Map p1 = new HashMap<>(); @@ -37,7 +45,7 @@ public class McpToolRegistry { // 2. iot_telemetry_latest Map t2 = new HashMap<>(); t2.put("name", "iot_telemetry_latest"); - t2.put("description", "召测指定工业设备的最新实时点位遥测数据(注塑机温度、注塑压力、螺杆转速、能耗电流等)。"); + t2.put("description", "召测指定工业设备的最新实时点位遥测数据(注塑机温度、注塑压力、螺杆转速、能耗电流等)。所需权限范围: telemetry.read"); Map s2 = new HashMap<>(); s2.put("type", "object"); Map p2 = new HashMap<>(); @@ -50,12 +58,12 @@ public class McpToolRegistry { // 3. iot_device_control Map t3 = new HashMap<>(); t3.put("name", "iot_device_control"); - t3.put("description", "向边缘物联设备安全下发反写控制指令(如设定注塑温度、设备启停控制、工艺参数微调)。"); + t3.put("description", "向边缘物联设备安全下发反写控制指令(如设定注塑温度、设备启停控制、工艺参数微调)。所需权限范围: device.control"); Map s3 = new HashMap<>(); s3.put("type", "object"); Map p3 = new HashMap<>(); p3.put("deviceId", Map.of("type", "string", "description", "目标设备编码")); - p3.put("command", Map.of("type", "string", "description", "指令动作: start/stop/set_param")); + p3.put("command", Map.of("type", "string", "description", "指令动作: start/stop/reboot/set_param")); p3.put("params", Map.of("type", "object", "description", "指令参数键值对")); s3.put("properties", p3); s3.put("required", List.of("deviceId", "command")); @@ -65,7 +73,7 @@ public class McpToolRegistry { // 4. iot_alarm_query Map t4 = new HashMap<>(); t4.put("name", "iot_alarm_query"); - t4.put("description", "按设备与时间范围检索未处理的设备故障与历史严重告警记录。"); + t4.put("description", "按设备与时间范围检索未处理的设备故障与历史严重告警记录。所需权限范围: alarm.read"); Map s4 = new HashMap<>(); s4.put("type", "object"); Map p4 = new HashMap<>(); @@ -78,8 +86,20 @@ public class McpToolRegistry { return tools; } - public Map executeTool(String toolName, Map args, Long tenantId) { - log.info("执行 MCP 工具调用: tool={}, tenantId={}, args={}", toolName, tenantId, args); + /** + * 执行 MCP 工具调用,严格校验租户上下文与授权范围 Scope + */ + public Map executeTool(String toolName, Map args, Long tenantId, List 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 result = new HashMap<>(); @@ -103,22 +123,40 @@ public class McpToolRegistry { return result; } - // 兜底降级响应 + // 非 mock 模式下直接明确报错 + if (!mockEnabled) { + String errMsg = resp != null && resp.has("message") ? resp.get("message").asText() : "核心物联后端服务不可用或查询超时"; + throw new BizException(502, "召测设备列表失败: " + errMsg); + } + + // 开发演示兜底 result.put("tenantId", tenantId); + result.put("isMock", true); result.put("total", 2); result.put("devices", List.of( Map.of("deviceCode", "DEV20261009001", "deviceName", "1号注塑机 (海天MA1200)", "online", 1), Map.of("deviceCode", "DEV20261009002", "deviceName", "2号注塑机 (海天MA2000)", "online", 0) )); } else if ("iot_telemetry_latest".equals(toolName)) { - String deviceId = args != null && args.containsKey("deviceId") ? String.valueOf(args.get("deviceId")) : "DEV20261009001"; + String deviceId = args != null && args.containsKey("deviceId") ? String.valueOf(args.get("deviceId")) : ""; + if (deviceId.isEmpty()) { + throw new BizException(400, "缺少必填参数: deviceId"); + } + JsonNode resp = internalClient.get("/api/internal/open/telemetry/latest", tenantId, Map.of("deviceId", deviceId)); if (resp != null && resp.has("code") && resp.get("code").asInt() == 200 && resp.has("data")) { result.put("tenantId", tenantId); + result.put("deviceId", deviceId); result.put("data", resp.get("data")); return result; } + if (!mockEnabled) { + String errMsg = resp != null && resp.has("message") ? resp.get("message").asText() : "后端遥测接口不可用或设备无数据"; + throw new BizException(502, "召测设备遥测失败: " + errMsg); + } + + result.put("isMock", true); result.put("deviceId", deviceId); result.put("timestamp", System.currentTimeMillis()); result.put("telemetry", Map.of( @@ -128,17 +166,26 @@ public class McpToolRegistry { "screw_speed_rpm", 85.0 )); } else if ("iot_device_control".equals(toolName)) { + // 控制指令下发:绝不能在后端失败时误报“EXECUTED” JsonNode resp = internalClient.post("/api/internal/open/commands", tenantId, args); if (resp != null && resp.has("code") && resp.get("code").asInt() == 200 && resp.has("data")) { result.put("tenantId", tenantId); - result.put("result", resp.get("data")); + result.put("success", true); + result.put("status", "EXECUTED"); + result.put("data", resp.get("data")); return result; } - result.put("success", true); - result.put("deviceId", args != null ? args.get("deviceId") : ""); - result.put("status", "EXECUTED"); - result.put("message", "控制指令已成功通过网关下发"); + String errMsg = resp != null && resp.has("message") ? resp.get("message").asText() : "后端指令网关通信超时或未响应"; + if (!mockEnabled) { + log.error("下发设备控制指令失败: tool={}, tenantId={}, args={}, err={}", toolName, tenantId, args, errMsg); + throw new BizException(502, "控制指令执行失败: " + errMsg); + } + + result.put("isMock", true); + result.put("success", false); + result.put("status", "MOCK_MODE_REFUSED"); + result.put("message", "当前为开发演示模式,已拒绝向物理设备真实下发控制指令: " + errMsg); } else if ("iot_alarm_query".equals(toolName)) { Map params = new HashMap<>(); if (args != null && args.containsKey("deviceId")) params.put("deviceId", String.valueOf(args.get("deviceId"))); @@ -151,13 +198,50 @@ public class McpToolRegistry { return result; } + if (!mockEnabled) { + String errMsg = resp != null && resp.has("message") ? resp.get("message").asText() : "后端告警接口不可用"; + throw new BizException(502, "查询告警失败: " + errMsg); + } + + result.put("isMock", true); result.put("alarms", List.of( Map.of("alarmId", "ALM-2026100901", "deviceId", "DEV20261009001", "severity", 3, "title", "模具温度异常过高") )); } else { - result.put("error", "未知的工具名称: " + toolName); + throw new BizException(400, "未知的 MCP 工具名称: " + toolName); } return result; } + + private void checkToolScope(String toolName, List 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 + "] 授权范围"); + } + } } diff --git a/src/main/java/com/iot/gateway/oauth/GatewaySecurityContext.java b/src/main/java/com/iot/gateway/oauth/GatewaySecurityContext.java index 7f71a52..13d30ee 100644 --- a/src/main/java/com/iot/gateway/oauth/GatewaySecurityContext.java +++ b/src/main/java/com/iot/gateway/oauth/GatewaySecurityContext.java @@ -19,6 +19,8 @@ public class GatewaySecurityContext { private Long tenantId; private List scopes; + private boolean admin; + public static void set(GatewaySecurityContext context) { CONTEXT_HOLDER.set(context); } @@ -31,13 +33,32 @@ public class GatewaySecurityContext { CONTEXT_HOLDER.remove(); } - public static Long getTenantIdOrDefault(Long defaultVal) { + /** + * 严格获取当前已认证租户 ID,未认证或缺少租户时抛出异常,杜绝默认兜底到租户 1 + */ + public static Long requireTenantId() { GatewaySecurityContext ctx = get(); - return (ctx != null && ctx.getTenantId() != null) ? ctx.getTenantId() : defaultVal; + if (ctx == null || ctx.getTenantId() == null) { + throw new com.iot.gateway.common.BizException(401, "未识别到合法租户授权上下文,请先完成认证"); + } + return ctx.getTenantId(); + } + + public static Long getTenantIdOrNull() { + GatewaySecurityContext ctx = get(); + return ctx != null ? ctx.getTenantId() : null; } public boolean hasScope(String requiredScope) { + if (admin) return true; if (scopes == null || scopes.isEmpty()) return false; - return scopes.contains(requiredScope) || scopes.contains("all") || scopes.contains("*"); + String normalized = requiredScope != null ? requiredScope.replace(":", ".") : ""; + for (String s : scopes) { + String norm = s.trim().replace(":", "."); + if (norm.equals(normalized) || norm.equals("all") || norm.equals("*")) { + return true; + } + } + return false; } } diff --git a/src/main/java/com/iot/gateway/oauth/JwtTokenProvider.java b/src/main/java/com/iot/gateway/oauth/JwtTokenProvider.java index dcc1f74..b98bde7 100644 --- a/src/main/java/com/iot/gateway/oauth/JwtTokenProvider.java +++ b/src/main/java/com/iot/gateway/oauth/JwtTokenProvider.java @@ -13,22 +13,33 @@ import java.util.Map; import lombok.extern.slf4j.Slf4j; import org.springframework.beans.factory.annotation.Value; import org.springframework.stereotype.Component; +import org.springframework.util.StringUtils; @Slf4j @Component public class JwtTokenProvider { - @Value("${gateway.oauth.jwt-secret:iot-gateway-oauth2-super-secret-key-666888-abcdefghijklmnopqrstuvwxyz}") + @Value("${gateway.oauth.jwt-secret:}") private String jwtSecret; @Value("${gateway.oauth.token-expire-seconds:7200}") private long tokenExpireSeconds; private Key getSigningKey() { + if (!StringUtils.hasText(jwtSecret)) { + throw new IllegalStateException("安全配置缺失: 未注入 gateway.oauth.jwt-secret 环境变量,严禁启动空私钥运行"); + } return Keys.hmacShaKeyFor(jwtSecret.getBytes(StandardCharsets.UTF_8)); } public String generateToken(OpenClient client) { + return generateToken(client, client.getScopes()); + } + + /** + * 生成支持 Scope 收窄的访问令牌 + */ + public String generateToken(OpenClient client, String effectiveScopes) { long nowMillis = System.currentTimeMillis(); Date now = new Date(nowMillis); Date exp = new Date(nowMillis + tokenExpireSeconds * 1000); @@ -36,8 +47,9 @@ public class JwtTokenProvider { Map claims = new HashMap<>(); claims.put("tenant_id", client.getTenantId()); claims.put("client_name", client.getClientName()); - claims.put("scopes", client.getScopes()); + claims.put("scopes", effectiveScopes != null ? effectiveScopes : client.getScopes()); claims.put("ip_whitelist", client.getIpWhitelist()); + claims.put("rate_limit", client.getRateLimit() != null ? client.getRateLimit() : 60); return Jwts.builder() .setClaims(claims) diff --git a/src/main/java/com/iot/gateway/oauth/OAuth2AuthenticationFilter.java b/src/main/java/com/iot/gateway/oauth/OAuth2AuthenticationFilter.java index a4713dd..0aff223 100644 --- a/src/main/java/com/iot/gateway/oauth/OAuth2AuthenticationFilter.java +++ b/src/main/java/com/iot/gateway/oauth/OAuth2AuthenticationFilter.java @@ -1,16 +1,19 @@ package com.iot.gateway.oauth; +import com.iot.gateway.common.IpUtils; import io.jsonwebtoken.Claims; import jakarta.servlet.FilterChain; import jakarta.servlet.ServletException; import jakarta.servlet.http.HttpServletRequest; import jakarta.servlet.http.HttpServletResponse; import java.io.IOException; +import java.util.ArrayList; import java.util.Arrays; import java.util.Collections; import java.util.List; import lombok.extern.slf4j.Slf4j; import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.beans.factory.annotation.Value; import org.springframework.security.authentication.UsernamePasswordAuthenticationToken; import org.springframework.security.core.authority.SimpleGrantedAuthority; import org.springframework.security.core.context.SecurityContextHolder; @@ -25,52 +28,148 @@ public class OAuth2AuthenticationFilter extends OncePerRequestFilter { @Autowired private JwtTokenProvider tokenProvider; + @Value("${gateway.backend.internal-secret:}") + private String internalSecret; + + @Value("${gateway.admin.secret:}") + private String adminSecret; + @Override protected void doFilterInternal(HttpServletRequest request, HttpServletResponse response, FilterChain filterChain) throws ServletException, IOException { + // 1. 检查内部通道通信密钥 (例如核心后端调用网关,或特权管理脚本调用) + String requestInternalSecret = request.getHeader("X-Internal-Secret"); + String requestAdminSecret = request.getHeader("X-Gateway-Admin-Secret"); + + if ((StringUtils.hasText(internalSecret) && internalSecret.equals(requestInternalSecret)) || + (StringUtils.hasText(adminSecret) && adminSecret.equals(requestAdminSecret))) { + + String headerTenant = request.getHeader("X-Tenant-Id"); + Long tenantId = null; + if (StringUtils.hasText(headerTenant)) { + try { + tenantId = Long.parseLong(headerTenant); + } catch (NumberFormatException ignored) {} + } + + GatewaySecurityContext context = GatewaySecurityContext.builder() + .clientId("internal_admin") + .clientName("系统特权管理员") + .tenantId(tenantId) + .scopes(List.of("admin", "all", "device.read", "device.control", "telemetry.read", "alarm.read")) + .admin(true) + .build(); + GatewaySecurityContext.set(context); + + List authorities = List.of( + new SimpleGrantedAuthority("ROLE_ADMIN"), + new SimpleGrantedAuthority("SCOPE_admin"), + new SimpleGrantedAuthority("SCOPE_all") + ); + UsernamePasswordAuthenticationToken auth = + new UsernamePasswordAuthenticationToken("internal_admin", null, authorities); + SecurityContextHolder.getContext().setAuthentication(auth); + + try { + filterChain.doFilter(request, response); + } finally { + GatewaySecurityContext.clear(); + } + return; + } + + // 2. 解析 Bearer Token String token = resolveToken(request); if (StringUtils.hasText(token) && tokenProvider.validateToken(token)) { Claims claims = tokenProvider.parseToken(token); if (claims != null) { - 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); + // 判断是后端用户登录凭据还是开放客户端凭据 + Object uidObj = claims.get("uid"); + Object userTypeObj = claims.get("type"); - // 校验客户端当前请求 IP 是否在白名单内 - if (StringUtils.hasText(ipWhitelist)) { - String clientIp = com.iot.gateway.common.IpUtils.getClientIp(request); - if (!com.iot.gateway.common.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; + if (uidObj != null || userTypeObj != null) { + // 核心业务后端签发的管理员或租户用户 JWT (来自 Web 前端控制台) + String username = claims.getSubject(); + String userType = userTypeObj != null ? String.valueOf(userTypeObj) : ""; + Long tenantId = null; + Object tidObj = claims.get("tenantId"); + if (tidObj != null) { + try { + tenantId = Long.parseLong(String.valueOf(tidObj)); + } catch (Exception ignored) {} } + + boolean isPlatformAdmin = "PLATFORM".equalsIgnoreCase(userType); + List 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 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 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 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 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 ")) { return bearer.substring(7).trim(); } - // 允许通过 ?token= 查询参数传递 (供 SSE / EventSource 连接时携带) - String queryToken = request.getParameter("token"); + // 允许通过 ?token= 或 ?access_token= 查询参数传递 (供 SSE / EventSource 连接时携带) + String queryToken = request.getParameter("access_token"); + if (!StringUtils.hasText(queryToken)) { + queryToken = request.getParameter("token"); + } if (StringUtils.hasText(queryToken)) { return queryToken.trim(); } diff --git a/src/main/java/com/iot/gateway/oauth/OAuth2TokenController.java b/src/main/java/com/iot/gateway/oauth/OAuth2TokenController.java index c25bfb3..db24a1d 100644 --- a/src/main/java/com/iot/gateway/oauth/OAuth2TokenController.java +++ b/src/main/java/com/iot/gateway/oauth/OAuth2TokenController.java @@ -1,19 +1,26 @@ package com.iot.gateway.oauth; import com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper; +import com.fasterxml.jackson.databind.JsonNode; +import com.fasterxml.jackson.databind.ObjectMapper; +import com.iot.gateway.common.IpUtils; import com.iot.gateway.entity.OpenClient; import com.iot.gateway.mapper.OpenClientMapper; import io.swagger.v3.oas.annotations.Operation; import io.swagger.v3.oas.annotations.tags.Tag; import java.time.LocalDateTime; +import java.util.Arrays; +import java.util.Collections; import java.util.HashMap; +import java.util.List; import java.util.Map; import lombok.extern.slf4j.Slf4j; import org.springframework.beans.factory.annotation.Autowired; import org.springframework.http.HttpStatus; import org.springframework.http.ResponseEntity; +import org.springframework.security.crypto.password.PasswordEncoder; +import org.springframework.util.StringUtils; import org.springframework.web.bind.annotation.PostMapping; -import org.springframework.web.bind.annotation.RequestBody; import org.springframework.web.bind.annotation.RequestMapping; import org.springframework.web.bind.annotation.RequestParam; import org.springframework.web.bind.annotation.RestController; @@ -30,20 +37,41 @@ public class OAuth2TokenController { @Autowired private JwtTokenProvider jwtTokenProvider; - @Operation(summary = "客户端凭据模式换取访问令牌 (Client Credentials Grant)") + @Autowired + private PasswordEncoder passwordEncoder; + + private final ObjectMapper objectMapper = new ObjectMapper(); + + @Operation(summary = "客户端凭据模式换取访问令牌 (支持 form-urlencoded 与 application/json)") @PostMapping(value = "/token") public ResponseEntity> issueToken( @RequestParam(value = "grant_type", required = false) String formGrantType, @RequestParam(value = "client_id", required = false) String formClientId, @RequestParam(value = "client_secret", required = false) String formClientSecret, @RequestParam(value = "scope", required = false) String formScope, - @RequestBody(required = false) Map jsonBody, jakarta.servlet.http.HttpServletRequest request) { - String grantType = formGrantType != null ? formGrantType : (jsonBody != null ? jsonBody.get("grant_type") : null); - String clientId = formClientId != null ? formClientId : (jsonBody != null ? jsonBody.get("client_id") : null); - String clientSecret = formClientSecret != null ? formClientSecret : (jsonBody != null ? jsonBody.get("client_secret") : null); - String scope = formScope != null ? formScope : (jsonBody != null ? jsonBody.get("scope") : null); + String grantType = formGrantType; + String clientId = formClientId; + String clientSecret = formClientSecret; + String scope = formScope; + + // 兼容自适应解析 application/json 请求载荷 + String contentType = request.getContentType(); + if ((!StringUtils.hasText(clientId) || !StringUtils.hasText(clientSecret)) + && contentType != null && contentType.toLowerCase().contains("application/json")) { + try { + JsonNode root = objectMapper.readTree(request.getInputStream()); + if (root != null) { + if (grantType == null && root.has("grant_type")) grantType = root.get("grant_type").asText(); + if (clientId == null && root.has("client_id")) clientId = root.get("client_id").asText(); + if (clientSecret == null && root.has("client_secret")) clientSecret = root.get("client_secret").asText(); + if (scope == null && root.has("scope")) scope = root.get("scope").asText(); + } + } catch (Exception e) { + log.warn("解析 JSON 格式 OAuth 请求体异常: {}", e.getMessage()); + } + } Map error = new HashMap<>(); @@ -53,16 +81,36 @@ public class OAuth2TokenController { return ResponseEntity.status(HttpStatus.BAD_REQUEST).body(error); } - if (clientId == null || clientSecret == null) { + if (!StringUtils.hasText(clientId) || !StringUtils.hasText(clientSecret)) { error.put("error", "invalid_client"); error.put("error_description", "client_id 或 client_secret 不能为空"); return ResponseEntity.status(HttpStatus.UNAUTHORIZED).body(error); } OpenClient client = clientMapper.selectOne(new LambdaQueryWrapper() - .eq(OpenClient::getClientId, clientId)); + .eq(OpenClient::getClientId, clientId.trim())); - if (client == null || !client.getClientSecret().equals(clientSecret)) { + if (client == null) { + error.put("error", "invalid_client"); + error.put("error_description", "客户端标识或密钥错误"); + return ResponseEntity.status(HttpStatus.UNAUTHORIZED).body(error); + } + + // 密钥比对:优先支持 BCrypt 哈希比对;兼顾历史明文并自动平滑升级为 BCrypt 哈希 + boolean secretMatch = false; + String storedSecret = client.getClientSecret(); + if (storedSecret != null) { + if (storedSecret.startsWith("$2a$") || storedSecret.startsWith("$2b$")) { + secretMatch = passwordEncoder.matches(clientSecret, storedSecret); + } else if (storedSecret.equals(clientSecret)) { + secretMatch = true; + client.setClientSecret(passwordEncoder.encode(clientSecret)); + clientMapper.updateById(client); + log.info("检测到明文密钥成功验证,已自动无感升级为 BCrypt 哈希: clientId={}", clientId); + } + } + + if (!secretMatch) { error.put("error", "invalid_client"); error.put("error_description", "客户端标识或密钥错误"); return ResponseEntity.status(HttpStatus.UNAUTHORIZED).body(error); @@ -80,9 +128,9 @@ public class OAuth2TokenController { return ResponseEntity.status(HttpStatus.FORBIDDEN).body(error); } - // 校验 IP 白名单 - String clientIp = com.iot.gateway.common.IpUtils.getClientIp(request); - if (!com.iot.gateway.common.IpUtils.isAllowed(clientIp, client.getIpWhitelist())) { + // 校验客户端来源 IP 白名单(基于可信反向代理验证) + String clientIp = IpUtils.getClientIp(request); + if (!IpUtils.isAllowed(clientIp, client.getIpWhitelist())) { log.warn("OAuth 2.0 客户端 IP 白名单拦截: clientId={}, clientIp={}, whitelist={}", clientId, clientIp, client.getIpWhitelist()); error.put("error", "unauthorized_client"); @@ -90,14 +138,38 @@ public class OAuth2TokenController { return ResponseEntity.status(HttpStatus.FORBIDDEN).body(error); } - String accessToken = jwtTokenProvider.generateToken(client); - log.info("OAuth 2.0 成功颁发令牌: clientId={}, clientIp={}, tenantId={}", clientId, clientIp, client.getTenantId()); + // 验证 Scope 并支持合法收窄 (Scope Narrowing) + String effectiveScopes = client.getScopes(); + if (StringUtils.hasText(scope)) { + List clientAllowed = StringUtils.hasText(client.getScopes()) + ? Arrays.asList(client.getScopes().split("[,;\\s]+")) + : Collections.emptyList(); + List 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 response = new HashMap<>(); response.put("access_token", accessToken); response.put("token_type", "Bearer"); response.put("expires_in", jwtTokenProvider.getTokenExpireSeconds()); - response.put("scope", client.getScopes()); + response.put("scope", effectiveScopes); response.put("tenant_id", client.getTenantId()); response.put("client_name", client.getClientName()); diff --git a/src/main/java/com/iot/gateway/oauth/OpenClientRateLimitFilter.java b/src/main/java/com/iot/gateway/oauth/OpenClientRateLimitFilter.java new file mode 100644 index 0000000..de9c8b2 --- /dev/null +++ b/src/main/java/com/iot/gateway/oauth/OpenClientRateLimitFilter.java @@ -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 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 次 + } +} diff --git a/src/main/java/com/iot/gateway/oauth/SecurityConfig.java b/src/main/java/com/iot/gateway/oauth/SecurityConfig.java index db6648e..64ffbfd 100644 --- a/src/main/java/com/iot/gateway/oauth/SecurityConfig.java +++ b/src/main/java/com/iot/gateway/oauth/SecurityConfig.java @@ -8,6 +8,8 @@ import org.springframework.security.config.annotation.method.configuration.Enabl import org.springframework.security.config.annotation.web.builders.HttpSecurity; import org.springframework.security.config.annotation.web.configuration.EnableWebSecurity; import org.springframework.security.config.http.SessionCreationPolicy; +import org.springframework.security.crypto.bcrypt.BCryptPasswordEncoder; +import org.springframework.security.crypto.password.PasswordEncoder; import org.springframework.security.web.SecurityFilterChain; import org.springframework.security.web.authentication.UsernamePasswordAuthenticationFilter; @@ -19,26 +21,48 @@ public class SecurityConfig { @Autowired private OAuth2AuthenticationFilter oauth2Filter; + @Autowired + private OpenClientRateLimitFilter rateLimitFilter; + + @Bean + public PasswordEncoder passwordEncoder() { + return new BCryptPasswordEncoder(); + } + @Bean public SecurityFilterChain filterChain(HttpSecurity http) throws Exception { http.csrf(csrf -> csrf.disable()) .cors(Customizer.withDefaults()) .sessionManagement(sm -> sm.sessionCreationPolicy(SessionCreationPolicy.STATELESS)) .authorizeHttpRequests(auth -> auth + // 1. 公开端点 (仅限 Token 申请、网关健康探针与 OpenAPI 接口文档) .requestMatchers( - "/oauth2/**", - "/open/gateway/**", - "/mcp/**", - "/api/**", - "/actuator/**", + "/oauth2/token", + "/open/gateway/health", + "/open/gateway/catalog", + "/actuator/health", "/v3/api-docs/**", "/swagger-ui/**", "/swagger-ui.html", "/error" ).permitAll() + + // 2. 开放平台管理后台端点 (必须具备管理员权限,严禁匿名或外部三方调用) + .requestMatchers("/api/open/**").hasAnyAuthority( + "ROLE_ADMIN", "SCOPE_admin", "SCOPE_all", "SCOPE_tenant_admin" + ) + + // 3. MCP 智能体长连接与消息端点 (必须通过 OAuth 2.0 客户端或管理员认证) + .requestMatchers("/mcp/**").authenticated() + + // 4. OpenAPI 业务接口 (必须通过 OAuth 2.0 Bearer 认证) .requestMatchers("/open/v1/**").authenticated() - .anyRequest().permitAll()) - .addFilterBefore(oauth2Filter, UsernamePasswordAuthenticationFilter.class); + + // 其他任意未显式列出的路径默认全阻断 + .anyRequest().authenticated() + ) + .addFilterBefore(oauth2Filter, UsernamePasswordAuthenticationFilter.class) + .addFilterAfter(rateLimitFilter, OAuth2AuthenticationFilter.class); return http.build(); } diff --git a/src/main/java/com/iot/gateway/openapi/OpenAlarmController.java b/src/main/java/com/iot/gateway/openapi/OpenAlarmController.java index d82a3ba..c61488a 100644 --- a/src/main/java/com/iot/gateway/openapi/OpenAlarmController.java +++ b/src/main/java/com/iot/gateway/openapi/OpenAlarmController.java @@ -1,12 +1,18 @@ package com.iot.gateway.openapi; +import com.fasterxml.jackson.databind.JsonNode; +import com.iot.gateway.common.BackendInternalClient; +import com.iot.gateway.common.BizException; import com.iot.gateway.common.Result; import com.iot.gateway.oauth.GatewaySecurityContext; import io.swagger.v3.oas.annotations.Operation; import io.swagger.v3.oas.annotations.tags.Tag; +import java.util.HashMap; import java.util.List; import java.util.Map; import lombok.extern.slf4j.Slf4j; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.beans.factory.annotation.Value; import org.springframework.security.access.prepost.PreAuthorize; import org.springframework.web.bind.annotation.GetMapping; import org.springframework.web.bind.annotation.PostMapping; @@ -21,6 +27,12 @@ import org.springframework.web.bind.annotation.RestController; @RequestMapping("/open/v1/alarms") public class OpenAlarmController { + @Autowired + private BackendInternalClient internalClient; + + @Value("${gateway.mock.enabled:false}") + private boolean mockEnabled; + @Operation(summary = "按租户与设备检索未确认/历史告警") @GetMapping @PreAuthorize("hasAuthority('SCOPE_alarm.read') or hasAuthority('SCOPE_all')") @@ -28,9 +40,23 @@ public class OpenAlarmController { @RequestParam(required = false) String deviceId, @RequestParam(required = false) Integer severity) { - Long tenantId = GatewaySecurityContext.getTenantIdOrDefault(1L); + Long tenantId = GatewaySecurityContext.requireTenantId(); log.info("三方系统查询告警列表: tenantId={}, deviceId={}, severity={}", tenantId, deviceId, severity); + Map 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> alarms = List.of( Map.of("alarmId", "ALM-9821", "deviceId", "DEV-ZSJ-002", "severity", 3, "title", "注塑模具温度异常过高 (当前 265℃)", "createTime", "2026-10-09 08:15:20"), @@ -38,14 +64,15 @@ public class OpenAlarmController { "title", "射胶压力超限预警", "createTime", "2026-10-09 08:22:10") ); - return Result.success(Map.of("tenantId", tenantId, "total", alarms.size(), "list", alarms)); + return Result.success(Map.of("tenantId", tenantId, "isMock", true, "total", alarms.size(), "list", alarms)); } @Operation(summary = "三方系统确认告警回执") @PostMapping("/ack") @PreAuthorize("hasAuthority('SCOPE_alarm.read') or hasAuthority('SCOPE_all')") public Result> ackAlarm(@RequestBody Map req) { + Long tenantId = GatewaySecurityContext.requireTenantId(); String alarmId = String.valueOf(req.get("alarmId")); - return Result.success(Map.of("alarmId", alarmId, "status", "ACKNOWLEDGED", "ackTime", System.currentTimeMillis())); + return Result.success(Map.of("alarmId", alarmId, "tenantId", tenantId, "status", "ACKNOWLEDGED", "ackTime", System.currentTimeMillis())); } } diff --git a/src/main/java/com/iot/gateway/openapi/OpenCommandController.java b/src/main/java/com/iot/gateway/openapi/OpenCommandController.java index d845ac9..9e619d8 100644 --- a/src/main/java/com/iot/gateway/openapi/OpenCommandController.java +++ b/src/main/java/com/iot/gateway/openapi/OpenCommandController.java @@ -1,11 +1,16 @@ package com.iot.gateway.openapi; +import com.fasterxml.jackson.databind.JsonNode; +import com.iot.gateway.common.BackendInternalClient; +import com.iot.gateway.common.BizException; import com.iot.gateway.common.Result; import com.iot.gateway.oauth.GatewaySecurityContext; import io.swagger.v3.oas.annotations.Operation; import io.swagger.v3.oas.annotations.tags.Tag; import java.util.Map; import lombok.extern.slf4j.Slf4j; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.beans.factory.annotation.Value; import org.springframework.security.access.prepost.PreAuthorize; import org.springframework.web.bind.annotation.PostMapping; import org.springframework.web.bind.annotation.RequestBody; @@ -18,23 +23,47 @@ import org.springframework.web.bind.annotation.RestController; @RequestMapping("/open/v1/commands") public class OpenCommandController { - @Operation(summary = "下发设备控制与工艺参数反写指令 (MQTT桥接)") + @Autowired + private BackendInternalClient internalClient; + + @Value("${gateway.mock.enabled:false}") + private boolean mockEnabled; + + @Operation(summary = "下发设备控制与工艺参数反写指令 (真实专线桥接,严禁伪报已执行)") @PostMapping @PreAuthorize("hasAuthority('SCOPE_device.control') or hasAuthority('SCOPE_all')") public Result> sendCommand(@RequestBody Map req) { - Long tenantId = GatewaySecurityContext.getTenantIdOrDefault(1L); + Long tenantId = GatewaySecurityContext.requireTenantId(); String deviceId = String.valueOf(req.get("deviceId")); String command = String.valueOf(req.get("command")); log.info("三方系统下发设备反写指令: tenantId={}, deviceId={}, command={}", tenantId, deviceId, command); + JsonNode resp = internalClient.post("/api/internal/open/commands", tenantId, req); + if (resp != null && resp.has("code") && resp.get("code").asInt() == 200 && resp.has("data")) { + return Result.success(Map.of( + "success", true, + "commandId", "CMD-" + System.currentTimeMillis(), + "deviceId", deviceId, + "command", command, + "status", "EXECUTED", + "data", resp.get("data") + )); + } + + String errMsg = resp != null && resp.has("message") ? resp.get("message").asText() : "核心控制网关通信超时或未响应"; + if (!mockEnabled) { + log.error("下发设备控制指令失败 (拒绝伪报成功): tenantId={}, deviceId={}, err={}", tenantId, deviceId, errMsg); + throw new BizException(502, "控制指令执行失败: " + errMsg); + } + return Result.success(Map.of( - "success", true, - "commandId", "CMD-" + System.currentTimeMillis(), + "success", false, + "isMock", true, "deviceId", deviceId, "command", command, - "status", "DELIVERED", - "message", "指令已成功路由至对应租户的边缘网关执行" + "status", "MOCK_MODE_REFUSED", + "message", "当前为开发演示环境,已拒绝向物理设备下发控制指令: " + errMsg )); } } diff --git a/src/main/java/com/iot/gateway/openapi/OpenDeviceController.java b/src/main/java/com/iot/gateway/openapi/OpenDeviceController.java index 709304f..566c2e7 100644 --- a/src/main/java/com/iot/gateway/openapi/OpenDeviceController.java +++ b/src/main/java/com/iot/gateway/openapi/OpenDeviceController.java @@ -2,6 +2,7 @@ package com.iot.gateway.openapi; import com.fasterxml.jackson.databind.JsonNode; import com.iot.gateway.common.BackendInternalClient; +import com.iot.gateway.common.BizException; import com.iot.gateway.common.Result; import com.iot.gateway.oauth.GatewaySecurityContext; import io.swagger.v3.oas.annotations.Operation; @@ -11,6 +12,7 @@ import java.util.List; import java.util.Map; import lombok.extern.slf4j.Slf4j; import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.beans.factory.annotation.Value; import org.springframework.security.access.prepost.PreAuthorize; import org.springframework.web.bind.annotation.GetMapping; import org.springframework.web.bind.annotation.PathVariable; @@ -27,6 +29,9 @@ public class OpenDeviceController { @Autowired private BackendInternalClient internalClient; + @Value("${gateway.mock.enabled:false}") + private boolean mockEnabled; + @Operation(summary = "查询当前租户名下设备列表") @GetMapping @PreAuthorize("hasAuthority('SCOPE_device.read') or hasAuthority('SCOPE_all')") @@ -35,7 +40,8 @@ public class OpenDeviceController { @RequestParam(required = false, defaultValue = "20") Integer pageSize, @RequestParam(required = false) String status) { - Long tenantId = GatewaySecurityContext.getTenantIdOrDefault(1L); + // 严格获取当前已认证租户 ID,未认证或缺少租户时抛出 401 异常,严禁默认落到租户 1 + Long tenantId = GatewaySecurityContext.requireTenantId(); log.info("三方系统查询设备列表: tenantId={}, status={}", tenantId, status); Map params = new HashMap<>(); @@ -58,9 +64,15 @@ public class OpenDeviceController { )); } - // 兜底返回 + if (!mockEnabled) { + String errMsg = resp != null && resp.has("message") ? resp.get("message").asText() : "核心物联后端不可用或请求超时"; + throw new BizException(502, "召测设备列表失败: " + errMsg); + } + + // 仅在显式配置 mockEnabled=true 时允许返回演示数据 return Result.success(Map.of( "tenantId", tenantId, + "isMock", true, "total", 2, "pageNum", pageNum, "pageSize", pageSize, @@ -75,16 +87,22 @@ public class OpenDeviceController { @GetMapping("/{deviceId}") @PreAuthorize("hasAuthority('SCOPE_device.read') or hasAuthority('SCOPE_all')") public Result getDevice(@PathVariable String deviceId) { - Long tenantId = GatewaySecurityContext.getTenantIdOrDefault(1L); + Long tenantId = GatewaySecurityContext.requireTenantId(); JsonNode resp = internalClient.get("/api/internal/open/devices/" + deviceId, tenantId, null); if (resp != null && resp.has("code") && resp.get("code").asInt() == 200 && resp.has("data")) { return Result.success(resp.get("data")); } + if (!mockEnabled) { + String errMsg = resp != null && resp.has("message") ? resp.get("message").asText() : "设备不存在或核心服务未响应"; + throw new BizException(502, "查询设备详情失败: " + errMsg); + } + return Result.success(Map.of( "deviceCode", deviceId, "tenantId", tenantId, + "isMock", true, "deviceName", "1号注塑机 (海天MA1200)", "online", 1 )); diff --git a/src/main/java/com/iot/gateway/openapi/OpenTelemetryController.java b/src/main/java/com/iot/gateway/openapi/OpenTelemetryController.java index 69a7e38..244496c 100644 --- a/src/main/java/com/iot/gateway/openapi/OpenTelemetryController.java +++ b/src/main/java/com/iot/gateway/openapi/OpenTelemetryController.java @@ -1,11 +1,16 @@ package com.iot.gateway.openapi; +import com.fasterxml.jackson.databind.JsonNode; +import com.iot.gateway.common.BackendInternalClient; +import com.iot.gateway.common.BizException; import com.iot.gateway.common.Result; import com.iot.gateway.oauth.GatewaySecurityContext; import io.swagger.v3.oas.annotations.Operation; import io.swagger.v3.oas.annotations.tags.Tag; import java.util.Map; import lombok.extern.slf4j.Slf4j; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.beans.factory.annotation.Value; import org.springframework.security.access.prepost.PreAuthorize; import org.springframework.web.bind.annotation.GetMapping; import org.springframework.web.bind.annotation.PathVariable; @@ -19,16 +24,38 @@ import org.springframework.web.bind.annotation.RestController; @RequestMapping("/open/v1/telemetry") public class OpenTelemetryController { + @Autowired + private BackendInternalClient internalClient; + + @Value("${gateway.mock.enabled:false}") + private boolean mockEnabled; + @Operation(summary = "召测单设备最新实时遥测点位数据") @GetMapping("/latest/{deviceId}") @PreAuthorize("hasAuthority('SCOPE_telemetry.read') or hasAuthority('SCOPE_all')") public Result> getLatestTelemetry(@PathVariable String deviceId) { - Long tenantId = GatewaySecurityContext.getTenantIdOrDefault(1L); + Long tenantId = GatewaySecurityContext.requireTenantId(); log.info("三方系统召测最新遥测: deviceId={}, tenantId={}", deviceId, tenantId); + JsonNode resp = internalClient.get("/api/internal/open/telemetry/latest", tenantId, Map.of("deviceId", deviceId)); + if (resp != null && resp.has("code") && resp.get("code").asInt() == 200 && resp.has("data")) { + return Result.success(Map.of( + "deviceId", deviceId, + "tenantId", tenantId, + "timestamp", System.currentTimeMillis(), + "data", resp.get("data") + )); + } + + if (!mockEnabled) { + String errMsg = resp != null && resp.has("message") ? resp.get("message").asText() : "后端遥测接口不可用或设备无数据"; + throw new BizException(502, "召测设备遥测失败: " + errMsg); + } + return Result.success(Map.of( "deviceId", deviceId, "tenantId", tenantId, + "isMock", true, "timestamp", System.currentTimeMillis(), "values", Map.of( "barrel_temp_zone1", 215.4, @@ -49,9 +76,11 @@ public class OpenTelemetryController { @RequestParam(required = false) Long startTime, @RequestParam(required = false) Long endTime) { + Long tenantId = GatewaySecurityContext.requireTenantId(); long now = System.currentTimeMillis(); return Result.success(Map.of( "deviceId", deviceId, + "tenantId", tenantId, "pointKey", pointKey, "points", java.util.List.of( Map.of("ts", now - 60000, "val", 214.8), diff --git a/src/main/resources/application-prod.yml b/src/main/resources/application-prod.yml index 455623c..d63fc17 100644 --- a/src/main/resources/application-prod.yml +++ b/src/main/resources/application-prod.yml @@ -1,12 +1,19 @@ server: - port: 18082 + port: ${PORT:18082} spring: datasource: - url: jdbc:mysql://127.0.0.1:14406/iot_platform?useUnicode=true&characterEncoding=utf8&useSSL=false&serverTimezone=Asia/Shanghai&allowPublicKeyRetrieval=true - username: iot_app - password: DRq7DfU9VykOFcIoHXKa + url: ${DB_URL:jdbc:mysql://127.0.0.1:14406/iot_platform?useUnicode=true&characterEncoding=utf8&useSSL=false&serverTimezone=Asia/Shanghai&allowPublicKeyRetrieval=true} + username: ${DB_USERNAME:iot_app} + password: ${DB_PASSWORD:} gateway: + oauth: + jwt-secret: ${GATEWAY_JWT_SECRET:} backend: - base-url: http://127.0.0.1:18086 + base-url: ${GATEWAY_BACKEND_URL:http://127.0.0.1:18086} + internal-secret: ${GATEWAY_INTERNAL_SECRET:} + admin: + secret: ${GATEWAY_ADMIN_SECRET:} + mock: + enabled: ${GATEWAY_MOCK_ENABLED:false} diff --git a/src/main/resources/application.yml b/src/main/resources/application.yml index bd1c734..01d8f19 100644 --- a/src/main/resources/application.yml +++ b/src/main/resources/application.yml @@ -1,5 +1,5 @@ server: - port: 8082 + port: ${PORT:8082} spring: application: @@ -7,9 +7,9 @@ spring: datasource: driver-class-name: com.mysql.cj.jdbc.Driver - url: jdbc:mysql://8.130.108.77:3309/iot_platform?useUnicode=true&characterEncoding=utf8&useSSL=false&serverTimezone=Asia/Shanghai&allowPublicKeyRetrieval=true - username: root - password: "123456" + url: ${DB_URL:jdbc:mysql://127.0.0.1:3306/iot_platform?useUnicode=true&characterEncoding=utf8&useSSL=false&serverTimezone=Asia/Shanghai&allowPublicKeyRetrieval=true} + username: ${DB_USERNAME:iot_app} + password: ${DB_PASSWORD:} mybatis-plus: configuration: @@ -20,16 +20,22 @@ mybatis-plus: db-config: id-type: auto -# 开放网关核心治理配置 +# 开放网关核心治理配置 (生产凭据由运行环境注入,严禁在仓库硬编码) gateway: # OAuth 2.0 JWT 签名私钥 oauth: - jwt-secret: iot-gateway-oauth2-super-secret-key-666888-abcdefghijklmnopqrstuvwxyz - token-expire-seconds: 7200 # 2小时 - # 核心物联后端内网地址 (用于设备遥测数据拉取与控制反写代理) + jwt-secret: ${GATEWAY_JWT_SECRET:} + token-expire-seconds: ${GATEWAY_TOKEN_EXPIRE:7200} + # 核心物联后端内网地址与专线通信密钥 backend: base-url: ${GATEWAY_BACKEND_URL:http://localhost:8080} - internal-secret: ${GATEWAY_INTERNAL_SECRET:iot-internal-open-gateway-secret-key-20261009-xyz} + internal-secret: ${GATEWAY_INTERNAL_SECRET:} + # 管理员运维密钥通道 + admin: + secret: ${GATEWAY_ADMIN_SECRET:} + # 演示/Mock 数据开关 (生产环境默认关闭) + mock: + enabled: ${GATEWAY_MOCK_ENABLED:false} springdoc: swagger-ui: