feat:【IoT 物联网】增加网关 HTTP 协议的鉴权,基于 JWT 轻量级

This commit is contained in:
YunaiV
2025-06-03 13:22:55 +08:00
parent 1498389d26
commit 643cc4cfd2
40 changed files with 793 additions and 498 deletions

View File

@@ -24,9 +24,8 @@ public class IotGatewayConfiguration {
public static class HttpProtocolConfiguration {
@Bean
public IotHttpUpstreamProtocol iotHttpUpstreamProtocol(IotGatewayProperties gatewayProperties,
IotDeviceMessageProducer deviceMessageProducer) {
return new IotHttpUpstreamProtocol(gatewayProperties.getProtocol().getHttp(), deviceMessageProducer);
public IotHttpUpstreamProtocol iotHttpUpstreamProtocol(IotGatewayProperties gatewayProperties) {
return new IotHttpUpstreamProtocol(gatewayProperties.getProtocol().getHttp());
}
@Bean

View File

@@ -1,9 +1,12 @@
package cn.iocoder.yudao.module.iot.gateway.config;
import jakarta.validation.constraints.NotEmpty;
import jakarta.validation.constraints.NotNull;
import lombok.Data;
import org.springframework.boot.context.properties.ConfigurationProperties;
import org.springframework.validation.annotation.Validated;
import java.time.Duration;
import java.util.List;
@ConfigurationProperties(prefix = "yudao.iot.gateway")
@@ -15,6 +18,10 @@ public class IotGatewayProperties {
* 设备 RPC 服务配置
*/
private RpcProperties rpc;
/**
* Token 配置
*/
private TokenProperties token;
/**
* 协议配置
@@ -27,15 +34,34 @@ public class IotGatewayProperties {
/**
* 主程序 API 地址
*/
@NotEmpty(message = "主程序 API 地址不能为空")
private String url;
/**
* 连接超时时间
*/
private String connectTimeout;
@NotNull(message = "连接超时时间不能为空")
private Duration connectTimeout;
/**
* 读取超时时间
*/
private String readTimeout;
@NotNull(message = "读取超时时间不能为空")
private Duration readTimeout;
}
@Data
public static class TokenProperties {
/**
* 密钥
*/
@NotEmpty(message = "密钥不能为空")
private String secret;
/**
* 令牌有效期
*/
@NotNull(message = "令牌有效期不能为空")
private Duration expiration;
}
@@ -60,6 +86,7 @@ public class IotGatewayProperties {
/**
* 是否开启
*/
@NotNull(message = "是否开启不能为空")
private Boolean enabled;
/**
* 服务端口
@@ -74,6 +101,7 @@ public class IotGatewayProperties {
/**
* 是否开启
*/
@NotNull(message = "是否开启不能为空")
private Boolean enabled;
/**
* MQTT 服务器地址

View File

@@ -0,0 +1,16 @@
package cn.iocoder.yudao.module.iot.gateway.enums;
import cn.iocoder.yudao.framework.common.exception.ErrorCode;
/**
* iot gateway 错误码枚举类
* <p>
* iot 系统,使用 1-051-000-000 段
*/
public interface ErrorCodeConstants {
// ========== 设备认证 1-050-001-000 ============
ErrorCode DEVICE_AUTH_FAIL = new ErrorCode(1_051_001_000, "设备鉴权失败"); // 对应阿里云 20000
ErrorCode DEVICE_TOKEN_EXPIRED = new ErrorCode(1_051_001_002, "token 失效。需重新调用 auth 进行鉴权获取token"); // 对应阿里云 20001
}

View File

@@ -1,8 +1,8 @@
package cn.iocoder.yudao.module.iot.gateway.protocol.http;
import cn.iocoder.yudao.module.iot.core.mq.producer.IotDeviceMessageProducer;
import cn.iocoder.yudao.module.iot.core.util.IotDeviceMessageUtils;
import cn.iocoder.yudao.module.iot.gateway.config.IotGatewayProperties;
import cn.iocoder.yudao.module.iot.gateway.protocol.http.router.IotHttpAuthHandler;
import cn.iocoder.yudao.module.iot.gateway.protocol.http.router.IotHttpUpstreamHandler;
import io.vertx.core.AbstractVerticle;
import io.vertx.core.Vertx;
@@ -25,8 +25,6 @@ public class IotHttpUpstreamProtocol extends AbstractVerticle {
private final IotGatewayProperties.HttpProperties httpProperties;
private final IotDeviceMessageProducer deviceMessageProducer;
private HttpServer httpServer;
@Override
@@ -38,10 +36,11 @@ public class IotHttpUpstreamProtocol extends AbstractVerticle {
router.route().handler(BodyHandler.create());
// 创建处理器,添加路由处理器
IotHttpUpstreamHandler upstreamHandler = new IotHttpUpstreamHandler(
this, deviceMessageProducer);
IotHttpUpstreamHandler upstreamHandler = new IotHttpUpstreamHandler(this);
router.post(IotHttpUpstreamHandler.PROPERTY_PATH).handler(upstreamHandler);
router.post(IotHttpUpstreamHandler.EVENT_PATH).handler(upstreamHandler);
IotHttpAuthHandler authHandler = new IotHttpAuthHandler(this);
router.post(IotHttpAuthHandler.PATH).handler(authHandler);
// 启动 HTTP 服务器
try {

View File

@@ -0,0 +1,98 @@
package cn.iocoder.yudao.module.iot.gateway.protocol.http.router;
import cn.hutool.core.lang.Assert;
import cn.hutool.core.util.ObjUtil;
import cn.hutool.core.util.StrUtil;
import cn.hutool.extra.spring.SpringUtil;
import cn.iocoder.yudao.framework.common.exception.ServiceException;
import cn.iocoder.yudao.framework.common.pojo.CommonResult;
import cn.iocoder.yudao.framework.common.util.json.JsonUtils;
import cn.iocoder.yudao.module.iot.core.util.IotDeviceAuthUtils;
import cn.iocoder.yudao.module.iot.gateway.service.auth.IotDeviceTokenService;
import io.vertx.core.Handler;
import io.vertx.core.http.HttpHeaders;
import io.vertx.ext.web.RoutingContext;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import org.springframework.http.MediaType;
import static cn.iocoder.yudao.framework.common.exception.enums.GlobalErrorCodeConstants.FORBIDDEN;
import static cn.iocoder.yudao.framework.common.exception.enums.GlobalErrorCodeConstants.INTERNAL_SERVER_ERROR;
import static cn.iocoder.yudao.framework.common.exception.util.ServiceExceptionUtil.exception;
import static cn.iocoder.yudao.framework.common.exception.util.ServiceExceptionUtil.invalidParamException;
/**
* IoT 网关 HTTP 协议的处理器抽象基类:提供通用的前置处理(认证)、全局的异常捕获等
*
* @author 芋道源码
*/
@RequiredArgsConstructor
@Slf4j
public abstract class IotHttpAbstractHandler implements Handler<RoutingContext> {
private final IotDeviceTokenService deviceTokenService = SpringUtil.getBean(IotDeviceTokenService.class);
@Override
public void handle(RoutingContext context) {
try {
// 1. 前置处理
CommonResult<Object> result = beforeHandle(context);
if (result != null) {
writeResponse(context, result);
return;
}
// 2. 执行逻辑
result = handle0(context);
writeResponse(context, result);
} catch (ServiceException e) {
writeResponse(context, CommonResult.error(e.getCode(), e.getMessage()));
} catch (Exception e) {
log.error("[handle][path({}) 处理异常]", context.request().path(), e);
writeResponse(context, CommonResult.error(INTERNAL_SERVER_ERROR));
}
}
protected abstract CommonResult<Object> handle0(RoutingContext context);
private CommonResult<Object> beforeHandle(RoutingContext context) {
// 如果不需要认证,则不走前置处理
String path = context.request().path();
if (ObjUtil.equal(path, IotHttpAuthHandler.PATH)) {
return null;
}
// 解析参数
String token = context.request().getHeader(HttpHeaders.AUTHORIZATION);
if (StrUtil.isEmpty(token)) {
throw invalidParamException("token 不能为空");
}
String productKey = context.pathParam("productKey");
if (StrUtil.isEmpty(productKey)) {
throw invalidParamException("productKey 不能为空");
}
String deviceName = context.pathParam("deviceName");
if (StrUtil.isEmpty(deviceName)) {
throw invalidParamException("deviceName 不能为空");
}
// 校验 token
IotDeviceAuthUtils.DeviceInfo deviceInfo = deviceTokenService.verifyToken(token);
Assert.notNull(deviceInfo, "设备信息不能为空");
// 校验设备信息是否匹配
if (ObjUtil.notEqual(productKey, deviceInfo.getProductKey())
|| ObjUtil.notEqual(deviceName, deviceInfo.getDeviceName())) {
throw exception(FORBIDDEN);
}
return null;
}
@SuppressWarnings("deprecation")
public static void writeResponse(RoutingContext context, Object data) {
context.response()
.setStatusCode(200)
.putHeader(HttpHeaders.CONTENT_TYPE, MediaType.APPLICATION_JSON_UTF8_VALUE)
.end(JsonUtils.toJsonString(data));
}
}

View File

@@ -0,0 +1,84 @@
package cn.iocoder.yudao.module.iot.gateway.protocol.http.router;
import cn.hutool.core.lang.Assert;
import cn.hutool.core.map.MapUtil;
import cn.hutool.core.util.StrUtil;
import cn.hutool.extra.spring.SpringUtil;
import cn.iocoder.yudao.framework.common.pojo.CommonResult;
import cn.iocoder.yudao.module.iot.core.biz.IotDeviceCommonApi;
import cn.iocoder.yudao.module.iot.core.biz.dto.IotDeviceAuthReqDTO;
import cn.iocoder.yudao.module.iot.core.mq.producer.IotDeviceMessageProducer;
import cn.iocoder.yudao.module.iot.core.util.IotDeviceAuthUtils;
import cn.iocoder.yudao.module.iot.gateway.protocol.http.IotHttpUpstreamProtocol;
import cn.iocoder.yudao.module.iot.gateway.service.auth.IotDeviceTokenService;
import io.vertx.core.json.JsonObject;
import io.vertx.ext.web.RoutingContext;
import static cn.iocoder.yudao.framework.common.exception.util.ServiceExceptionUtil.exception;
import static cn.iocoder.yudao.framework.common.exception.util.ServiceExceptionUtil.invalidParamException;
import static cn.iocoder.yudao.framework.common.pojo.CommonResult.success;
import static cn.iocoder.yudao.module.iot.gateway.enums.ErrorCodeConstants.DEVICE_AUTH_FAIL;
/**
* IoT 网关 HTTP 协议的【认证】处理器
*
* 参考 <a href="阿里云 IoT —— HTTPS 连接通信">https://help.aliyun.com/zh/iot/user-guide/establish-connections-over-https</a>
*
* @author 芋道源码
*/
public class IotHttpAuthHandler extends IotHttpAbstractHandler {
public static final String PATH = "/auth";
private final IotHttpUpstreamProtocol protocol;
private final IotDeviceMessageProducer deviceMessageProducer;
private final IotDeviceTokenService deviceTokenService;
private final IotDeviceCommonApi deviceClientService;
public IotHttpAuthHandler(IotHttpUpstreamProtocol protocol) {
this.protocol = protocol;
this.deviceMessageProducer = SpringUtil.getBean(IotDeviceMessageProducer.class);
this.deviceTokenService = SpringUtil.getBean(IotDeviceTokenService.class);
this.deviceClientService = SpringUtil.getBean(IotDeviceCommonApi.class);
}
@Override
public CommonResult<Object> handle0(RoutingContext context) {
// 解析参数
JsonObject body = context.body().asJsonObject();
String clientId = body.getString("clientId");
if (StrUtil.isEmpty(clientId)) {
throw invalidParamException("clientId 不能为空");
}
String username = body.getString("username");
if (StrUtil.isEmpty(username)) {
throw invalidParamException("username 不能为空");
}
String password = body.getString("password");
if (StrUtil.isEmpty(password)) {
throw invalidParamException("password 不能为空");
}
// 执行认证
CommonResult<Boolean> result = deviceClientService.authDevice(new IotDeviceAuthReqDTO()
.setClientId(clientId).setUsername(username).setPassword(password));
if (result == null || !result.isSuccess()) {
throw exception(DEVICE_AUTH_FAIL);
}
// 生成 Token
IotDeviceAuthUtils.DeviceInfo deviceInfo = deviceTokenService.parseUsername(username);
Assert.notNull(deviceInfo, "设备信息不能为空");
String token = deviceTokenService.createToken(deviceInfo.getProductKey(), deviceInfo.getDeviceName());
Assert.notBlank(token, "生成 token 不能为空位");
// TODO @芋艿:发送上线消息;
// 构建响应数据
return success(MapUtil.of("token", token));
}
}

View File

@@ -3,6 +3,7 @@ package cn.iocoder.yudao.module.iot.gateway.protocol.http.router;
import cn.hutool.core.collection.CollUtil;
import cn.hutool.core.map.MapUtil;
import cn.hutool.core.util.StrUtil;
import cn.hutool.extra.spring.SpringUtil;
import cn.iocoder.yudao.framework.common.pojo.CommonResult;
import cn.iocoder.yudao.module.iot.core.mq.message.IotDeviceMessage;
import cn.iocoder.yudao.module.iot.core.mq.producer.IotDeviceMessageProducer;
@@ -16,11 +17,8 @@ import lombok.extern.slf4j.Slf4j;
import java.util.Map;
import static cn.iocoder.yudao.framework.common.exception.enums.GlobalErrorCodeConstants.BAD_REQUEST;
import static cn.iocoder.yudao.framework.common.exception.enums.GlobalErrorCodeConstants.INTERNAL_SERVER_ERROR;
/**
* IoT 网关 HTTP 协议的处理器
* IoT 网关 HTTP 协议的【上行】处理器
*
* @author 芋道源码
*/
@@ -54,47 +52,35 @@ public class IotHttpUpstreamHandler implements Handler<RoutingContext> {
private static final String EVENT_METHOD_SUFFIX = ".post";
private final IotHttpUpstreamProtocol protocol;
// /**
// * 设备上行 API
// */
// private final IotDeviceUpstreamApi deviceUpstreamApi;
/**
* 设备消息生产者
*/
private final IotDeviceMessageProducer deviceMessageProducer;
public IotHttpUpstreamHandler(IotHttpUpstreamProtocol protocol) {
this.protocol = protocol;
this.deviceMessageProducer = SpringUtil.getBean(IotDeviceMessageProducer.class);
}
@Override
public void handle(RoutingContext routingContext) {
String path = routingContext.request().path();
public void handle(RoutingContext context) {
String path = context.request().path();
// 1. 解析通用参数
Map<String, String> params = parseCommonParams(context);
String productKey = params.get("productKey");
String deviceName = params.get("deviceName");
JsonObject body = context.body().asJsonObject();
try {
// 1. 解析通用参数
Map<String, String> params = parseCommonParams(routingContext);
String productKey = params.get("productKey");
String deviceName = params.get("deviceName");
JsonObject body = routingContext.body().asJsonObject();
// 2. 根据路径模式处理不同类型的请求
if (isPropertyPostPath(path)) {
// 处理属性上报
handlePropertyPost(context, productKey, deviceName, body);
return;
}
// 2. 根据路径模式处理不同类型的请求
if (isPropertyPostPath(path)) {
// 处理属性上报
handlePropertyPost(routingContext, productKey, deviceName, body);
return;
}
if (isEventPostPath(path)) {
// 处理事件上报
String identifier = routingContext.pathParam("identifier");
handleEventPost(routingContext, productKey, deviceName, identifier, body);
return;
}
// 不支持的请求路径
sendErrorResponse(routingContext, "unknown", BAD_REQUEST.getCode(), "不支持的请求路径");
} catch (Exception e) {
log.error("[handle][处理上行请求异常] path={}", path, e);
String method = determineMethodFromPath(path, routingContext);
sendErrorResponse(routingContext, method, INTERNAL_SERVER_ERROR.getCode(),
INTERNAL_SERVER_ERROR.getMsg());
if (isEventPostPath(path)) {
// 处理事件上报
String identifier = context.pathParam("identifier");
handleEventPost(context, productKey, deviceName, identifier, body);
return;
}
}
@@ -169,7 +155,6 @@ public class IotHttpUpstreamHandler implements Handler<RoutingContext> {
//
// // 事件上报
// CommonResult<Boolean> result = deviceUpstreamApi.reportDeviceEvent(reportReqDTO);
// String method = EVENT_METHOD_PREFIX + identifier + EVENT_METHOD_SUFFIX;
//
// // 返回响应
// sendResponse(routingContext, requestId, method, result);
@@ -195,20 +180,6 @@ public class IotHttpUpstreamHandler implements Handler<RoutingContext> {
// IotNetComponentCommonUtils.writeJsonResponse(routingContext, response);
}
/**
* 发送错误响应
*
* @param routingContext 路由上下文
* @param method 方法名
* @param code 错误代码
* @param message 错误消息
*/
private void sendErrorResponse(RoutingContext routingContext, String method, Integer code,
String message) {
// IotStandardResponse errorResponse = IotStandardResponse.error(requestId, method, code, message);
// IotNetComponentCommonUtils.writeJsonResponse(routingContext, errorResponse);
}
/**
* 从路径确定方法名
*

View File

@@ -0,0 +1,37 @@
package cn.iocoder.yudao.module.iot.gateway.service.auth;
import cn.iocoder.yudao.module.iot.core.util.IotDeviceAuthUtils;
/**
* IoT 设备 Token 服务 Service 接口
*
* @author 芋道源码
*/
public interface IotDeviceTokenService {
/**
* 创建设备 Token
*
* @param productKey 产品 Key
* @param deviceName 设备名称
* @return 设备 Token
*/
String createToken(String productKey, String deviceName);
/**
* 验证设备 Token
*
* @param token 设备 Token
* @return 设备信息
*/
IotDeviceAuthUtils.DeviceInfo verifyToken(String token);
/**
* 解析用户名
*
* @param username 用户名
* @return 设备信息
*/
IotDeviceAuthUtils.DeviceInfo parseUsername(String username);
}

View File

@@ -0,0 +1,79 @@
package cn.iocoder.yudao.module.iot.gateway.service.auth;
import cn.hutool.core.lang.Assert;
import cn.hutool.json.JSONObject;
import cn.hutool.jwt.JWT;
import cn.hutool.jwt.JWTUtil;
import cn.iocoder.yudao.framework.common.util.date.LocalDateTimeUtils;
import cn.iocoder.yudao.module.iot.core.util.IotDeviceAuthUtils;
import cn.iocoder.yudao.module.iot.gateway.config.IotGatewayProperties;
import jakarta.annotation.Resource;
import lombok.extern.slf4j.Slf4j;
import org.springframework.stereotype.Service;
import java.time.LocalDateTime;
import java.util.HashMap;
import java.util.Map;
import static cn.iocoder.yudao.framework.common.exception.util.ServiceExceptionUtil.exception;
import static cn.iocoder.yudao.module.iot.gateway.enums.ErrorCodeConstants.DEVICE_TOKEN_EXPIRED;
/**
* IoT 设备 Token Service 实现类:调用远程的 device http 接口,进行设备 Token 生成、解析等逻辑
*
* 注意:目前仅 HTTP 协议使用
*
* @author 芋道源码
*/
@Service
@Slf4j
public class IotDeviceTokenServiceImpl implements IotDeviceTokenService {
@Resource
private IotGatewayProperties gatewayProperties;
@Override
public String createToken(String productKey, String deviceName) {
Assert.notBlank(productKey, "productKey 不能为空");
Assert.notBlank(deviceName, "deviceName 不能为空");
// 构建 JWT payload
Map<String, Object> payload = new HashMap<>();
payload.put("productKey", productKey);
payload.put("deviceName", deviceName);
LocalDateTime expireTime = LocalDateTimeUtils.addTime(gatewayProperties.getToken().getExpiration());
payload.put("exp", LocalDateTimeUtils.toEpochSecond(expireTime)); // 过期时间exp 是 JWT 规范推荐)
// 生成 JWT Token
return JWTUtil.createToken(payload, gatewayProperties.getToken().getSecret().getBytes());
}
@Override
public IotDeviceAuthUtils.DeviceInfo verifyToken(String token) {
Assert.notBlank(token, "token 不能为空");
// 校验 JWT Token
boolean verify = JWTUtil.verify(token, gatewayProperties.getToken().getSecret().getBytes());
if (!verify) {
throw exception(DEVICE_TOKEN_EXPIRED);
}
// 解析 Token
JWT jwt = JWTUtil.parseToken(token);
JSONObject payload = jwt.getPayloads();
// 检查过期时间
Long exp = payload.getLong("exp");
if (exp == null || exp > System.currentTimeMillis() / 1000) {
throw exception(DEVICE_TOKEN_EXPIRED);
}
String productKey = payload.getStr("productKey");
String deviceName = payload.getStr("deviceName");
Assert.notBlank(productKey, "productKey 不能为空");
Assert.notBlank(deviceName, "deviceName 不能为空");
return new IotDeviceAuthUtils.DeviceInfo().setProductKey(productKey).setDeviceName(deviceName);
}
@Override
public IotDeviceAuthUtils.DeviceInfo parseUsername(String username) {
return IotDeviceAuthUtils.parseUsername(username);
}
}

View File

@@ -0,0 +1,58 @@
package cn.iocoder.yudao.module.iot.gateway.service.device;
import cn.iocoder.yudao.framework.common.pojo.CommonResult;
import cn.iocoder.yudao.module.iot.core.biz.IotDeviceCommonApi;
import cn.iocoder.yudao.module.iot.core.biz.dto.IotDeviceAuthReqDTO;
import cn.iocoder.yudao.module.iot.gateway.config.IotGatewayProperties;
import jakarta.annotation.PostConstruct;
import jakarta.annotation.Resource;
import lombok.extern.slf4j.Slf4j;
import org.springframework.boot.web.client.RestTemplateBuilder;
import org.springframework.stereotype.Service;
import org.springframework.web.client.RestTemplate;
import static cn.iocoder.yudao.framework.common.exception.enums.GlobalErrorCodeConstants.INTERNAL_SERVER_ERROR;
/**
* Iot 设备信息 Service 实现类:调用远程的 device http 接口,进行设备认证、设备获取等
*
* @author 芋道源码
*/
@Service
@Slf4j
public class IotDeviceClientServiceImpl implements IotDeviceCommonApi {
@Resource
private IotGatewayProperties gatewayProperties;
private RestTemplate restTemplate;
@PostConstruct
public void init() {
IotGatewayProperties.RpcProperties rpc = gatewayProperties.getRpc();
restTemplate = new RestTemplateBuilder()
.rootUri(rpc.getUrl() + "/rpc-api/iot/device/")
.readTimeout(rpc.getReadTimeout())
.connectTimeout(rpc.getConnectTimeout())
.build();
}
@Override
public CommonResult<Boolean> authDevice(IotDeviceAuthReqDTO authReqDTO) {
return doPost("auth", authReqDTO);
}
@SuppressWarnings("unchecked")
private <T> CommonResult<Boolean> doPost(String url, T requestBody) {
try {
CommonResult<Boolean> result = restTemplate.postForObject(url, requestBody,
(Class<CommonResult<Boolean>>) (Class<?>) CommonResult.class);
log.info("[doPost][url({}) requestBody({}) result({})]", url, requestBody, result);
return result;
} catch (Exception e) {
log.error("[doPost][url({}) requestBody({}) 发生异常]", url, requestBody, e);
return CommonResult.error(INTERNAL_SERVER_ERROR);
}
}
}

View File

@@ -22,6 +22,10 @@ yudao:
url: http://127.0.0.1:48080 # 主程序 API 地址
connect-timeout: 30s
read-timeout: 30s
# 设备 Token 配置
token:
secret: 1234567890123456789012345678901
expiration: 7d
# 协议配置
protocol: