fix: harden gateway auth and credential handling
This commit is contained in:
@@ -2,10 +2,16 @@ package com.iot.gateway.common;
|
||||
|
||||
import com.fasterxml.jackson.databind.JsonNode;
|
||||
import com.fasterxml.jackson.databind.ObjectMapper;
|
||||
import java.net.ConnectException;
|
||||
import java.net.SocketTimeoutException;
|
||||
import java.net.URI;
|
||||
import java.net.URLEncoder;
|
||||
import java.net.http.HttpClient;
|
||||
import java.net.http.HttpConnectTimeoutException;
|
||||
import java.net.http.HttpRequest;
|
||||
import java.net.http.HttpResponse;
|
||||
import java.net.http.HttpTimeoutException;
|
||||
import java.nio.charset.StandardCharsets;
|
||||
import java.time.Duration;
|
||||
import java.util.HashMap;
|
||||
import java.util.Map;
|
||||
@@ -16,8 +22,7 @@ import org.springframework.stereotype.Component;
|
||||
/**
|
||||
* 网关与核心业务后端 (IOT-backend) 的专线 HTTP 桥接客户端
|
||||
* <p>
|
||||
* 自动向核心后端传递 X-Internal-Secret 与 X-Tenant-Id,
|
||||
* 触发核心后端切入指定租户的独立物理数据库。
|
||||
* 具备 URL 安全编码、错误细粒度分类、幂等 GET 有限重试与租户强制隔离。
|
||||
*/
|
||||
@Slf4j
|
||||
@Component
|
||||
@@ -26,77 +31,164 @@ public class BackendInternalClient {
|
||||
@Value("${gateway.backend.base-url:http://localhost:8080}")
|
||||
private String backendBaseUrl;
|
||||
|
||||
@Value("${gateway.backend.internal-secret:iot-internal-open-gateway-secret-key-20261009-xyz}")
|
||||
@Value("${gateway.backend.internal-secret:}")
|
||||
private String internalSecret;
|
||||
|
||||
private final HttpClient httpClient = HttpClient.newBuilder()
|
||||
.connectTimeout(Duration.ofSeconds(5))
|
||||
.connectTimeout(Duration.ofSeconds(4))
|
||||
.build();
|
||||
|
||||
private final ObjectMapper objectMapper = new ObjectMapper();
|
||||
|
||||
/**
|
||||
* 内部专线 GET 请求
|
||||
* 内部专线 GET 请求 (支持参数 URL 编码、错误细粒度分类与网络抖动单次有限重试)
|
||||
*/
|
||||
public JsonNode get(String path, Long tenantId, Map<String, String> queryParams) {
|
||||
StringBuilder urlBuilder = new StringBuilder(backendBaseUrl).append(path);
|
||||
if (queryParams != null && !queryParams.isEmpty()) {
|
||||
urlBuilder.append("?");
|
||||
queryParams.forEach((k, v) -> {
|
||||
if (v != null && !v.isEmpty()) {
|
||||
urlBuilder.append(k).append("=").append(v).append("&");
|
||||
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<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;
|
||||
}
|
||||
});
|
||||
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<String> resp = httpClient.send(request, HttpResponse.BodyHandlers.ofString());
|
||||
if (resp.statusCode() == 200) {
|
||||
return objectMapper.readTree(resp.body());
|
||||
} else {
|
||||
log.warn("核心后端内部专线响应非 200: status={}, body={}", resp.statusCode(), resp.body());
|
||||
} 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<String, Object> body) {
|
||||
String url = backendBaseUrl + path;
|
||||
if (tenantId == null) {
|
||||
throw new BizException(401, "专线请求缺少租户上下文,严禁跨租户或无租户调用");
|
||||
}
|
||||
|
||||
String fullUrl = backendBaseUrl + path;
|
||||
try {
|
||||
String jsonStr = objectMapper.writeValueAsString(body != null ? body : new HashMap<>());
|
||||
HttpRequest request = HttpRequest.newBuilder()
|
||||
.uri(URI.create(url))
|
||||
.header("X-Internal-Secret", internalSecret)
|
||||
.header("X-Tenant-Id", String.valueOf(tenantId != null ? tenantId : 1L))
|
||||
.uri(URI.create(fullUrl))
|
||||
.header("X-Internal-Secret", internalSecret != null ? internalSecret : "")
|
||||
.header("X-Tenant-Id", String.valueOf(tenantId))
|
||||
.header("Content-Type", "application/json")
|
||||
.timeout(Duration.ofSeconds(6))
|
||||
.POST(HttpRequest.BodyPublishers.ofString(jsonStr))
|
||||
.build();
|
||||
|
||||
HttpResponse<String> resp = httpClient.send(request, HttpResponse.BodyHandlers.ofString());
|
||||
if (resp.statusCode() == 200) {
|
||||
return objectMapper.readTree(resp.body());
|
||||
} else {
|
||||
log.warn("核心后端内部专线响应非 200: status={}, body={}", resp.statusCode(), resp.body());
|
||||
}
|
||||
return handleResponse(resp, fullUrl);
|
||||
|
||||
} catch (HttpTimeoutException | SocketTimeoutException e) {
|
||||
log.error("核心后端 POST 请求超时 (未重试,防止指令重放): url={}, tenantId={}", fullUrl, tenantId);
|
||||
throw new BizException(504, "核心控制通道通信超时,无法确认物理设备指令下发状态");
|
||||
|
||||
} catch (ConnectException e) {
|
||||
log.error("核心后端连接失败: url={}, tenantId={}, err={}", fullUrl, tenantId, e.getMessage());
|
||||
throw new BizException(503, "核心控制网关服务不可达");
|
||||
|
||||
} catch (BizException be) {
|
||||
throw be;
|
||||
|
||||
} catch (Exception e) {
|
||||
log.warn("调用核心后端内部 POST 异常: url={}, tenantId={}, err={}", url, tenantId, e.getMessage());
|
||||
log.error("核心后端 POST 调用异常: url={}, tenantId={}, err={}", fullUrl, tenantId, e.getMessage());
|
||||
throw new BizException(502, "控制指令网关通信失败: " + e.getMessage());
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
private JsonNode handleResponse(HttpResponse<String> resp, String fullUrl) {
|
||||
int status = resp.statusCode();
|
||||
String body = resp.body();
|
||||
|
||||
if (status == 200) {
|
||||
try {
|
||||
return objectMapper.readTree(body);
|
||||
} catch (Exception e) {
|
||||
log.error("核心后端响应非标准 JSON: body={}", body);
|
||||
throw new BizException(502, "核心后端返回格式错误");
|
||||
}
|
||||
}
|
||||
|
||||
if (status == 401 || status == 403) {
|
||||
log.error("专线内部鉴权失败: status={}, body={}, url={}", status, body, fullUrl);
|
||||
throw new BizException(500, "内部通信专线密钥配置不匹配或租户无权访问");
|
||||
}
|
||||
|
||||
if (status == 404) {
|
||||
throw new BizException(404, "核心后端未找到目标接口: " + fullUrl);
|
||||
}
|
||||
|
||||
log.warn("核心后端返回错误状态码: status={}, body={}, url={}", status, body, fullUrl);
|
||||
try {
|
||||
JsonNode errJson = objectMapper.readTree(body);
|
||||
if (errJson.has("message")) {
|
||||
throw new BizException(502, errJson.get("message").asText());
|
||||
}
|
||||
} catch (BizException be) {
|
||||
throw be;
|
||||
} catch (Exception ignored) {}
|
||||
|
||||
throw new BizException(502, "核心后端服务返回异常代码: HTTP " + status);
|
||||
}
|
||||
|
||||
private String buildGetUrl(String path, Map<String, String> queryParams) {
|
||||
StringBuilder urlBuilder = new StringBuilder(backendBaseUrl).append(path);
|
||||
if (queryParams != null && !queryParams.isEmpty()) {
|
||||
urlBuilder.append("?");
|
||||
for (Map.Entry<String, String> entry : queryParams.entrySet()) {
|
||||
if (entry.getValue() != null) {
|
||||
String k = URLEncoder.encode(entry.getKey(), StandardCharsets.UTF_8);
|
||||
String v = URLEncoder.encode(entry.getValue(), StandardCharsets.UTF_8);
|
||||
urlBuilder.append(k).append("=").append(v).append("&");
|
||||
}
|
||||
}
|
||||
urlBuilder.deleteCharAt(urlBuilder.length() - 1);
|
||||
}
|
||||
return urlBuilder.toString();
|
||||
}
|
||||
|
||||
private void backoff(long ms) {
|
||||
try {
|
||||
Thread.sleep(ms);
|
||||
} catch (InterruptedException ignored) {}
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user