feat: 完成数据中心转发网关核心功能(真实联调通过)
核心功能(已连接真实源服务器验证):
- /sys/watchUserLogin 本地处理:RSA解密验证设备密码(防伪造) + 单飞换token + Redis缓存
- 业务接口经本地通配Controller手动HttpUtils透传至源服务器 + 401检测清缓存
- token失效三重自愈:10秒探活 + 预过期刷新 + 被动401兜底
配置隔离(dotenv风格)+ 安全:
- .env 存敏感信息不入库;app.yml 用 ${KEY} 占位符引用;EnvLoader启动前加载
- 移除fastjson(规避0day),JSON改用Solon内置snack4
联调修复多个Solon实际API问题(配置文件名/YAML插件/打包/redisx/注解路径等)
Co-Authored-By: Claude <noreply@anthropic.com>
This commit is contained in:
@@ -0,0 +1,35 @@
|
||||
package com.renkang.gateway;
|
||||
|
||||
import com.renkang.gateway.config.EnvLoader;
|
||||
import org.noear.solon.Solon;
|
||||
import org.noear.solon.annotation.SolonMain;
|
||||
import org.noear.solon.scheduling.annotation.EnableScheduling;
|
||||
|
||||
/**
|
||||
* 数据中心转发网关启动类。
|
||||
* <p>
|
||||
* 职责:作为中转服务器,收敛健康设备的 token 获取请求,避免海量设备直连源服务器
|
||||
* 触发 HTTP 爆破风控误报(多设备共用同一接口账号,RSA 加密密码每次不同)。
|
||||
* <p>
|
||||
* 核心能力:
|
||||
* <ol>
|
||||
* <li>/sys/watchUserLogin 本地处理:私钥解密设备密码 → 比对明文 → 单飞换 token → 缓存</li>
|
||||
* <li>其余请求经 GatewayController 手动 HttpUtils 透传至源服务器(http://10.10.10.228:29999)</li>
|
||||
* <li>token 失效三重自愈:10秒主动探活 + 预过期刷新 + 业务接口401被动兜底</li>
|
||||
* </ol>
|
||||
* <p>
|
||||
* 架构说明:不使用 solon.cloud.gateway.routes 路由透传(其 Path=/** 会覆盖本地 watchUserLogin 处理),
|
||||
* 改用本地通配 Controller(@Mapping("/**"))+ HttpUtils 手动透传,Solon 具体路径优先于通配,
|
||||
* 确保 /sys/watchUserLogin 走本地 TokenController,其余业务接口透传源服务器。
|
||||
*
|
||||
* @author renkang
|
||||
*/
|
||||
@SolonMain
|
||||
@EnableScheduling
|
||||
public class App {
|
||||
public static void main(String[] args) {
|
||||
// 启动前加载 .env,将敏感配置注入系统属性,供 app.yml 的 ${KEY} 占位符解析
|
||||
EnvLoader.load();
|
||||
Solon.start(App.class, args);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,93 @@
|
||||
package com.renkang.gateway.config;
|
||||
|
||||
import java.io.BufferedReader;
|
||||
import java.io.File;
|
||||
import java.io.FileInputStream;
|
||||
import java.io.InputStreamReader;
|
||||
import java.nio.charset.StandardCharsets;
|
||||
|
||||
/**
|
||||
* .env 文件加载器(类似 dotenv)。
|
||||
* <p>
|
||||
* 在 Solon 启动前调用 {@link #load()},读取项目根目录的 .env 文件,
|
||||
* 将其中的 KEY=VALUE 注入为系统属性({@link System#setProperty}),
|
||||
* 使 application.yml 的 ${KEY} 占位符能正确解析。
|
||||
* <p>
|
||||
* 约定:
|
||||
* <ul>
|
||||
* <li>.env 位于项目根目录(即 jar 运行的工作目录,或开发时项目根)</li>
|
||||
* <li>每行格式 KEY=VALUE,KEY 不允许有空格</li>
|
||||
* <li>VALUE 可用双引号包裹(支持含空格、特殊字符、换行的值);未引号则取等号后到行尾(去除首尾空格)</li>
|
||||
* <li>以 # 开头的行视为注释,跳过;空行跳过</li>
|
||||
* <li>不覆盖已存在的环境变量/系统属性(优先尊重运行环境注入的值)</li>
|
||||
* </ul>
|
||||
*
|
||||
* @author renkang
|
||||
*/
|
||||
public class EnvLoader {
|
||||
|
||||
/** .env 文件名(位于工作目录/项目根目录) */
|
||||
private static final String ENV_FILE = ".env";
|
||||
|
||||
/**
|
||||
* 加载 .env 文件并注入为系统属性。
|
||||
* <p>
|
||||
* 必须在 {@code Solon.start} 之前调用,确保 yml 占位符解析时属性已就绪。
|
||||
* 文件不存在时静默跳过(开发环境可能依赖系统真实环境变量)。
|
||||
*/
|
||||
public static void load() {
|
||||
File file = new File(ENV_FILE);
|
||||
if (!file.exists()) {
|
||||
// 尝试 classpath 下的 .env(打包后从工作目录读取,开发时从项目根读取;此处兜底)
|
||||
return;
|
||||
}
|
||||
try (BufferedReader reader = new BufferedReader(
|
||||
new InputStreamReader(new FileInputStream(file), StandardCharsets.UTF_8))) {
|
||||
String line;
|
||||
while ((line = reader.readLine()) != null) {
|
||||
parseAndSet(line);
|
||||
}
|
||||
} catch (Exception e) {
|
||||
// .env 加载失败不应阻断启动,打印警告即可
|
||||
System.err.println("[EnvLoader] 加载 .env 失败:" + e.getMessage());
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 解析单行并设置系统属性。
|
||||
*/
|
||||
private static void parseAndSet(String line) {
|
||||
if (line == null) {
|
||||
return;
|
||||
}
|
||||
String trimmed = line.trim();
|
||||
// 空行或注释跳过
|
||||
if (trimmed.isEmpty() || trimmed.startsWith("#")) {
|
||||
return;
|
||||
}
|
||||
int eq = trimmed.indexOf('=');
|
||||
if (eq <= 0) {
|
||||
return;
|
||||
}
|
||||
String key = trimmed.substring(0, eq).trim();
|
||||
if (key.isEmpty()) {
|
||||
return;
|
||||
}
|
||||
String rawValue = trimmed.substring(eq + 1).trim();
|
||||
String value = unquote(rawValue);
|
||||
// 不覆盖已存在的系统属性/环境变量(运行环境优先)
|
||||
if (System.getProperty(key) == null && System.getenv(key) == null) {
|
||||
System.setProperty(key, value);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 去除首尾双引号(若有)。支持单行值;多行值应在 .env 中合并为单行。
|
||||
*/
|
||||
private static String unquote(String value) {
|
||||
if (value.length() >= 2 && value.startsWith("\"") && value.endsWith("\"")) {
|
||||
return value.substring(1, value.length() - 1);
|
||||
}
|
||||
return value;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,100 @@
|
||||
package com.renkang.gateway.config;
|
||||
|
||||
import org.noear.solon.Solon;
|
||||
|
||||
/**
|
||||
* 网关配置:从 application.yml 读取配置项(值经 ${ENV_VAR} 占位符引用 .env 变量)。
|
||||
* <p>
|
||||
* 采用静态方法读取而非注解注入,避免 bean 初始化时序问题(Solon.start 后 cfg 即就绪)。
|
||||
* <p>
|
||||
* 读取策略(双保险):先从 Solon.cfg 读 yml 解析后的值;若为空,则 fallback 到
|
||||
* {@link System#getProperty}(由 {@link EnvLoader} 从 .env 注入),
|
||||
* 确保 yml 占位符即使未解析也能从系统属性取到 .env 值。
|
||||
*
|
||||
* @author renkang
|
||||
*/
|
||||
public class GatewayConfig {
|
||||
|
||||
/** 敏感配置对应的 .env 环境变量名映射 */
|
||||
private static final String ENV_SOURCE_BASE_URL = "SOURCE_BASE_URL";
|
||||
private static final String ENV_USERNAME = "TOKEN_USERNAME";
|
||||
private static final String ENV_PASSWORD = "TOKEN_PASSWORD";
|
||||
private static final String ENV_RSA_PUBLIC = "TOKEN_RSA_PUBLIC_KEY";
|
||||
private static final String ENV_RSA_PRIVATE = "TOKEN_RSA_PRIVATE_KEY";
|
||||
|
||||
private GatewayConfig() {
|
||||
}
|
||||
|
||||
/**
|
||||
* 读取配置:先 Solon.cfg,再 fallback System.getProperty(.env 注入值)。
|
||||
*/
|
||||
private static String get(String cfgKey, String envKey) {
|
||||
String val = Solon.cfg().get(cfgKey);
|
||||
if (val == null || val.isEmpty()) {
|
||||
val = System.getProperty(envKey);
|
||||
}
|
||||
return val;
|
||||
}
|
||||
|
||||
// ==================== 源服务器 ====================
|
||||
|
||||
/** 源服务器网关地址(透传目标 + 换token/探活目标) */
|
||||
public static String sourceBaseUrl() {
|
||||
return get("gateway.source.base-url", ENV_SOURCE_BASE_URL);
|
||||
}
|
||||
|
||||
/** 换 token 接口路径 */
|
||||
public static String tokenPath() {
|
||||
return Solon.cfg().get("gateway.source.token-path");
|
||||
}
|
||||
|
||||
/** 探活接口路径 */
|
||||
public static String probePath() {
|
||||
return Solon.cfg().get("gateway.source.probe-path");
|
||||
}
|
||||
|
||||
// ==================== 凭证与密钥 ====================
|
||||
|
||||
/** 统一接口账号(所有设备共用) */
|
||||
public static String username() {
|
||||
return get("gateway.token.username", ENV_USERNAME);
|
||||
}
|
||||
|
||||
/** 明文密码:解密设备密码后比对;公钥加密后发源服务器 */
|
||||
public static String password() {
|
||||
return get("gateway.token.password", ENV_PASSWORD);
|
||||
}
|
||||
|
||||
/** RSA 公钥(X.509 Base64,加密密码发源服务器) */
|
||||
public static String rsaPublicKey() {
|
||||
return get("gateway.token.rsa-public-key", ENV_RSA_PUBLIC);
|
||||
}
|
||||
|
||||
/** RSA 私钥(PKCS#8 Base64,解密设备发来的密码) */
|
||||
public static String rsaPrivateKey() {
|
||||
return get("gateway.token.rsa-private-key", ENV_RSA_PRIVATE);
|
||||
}
|
||||
|
||||
// ==================== 定时与刷新策略 ====================
|
||||
|
||||
/** 主动探活间隔(秒) */
|
||||
public static int probeInterval() {
|
||||
return Solon.cfg().getInt("gateway.token.probe-interval", 10);
|
||||
}
|
||||
|
||||
/** 预过期刷新阈值(秒):token 距 JWT exp 过期不足此值时主动刷新 */
|
||||
public static int refreshThreshold() {
|
||||
return Solon.cfg().getInt("gateway.token.refresh-threshold", 3600);
|
||||
}
|
||||
|
||||
// ==================== 缓存 key 常量(与源服务器 prefix_user_token: 严格隔离)====================
|
||||
|
||||
/** 当前缓存的 token */
|
||||
public static final String CACHE_TOKEN_KEY = "gateway:token:current";
|
||||
|
||||
/** 单飞分布式锁 key(单实例下用本地锁,此 key 备用) */
|
||||
public static final String LOCK_KEY = "gateway:token:lock";
|
||||
|
||||
/** token 上锁的过期时间(秒),防止持锁进程崩溃导致死锁 */
|
||||
public static final int LOCK_EXPIRE_SECONDS = 30;
|
||||
}
|
||||
@@ -0,0 +1,51 @@
|
||||
package com.renkang.gateway.config;
|
||||
|
||||
import org.noear.redisx.RedisClient;
|
||||
import org.noear.solon.Solon;
|
||||
import org.noear.solon.annotation.Bean;
|
||||
import org.noear.solon.annotation.Configuration;
|
||||
|
||||
import java.util.Properties;
|
||||
|
||||
/**
|
||||
* Redis 配置:手动构建 redisx RedisClient Bean。
|
||||
* <p>
|
||||
* redisx 是独立库(非 Solon 插件),无自动注入机制,需根据配置手动 new RedisClient(Properties)。
|
||||
* <p>
|
||||
* 配置来源(双保险):
|
||||
* <ol>
|
||||
* <li>优先从 Solon.cfg 的 redis 段读取(yml 占位符解析后的值)</li>
|
||||
* <li>若为空,则从 System.getProperty 读取(由 EnvLoader 从 .env 注入)</li>
|
||||
* </ol>
|
||||
* Properties 的 key 遵循 redisx 约定:server / db / password / user / maxTotal。
|
||||
*
|
||||
* @author renkang
|
||||
*/
|
||||
@Configuration
|
||||
public class RedisConfig {
|
||||
|
||||
/**
|
||||
* 构建 RedisClient Bean(typed=true 设为默认,供 TokenCache @Inject 注入)。
|
||||
*/
|
||||
@Bean(typed = true)
|
||||
public RedisClient redisClient() {
|
||||
Properties props = new Properties();
|
||||
// server/db/password:优先 Solon.cfg(yml 占位符),fallback System.property(.env)
|
||||
props.setProperty("server", resolve("redis.server", "REDIS_SERVER"));
|
||||
props.setProperty("db", resolve("redis.db", "REDIS_DB"));
|
||||
props.setProperty("password", resolve("redis.password", "REDIS_PASSWORD"));
|
||||
return new RedisClient(props);
|
||||
}
|
||||
|
||||
/**
|
||||
* 解析配置值:先取 Solon.cfg(yml),为空则取 System.property(.env)。
|
||||
*/
|
||||
private String resolve(String cfgKey, String envKey) {
|
||||
String val = Solon.cfg().get(cfgKey);
|
||||
if (val == null || val.isEmpty() || val.startsWith("${")) {
|
||||
// yml 占位符未解析(仍为 ${...})时,回退到系统属性(.env)
|
||||
val = System.getProperty(envKey);
|
||||
}
|
||||
return val;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,88 @@
|
||||
package com.renkang.gateway.controller;
|
||||
|
||||
import com.renkang.gateway.config.GatewayConfig;
|
||||
import com.renkang.gateway.manager.TokenManager;
|
||||
import org.noear.solon.annotation.Controller;
|
||||
import org.noear.solon.annotation.Inject;
|
||||
import org.noear.solon.annotation.Mapping;
|
||||
import org.noear.solon.core.handle.Context;
|
||||
import org.noear.solon.net.http.HttpResponse;
|
||||
import org.noear.solon.net.http.HttpUtils;
|
||||
import org.slf4j.Logger;
|
||||
import org.slf4j.LoggerFactory;
|
||||
|
||||
import java.util.Collection;
|
||||
|
||||
/**
|
||||
* 业务接口透传控制器:通配 /** 拦截所有未匹配具体路径的请求,手动 HttpUtils 透传至源服务器。
|
||||
* <p>
|
||||
* Solon 本地路由器中,具体路径({@code /sys/watchUserLogin})优先于通配({@code /**}),
|
||||
* 故 /sys/watchUserLogin 走本地 {@link TokenController},其余业务接口走本控制器透传。
|
||||
* <p>
|
||||
* 透传时检测源服务器响应:若 401(token 失效),调用 {@link TokenManager#markTokenInvalid()} 清缓存,
|
||||
* 设备收到 401 后自行重新调 watchUserLogin(策略B,设备已具备重试逻辑)。
|
||||
*
|
||||
* @author renkang
|
||||
*/
|
||||
@Controller
|
||||
public class GatewayController {
|
||||
|
||||
private static final Logger log = LoggerFactory.getLogger(GatewayController.class);
|
||||
|
||||
@Inject
|
||||
TokenManager tokenManager;
|
||||
|
||||
/**
|
||||
* 通配透传:将请求原样转发至源服务器,并透传响应。
|
||||
* <p>
|
||||
* 透传内容包括:请求方法、路径+查询、请求头、请求体;响应状态码、Content-Type、响应体。
|
||||
*
|
||||
* @param ctx Solon 请求上下文
|
||||
*/
|
||||
@Mapping("/**")
|
||||
public void passthrough(Context ctx) throws Throwable {
|
||||
// 构造目标 URL:源服务器 + path + query
|
||||
String path = ctx.path();
|
||||
String query = ctx.queryString();
|
||||
String pathQuery = (query != null && !query.isEmpty()) ? (path + "?" + query) : path;
|
||||
String targetUrl = GatewayConfig.sourceBaseUrl() + pathQuery;
|
||||
|
||||
String method = ctx.method();
|
||||
HttpUtils http = HttpUtils.http(targetUrl).timeout(30);
|
||||
|
||||
// 透传请求头(含 X-Access-Token 等鉴权头)
|
||||
Collection<String> headerNames = ctx.headerNames();
|
||||
if (headerNames != null) {
|
||||
for (String name : headerNames) {
|
||||
http.header(name, ctx.header(name));
|
||||
}
|
||||
}
|
||||
|
||||
// 透传请求体
|
||||
String body = ctx.body();
|
||||
if (body != null && !body.isEmpty()) {
|
||||
http.bodyOfJson(body);
|
||||
}
|
||||
|
||||
// 转发并透传响应
|
||||
try (HttpResponse resp = http.exec(method)) {
|
||||
int code = resp.code();
|
||||
ctx.status(code);
|
||||
// 透传关键响应头
|
||||
String contentType = resp.header("Content-Type");
|
||||
if (contentType != null) {
|
||||
ctx.headerSet("Content-Type", contentType);
|
||||
}
|
||||
String bodyStr = resp.bodyAsString();
|
||||
if (bodyStr != null) {
|
||||
ctx.output(bodyStr);
|
||||
}
|
||||
|
||||
// 401 检测:token 失效,清缓存,设备将自行重新获取 token
|
||||
if (code == 401) {
|
||||
log.warn("业务接口返回401,token失效,path={}", pathQuery);
|
||||
tokenManager.markTokenInvalid();
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,65 @@
|
||||
package com.renkang.gateway.controller;
|
||||
|
||||
import com.renkang.gateway.manager.TokenManager;
|
||||
import org.noear.solon.annotation.Body;
|
||||
import org.noear.solon.annotation.Controller;
|
||||
import org.noear.solon.annotation.Inject;
|
||||
import org.noear.solon.annotation.Mapping;
|
||||
import org.noear.solon.annotation.Produces;
|
||||
import org.noear.snack4.ONode;
|
||||
|
||||
/**
|
||||
* 本地 Token 控制器:拦截 /sys/watchUserLogin,本地处理而非透传源服务器。
|
||||
* <p>
|
||||
* 设备请求该接口时,中转:
|
||||
* <ol>
|
||||
* <li>解析设备请求体(username + RSA 加密 password)</li>
|
||||
* <li>交由 {@link TokenManager} 验证密码合法性 + 获取缓存/新换的 token</li>
|
||||
* <li>返回与源服务器一致的 jeecg Result 格式(标准 JSON)</li>
|
||||
* </ol>
|
||||
* 伪造请求(密码验证失败)返回错误,不换 token、不访问源服务器。
|
||||
*
|
||||
* @author renkang
|
||||
*/
|
||||
@Controller
|
||||
public class TokenController {
|
||||
|
||||
@Inject
|
||||
TokenManager tokenManager;
|
||||
|
||||
/**
|
||||
* 手表用户登录/获取 token 接口(与源服务器路径一致,本地拦截)。
|
||||
* <p>
|
||||
* 请求体:{@code {"username":"watchUser", "password":"<RSA加密密码>"}}。
|
||||
* 响应体:{@code {"success":true,"code":200,"result":{"token":"xxx"}}}。
|
||||
*
|
||||
* @param body 原始请求体 JSON
|
||||
* @return jeecg Result 格式的 token 响应(JSONObject 确保标准 JSON 序列化)
|
||||
*/
|
||||
@Mapping("/sys/watchUserLogin")
|
||||
@Produces("application/json")
|
||||
public Object watchUserLogin(@Body String body) {
|
||||
ONode req = ONode.ofJson(body);
|
||||
String encPassword = req.getOrNull("password").getString();
|
||||
|
||||
String token = tokenManager.getTokenForDevice(encPassword);
|
||||
if (token == null) {
|
||||
// 密码错误(伪造请求)或换 token 失败
|
||||
ONode err = new ONode().asObject();
|
||||
err.set("success", false);
|
||||
err.set("code", 500);
|
||||
err.set("message", "密码错误或获取token失败");
|
||||
return err;
|
||||
}
|
||||
|
||||
// 返回与源服务器一致的格式(ONode 由 Solon 序列化为标准 JSON)
|
||||
ONode result = new ONode().asObject();
|
||||
result.set("success", true);
|
||||
result.set("code", 200);
|
||||
ONode data = new ONode().asObject();
|
||||
data.set("token", token);
|
||||
result.set("result", data);
|
||||
return result;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,237 @@
|
||||
package com.renkang.gateway.manager;
|
||||
|
||||
import com.renkang.gateway.config.GatewayConfig;
|
||||
import com.renkang.gateway.util.RsaEncryptUtil;
|
||||
import com.renkang.gateway.util.TokenCache;
|
||||
import org.noear.solon.annotation.Component;
|
||||
import org.noear.solon.annotation.Inject;
|
||||
import org.noear.solon.net.http.HttpResponse;
|
||||
import org.noear.solon.net.http.HttpUtils;
|
||||
import org.noear.snack4.ONode;
|
||||
import org.slf4j.Logger;
|
||||
import org.slf4j.LoggerFactory;
|
||||
|
||||
import java.util.Base64;
|
||||
import java.util.LinkedHashMap;
|
||||
import java.util.Map;
|
||||
import java.util.Map;
|
||||
|
||||
/**
|
||||
* Token 管理器:设备密码验证 + 单飞换 token + 缓存 + 失效检测。
|
||||
* <p>
|
||||
* 核心职责:
|
||||
* <ol>
|
||||
* <li>设备密码验证:私钥解密设备发来的加密密码,与配置明文比对,拦截伪造请求</li>
|
||||
* <li>单飞换 token:本地锁 + 双检缓存,同一时刻仅一个线程真正去源服务器换 token(防并发风暴)</li>
|
||||
* <li>探活:用当前 token 调探活接口,检测 token 是否仍有效</li>
|
||||
* <li>预过期检测:解析 JWT exp,距过期不足阈值则刷新</li>
|
||||
* </ol>
|
||||
* <p>
|
||||
* 三条刷新触发线(探活失效、预过期、被动401)均汇入 {@link #refreshTokenSingleFlight},共享同一本地锁。
|
||||
*
|
||||
* @author renkang
|
||||
*/
|
||||
@Component
|
||||
public class TokenManager {
|
||||
|
||||
private static final Logger log = LoggerFactory.getLogger(TokenManager.class);
|
||||
|
||||
@Inject
|
||||
TokenCache tokenCache;
|
||||
|
||||
/** 单飞锁:单实例下用本地锁,同一时刻仅一个线程真正去源服务器换 token */
|
||||
private final Object refreshLock = new Object();
|
||||
|
||||
/**
|
||||
* 设备获取 token 入口(对应 /sys/watchUserLogin)。
|
||||
* <p>
|
||||
* 流程:① 私钥解密设备密码 → 比对明文(防伪造)→ ② 缓存命中直接返回 → ③ 缓存 miss 则单飞换 token。
|
||||
*
|
||||
* @param deviceEncPassword 设备用公钥加密后的密码(Base64)
|
||||
* @return token 字符串;密码验证失败(伪造请求)或换 token 失败返回 null
|
||||
*/
|
||||
public String getTokenForDevice(String deviceEncPassword) {
|
||||
// ① 验证设备密码(防伪造)
|
||||
if (!verifyDevicePassword(deviceEncPassword)) {
|
||||
log.warn("设备密码验证失败,疑似伪造请求,拒绝换token");
|
||||
return null;
|
||||
}
|
||||
// ② 缓存命中直接返回
|
||||
String cached = tokenCache.getToken();
|
||||
if (cached != null) {
|
||||
return cached;
|
||||
}
|
||||
// ③ 单飞换 token
|
||||
return refreshTokenSingleFlight();
|
||||
}
|
||||
|
||||
/**
|
||||
* 验证设备密码:私钥解密后与配置明文比对。
|
||||
* <p>
|
||||
* 解密失败或明文不匹配,均判定为伪造请求,拒绝换 token。
|
||||
*
|
||||
* @param deviceEncPassword 设备发来的加密密码
|
||||
* @return true 表示密码正确,是合法设备
|
||||
*/
|
||||
private boolean verifyDevicePassword(String deviceEncPassword) {
|
||||
if (deviceEncPassword == null || deviceEncPassword.isEmpty()) {
|
||||
return false;
|
||||
}
|
||||
String decrypted = RsaEncryptUtil.decrypt(deviceEncPassword, GatewayConfig.rsaPrivateKey());
|
||||
if (decrypted == null) {
|
||||
return false;
|
||||
}
|
||||
return decrypted.equals(GatewayConfig.password());
|
||||
}
|
||||
|
||||
/**
|
||||
* 单飞刷新 token:本地锁 + 双检缓存。
|
||||
* <p>
|
||||
* 探活失效、预过期、被动 401、设备请求缓存 miss 均调用此方法。
|
||||
* 同一时刻仅一个线程真正去源服务器换 token,其余线程等锁后双检命中缓存复用,
|
||||
* 从而把源服务器收到的换 token 请求收敛为 1 次,规避风控。
|
||||
*
|
||||
* @return 新 token;失败返回 null
|
||||
*/
|
||||
public String refreshTokenSingleFlight() {
|
||||
// 快速路径:缓存已有
|
||||
String cached = tokenCache.getToken();
|
||||
if (cached != null) {
|
||||
return cached;
|
||||
}
|
||||
synchronized (refreshLock) {
|
||||
// 双检:可能在等锁期间已被其他线程刷新
|
||||
cached = tokenCache.getToken();
|
||||
if (cached != null) {
|
||||
return cached;
|
||||
}
|
||||
String newToken = doRefreshFromSource();
|
||||
if (newToken != null) {
|
||||
tokenCache.setToken(newToken);
|
||||
log.info("token刷新成功并已缓存");
|
||||
}
|
||||
return newToken;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 向源服务器换 token:公钥加密明文密码 → POST /sys/watchUserLogin。
|
||||
* <p>
|
||||
* 源服务器返回 jeecg Result 格式:{@code {success:true, code:200, result:{token:"xxx"}}}。
|
||||
*
|
||||
* @return 新 token;失败返回 null
|
||||
*/
|
||||
private String doRefreshFromSource() {
|
||||
String encPassword = RsaEncryptUtil.encrypt(GatewayConfig.password(), GatewayConfig.rsaPublicKey());
|
||||
if (encPassword == null) {
|
||||
log.error("公钥加密密码失败,无法换token");
|
||||
return null;
|
||||
}
|
||||
Map<String, Object> body = new LinkedHashMap<>();
|
||||
body.put("username", GatewayConfig.username());
|
||||
body.put("password", encPassword);
|
||||
|
||||
String url = GatewayConfig.sourceBaseUrl() + GatewayConfig.tokenPath();
|
||||
try {
|
||||
HttpResponse response = HttpUtils.http(url)
|
||||
.header("Content-Type", "application/json")
|
||||
.bodyOfJson(ONode.serialize(body))
|
||||
.exec("POST");
|
||||
if (response.code() == 200) {
|
||||
ONode result = ONode.ofJson(response.bodyAsString());
|
||||
ONode data = result.getOrNull("result");
|
||||
if (data != null) {
|
||||
return data.getOrNull("token").getString();
|
||||
}
|
||||
}
|
||||
log.warn("换token失败,源服务器响应码:{}", response.code());
|
||||
} catch (Exception e) {
|
||||
log.error("换token异常,url={}", url, e);
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
/**
|
||||
* 探活:用当前 token 调探活接口,检测是否仍有效。
|
||||
* <p>
|
||||
* 探活接口 watch/dcWatchEmeMobile/list 数据量少、查询快。
|
||||
* 返回 200 表示有效;401 或异常表示失效。
|
||||
*
|
||||
* @return true 表示 token 有效
|
||||
*/
|
||||
public boolean probeToken() {
|
||||
String token = tokenCache.getToken();
|
||||
if (token == null) {
|
||||
return false;
|
||||
}
|
||||
String url = GatewayConfig.sourceBaseUrl() + GatewayConfig.probePath();
|
||||
try {
|
||||
HttpResponse response = HttpUtils.http(url)
|
||||
.header("X-Access-Token", token)
|
||||
.exec("GET");
|
||||
int code = response.code();
|
||||
if (code == 200) {
|
||||
return true;
|
||||
}
|
||||
log.warn("探活失效,响应码:{}", code);
|
||||
return false;
|
||||
} catch (Exception e) {
|
||||
log.error("探活异常,url={}", url, e);
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 检查 token 是否将过期(距 JWT exp 不足刷新阈值)。
|
||||
* <p>
|
||||
* 解析 JWT payload 的 exp 字段,与当前时间比较。
|
||||
* 由于源服务器滑动续期,token 实际不易自然过期,此为双保险。
|
||||
*
|
||||
* @return true 表示需要刷新(距过期不足阈值或无 token)
|
||||
*/
|
||||
public boolean isTokenExpiringSoon() {
|
||||
String token = tokenCache.getToken();
|
||||
if (token == null) {
|
||||
return true;
|
||||
}
|
||||
try {
|
||||
String[] parts = token.split("\\.");
|
||||
if (parts.length < 2) {
|
||||
return false;
|
||||
}
|
||||
// JWT payload 是 Base64URL 编码(- 和 _ 代替 + /,无 padding)
|
||||
byte[] payloadBytes = Base64.getUrlDecoder().decode(parts[1]);
|
||||
ONode payload = ONode.ofJson(new String(payloadBytes));
|
||||
Long exp = payload.getOrNull("exp").getLong();
|
||||
if (exp == null) {
|
||||
return false;
|
||||
}
|
||||
long remain = exp - System.currentTimeMillis() / 1000;
|
||||
return remain < GatewayConfig.refreshThreshold();
|
||||
} catch (Exception e) {
|
||||
log.warn("解析JWT exp失败,跳过预过期判断", e);
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 标记 token 失效(被动 401 时调用):清缓存,下次请求触发单飞刷新。
|
||||
*/
|
||||
public void markTokenInvalid() {
|
||||
tokenCache.clearToken();
|
||||
}
|
||||
|
||||
/**
|
||||
* 强制刷新 token:先清缓存,再单飞换新。
|
||||
* <p>
|
||||
* 用于探活失效、被动 401 等已确认 token 失效的场景。
|
||||
* 若不清缓存直接 refresh,会命中旧缓存返回失效 token(死循环),故必须先清。
|
||||
*
|
||||
* @return 新 token;失败返回 null
|
||||
*/
|
||||
public String forceRefresh() {
|
||||
log.info("强制刷新token(先清缓存再单飞换新)");
|
||||
tokenCache.clearToken();
|
||||
return refreshTokenSingleFlight();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,52 @@
|
||||
package com.renkang.gateway.task;
|
||||
|
||||
import com.renkang.gateway.manager.TokenManager;
|
||||
import org.noear.solon.annotation.Component;
|
||||
import org.noear.solon.annotation.Inject;
|
||||
import org.noear.solon.scheduling.annotation.Scheduled;
|
||||
|
||||
/**
|
||||
* Token 健康监测定时任务。
|
||||
* <p>
|
||||
* 两重机制保障 token 有效性:
|
||||
* <ol>
|
||||
* <li>探活任务:每 10 秒调用探活接口,失效则强制刷新(主)</li>
|
||||
* <li>预过期任务:每分钟检查 JWT exp,距过期不足阈值则强制刷新(双保险)</li>
|
||||
* </ol>
|
||||
* 两任务均汇入 {@link TokenManager#forceRefresh()},共享单飞锁。
|
||||
*
|
||||
* @author renkang
|
||||
*/
|
||||
@Component
|
||||
public class TokenProbeTask {
|
||||
|
||||
@Inject
|
||||
TokenManager tokenManager;
|
||||
|
||||
/**
|
||||
* 探活:每 10 秒执行一次。
|
||||
* <p>
|
||||
* 用当前 token 调探活接口,返回 401 或异常即判定失效,强制刷新(先清缓存再单飞换新)。
|
||||
* 探活成功会顺带刷新源服务器 Redis TTL(滑动续期),令 token 保持有效。
|
||||
*/
|
||||
@Scheduled(fixedRate = 10000)
|
||||
public void probe() {
|
||||
boolean valid = tokenManager.probeToken();
|
||||
if (!valid) {
|
||||
tokenManager.forceRefresh();
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 预过期检查:每分钟执行一次。
|
||||
* <p>
|
||||
* 解析缓存 token 的 JWT exp,距过期不足刷新阈值(默认 1 小时)则强制刷新。
|
||||
* 由于源服务器滑动续期,token 实际不易自然过期,此为双保险。
|
||||
*/
|
||||
@Scheduled(fixedRate = 60000)
|
||||
public void checkExpire() {
|
||||
if (tokenManager.isTokenExpiringSoon()) {
|
||||
tokenManager.forceRefresh();
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,94 @@
|
||||
package com.renkang.gateway.util;
|
||||
|
||||
import javax.crypto.Cipher;
|
||||
import java.security.KeyFactory;
|
||||
import java.security.PrivateKey;
|
||||
import java.security.PublicKey;
|
||||
import java.security.spec.PKCS8EncodedKeySpec;
|
||||
import java.security.spec.X509EncodedKeySpec;
|
||||
import java.util.Base64;
|
||||
|
||||
/**
|
||||
* RSA 加解密工具。
|
||||
* <p>
|
||||
* 与源服务器 jeecg 的 {@code RSAEncryptUtils} 完全对齐,确保加解密互通:
|
||||
* <ul>
|
||||
* <li>transformation = "RSA"(等价 RSA/ECB/PKCS1Padding,PKCS#1 v1.5 填充)</li>
|
||||
* <li>公钥格式:Base64 编码的 X.509 SubjectPublicKeyInfo({@link X509EncodedKeySpec})</li>
|
||||
* <li>私钥格式:Base64 编码的 PKCS#8({@link PKCS8EncodedKeySpec})</li>
|
||||
* <li>明文 UTF-8 编码,密文 Base64 标准编码输出</li>
|
||||
* <li>1024 位密钥,单次 doFinal,明文不超过 117 字节</li>
|
||||
* <li>Java 原生 Cipher,无第三方库依赖</li>
|
||||
* </ul>
|
||||
* <p>
|
||||
* 用途:
|
||||
* <ul>
|
||||
* <li>{@link #encrypt}:中转向源服务器换 token 前,用公钥加密明文密码(源服务器用对应私钥 decrypt1 解密)</li>
|
||||
* <li>{@link #decrypt}:中转解密设备发来的加密密码,验证设备合法性(防伪造)</li>
|
||||
* </ul>
|
||||
*
|
||||
* @author renkang
|
||||
*/
|
||||
public class RsaEncryptUtil {
|
||||
|
||||
/** RSA 算法名,与源服务器 RSAEncryptUtils.KEY_ALGORITHM 一致 */
|
||||
private static final String KEY_ALGORITHM = "RSA";
|
||||
|
||||
/**
|
||||
* 公钥加密明文(用于中转向源服务器换 token)。
|
||||
* <p>
|
||||
* 流程:明文密码 → 公钥加密 → Base64 密文 → 发送源服务器 → 源服务器用私钥 decrypt1 解密。
|
||||
*
|
||||
* @param plaintext 明文(如密码 aECrgTES@@q32)
|
||||
* @param publicKeyBase64 Base64 编码的 X.509 公钥
|
||||
* @return Base64 编码的密文;失败返回 null
|
||||
*/
|
||||
public static String encrypt(String plaintext, String publicKeyBase64) {
|
||||
try {
|
||||
byte[] keyBytes = Base64.getDecoder().decode(publicKeyBase64);
|
||||
X509EncodedKeySpec keySpec = new X509EncodedKeySpec(keyBytes);
|
||||
KeyFactory keyFactory = KeyFactory.getInstance(KEY_ALGORITHM);
|
||||
PublicKey publicKey = keyFactory.generatePublic(keySpec);
|
||||
|
||||
Cipher cipher = Cipher.getInstance(KEY_ALGORITHM);
|
||||
cipher.init(Cipher.ENCRYPT_MODE, publicKey);
|
||||
byte[] encrypted = cipher.doFinal(plaintext.getBytes("UTF-8"));
|
||||
return Base64.getEncoder().encodeToString(encrypted);
|
||||
} catch (Exception e) {
|
||||
e.printStackTrace();
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 私钥解密密文(用于中转解密设备发来的加密密码,验证设备合法性)。
|
||||
* <p>
|
||||
* 设备用公钥加密密码后发给中转,中调用本方法解密,再与配置的明文密码比对。
|
||||
*
|
||||
* @param ciphertextBase64 Base64 编码的密文(设备用公钥加密的密码)
|
||||
* @param privateKeyBase64 Base64 编码的 PKCS#8 私钥
|
||||
* @return 解密后的明文;失败返回 null
|
||||
*/
|
||||
public static String decrypt(String ciphertextBase64, String privateKeyBase64) {
|
||||
if (ciphertextBase64 == null || ciphertextBase64.isEmpty()
|
||||
|| privateKeyBase64 == null || privateKeyBase64.isEmpty()) {
|
||||
return null;
|
||||
}
|
||||
try {
|
||||
byte[] keyBytes = Base64.getDecoder().decode(privateKeyBase64);
|
||||
PKCS8EncodedKeySpec keySpec = new PKCS8EncodedKeySpec(keyBytes);
|
||||
KeyFactory keyFactory = KeyFactory.getInstance(KEY_ALGORITHM);
|
||||
PrivateKey privateKey = keyFactory.generatePrivate(keySpec);
|
||||
|
||||
Cipher cipher = Cipher.getInstance(KEY_ALGORITHM);
|
||||
cipher.init(Cipher.DECRYPT_MODE, privateKey);
|
||||
byte[] decoded = Base64.getDecoder().decode(ciphertextBase64.getBytes("UTF-8"));
|
||||
byte[] decrypted = cipher.doFinal(decoded);
|
||||
// 源服务器用 new String(tempBytes)(默认编码),这里显式 UTF-8 更稳妥,纯 ASCII 密码不受影响
|
||||
return new String(decrypted, "UTF-8");
|
||||
} catch (Exception e) {
|
||||
// 解密失败通常是密文格式错误或私钥不匹配,可能是伪造请求
|
||||
return null;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,55 @@
|
||||
package com.renkang.gateway.util;
|
||||
|
||||
import com.renkang.gateway.config.GatewayConfig;
|
||||
import org.noear.redisx.RedisClient;
|
||||
import org.noear.solon.annotation.Component;
|
||||
import org.noear.solon.annotation.Inject;
|
||||
|
||||
/**
|
||||
* Token 缓存:基于 Redis(redisx)存储当前有效 token。
|
||||
* <p>
|
||||
* 缓存 key 为 {@value GatewayConfig#CACHE_TOKEN_KEY},与源服务器 {@code prefix_user_token:} 严格隔离。
|
||||
* 中转为单实例,token 缓存跨请求线程共享;单飞控制使用本地 JVM 锁(见 {@link com.renkang.gateway.manager.TokenManager})。
|
||||
*
|
||||
* @author renkang
|
||||
*/
|
||||
@Component
|
||||
public class TokenCache {
|
||||
|
||||
/** token 缓存过期时间(秒),与源服务器 Redis TTL 一致 7 天 */
|
||||
private static final int TOKEN_TTL = 604800;
|
||||
|
||||
/** 注入 redisx Redis 客户端 */
|
||||
@Inject
|
||||
RedisClient redisClient;
|
||||
|
||||
/**
|
||||
* 获取当前缓存的 token。
|
||||
*
|
||||
* @return token 字符串;无缓存或已过期返回 null
|
||||
*/
|
||||
public String getToken() {
|
||||
return redisClient.getBucket().get(GatewayConfig.CACHE_TOKEN_KEY);
|
||||
}
|
||||
|
||||
/**
|
||||
* 缓存 token(带过期时间)。
|
||||
*/
|
||||
public void setToken(String token) {
|
||||
redisClient.getBucket().store(GatewayConfig.CACHE_TOKEN_KEY, token, TOKEN_TTL);
|
||||
}
|
||||
|
||||
/**
|
||||
* 清除缓存的 token(失效后立即清除,触发下次请求重新换取)。
|
||||
*/
|
||||
public void clearToken() {
|
||||
redisClient.open(session -> session.key(GatewayConfig.CACHE_TOKEN_KEY).delete());
|
||||
}
|
||||
|
||||
/**
|
||||
* 判断当前是否有缓存的 token。
|
||||
*/
|
||||
public boolean hasToken() {
|
||||
return getToken() != null;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,45 @@
|
||||
server:
|
||||
port: 8080
|
||||
|
||||
solon:
|
||||
app:
|
||||
name: dc-solon-gateway
|
||||
group: gateway
|
||||
|
||||
# ==================== 源服务器与凭证配置 ====================
|
||||
# 敏感值通过 ${ENV_VAR} 占位符引用 .env 文件中的变量(见项目根目录 .env / .env.example)
|
||||
gateway:
|
||||
source:
|
||||
# 源服务器网关地址(透传目标 + 换token/探活目标)
|
||||
base-url: "${SOURCE_BASE_URL}"
|
||||
# 换 token 接口路径(设备登录接口)
|
||||
token-path: "/sys/watchUserLogin"
|
||||
# 探活接口路径(轻量查询,用于主动检测 token 有效性)
|
||||
probe-path: "/watch/dcWatchEmeMobile/list"
|
||||
token:
|
||||
# 统一接口账号(所有健康设备共用)
|
||||
username: "${TOKEN_USERNAME}"
|
||||
# 明文密码:① 解密设备发来的密码后与此比对(防伪造)② 公钥加密后发源服务器换token
|
||||
password: "${TOKEN_PASSWORD}"
|
||||
# RSA 公钥(X.509 Base64,1024位):加密明文密码后发源服务器
|
||||
rsa-public-key: "${TOKEN_RSA_PUBLIC_KEY}"
|
||||
# RSA 私钥(PKCS#8 Base64,1024位):解密设备发来的加密密码,验证设备合法性
|
||||
rsa-private-key: "${TOKEN_RSA_PRIVATE_KEY}"
|
||||
# 主动探活间隔(秒):定时调用探活接口检测 token 有效性
|
||||
probe-interval: 10
|
||||
# 预过期刷新阈值(秒):token 距 JWT exp 过期不足此值时主动刷新
|
||||
refresh-threshold: 3600
|
||||
# token 总有效期(秒,用于参考,实际由 JWT exp 与源服务器 Redis TTL 共同控制)
|
||||
expire-seconds: 604800
|
||||
|
||||
# ==================== Redis 配置(redisx,复用228服务器,db11与源服务器隔离)====================
|
||||
# key 前缀统一 gateway:,与源服务器 prefix_user_token: 严格隔离
|
||||
redis:
|
||||
server: "${REDIS_SERVER}"
|
||||
db: "${REDIS_DB}"
|
||||
password: "${REDIS_PASSWORD}"
|
||||
|
||||
# ==================== 透传说明 ====================
|
||||
# 本项目用本地 GatewayController(@Mapping("/**"))手动 HttpUtils 透传业务接口至源服务器,
|
||||
# 不使用 solon.cloud.gateway.routes 路由透传(因其 Path=/** 会覆盖本地 watchUserLogin 处理)。
|
||||
# /sys/watchUserLogin 由本地 TokenController 处理(Solon 具体路径优先于通配 /**)。
|
||||
Reference in New Issue
Block a user