fix(规则脚本): 规则引擎 Groovy 脚本注入导致远程代码执行漏洞修复

This commit is contained in:
gx_ma
2026-10-01 03:05:41 +08:00
parent 284b714c1e
commit 890a338460
56 changed files with 2124 additions and 88 deletions

View File

@@ -16,6 +16,12 @@
</description>
<dependencies>
<dependency>
<groupId>junit</groupId><artifactId>junit</artifactId><scope>test</scope>
</dependency>
<dependency>
<groupId>org.mockito</groupId><artifactId>mockito-core</artifactId><version>3.9.0</version><scope>test</scope>
</dependency>
<!-- spring-boot-devtools -->
<dependency>

View File

@@ -57,6 +57,8 @@ public class SysLoginController
String token = loginService.login(loginBody.getUsername(), loginBody.getPassword(), loginBody.getCode(),
loginBody.getUuid(), request.getHeader(LANGUAGE));
ajax.put(Constants.TOKEN, token);
// Use the authenticated account and the same policy as GetInfo, HTTP and MQTT.
ajax.put("needChangePwd", com.fastbee.common.utils.PasswordPolicy.requiresChange(tokenService.getLoginUserByToken(token)));
return ajax;
}
@@ -81,6 +83,7 @@ public class SysLoginController
}
AjaxResult ajax = AjaxResult.success();
ajax.put("user", user);
ajax.put("needChangePwd", com.fastbee.common.utils.PasswordPolicy.requiresChange(loginUser));
ajax.put("roles", roles);
ajax.put("permissions", permissions);
return ajax;

View File

@@ -112,9 +112,21 @@ public class SysProfileController extends BaseController
{
String oldPassword = params.get("oldPassword");
String newPassword = params.get("newPassword");
LoginUser loginUser = getLoginUser();
if (newPassword == null || newPassword.length() < com.fastbee.common.constant.UserConstants.PASSWORD_MIN_LENGTH
|| newPassword.length() > com.fastbee.common.constant.UserConstants.PASSWORD_MAX_LENGTH
|| com.fastbee.common.utils.PasswordPolicy.isDefault(newPassword)) {
return error("请输入符合长度要求且不是出厂默认值的新密码");
}
// Update this authenticated session, not the last session stored under the shared user-id cache.
LoginUser loginUser = SecurityUtils.getLoginUser();
String userName = loginUser.getUsername();
String password = loginUser.getPassword();
SysUser currentUser = userService.selectUserById(loginUser.getUserId());
if (currentUser == null || !"0".equals(currentUser.getStatus()) || "2".equals(currentUser.getDelFlag())) {
return error("当前账号不可用,请重新登录");
}
// A restricted session issued before another session changed the password must not
// regain the account by checking the old, cached default-password hash.
String password = currentUser.getPassword();
if (!SecurityUtils.matchesPassword(oldPassword, password))
{
return error(MessageUtils.message("user.update.failed.password.wrong"));
@@ -128,6 +140,7 @@ public class SysProfileController extends BaseController
{
// 更新缓存用户密码
loginUser.getUser().setPassword(newPassword);
loginUser.setNeedChangePwd(false);
tokenService.setLoginUser(loginUser);
return success();
}

View File

@@ -102,3 +102,9 @@ xss:
enabled: true # 过滤开关
excludes: /system/notice # 排除链接(多个用逗号分隔)
urlPatterns: /system/*,/monitor/*,/tool/* # 匹配链接
# 规则脚本统一使用受限编译器和业务能力白名单,禁止关闭安全边界。
script:
sandbox:
enabled: true
allow-network: true # 保留既有 HTTP 集成;设为 false 禁止脚本主动 HTTP 调用

View File

@@ -384,7 +384,7 @@ oauthCode.code.not.exist=code \u4e0d\u5b58\u5728
##protocol
protocol.input.content.is.empty=\u8f93\u5165\u5185\u5bb9\u4e3a\u7a7a
protocol.data.parse.error=\u6570\u636e\u89e3\u6790\u51fa\u9519[{}]
protocol.data.parse.exception=\u6570\u636e\u89e3\u6790\u5f02\u5e38[{}]
protocol.data.parse.exception=\u6570\u636e\u89e3\u6790\u5f02\u5e38[{0}]
protocol.instruction.number.exception=\u6307\u4ee4\u7f16\u53f7\u5f02\u5e38\uff1a[{}]
##modbusJob

View File

@@ -384,7 +384,7 @@ oauthCode.code.not.exist=Code does not exist
##protocol
protocol.input.content.is.empty=The input content is empty
protocol.data.parse.error=Data parsing error [{}]
protocol.data.parse.exception=Data parsing exception [{}]
protocol.data.parse.exception=Data parsing exception [{0}]
protocol.instruction.number.exception=Instruction number exception: [{}]
##modbusJob

View File

@@ -384,7 +384,7 @@ oauthCode.code.not.exist=code \u4e0d\u5b58\u5728
##protocol
protocol.input.content.is.empty=\u8f93\u5165\u5185\u5bb9\u4e3a\u7a7a
protocol.data.parse.error=\u6570\u636e\u89e3\u6790\u51fa\u9519[{}]
protocol.data.parse.exception=\u6570\u636e\u89e3\u6790\u5f02\u5e38[{}]
protocol.data.parse.exception=\u6570\u636e\u89e3\u6790\u5f02\u5e38[{0}]
protocol.instruction.number.exception=\u6307\u4ee4\u7f16\u53f7\u5f02\u5e38\uff1a[{}]
##modbusJob
@@ -426,4 +426,4 @@ workOrder.please.select.user=\u8bf7\u6307\u5b9a\u8054\u7cfb\u4eba
workOrder.please.fill.result.info=\u8bf7\u586b\u5199\u7ed3\u5355\u4fe1\u606f
## subGateway
subGateway.subDeviceAddress.is.repeat=\u5b58\u5728\u76f8\u540c\u7684\u5b50\u8bbe\u5907\u5730\u5740\uff0c\u8bf7\u4fee\u6539\u540e\u91cd\u8bd5\uff01
subGateway.subDeviceAddress.is.repeat=\u5b58\u5728\u76f8\u540c\u7684\u5b50\u8bbe\u5907\u5730\u5740\uff0c\u8bf7\u4fee\u6539\u540e\u91cd\u8bd5\uff01

View File

@@ -0,0 +1,415 @@
package com.fastbee.security;
import com.fastbee.common.constant.CacheConstants;
import com.fastbee.common.core.domain.AjaxResult;
import com.fastbee.common.core.domain.entity.*;
import com.fastbee.common.core.domain.model.*;
import com.fastbee.common.core.redis.RedisCache;
import com.fastbee.common.utils.*;
import com.fastbee.framework.security.filter.JwtAuthenticationTokenFilter;
import com.fastbee.framework.web.service.TokenService;
import com.fastbee.iot.domain.Device;
import com.fastbee.iot.domain.DeviceUser;
import com.fastbee.iot.domain.Script;
import com.fastbee.iot.mapper.*;
import com.fastbee.iot.model.*;
import com.fastbee.iot.ruleEngine.RuleProcess;
import com.fastbee.iot.ruleEngine.security.ScriptSecurityValidator;
import com.fastbee.iot.service.*;
import com.fastbee.iot.service.impl.ScriptServiceImpl;
import com.fastbee.mqtt.acl.MqttAccessControl;
import com.fastbee.mqtt.auth.AuthService;
import com.fastbee.ruleEngine.context.MsgContext;
import com.fastbee.ruleEngine.security.SecureGroovyScriptExecutor;
import com.fastbee.system.service.ISysUserService;
import com.fastbee.web.controller.system.SysProfileController;
import com.yomahub.liteflow.script.ScriptExecutorFactory;
import org.junit.*;
import org.mockito.ArgumentCaptor;
import org.springframework.security.authentication.UsernamePasswordAuthenticationToken;
import org.springframework.security.core.context.SecurityContextHolder;
import org.springframework.transaction.support.*;
import javax.servlet.*;
import javax.servlet.http.*;
import java.io.*;
import java.lang.reflect.Field;
import java.util.*;
import static org.junit.Assert.*;
import static org.mockito.Mockito.*;
/** Isolated component regressions: no deployment, database, Redis or real devices are contacted. */
public class SecurityRegressionTest {
private static final String HASH = SecurityUtils.encryptPassword("SecurityTestPassword!");
private static org.springframework.context.support.GenericApplicationContext flowContext;
@BeforeClass public static void initializeLocalLiteflowContext() {
flowContext=new org.springframework.context.support.GenericApplicationContext();
com.yomahub.liteflow.property.LiteflowConfig config=new com.yomahub.liteflow.property.LiteflowConfig();
config.setRuleSource("security/local-flow.xml");
flowContext.getBeanFactory().registerSingleton("liteflowConfig",config);
flowContext.refresh(); new com.yomahub.liteflow.spi.spring.SpringAware().setApplicationContext(flowContext);
}
@AfterClass public static void closeLocalLiteflowContext() {
com.yomahub.liteflow.flow.FlowBus.cleanCache();
flowContext.close(); new com.yomahub.liteflow.spi.spring.SpringAware().setApplicationContext(null);
}
public static class LocalFixtureNode extends com.yomahub.liteflow.core.NodeComponent {
@Override public void process() { }
}
private LoginUser login;
private SysUser user;
private RedisCache redis;
private ISysUserService users;
private AuthService auth;
private MqttClientPrincipal principal;
private MqttAccessControl acl;
private DeviceMapper devices;
private DeviceUserMapper shares;
private Device device;
private static void field(Object target, String name, Object value) throws Exception {
for (Class<?> c=target.getClass(); c!=null; c=c.getSuperclass()) {
try { Field f=c.getDeclaredField(name); f.setAccessible(true); f.set(target,value); return; }
catch (NoSuchFieldException ignored) {}
}
throw new NoSuchFieldException(name);
}
@Before public void setup() throws Exception {
user = new SysUser(); user.setUserId(42L); user.setUserName("owner"); user.setStatus("0"); user.setDelFlag("0"); user.setPassword(HASH); user.setRoles(new ArrayList<>());
login = new LoginUser(42L,1L,user,new HashSet<>()); login.setToken("security-test-session"); login.setNeedChangePwd(false);
SecurityContextHolder.getContext().setAuthentication(new UsernamePasswordAuthenticationToken(login,null,Collections.emptyList()));
redis=mock(RedisCache.class); users=mock(ISysUserService.class); auth=new AuthService();
field(auth,"redisCache",redis); field(auth,"userService",users);
when(redis.getCacheObject(CacheConstants.LOGIN_TOKEN_KEY+login.getToken())).thenReturn(login);
when(users.selectUserById(42L)).thenReturn(user);
principal=MqttClientPrincipal.ofWeb("web-security-test",42L,false); principal.setSessionId(login.getToken());
devices=mock(DeviceMapper.class); shares=mock(DeviceUserMapper.class); acl=new MqttAccessControl();
field(acl,"authService",auth); field(acl,"deviceMapper",devices); field(acl,"deviceUserMapper",shares);
device=new Device(); device.setDeviceId(9L); device.setProductId(41L); device.setUserId(42L); device.setTenantId(99L);
when(devices.selectDeviceBySerialNumber("device")).thenReturn(device);
}
@After public void cleanup() {
SecurityContextHolder.clearContext();
if(TransactionSynchronizationManager.isSynchronizationActive()) TransactionSynchronizationManager.clearSynchronization();
ScriptExecutorFactory.loadInstance().getScriptExecutor("groovy").cleanCache();
}
@Test public void oldDefaultPasswordSessionsAreDetected() {
login.setNeedChangePwd(null); user.setPassword(SecurityUtils.encryptPassword("admin123"));
assertTrue(PasswordPolicy.requiresChange(login));
login.setNeedChangePwd(null); user.setPassword(HASH);
assertFalse(PasswordPolicy.requiresChange(login));
}
@Test public void onlyTheExactNonAdminDemoAccountIsExempt() {
user.setPassword(SecurityUtils.encryptPassword("123456"));
user.setUserName("fastbee"); login.setNeedChangePwd(true);
assertFalse(PasswordPolicy.requiresChange(login)); assertEquals(Boolean.FALSE,login.getNeedChangePwd());
for(String name:Arrays.asList("admin","fastbee-t1","FastBee","fastbee-other")) {
user.setUserName(name); login.setNeedChangePwd(null); assertTrue(name,PasswordPolicy.requiresChange(login));
}
user.setUserName("fastbee"); user.setUserId(1L); login.setNeedChangePwd(null);
assertTrue("The built-in administrator is never a demo exemption",PasswordPolicy.requiresChange(login));
}
@Test public void demoLoginResponseUsesAuthenticatedSessionPolicy() throws Exception {
com.fastbee.web.controller.system.SysLoginController controller=new com.fastbee.web.controller.system.SysLoginController();
com.fastbee.framework.web.service.SysLoginService loginService=mock(com.fastbee.framework.web.service.SysLoginService.class);
TokenService tokens=mock(TokenService.class); field(controller,"loginService",loginService); field(controller,"tokenService",tokens);
LoginBody body=new LoginBody(); body.setUsername("FastBee"); body.setPassword("123456"); body.setCode("code"); body.setUuid("uuid");
HttpServletRequest request=mock(HttpServletRequest.class);
when(loginService.login(eq("FastBee"),eq("123456"),eq("code"),eq("uuid"),isNull())).thenReturn("demo-token");
when(tokens.getLoginUserByToken("demo-token")).thenReturn(login);
user.setUserName("fastbee"); login.setNeedChangePwd(true);
assertEquals(Boolean.FALSE,controller.login(request,body).get("needChangePwd"));
user.setUserName("admin"); login.setNeedChangePwd(true);
assertEquals(Boolean.TRUE,controller.login(request,body).get("needChangePwd"));
}
@Test public void cachedDemoRestrictionIsClearedWithoutBlockingHomepage() throws Exception {
user.setUserName("fastbee"); login.setNeedChangePwd(true); Filter filter=new Filter();
TokenService tokens=mock(TokenService.class); field(filter,"tokenService",tokens);
HttpServletRequest request=mock(HttpServletRequest.class); HttpServletResponse response=mock(HttpServletResponse.class); FilterChain chain=mock(FilterChain.class);
when(tokens.getLoginUser(request)).thenReturn(login); when(request.getMethod()).thenReturn("GET"); when(request.getServletPath()).thenReturn("/iot/device/statistic");
filter.invoke(request,response,chain);
assertEquals(Boolean.FALSE,login.getNeedChangePwd()); verify(tokens).setLoginUser(login); verify(chain).doFilter(request,response);
verify(response,never()).setStatus(403);
}
@Test public void demoMqttExemptionStillEnforcesDeviceAccessAndRevocation() {
user.setUserName("fastbee"); user.setPassword(SecurityUtils.encryptPassword("123456")); login.setNeedChangePwd(true);
assertTrue(acl.canPublish(principal,"/41/device/monitor/get","{}"));
device.setUserId(77L); assertFalse(acl.canSubscribe(principal,"/41/device/#"));
device.setUserId(42L); when(redis.getCacheObject(CacheConstants.LOGIN_TOKEN_KEY+login.getToken())).thenReturn(null);
assertFalse(acl.canPublish(principal,"/41/device/monitor/get","{}"));
}
private static class Filter extends JwtAuthenticationTokenFilter {
void invoke(HttpServletRequest request,HttpServletResponse response,FilterChain chain) throws Exception { super.doFilterInternal(request,response,chain); }
}
@Test public void mandatoryChangeBlocksBackendEvenWithClientFlagRemoved() throws Exception {
login.setNeedChangePwd(true); Filter filter=new Filter(); TokenService tokens=mock(TokenService.class); field(filter,"tokenService",tokens);
HttpServletRequest request=mock(HttpServletRequest.class); HttpServletResponse response=mock(HttpServletResponse.class); FilterChain chain=mock(FilterChain.class);
when(tokens.getLoginUser(request)).thenReturn(login); when(request.getMethod()).thenReturn("POST"); when(request.getServletPath()).thenReturn("/iot/script/validate");
StringWriter body=new StringWriter(); when(response.getWriter()).thenReturn(new PrintWriter(body));
filter.invoke(request,response,chain); verify(response).setStatus(403); verifyNoInteractions(chain); assertTrue(body.toString().contains("needChangePwd"));
when(request.getMethod()).thenReturn("PUT"); when(request.getServletPath()).thenReturn("/system/user/profile/updatePwd");
filter.invoke(request,response,chain); verify(chain).doFilter(request,response);
assertFalse(PasswordPolicy.allowedWhileChanging("GET","/system/user/profile/updatePwd"));
}
@Test public void passwordChangeRejectsAnotherDefaultAndRefreshesState() throws Exception {
login.setNeedChangePwd(true); SysProfileController controller=new SysProfileController(); TokenService tokens=mock(TokenService.class);
field(controller,"userService",users); field(controller,"tokenService",tokens);
Map<String,String> input=new HashMap<>(); input.put("oldPassword","SecurityTestPassword!"); input.put("newPassword","123456");
assertNotEquals(200,controller.updatePwd(input).get("code")); verify(users,never()).resetUserPwd(anyString(),anyString());
input.put("newPassword","ChangedPassword!"); when(users.resetUserPwd(eq("owner"),anyString())).thenReturn(1);
assertEquals(200,controller.updatePwd(input).get("code")); assertFalse(login.getNeedChangePwd()); verify(tokens).setLoginUser(login);
}
@Test public void mqttOwnerAdminDeviceAndServerKeepWorking() {
assertTrue(acl.canPublish(principal,"/41/device/monitor/get","{}"));
assertTrue(acl.canPublish(principal,"/41/device/ota/get","{}"));
assertTrue(acl.canSubscribe(principal,"/41/device/#"));
assertTrue(acl.canPublish(MqttClientPrincipal.of("device",MqttClientPrincipal.ClientType.DEVICE),"/41/device/property/post"));
assertTrue(acl.canSubscribe(MqttClientPrincipal.of("server",MqttClientPrincipal.ClientType.SERVER),"/+/+/property/post"));
user.setUserId(1L); principal.setUserId(1L); when(users.selectUserById(1L)).thenReturn(user);
assertTrue(acl.canSubscribe(principal,"/+/+/property/post"));
}
@Test public void oldRestrictedSessionCannotResetAnAlreadyChangedPassword() throws Exception {
login.setNeedChangePwd(true); user.setPassword(SecurityUtils.encryptPassword("admin123"));
SysUser current=new SysUser(); current.setUserId(42L); current.setStatus("0"); current.setDelFlag("0"); current.setPassword(HASH);
when(users.selectUserById(42L)).thenReturn(current);
SysProfileController controller=new SysProfileController(); field(controller,"userService",users); field(controller,"tokenService",mock(TokenService.class));
Map<String,String> input=new HashMap<>(); input.put("oldPassword","admin123"); input.put("newPassword","AttackerReplacement!");
org.springframework.context.support.StaticMessageSource messages=new org.springframework.context.support.StaticMessageSource();
messages.addMessage("user.update.failed.password.wrong",org.springframework.context.i18n.LocaleContextHolder.getLocale(),"Old password does not match");
org.springframework.beans.factory.support.DefaultListableBeanFactory factory=new org.springframework.beans.factory.support.DefaultListableBeanFactory();
factory.registerSingleton("messageSource",messages); new com.fastbee.common.utils.spring.SpringUtils().postProcessBeanFactory(factory);
assertNotEquals(200,controller.updatePwd(input).get("code"));
verify(users,never()).resetUserPwd(anyString(),anyString());
}
@Test public void mqttRevocationStatusAndDefaultPasswordsApplyToExistingConnections() {
assertTrue(acl.canPublish(principal,"/41/device/monitor/get"));
when(redis.getCacheObject(CacheConstants.LOGIN_TOKEN_KEY+login.getToken())).thenReturn(null);
assertFalse(acl.canPublish(principal,"/41/device/monitor/get")); assertFalse(acl.canSubscribe(principal,"/41/device/#"));
when(redis.getCacheObject(CacheConstants.LOGIN_TOKEN_KEY+login.getToken())).thenReturn(login); user.setStatus("1");
assertFalse(acl.canPublish(principal,"/41/device/monitor/get"));
user.setStatus("0"); login.setNeedChangePwd(true); assertFalse(acl.canSubscribe(principal,"/41/device/#"));
assertFalse(acl.canPublish(null,"/41/device/property/post"));
}
@Test public void crossDeviceAndSpoofedProductTopicsAreRejected() {
device.setUserId(77L); assertFalse(acl.canPublish(principal,"/41/device/monitor/get"));
assertFalse(acl.canSubscribe(principal,"/41/device/#")); device.setUserId(42L);
assertFalse(acl.canPublish(principal,"/999/device/monitor/get"));
assertFalse(acl.canSubscribe(principal,"/41/+/#"));
assertFalse(acl.canPublish(principal,"garbage"));
}
@Test public void tenantAndSharedModelPermissionsArePreserved() {
device.setUserId(77L); device.setTenantId(42L); SysRole role=new SysRole(); role.setRoleKey("tenant"); user.setRoles(Collections.singletonList(role));
assertTrue(acl.canPublish(principal,"/41/device/ota/get"));
user.setRoles(Collections.emptyList()); DeviceUser share=new DeviceUser(); share.setIsOwner(0); share.setPerms("monitor,temp, light");
when(shares.selectDeviceUserByDeviceIdAndUserId(9L,42L)).thenReturn(share);
assertTrue(acl.canSubscribe(principal,"/41/device/property/post"));
assertTrue(acl.canPublish(principal,"/41/device/monitor/get","{}"));
assertTrue(acl.canPublish(principal,"/41/device/function/get","[{\"id\":\"light\",\"value\":1}]"));
assertFalse(acl.canPublish(principal,"/41/device/function/get","[{\"id\":\"other\"}]"));
assertFalse(acl.canPublish(principal,"/41/device/property/post","{}"));
assertFalse(acl.canPublish(principal,"/41/device/ota/get","{}"));
}
@Test public void legacyDeviceCacheUpgradesAndDisabledScriptsAreNotSelected() throws Exception {
RuleProcess process=new RuleProcess(); IProductService products=mock(IProductService.class); IScriptService scripts=mock(IScriptService.class);
field(process,"redisCache",redis); field(process,"productService",products); field(process,"scriptService",scripts);
ProductCode product=new ProductCode(); product.setProductId(41L); product.setProtocolCode("JSON"); product.setUserId(42L);
when(redis.containsKey(anyString())).thenReturn(true); when(redis.getCacheObject(anyString())).thenReturn("{\"productId\":41,\"protocolCode\":\"JSON\"}");
when(products.getProtocolBySerialNumber("device")).thenReturn(product);
assertEquals(Long.valueOf(42),process.getDeviceDetail("device").getUserId());
when(scripts.selectRuleScriptIdArray(any())).thenReturn(new String[0]);
process.processRuleScript("device",1,"topic","{}");
ArgumentCaptor<ScriptCondition> condition=ArgumentCaptor.forClass(ScriptCondition.class); verify(scripts).selectRuleScriptIdArray(condition.capture());
assertEquals(Integer.valueOf(1),condition.getValue().getEnable());
}
private Script script(String id,String source) {
Script s=new Script(); s.setScriptId(id); s.setScriptLanguage("groovy"); s.setScriptData(source); s.setUserId(42L); s.setEnable(1); return s;
}
private ScriptServiceImpl service(ScriptMapper mapper) throws Exception {
ScriptServiceImpl service=new ScriptServiceImpl(); field(service,"ruleScriptMapper",mapper); field(service,"scriptSecurityValidator",new ScriptSecurityValidator()); return service;
}
@Test public void mqttRuleConversionFeedsTheJsonDecoder() throws Exception {
String id = "Dconsole_compatibility_report";
String source = "System.out.println('before conversion'); def input=JSONUtil.parseObj(msgContext.payload); "
+ "def values=[]; input.keySet().forEach(key -> { values.add([id:key,value:input.getStr(key)]) }); "
+ "msgContext.payload=JSONUtil.toJsonStr(values); System.out.println(msgContext.payload)";
com.yomahub.liteflow.core.FlowExecutor flow = new com.yomahub.liteflow.core.FlowExecutor(flowContext.getBean(com.yomahub.liteflow.property.LiteflowConfig.class));
com.fastbee.ruleEngine.core.FlowLogExecutor logExecutor = new com.fastbee.ruleEngine.core.FlowLogExecutor(); field(logExecutor,"flowExecutor",flow);
com.yomahub.liteflow.builder.LiteFlowNodeBuilder.createScriptNode().setId(id).setLanguage("groovy").setScript(source).build();
RuleProcess process = new RuleProcess(); IProductService products=mock(IProductService.class); IScriptService lookup=mock(IScriptService.class);
field(process,"redisCache",redis); field(process,"productService",products); field(process,"scriptService",lookup);
field(process,"flowLogExecutor",logExecutor); field(process,"scriptSandbox",new com.fastbee.ruleEngine.security.ScriptSandbox());
ProductCode product=new ProductCode(); product.setProductId(41L); product.setProtocolCode("JSON"); product.setUserId(42L);
when(products.getProtocolBySerialNumber("D1ELV3A5TOJS")).thenReturn(product);
when(lookup.selectRuleScriptIdArray(any())).thenReturn(new String[]{id});
try {
MsgContext converted = process.processRuleScript("D1ELV3A5TOJS",1,"/41/D1ELV3A5TOJS/property/post","{\"co2\":666,\"temperature\":23.5}");
assertEquals("/41/D1ELV3A5TOJS/property/post",converted.getTopic());
com.fastbee.common.core.mq.DeviceReport report = new com.fastbee.json.JsonProtocolService().decode(
com.fastbee.common.core.mq.message.DeviceData.builder().data(converted.getPayload().getBytes(java.nio.charset.StandardCharsets.UTF_8)).build(),"D1ELV3A5TOJS");
assertEquals("D1ELV3A5TOJS",report.getSerialNumber());
List<com.fastbee.common.core.thingsModel.ThingsModelSimpleItem> values = report.getValuesInput().getThingsModelValueRemarkItem();
assertEquals(2,values.size());
Map<String,String> actual = new HashMap<>();
for(com.fastbee.common.core.thingsModel.ThingsModelSimpleItem value:values) actual.put(value.getId(),value.getValue());
assertEquals("666",actual.get("co2")); assertEquals("23.5",actual.get("temperature"));
} finally {
com.yomahub.liteflow.flow.FlowBus.removeChain("dataChain_"+id);
com.yomahub.liteflow.flow.FlowBus.removeNode(id);
}
}
@Test public void invalidProtocolPayloadReportsTheOriginalParsingErrorInAllLocales() throws Exception {
org.springframework.context.support.ResourceBundleMessageSource messages = new org.springframework.context.support.ResourceBundleMessageSource();
messages.setBasename("i18n/messages"); messages.setDefaultEncoding("UTF-8"); messages.setFallbackToSystemLocale(false);
org.springframework.beans.factory.support.DefaultListableBeanFactory factory = new org.springframework.beans.factory.support.DefaultListableBeanFactory();
factory.registerSingleton("messageSource",messages);
Field beanFactory = com.fastbee.common.utils.spring.SpringUtils.class.getDeclaredField("beanFactory"); beanFactory.setAccessible(true);
Object previousFactory = beanFactory.get(null);
org.springframework.context.i18n.LocaleContext previousLocale = org.springframework.context.i18n.LocaleContextHolder.getLocaleContext();
new com.fastbee.common.utils.spring.SpringUtils().postProcessBeanFactory(factory);
try {
for(Locale locale:Arrays.asList(Locale.ROOT,Locale.SIMPLIFIED_CHINESE,Locale.US)) {
org.springframework.context.i18n.LocaleContextHolder.setLocale(locale);
try {
new com.fastbee.json.JsonProtocolService().decode(com.fastbee.common.core.mq.message.DeviceData.builder()
.data("{\"co2\":666}".getBytes(java.nio.charset.StandardCharsets.UTF_8)).build(),"D1ELV3A5TOJS");
fail("An unconverted object must not pass the array decoder");
} catch (com.fastbee.common.exception.ServiceException expected) {
// This Fastjson version rejects the object while converting array items.
assertTrue(expected.getMessage(),expected.getMessage().contains("ClassCastException"));
assertTrue(expected.getMessage(),expected.getMessage().contains("ThingsModelSimpleItem"));
assertFalse(expected.getMessage().contains("{0}"));
}
}
} finally {
beanFactory.set(null,previousFactory);
org.springframework.context.i18n.LocaleContextHolder.setLocaleContext(previousLocale);
}
}
@Test public void newlyCreatedScriptRunsImmediatelyAndKeepsItsLifecycle() throws Exception {
com.yomahub.liteflow.core.FlowExecutor flow=new com.yomahub.liteflow.core.FlowExecutor(flowContext.getBean(com.yomahub.liteflow.property.LiteflowConfig.class));
com.fastbee.ruleEngine.core.FlowLogExecutor logExecutor=new com.fastbee.ruleEngine.core.FlowLogExecutor(); field(logExecutor,"flowExecutor",flow);
ScriptMapper mapper=mock(ScriptMapper.class); ScriptServiceImpl service=service(mapper);
when(mapper.insertRuleScript(any())).thenReturn(1); when(mapper.updateRuleScript(any())).thenReturn(1); when(mapper.deleteRuleScriptByIds(any())).thenReturn(1);
Script created=script(null,"msgContext.payload = 'created'"); created.setScriptType("script"); created.setScriptPurpose(1);
TransactionSynchronizationManager.initSynchronization();
assertEquals(1,service.insertRuleScript(created)); String id=created.getScriptId();
assertFalse(com.yomahub.liteflow.flow.FlowBus.containNode(id));
for(TransactionSynchronization synchronization:TransactionSynchronizationManager.getSynchronizations()) synchronization.afterCommit();
TransactionSynchronizationManager.clearSynchronization();
assertTrue("A committed new script must be available to LiteFlow immediately",com.yomahub.liteflow.flow.FlowBus.containNode(id));
when(mapper.selectRuleScriptById(id)).thenReturn(created);
RuleProcess process=new RuleProcess(); IProductService products=mock(IProductService.class); IScriptService lookup=mock(IScriptService.class);
field(process,"redisCache",redis); field(process,"productService",products); field(process,"scriptService",lookup);
field(process,"flowLogExecutor",logExecutor); field(process,"scriptSandbox",new com.fastbee.ruleEngine.security.ScriptSandbox());
ProductCode product=new ProductCode(); product.setProductId(41L); product.setProtocolCode("JSON"); product.setUserId(42L);
when(products.getProtocolBySerialNumber("device")).thenReturn(product); when(lookup.selectRuleScriptIdArray(any())).thenReturn(new String[]{id});
try {
assertEquals("created",process.processRuleScript("device",1,"/41/device/property/post","{}").getPayload());
Script edited=script(id,"msgContext.payload = 'updated'"); service.updateRuleScript(edited);
assertEquals("updated",process.processRuleScript("device",2,"/41/device/function/get","{}").getPayload());
assertEquals(edited.getScriptData(),com.yomahub.liteflow.flow.FlowBus.getNode(id).getScript());
// The mapper preserves stored ownership when an update DTO omits that column.
created.setScriptData(edited.getScriptData());
Script disabled=new Script(); disabled.setScriptId(id); disabled.setEnable(0); service.updateRuleScript(disabled);
assertFalse(ScriptExecutorFactory.loadInstance().getScriptExecutor("groovy").getNodeIds().contains(id));
MsgContext blocked=new MsgContext(); assertFalse(flow.execute2Resp("dataChain_"+id,null,blocked).isSuccess()); assertNull(blocked.getPayload());
created.setEnable(0);
Script enabled=new Script(); enabled.setScriptId(id); enabled.setEnable(1); service.updateRuleScript(enabled);
assertEquals("updated",process.processRuleScript("device",1,"/41/device/property/post","{}").getPayload());
service.deleteRuleScriptById(id); assertFalse(ScriptExecutorFactory.loadInstance().getScriptExecutor("groovy").getNodeIds().contains(id));
} finally {
com.yomahub.liteflow.flow.FlowBus.removeChain("dataChain_"+id);
com.yomahub.liteflow.flow.FlowBus.removeNode(id);
}
}
@Test public void rolledBackScriptNeverBecomesExecutable() throws Exception {
ScriptMapper mapper=mock(ScriptMapper.class); ScriptServiceImpl service=service(mapper); when(mapper.insertRuleScript(any())).thenReturn(1);
TransactionSynchronizationManager.initSynchronization(); Script created=script(null,"return 1"); service.insertRuleScript(created);
for(TransactionSynchronization synchronization:TransactionSynchronizationManager.getSynchronizations()) synchronization.afterCompletion(TransactionSynchronization.STATUS_ROLLED_BACK);
assertFalse(com.yomahub.liteflow.flow.FlowBus.containNode(created.getScriptId()));
assertFalse(ScriptExecutorFactory.loadInstance().getScriptExecutor("groovy").getNodeIds().contains(created.getScriptId()));
}
@Test public void editingNormalizesLanguagePreservesImportsAndCommitsBeforePublishing() throws Exception {
ScriptMapper mapper=mock(ScriptMapper.class); ScriptServiceImpl service=service(mapper); Script old=script("Dtest","return 1");
when(mapper.selectRuleScriptById("Dtest")).thenReturn(old); when(mapper.updateRuleScript(any())).thenReturn(1);
Script update=script("Dtest","import cn.hutool.json.JSONUtil; return 2"); update.setScriptLanguage(" GROOVY ");
TransactionSynchronizationManager.initSynchronization();
assertEquals(1,service.updateRuleScript(update)); assertEquals("groovy",update.getScriptLanguage()); assertTrue(update.getScriptData().startsWith("import cn.hutool.json.JSONUtil; return 2"));
assertTrue(ScriptExecutorFactory.loadInstance().getScriptExecutor("groovy").getNodeIds().isEmpty());
for(TransactionSynchronization synchronization:TransactionSynchronizationManager.getSynchronizations()) synchronization.afterCommit();
assertTrue(ScriptExecutorFactory.loadInstance().getScriptExecutor("groovy").getNodeIds().contains("Dtest"));
}
@Test public void annotationInjectionAndForeignEditsNeverReachDatabase() throws Exception {
ScriptMapper mapper=mock(ScriptMapper.class); ScriptServiceImpl service=service(mapper); Script old=script("Dtest","return 1"); when(mapper.selectRuleScriptById("Dtest")).thenReturn(old);
Script update=script("Dtest","@groovy.transform.ASTTest(value={ System.setProperty('fastbee.security.service','bad') }) def x=1");
System.clearProperty("fastbee.security.service");
try{service.updateRuleScript(update);fail();}catch(com.fastbee.common.exception.ServiceException expected){}
assertNull(System.getProperty("fastbee.security.service")); verify(mapper,never()).updateRuleScript(any());
old.setUserId(77L); try{service.updateRuleScript(script("Dtest","return 1"));fail();}catch(com.fastbee.common.exception.ServiceException expected){}
try{service.deleteRuleScriptByIds(new String[]{"Dtest"});fail();}catch(com.fastbee.common.exception.ServiceException expected){}
verify(mapper,never()).deleteRuleScriptByIds(any());
}
@Test public void disableAndDeleteEvictCompiledScript() throws Exception {
ScriptMapper mapper=mock(ScriptMapper.class); ScriptServiceImpl service=service(mapper); Script old=script("Dtest","return 1"); when(mapper.selectRuleScriptById("Dtest")).thenReturn(old);
when(mapper.updateRuleScript(any())).thenReturn(1); when(mapper.deleteRuleScriptByIds(any())).thenReturn(1);
com.yomahub.liteflow.script.ScriptExecutor executor=ScriptExecutorFactory.loadInstance().getScriptExecutor("groovy"); executor.load("Dtest","return 1");
Script update=new Script(); update.setScriptId("Dtest"); update.setEnable(0); service.updateRuleScript(update); assertFalse(executor.getNodeIds().contains("Dtest"));
executor.load("Dtest","return 1"); service.deleteRuleScriptById("Dtest"); assertFalse(executor.getNodeIds().contains("Dtest"));
}
@Test public void defaultAndExplicitGroovyShareTheSameReloadCache() {
assertSame(ScriptExecutorFactory.loadInstance().getScriptExecutor(null), ScriptExecutorFactory.loadInstance().getScriptExecutor("groovy"));
}
@Test public void generatedOfflineSceneStillCallsBusinessContext() throws Exception {
org.springframework.beans.factory.support.DefaultListableBeanFactory factory=new org.springframework.beans.factory.support.DefaultListableBeanFactory();
factory.registerSingleton("functionInvoke",mock(com.fastbee.mq.service.IFunctionInvoke.class));
factory.registerSingleton("deviceService",mock(IDeviceService.class)); factory.registerSingleton("deviceUserService",mock(IDeviceUserService.class)); factory.registerSingleton("redis",redis);
new com.fastbee.common.utils.spring.SpringUtils().postProcessBeanFactory(factory);
com.fastbee.mq.ruleEngine.SceneContext context=new com.fastbee.mq.ruleEngine.SceneContext("device",41L,6,Collections.emptyList());
String json="{\"purpose\":2,\"source\":1,\"silent\":0,\"type\":6,\"deviceNums\":\"device\",\"sceneId\":9,\"productId\":41,\"scriptId\":\"Ttest\"}";
String source="String json="+com.alibaba.fastjson2.JSON.toJSONString(json)+"; sceneContext.process(json);";
assertEquals(Boolean.TRUE,SecureGroovyScriptExecutor.evaluate(SecureGroovyScriptExecutor.compileSource(source),Collections.singletonMap("sceneContext",context)));
assertEquals(1,context.getSceneThingsModelItems().size());
}
@Test public void mqttAuthenticationBindsAndRevokesServerSession() throws Exception {
String secret="local-security-regression-signing-key-only"; field(auth,"secret",secret);
String jwt=io.jsonwebtoken.Jwts.builder().claim(com.fastbee.common.constant.Constants.LOGIN_USER_KEY,login.getToken())
.signWith(io.jsonwebtoken.SignatureAlgorithm.HS512,secret).compact();
MqttClientPrincipal authenticated=auth.authenticate("web-test","owner",jwt);
assertNotNull(authenticated); assertEquals(login.getToken(),authenticated.getSessionId());
login.setNeedChangePwd(true); assertNull(auth.authenticate("web-test","owner",jwt));
login.setNeedChangePwd(false); when(redis.getCacheObject(CacheConstants.LOGIN_TOKEN_KEY+login.getToken())).thenReturn(null);
assertNull(auth.authenticate("web-test","owner",jwt));
}
@Test public void simulatedMqttPublishCannotBypassAcl() throws Exception {
com.fastbee.mqtt.handler.MqttPublish handler=spy(new com.fastbee.mqtt.handler.MqttPublish()); MqttAccessControl policy=mock(MqttAccessControl.class); field(handler,"mqttAccessControl",policy);
io.netty.channel.embedded.EmbeddedChannel channel=new io.netty.channel.embedded.EmbeddedChannel();
io.netty.channel.ChannelHandlerContext context=mock(io.netty.channel.ChannelHandlerContext.class); when(context.channel()).thenReturn(channel);
com.fastbee.base.session.Session session=new com.fastbee.base.session.Session(); session.setPrincipal(principal); session.setClientId("web-test");
com.fastbee.base.util.AttributeUtils.setSession(channel,session); com.fastbee.base.util.AttributeUtils.setClientId(channel,"web-test");
io.netty.handler.codec.mqtt.MqttPublishMessage message=io.netty.handler.codec.mqtt.MqttMessageBuilders.publish().topicName("/41/device/property/simulate")
.qos(io.netty.handler.codec.mqtt.MqttQoS.AT_LEAST_ONCE).messageId(1).payload(io.netty.buffer.Unpooled.wrappedBuffer(new byte[]{123,125})).build();
try { handler.handler(context,message); verify(context).close(); verify(handler,never()).sendTestToMQ(any()); verify(policy).canPublish(eq(principal),eq("/41/device/property/simulate"),eq("{}")); }
finally { message.release(); channel.finishAndReleaseAll(); }
}
@Test public void subscriptionRejectsOnlyUnauthorizedTopicsWithSubackCodes() throws Exception {
com.fastbee.mqtt.handler.MqttSubscribe handler=new com.fastbee.mqtt.handler.MqttSubscribe(); field(handler,"mqttAccessControl",acl); field(handler,"redisCache",redis);
io.netty.channel.embedded.EmbeddedChannel channel=new io.netty.channel.embedded.EmbeddedChannel(); io.netty.channel.ChannelHandlerContext context=mock(io.netty.channel.ChannelHandlerContext.class);
when(context.channel()).thenReturn(channel); when(context.writeAndFlush(any())).thenReturn(channel.newSucceededFuture());
com.fastbee.base.session.Session session=new com.fastbee.base.session.Session(); session.setPrincipal(principal); session.setClientId("web-test"); session.setHandlerContext(context);
com.fastbee.base.util.AttributeUtils.setSession(channel,session);
io.netty.handler.codec.mqtt.MqttSubscribeMessage message=io.netty.handler.codec.mqtt.MqttMessageBuilders.subscribe().messageId(7)
.addSubscription(io.netty.handler.codec.mqtt.MqttQoS.AT_LEAST_ONCE,"/41/device/property/post")
.addSubscription(io.netty.handler.codec.mqtt.MqttQoS.AT_LEAST_ONCE,"/41/foreign/property/post").build();
try {
handler.subscribe(context,message); ArgumentCaptor<Object> ack=ArgumentCaptor.forClass(Object.class); verify(context).writeAndFlush(ack.capture());
assertEquals(Arrays.asList(1,128),((io.netty.handler.codec.mqtt.MqttSubAckMessage)ack.getValue()).payload().grantedQoSLevels());
} finally { com.fastbee.mqtt.manager.ClientManager.remove("web-test"); channel.finishAndReleaseAll(); }
}
@Test public void liveDeliveryStopsAfterSessionRevocation() throws Exception {
org.springframework.beans.factory.support.DefaultListableBeanFactory factory=new org.springframework.beans.factory.support.DefaultListableBeanFactory(); factory.registerSingleton("acl",acl);
new com.fastbee.common.utils.spring.SpringUtils().postProcessBeanFactory(factory);
io.netty.channel.ChannelHandlerContext context=mock(io.netty.channel.ChannelHandlerContext.class); com.fastbee.base.session.Session session=new com.fastbee.base.session.Session(); session.setPrincipal(principal); session.setHandlerContext(context);
when(redis.getCacheObject(CacheConstants.LOGIN_TOKEN_KEY+login.getToken())).thenReturn(null);
io.netty.handler.codec.mqtt.MqttPublishMessage message=io.netty.handler.codec.mqtt.MqttMessageBuilders.publish().topicName("/41/device/property/post").qos(io.netty.handler.codec.mqtt.MqttQoS.AT_MOST_ONCE).payload(io.netty.buffer.Unpooled.wrappedBuffer(new byte[]{123,125})).build();
try { com.fastbee.mqtt.manager.ResponseManager.publishClients(message,session); verify(context).close(); verify(context,never()).writeAndFlush(any()); } finally { message.release(); }
}
}

View File

@@ -0,0 +1,7 @@
<?xml version="1.0" encoding="UTF-8"?>
<flow>
<nodes>
<node id="securityFixture" class="com.fastbee.security.SecurityRegressionTest$LocalFixtureNode"/>
</nodes>
<chain name="securityFixtureChain">THEN(securityFixture)</chain>
</flow>

View File

@@ -80,6 +80,11 @@ public class LoginUser implements UserDetails {
private Boolean neverExpire = Boolean.FALSE;
// Nullable for sessions created before this upgrade. The server migrates them.
private Boolean needChangePwd;
public Boolean getNeedChangePwd() { return needChangePwd; }
public void setNeedChangePwd(Boolean value) { needChangePwd = value; }
public LoginUser() {
}

View File

@@ -0,0 +1,60 @@
package com.fastbee.common.core.domain.model;
import lombok.Data;
import java.io.Serializable;
/**
* MQTT 客户端认证主体。
*
* <p>认证阶段只校验凭据是否合法,这里额外记录"这条连接代表谁",供 PUBLISH 阶段做 topic 归属校验。
* web / phone 客户端使用平台登录 JWT 作为 MQTT 密码,若不绑定用户身份,任意一个登录用户都能向
* 任意设备的 topic 发布消息,进而触发该产品的规则脚本执行。</p>
*
* @author fastbee security
*/
@Data
public class MqttClientPrincipal implements Serializable {
private static final long serialVersionUID = 1L;
public enum ClientType {
/** 平台内部服务端,配置账号密码认证 */
SERVER,
/** Web 端 / 移动端,登录 JWT 认证 */
WEB,
/** 设备端,一机一密认证 */
DEVICE
}
private String clientId;
private ClientType clientType;
/** WEB 类型时对应的平台用户id,其余类型为 null */
private Long userId;
/** 是否为超级管理员,管理员不受设备归属限制 */
private boolean admin;
/** Redis login identifier, never the raw JWT or password. */
private String sessionId;
public static MqttClientPrincipal of(String clientId, ClientType clientType) {
MqttClientPrincipal principal = new MqttClientPrincipal();
principal.setClientId(clientId);
principal.setClientType(clientType);
return principal;
}
public static MqttClientPrincipal ofWeb(String clientId, Long userId, boolean admin) {
MqttClientPrincipal principal = of(clientId, ClientType.WEB);
principal.setUserId(userId);
principal.setAdmin(admin);
return principal;
}
public boolean isWeb() {
return clientType == ClientType.WEB;
}
}

View File

@@ -0,0 +1,33 @@
package com.fastbee.common.utils;
import com.fastbee.common.core.domain.model.LoginUser;
/** Server-owned password-change state, also applied to pre-upgrade sessions. */
public final class PasswordPolicy {
private PasswordPolicy() {}
public static boolean isDefault(String raw) { return "admin123".equals(raw) || "123456".equals(raw); }
/** The explicitly designated demo account only; never exempt the built-in administrator. */
public static boolean isDemoAccount(LoginUser login) {
return login != null && login.getUser() != null && !login.getUser().isAdmin()
&& "fastbee".equals(login.getUser().getUserName());
}
public static boolean requiresChange(LoginUser login) {
if (login == null || login.getUser() == null) return true;
if (isDemoAccount(login)) {
// Also migrate a demo session cached before this exception was introduced.
login.setNeedChangePwd(false);
return false;
}
if (login.getNeedChangePwd() == null) {
String hash = login.getUser().getPassword();
login.setNeedChangePwd(hash == null || SecurityUtils.matchesPassword("admin123", hash)
|| SecurityUtils.matchesPassword("123456", hash));
}
return Boolean.TRUE.equals(login.getNeedChangePwd());
}
public static boolean allowedWhileChanging(String method, String path) {
return "GET".equals(method) && ("/getInfo".equals(path) || "/getRouters".equals(path) || "/system/user/profile".equals(path))
|| "PUT".equals(method) && "/system/user/profile/updatePwd".equals(path)
|| "POST".equals(method) && "/logout".equals(path);
}
}

View File

@@ -32,6 +32,18 @@ public class JwtAuthenticationTokenFilter extends OncePerRequestFilter
throws ServletException, IOException
{
LoginUser loginUser = tokenService.getLoginUser(request);
if (loginUser != null) {
Boolean previousPasswordState = loginUser.getNeedChangePwd();
boolean required = com.fastbee.common.utils.PasswordPolicy.requiresChange(loginUser);
if (!java.util.Objects.equals(previousPasswordState, loginUser.getNeedChangePwd())) tokenService.setLoginUser(loginUser);
if (required && !com.fastbee.common.utils.PasswordPolicy.allowedWhileChanging(
request.getMethod(), request.getServletPath())) {
response.setStatus(HttpServletResponse.SC_FORBIDDEN);
response.setContentType("application/json;charset=UTF-8");
response.getWriter().write("{\"code\":403,\"needChangePwd\":true,\"msg\":\"请先修改出厂默认密码\"}");
return;
}
}
if (StringUtils.isNotNull(loginUser) && StringUtils.isNull(SecurityUtils.getAuthentication()))
{
tokenService.verifyToken(loginUser);

View File

@@ -18,6 +18,8 @@ import com.fastbee.framework.manager.factory.AsyncFactory;
import com.fastbee.framework.security.context.AuthenticationContextHolder;
import com.fastbee.system.service.ISysConfigService;
import com.fastbee.system.service.ISysUserService;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.security.authentication.AuthenticationManager;
import org.springframework.security.authentication.BadCredentialsException;
@@ -27,6 +29,10 @@ import org.springframework.security.core.userdetails.UserDetails;
import org.springframework.stereotype.Component;
import javax.annotation.Resource;
import java.util.Arrays;
import java.util.Collections;
import java.util.HashSet;
import java.util.Set;
/**
* 登录校验方法
@@ -36,6 +42,17 @@ import javax.annotation.Resource;
@Component
public class SysLoginService
{
private static final Logger log = LoggerFactory.getLogger(SysLoginService.class);
/**
* 出厂默认口令。命中的账号视为未改密。
*
* <p>默认口令是规则引擎脚本注入类漏洞的前置条件:脚本编辑权限只有管理员角色持有,
* 而 admin/admin123 是开源仓库公开的默认值,攻击者无需任何凭据即可拿到管理员会话。</p>
*/
private static final Set<String> DEFAULT_PASSWORDS = Collections.unmodifiableSet(
new HashSet<>(Arrays.asList("admin123", "123456")));
@Autowired
private TokenService tokenService;
@@ -102,10 +119,28 @@ public class SysLoginService
LoginUser loginUser = (LoginUser) authentication.getPrincipal();
loginUser.setLanguage(language);
recordLoginInfo(loginUser.getUserId());
warnIfDefaultPassword(loginUser, password);
// 生成token
return tokenService.createToken(loginUser);
}
/**
* 判断登录使用的口令是否为出厂默认口令
*/
public boolean isDefaultPassword(String rawPassword)
{
return rawPassword != null && DEFAULT_PASSWORDS.contains(rawPassword);
}
private void warnIfDefaultPassword(LoginUser loginUser, String rawPassword)
{
if (!isDefaultPassword(rawPassword) || com.fastbee.common.utils.PasswordPolicy.isDemoAccount(loginUser))
{
return;
}
log.warn("账号 {} 使用出厂默认口令,当前会话仅允许完成改密和退出,请立即更换。", loginUser.getUsername());
}
/**
* 第三方验证后,调用登录方法
* @param username 用户名

View File

@@ -152,6 +152,7 @@ public class TokenService {
* @return 令牌
*/
public String createToken(LoginUser loginUser) {
com.fastbee.common.utils.PasswordPolicy.requiresChange(loginUser);
String token = IdUtils.fastUUID();
loginUser.setToken(token);
setUserAgent(loginUser);
@@ -198,6 +199,7 @@ public class TokenService {
}
public String createToken(LoginUser loginUser, String secret, int expireTime) {
com.fastbee.common.utils.PasswordPolicy.requiresChange(loginUser);
String token = IdUtils.fastUUID();
loginUser.setToken(token);
setUserAgent(loginUser);

View File

@@ -107,8 +107,9 @@ public class ScriptController extends BaseController
}
/**
* 删除规则引擎脚本
* 验证规则脚本
*/
@PreAuthorize("@ss.hasAnyPermi('iot:script:add,iot:script:edit')")
@PostMapping("/validate")
public AjaxResult validateScript(@RequestBody Script ruleScript)
{

View File

@@ -18,6 +18,20 @@
</properties>
<dependencies>
<dependency>
<groupId>org.codehaus.groovy</groupId>
<artifactId>groovy</artifactId>
</dependency>
<dependency>
<groupId>org.kohsuke</groupId>
<artifactId>groovy-sandbox</artifactId>
<version>1.34.2</version>
</dependency>
<dependency>
<groupId>junit</groupId>
<artifactId>junit</artifactId>
<scope>test</scope>
</dependency>
<dependency>
<groupId>com.yomahub</groupId>
<artifactId>liteflow-core</artifactId>
@@ -42,4 +56,10 @@
</dependencies>
<repositories>
<repository>
<id>jenkins-releases</id>
<url>https://repo.jenkins-ci.org/releases/</url>
</repository>
</repositories>
</project>

View File

@@ -1,5 +1,6 @@
package com.fastbee.ruleEngine.config;
import com.fastbee.ruleEngine.security.SandboxThreadPoolExecutor;
import com.yomahub.liteflow.thread.ExecutorBuilder;
import org.springframework.scheduling.concurrent.CustomizableThreadFactory;
@@ -7,9 +8,10 @@ import java.util.concurrent.*;
public class MainExecutorBuilder implements ExecutorBuilder {
private ThreadFactory springThreadFactory = new CustomizableThreadFactory("liteflow-main-");
@Override
public ExecutorService buildExecutor() {
return new ThreadPoolExecutor(
return new SandboxThreadPoolExecutor(
10,
30,
5,

View File

@@ -1,5 +1,6 @@
package com.fastbee.ruleEngine.config;
import com.fastbee.ruleEngine.security.SandboxThreadPoolExecutor;
import com.yomahub.liteflow.thread.ExecutorBuilder;
import org.springframework.scheduling.concurrent.CustomizableThreadFactory;
@@ -7,9 +8,10 @@ import java.util.concurrent.*;
public class WhenExecutorBuilder implements ExecutorBuilder {
private ThreadFactory springThreadFactory = new CustomizableThreadFactory("liteflow-when-");
@Override
public ExecutorService buildExecutor() {
return new ThreadPoolExecutor(
return new SandboxThreadPoolExecutor(
10,
30,
5,

View File

@@ -0,0 +1,175 @@
package com.fastbee.ruleEngine.security;
import com.fastbee.ruleEngine.context.MsgContext;
import groovy.lang.Closure;
import groovy.lang.GString;
import groovy.lang.Script;
import org.kohsuke.groovy.sandbox.GroovyInterceptor;
import java.lang.reflect.Method;
import java.util.*;
/** Default-deny capabilities: never grant a package, reflection or a Spring bean. */
final class BusinessScriptInterceptor extends GroovyInterceptor {
private static Set<String> names(String text) { return new HashSet<>(Arrays.asList(text.split(" "))); }
private static final Set<String> VALUES = names(
"java.lang.String java.lang.StringBuilder java.lang.StringBuffer java.lang.Boolean java.lang.Character " +
"java.lang.Byte java.lang.Short java.lang.Integer java.lang.Long java.lang.Float java.lang.Double " +
"java.math.BigDecimal java.math.BigInteger java.math.RoundingMode java.util.Date java.util.UUID " +
"java.util.ArrayList java.util.LinkedList java.util.HashMap java.util.LinkedHashMap java.util.TreeMap " +
"java.util.HashSet java.util.LinkedHashSet java.util.TreeSet cn.hutool.json.JSONObject cn.hutool.json.JSONArray");
private static final Set<String> DATA_METHODS = names(
"toString equals hashCode compareTo isCase asBoolean size length isEmpty contains containsKey containsValue " +
"get getAt getOrDefault put putAt putAll putIfAbsent add addAll set remove removeAll retainAll clear " +
"keySet values entrySet getKey getValue setValue iterator listIterator hasNext next hasPrevious previous " +
"each eachWithIndex forEach collect collectMany collectEntries find findAll findIndexOf findIndexValues findResult " +
"any every count countBy groupBy inject sum min max sort reverse unique flatten join take drop takeWhile dropWhile " +
"first last head tail init toList toSet toArray subList collate transpose intersect disjoint plus minus multiply " +
"div intdiv mod power abs negate positive negative next previous round setScale scale precision signum " +
"add subtract divide remainder pow movePointLeft movePointRight stripTrailingZeros toPlainString " +
"intValue longValue doubleValue floatValue shortValue byteValue booleanValue toInteger toLong toDouble " +
"toFloat toBigDecimal toBigInteger toBoolean isNumber isInteger isLong isDouble isBigDecimal " +
"trim strip toLowerCase toUpperCase startsWith endsWith indexOf lastIndexOf substring subSequence charAt " +
"replace replaceAll replaceFirst split tokenize matches findAll padLeft padRight capitalize " +
"uncapitalize center getBytes getChars toCharArray concat equalsIgnoreCase compareToIgnoreCase " +
"append insert delete deleteCharAt encodeBase64 decodeBase64 encodeHex decodeHex " +
"getTime setTime before after format getYear getMonth getDate getHours getMinutes getSeconds " +
"getStr getInt getLong getDouble getFloat getBool getBigDecimal getBigInteger getJSONObject getJSONArray " +
"getObj getByPath setOnce toJSONString toStringPretty toJsonStr toJsonPrettyStr upto downto times step withIndex");
private static final Set<String> CONTEXT_METHODS = names(
"getTopic setTopic getPayload setPayload getSerialNumber setSerialNumber getProductId setProductId " +
"getProtocolCode setProtocolCode getData setData hasData printlog placeholders getDataMap setDataMap");
private static final Set<String> CONTEXT_PROPERTIES = names("topic payload serialNumber productId protocolCode dataMap");
private static final Map<String, Set<String>> STATICS = new HashMap<>();
static {
STATICS.put("cn.hutool.json.JSONUtil", names("parse parseObj parseArray toJsonStr toJsonPrettyStr isTypeJSON isTypeJSONObject isTypeJSONArray quote escape wrap"));
STATICS.put("cn.hutool.core.util.NumberUtil", names("add sub mul div round roundStr isNumber isInteger isLong isDouble isBigDecimal compare equals parseInt parseLong parseDouble parseFloat toBigDecimal toBigInteger max min count range"));
STATICS.put("java.lang.Math", names("abs min max round ceil floor sqrt pow log log10 exp sin cos tan asin acos atan atan2 random signum toDegrees toRadians"));
STATICS.put("java.lang.System", names("currentTimeMillis nanoTime"));
STATICS.put("java.util.UUID", names("randomUUID fromString"));
STATICS.put("java.util.Arrays", names("asList toString deepToString copyOf copyOfRange equals deepEquals sort binarySearch"));
STATICS.put("java.util.Collections", names("emptyList emptyMap emptySet singleton singletonList singletonMap sort reverse min max frequency disjoint nCopies"));
STATICS.put("cn.hutool.http.HttpUtil", names("get post createGet createPost createRequest"));
STATICS.put("cn.hutool.http.HttpRequest", names("get post put delete patch head options"));
}
private final Class<? extends Script> scriptType;
private final Map<String, Object> variables;
private final Set<String> helpers = new HashSet<>();
BusinessScriptInterceptor(Class<? extends Script> scriptType, Map<String, Object> variables) {
this.scriptType = scriptType;
this.variables = variables;
for (Method m : scriptType.getDeclaredMethods()) {
if (!m.isSynthetic() && !names("run main getMetaClass setMetaClass invokeMethod getProperty setProperty").contains(m.getName())) helpers.add(m.getName());
}
}
private SecurityException denied(Object receiver, String operation) {
String type = receiver == null ? "null" : receiver instanceof Class ? ((Class<?>) receiver).getName() : receiver.getClass().getName();
return new SecurityException("规则脚本无此能力: " + type + "." + operation);
}
private boolean data(Object object) {
if (object == null) return false;
Class<?> c = object.getClass();
return VALUES.contains(c.getName()) || object instanceof GString || c.isArray()
|| (c.getClassLoader() == null && c.getName().startsWith("java.util.")
&& (object instanceof Collection || object instanceof Map || object instanceof Map.Entry || object instanceof Iterator))
|| c.getName().equals("groovy.lang.IntRange") || c.getName().equals("groovy.lang.EmptyRange");
}
private boolean staticAllowed(Class<?> type, String method) {
Set<String> methods = STATICS.get(type.getName());
if (type.getName().startsWith("cn.hutool.http.") && !ScriptSandbox.isNetworkAllowed()) return false;
return methods != null && methods.contains(method)
|| VALUES.contains(type.getName()) && names("valueOf parseInt parseLong parseDouble parseFloat parseBoolean parseByte parseShort toString format join").contains(method);
}
@Override public Object onMethodCall(Invoker invoker, Object receiver, String method, Object... args) throws Throwable {
checkArguments(args);
if (receiver instanceof Class) {
if (!staticAllowed((Class<?>) receiver, method)) throw denied(receiver, method);
} else if (receiver != null && receiver.getClass() == scriptType) {
if (!helpers.contains(method)) throw denied(receiver, method);
} else if (receiver instanceof Closure && receiver.getClass().getClassLoader() == scriptType.getClassLoader()) {
if (!names("call doCall curry rcurry ncurry memoize trampoline").contains(method)) throw denied(receiver, method);
} else if (receiver != null && receiver.getClass() == MsgContext.class) {
if ("getLogger".equals(method) && args.length == 0) return ScriptLog.INSTANCE;
if (!CONTEXT_METHODS.contains(method)) throw denied(receiver, method);
} else if (receiver == ScriptLog.INSTANCE) {
if (!names("debug info warn error trace isDebugEnabled isInfoEnabled isWarnEnabled isErrorEnabled isTraceEnabled").contains(method)) throw denied(receiver, method);
} else if (receiver == ScriptConsole.OUT || receiver == ScriptConsole.ERR) {
if (!names("print println printf format").contains(method)) throw denied(receiver, method);
} else if (receiver != null && receiver == variables.get("sceneContext")
&& receiver.getClass().getName().equals("com.fastbee.mq.ruleEngine.SceneContext")) {
if (!"process".equals(method) || args.length != 1 || !(args[0] instanceof String)) throw denied(receiver, method);
} else if (receiver != null && receiver.getClass().getName().equals("cn.hutool.http.HttpRequest")) {
if (!ScriptSandbox.isNetworkAllowed() || !names("header body form timeout setConnectionTimeout setReadTimeout execute").contains(method)) throw denied(receiver, method);
} else if (receiver != null && receiver.getClass().getName().equals("cn.hutool.http.HttpResponse")) {
if (!names("body bodyBytes getStatus isOk header headers close").contains(method)) throw denied(receiver, method);
} else {
if (!data(receiver) || !DATA_METHODS.contains(method)) throw denied(receiver, method);
if (("getAt".equals(method) || "putAt".equals(method)) && args.length > 0) checkIndex(receiver, args[0]);
}
return super.onMethodCall(invoker, receiver, method, args);
}
@Override public Object onStaticCall(Invoker invoker, Class receiver, String method, Object... args) throws Throwable {
checkArguments(args);
if (!staticAllowed(receiver, method)) throw denied(receiver, method);
return super.onStaticCall(invoker, receiver, method, args);
}
@Override public Object onNewInstance(Invoker invoker, Class receiver, Object... args) throws Throwable {
checkArguments(args);
if (!VALUES.contains(receiver.getName())) throw denied(receiver, "new");
return super.onNewInstance(invoker, receiver, args);
}
@Override public Object onSuperCall(Invoker invoker, Class sender, Object receiver, String method, Object... args) { throw denied(receiver, "super." + method); }
@Override public void onSuperConstructor(Invoker invoker, Class receiver, Object... args) { throw denied(receiver, "super"); }
@Override public Object onGetProperty(Invoker invoker, Object receiver, String property) throws Throwable {
// Keep existing rule scripts working without handing them the shared JVM streams.
if (receiver == System.class && "out".equals(property)) return ScriptConsole.OUT;
if (receiver == System.class && "err".equals(property)) return ScriptConsole.ERR;
if (receiver != null && receiver.getClass() == MsgContext.class && "logger".equals(property)) return ScriptLog.INSTANCE;
if (receiver != null && receiver.getClass() == scriptType && variables.containsKey(property)) return variables.get(property);
if (receiver instanceof Map) return ((Map<?, ?>) receiver).get(property);
if (receiver != null && receiver.getClass() == MsgContext.class && CONTEXT_PROPERTIES.contains(property)) return super.onGetProperty(invoker, receiver, property);
if (receiver instanceof Class && VALUES.contains(((Class<?>) receiver).getName())) {
java.lang.reflect.Field field = ((Class<?>) receiver).getField(property);
if (java.lang.reflect.Modifier.isStatic(field.getModifiers()) && java.lang.reflect.Modifier.isFinal(field.getModifiers())
&& (field.getType().isPrimitive() || field.getType() == String.class || field.getType().isEnum())) return field.get(null);
}
if (data(receiver) && names("size length bytes empty time").contains(property)) return super.onGetProperty(invoker, receiver, property);
throw denied(receiver, property);
}
@Override public Object onSetProperty(Invoker invoker, Object receiver, String property, Object value) throws Throwable {
if (receiver instanceof Map) { ((Map) receiver).put(property, value); return value; }
if (receiver != null && receiver.getClass() == MsgContext.class && CONTEXT_PROPERTIES.contains(property)) return super.onSetProperty(invoker, receiver, property, value);
throw denied(receiver, property);
}
@Override public Object onGetAttribute(Invoker invoker, Object receiver, String attribute) { throw denied(receiver, attribute); }
@Override public Object onSetAttribute(Invoker invoker, Object receiver, String attribute, Object value) { throw denied(receiver, attribute); }
private void checkIndex(Object receiver, Object index) {
if (receiver instanceof Map) return;
if (!data(receiver) || !(index instanceof Number || index instanceof groovy.lang.Range || index instanceof Collection)) throw denied(receiver, "index");
if (index instanceof Collection) for (Object i : (Collection<?>) index) if (!(i instanceof Number)) throw denied(receiver, "index");
}
private void checkArguments(Object[] args) {
Set<Object> visited = Collections.newSetFromMap(new IdentityHashMap<Object, Boolean>());
for (Object arg : args) checkArgument(arg, visited);
}
private void checkArgument(Object arg, Set<Object> visited) {
if (arg == null || !visited.add(arg)) return;
if (arg instanceof Class) {
Class<?> type = (Class<?>) arg;
if (!VALUES.contains(type.getName()) && !type.isPrimitive()) throw denied(type, "argument type");
} else if (arg instanceof Map) {
for (Object entry : ((Map<?, ?>) arg).entrySet()) {
checkArgument(((Map.Entry<?, ?>) entry).getKey(), visited);
checkArgument(((Map.Entry<?, ?>) entry).getValue(), visited);
}
} else if (arg instanceof Collection) {
for (Object value : (Collection<?>) arg) checkArgument(value, visited);
} else if (arg instanceof Object[]) {
for (Object value : (Object[]) arg) checkArgument(value, visited);
} else if (!data(arg) && arg.getClass() != MsgContext.class
&& !(arg instanceof Closure && arg.getClass().getClassLoader() == scriptType.getClassLoader())) {
throw denied(arg, "argument");
}
}
@Override public Object onGetArray(Invoker invoker, Object receiver, Object index) throws Throwable { checkIndex(receiver, index); return super.onGetArray(invoker, receiver, index); }
@Override public Object onSetArray(Invoker invoker, Object receiver, Object index, Object value) throws Throwable { checkIndex(receiver, index); return super.onSetArray(invoker, receiver, index, value); }
}

View File

@@ -0,0 +1,21 @@
package com.fastbee.ruleEngine.security;
import java.util.concurrent.BlockingQueue;
import java.util.concurrent.ThreadFactory;
import java.util.concurrent.ThreadPoolExecutor;
import java.util.concurrent.TimeUnit;
/** Compatibility executor; each script invocation installs its own mandatory interceptor. */
public class SandboxThreadPoolExecutor extends ThreadPoolExecutor {
public SandboxThreadPoolExecutor(int corePoolSize, int maximumPoolSize, long keepAliveTime,
TimeUnit unit, BlockingQueue<Runnable> workQueue,
ThreadFactory threadFactory) {
super(corePoolSize, maximumPoolSize, keepAliveTime, unit, workQueue, threadFactory);
}
@Override
public void execute(Runnable command) {
super.execute(ScriptSandbox.propagate(command));
}
}

View File

@@ -0,0 +1,24 @@
package com.fastbee.ruleEngine.security;
/** Legacy console syntax backed by the script logger, never a JVM PrintStream. */
public final class ScriptConsole {
static final ScriptConsole OUT = new ScriptConsole(false);
static final ScriptConsole ERR = new ScriptConsole(true);
private final boolean error;
private ScriptConsole(boolean error) { this.error = error; }
private void write(String message) {
if (error) ScriptLog.INSTANCE.error(message);
else ScriptLog.INSTANCE.info(message);
}
public void print(Object value) { write(String.valueOf(value)); }
public void println(Object value) { print(value); }
public void println() { write(""); }
public ScriptConsole printf(String format, Object... args) {
write(String.format(format, args));
return this;
}
public ScriptConsole format(String format, Object... args) { return printf(format, args); }
}

View File

@@ -0,0 +1,18 @@
package com.fastbee.ruleEngine.security;
/** Preserve msgContext.logger without exposing Logback configuration or its application context. */
public final class ScriptLog {
static final ScriptLog INSTANCE = new ScriptLog();
private static final org.slf4j.Logger LOG = org.slf4j.LoggerFactory.getLogger("script");
private ScriptLog() {}
public void debug(String message, Object... args) { LOG.debug(message, args); }
public void info(String message, Object... args) { LOG.info(message, args); }
public void warn(String message, Object... args) { LOG.warn(message, args); }
public void error(String message, Object... args) { LOG.error(message, args); }
public void trace(String message, Object... args) { LOG.trace(message, args); }
public boolean isDebugEnabled() { return LOG.isDebugEnabled(); }
public boolean isInfoEnabled() { return LOG.isInfoEnabled(); }
public boolean isWarnEnabled() { return LOG.isWarnEnabled(); }
public boolean isErrorEnabled() { return LOG.isErrorEnabled(); }
public boolean isTraceEnabled() { return LOG.isTraceEnabled(); }
}

View File

@@ -0,0 +1,23 @@
package com.fastbee.ruleEngine.security;
import org.springframework.beans.factory.annotation.Value;
import org.springframework.stereotype.Component;
import javax.annotation.PostConstruct;
import java.util.function.Supplier;
/** Compatibility facade. The mandatory compiler and per-invocation interceptor enforce security. */
@Component
public final class ScriptSandbox {
private static volatile boolean networkAllowed = true;
@Value("${script.sandbox.enabled:true}") private boolean enabled = true;
@Value("${script.sandbox.allow-network:true}") private boolean allowNetwork = true;
@PostConstruct public void init() {
if (!enabled) throw new IllegalStateException("不允许关闭规则脚本安全校验");
networkAllowed = allowNetwork;
}
static boolean isNetworkAllowed() { return networkAllowed; }
public static boolean isEnforced() { return true; }
public static Runnable propagate(Runnable command) { return command; }
public <T> T call(Supplier<T> action) { return action.get(); }
public void run(Runnable action) { action.run(); }
}

View File

@@ -0,0 +1,129 @@
package com.fastbee.ruleEngine.security;
import com.yomahub.liteflow.enums.ScriptTypeEnum;
import com.yomahub.liteflow.script.ScriptExecuteWrap;
import com.yomahub.liteflow.script.ScriptExecutor;
import groovy.lang.Binding;
import groovy.lang.GroovyClassLoader;
import groovy.lang.Script;
import org.codehaus.groovy.ast.*;
import org.codehaus.groovy.classgen.GeneratorContext;
import org.codehaus.groovy.control.*;
import org.codehaus.groovy.control.customizers.CompilationCustomizer;
import org.codehaus.groovy.control.customizers.ImportCustomizer;
import org.codehaus.groovy.runtime.InvokerHelper;
import org.kohsuke.groovy.sandbox.SandboxTransformer;
import java.io.BufferedReader;
import java.io.InputStreamReader;
import java.net.URL;
import java.nio.charset.StandardCharsets;
import java.util.*;
import java.util.concurrent.ConcurrentHashMap;
/** The only LiteFlow script SPI: validation, SQL startup loading and hot reload share this compiler. */
public final class SecureGroovyScriptExecutor extends ScriptExecutor {
private static final SecureGroovyScriptExecutor SHARED = new SecureGroovyScriptExecutor();
private final Map<String, Class<? extends Script>> scripts = new ConcurrentHashMap<>();
// LiteFlow keeps separate language/default-language SPI keys; they must share reload/unload state.
@Override public ScriptExecutor init() { return SHARED; }
@Override public ScriptTypeEnum scriptType() { return ScriptTypeEnum.GROOVY; }
@Override public void load(String nodeId, String source) { scripts.put(nodeId, compileSource(source)); }
public void install(String nodeId, Class<? extends Script> type) { scripts.put(nodeId, type); }
@Override public void unLoad(String nodeId) { scripts.remove(nodeId); }
@Override public List<String> getNodeIds() { return new ArrayList<>(scripts.keySet()); }
@Override public void cleanCache() { scripts.clear(); }
@Override public Object compile(String source) { return compileSource(source); }
@Override public Object executeScript(ScriptExecuteWrap wrap) {
Class<? extends Script> type = scripts.get(wrap.getNodeId());
if (type == null) throw new IllegalStateException("Script is disabled or not loaded: " + wrap.getNodeId());
Map<String, Object> variables = new HashMap<>();
bindParam(wrap, variables::put, variables::putIfAbsent);
return evaluate(type, variables);
}
public static Object evaluate(Class<? extends Script> type, Map<String, Object> variables) {
BusinessScriptInterceptor policy = new BusinessScriptInterceptor(type, variables);
policy.register();
try {
// Register BEFORE construction: field initializers are untrusted code too.
return InvokerHelper.createScript(type, new Binding(new HashMap<>(variables))).run();
} finally {
policy.unregister();
}
}
@SuppressWarnings("unchecked")
public static Class<? extends Script> compileSource(String source) {
if (source == null || source.trim().isEmpty()) throw new IllegalArgumentException("脚本内容不能为空");
if (source.length() > 262144) throw new IllegalArgumentException("脚本不能超过 256 KiB");
CompilerConfiguration config = new CompilerConfiguration();
// No dependency downloads or classpath-provided global compilation callbacks.
Set<String> disabled = new HashSet<>();
disabled.add("groovy.grape.GrabAnnotationTransformation");
try {
Enumeration<URL> resources = SecureGroovyScriptExecutor.class.getClassLoader().getResources(
"META-INF/services/org.codehaus.groovy.transform.ASTTransformation");
while (resources.hasMoreElements()) {
try (BufferedReader reader = new BufferedReader(new InputStreamReader(
resources.nextElement().openStream(), StandardCharsets.UTF_8))) {
String line;
while ((line = reader.readLine()) != null) {
line = line.split("#", 2)[0].trim();
if (!line.isEmpty()) disabled.add(line);
}
}
}
} catch (java.io.IOException e) { throw new IllegalStateException("Cannot establish compiler policy", e); }
config.setDisabledGlobalASTTransformations(disabled);
ImportCustomizer imports = new ImportCustomizer();
imports.addImports("cn.hutool.json.JSONArray", "cn.hutool.json.JSONObject",
"cn.hutool.json.JSONUtil", "cn.hutool.core.util.NumberUtil");
config.addCompilationCustomizers(new SourceBoundary(), imports, new SandboxTransformer() {
@Override public void processConstructors(ClassCodeExpressionTransformer visitor, ClassNode node) {
// Groovy 3 synthesizes Script(Binding) before this phase. Transforming that
// trusted constructor with the Groovy 2 adapter generates invalid bytecode.
// Only compiler-generated constructors of the fixed Script base can skip it.
if (!node.isScript() || !"groovy.lang.Script".equals(node.getSuperClass().getName()))
throw new SecurityException("Unsupported script base class");
for (ConstructorNode constructor : node.getDeclaredConstructors())
if (constructor.getLineNumber() >= 0) throw new SecurityException("Custom constructors are forbidden");
}
});
try (GroovyClassLoader loader = new GroovyClassLoader(
SecureGroovyScriptExecutor.class.getClassLoader(), config)) {
return (Class<? extends Script>) loader.parseClass(source);
} catch (java.io.IOException e) { throw new IllegalStateException(e); }
}
/** Runs before semantic analysis, hence before local AST transforms such as ASTTest. */
private static final class SourceBoundary extends CompilationCustomizer {
SourceBoundary() { super(CompilePhase.CONVERSION); }
@Override public void call(SourceUnit source, GeneratorContext context, ClassNode node) {
if (!node.isScript() || source.getAST().getPackage() != null) {
throw new SecurityException("规则脚本不允许声明类或包");
}
ClassCodeVisitorSupport visitor = new ClassCodeVisitorSupport() {
@Override protected SourceUnit getSourceUnit() { return source; }
@Override public void visitAnnotations(AnnotatedNode annotated) {
if (!annotated.getAnnotations().isEmpty()) {
throw new SecurityException("规则脚本不允许注解或编译期转换");
}
}
};
for (MethodNode method : node.getMethods()) {
if (method.getLineNumber() > 0 && Arrays.asList("getMetaClass", "setMetaClass", "getProperty",
"setProperty", "invokeMethod", "getBinding", "setBinding", "finalize").contains(method.getName())) {
throw new SecurityException("不能覆盖脚本运行时基础方法");
}
}
visitor.visitClass(node);
for (ImportNode n : source.getAST().getImports()) visitor.visitAnnotations(n);
for (ImportNode n : source.getAST().getStarImports()) visitor.visitAnnotations(n);
for (ImportNode n : source.getAST().getStaticImports().values()) visitor.visitAnnotations(n);
for (ImportNode n : source.getAST().getStaticStarImports().values()) visitor.visitAnnotations(n);
}
}
}

View File

@@ -0,0 +1 @@
com.fastbee.ruleEngine.security.SecureGroovyScriptExecutor

View File

@@ -0,0 +1,163 @@
package com.fastbee.ruleEngine.security;
import com.fastbee.ruleEngine.context.MsgContext;
import com.yomahub.liteflow.script.ScriptExecutor;
import org.junit.Test;
import java.util.*;
import static org.junit.Assert.*;
public class SecureGroovyScriptExecutorTest {
private Object run(String source) { return run(source, Collections.emptyMap()); }
private Object run(String source, Map<String, Object> values) {
return SecureGroovyScriptExecutor.evaluate(SecureGroovyScriptExecutor.compileSource(source), values);
}
private void rejected(String source) {
try { run(source); fail("Unsafe script accepted: " + source); }
catch (Exception expected) { /* Compile rejection or capability denial, never script side effects. */ }
}
@Test public void helperMethodsClosuresLoopsAndKeywordsInText() {
assertEquals(12, run("def twice(x) { x * 2 }; def total = 0; [1,2,3].each { total += twice(it) }; assert 'ProcessBuilder' == 'ProcessBuilder'; return total"));
}
@Test public void jsonAndMutableMessageContext() {
MsgContext message = MsgContext.builder().topic("/1/device/property/post").payload("{\"value\":2}").build();
Object result = run("def obj = JSONUtil.parseObj(msgContext.getPayload()); obj.set('value', NumberUtil.mul(obj.getInt('value'), 3)); msgContext.setData('v', obj.getInt('value')); msgContext.setTopic('/1/device/property/post'); msgContext.setPayload(obj.toString()); return msgContext.getData('v')", Collections.singletonMap("msgContext", message));
assertEquals(6, ((Number) result).intValue());
assertTrue(message.getPayload().contains("6"));
}
@Test public void databaseSeedMessageForwardingConvertsObjectPayload() throws Exception {
String source;
try (java.io.InputStream input = getClass().getResourceAsStream("/database-message-forwarding.groovy")) {
assertNotNull(input);
source = new String(readBytes(input), java.nio.charset.StandardCharsets.UTF_8);
}
MsgContext message = MsgContext.builder().topic("/41/D1ELV3A5TOJS/property/post")
.productId(41L).serialNumber("D1ELV3A5TOJS").protocolCode("JSON").payload("{\"co2\":666}").build();
run(source, Collections.singletonMap("msgContext", message));
assertEquals("/41/D1ELV3A5TOJS/property/post", message.getTopic());
cn.hutool.json.JSONArray values = cn.hutool.json.JSONUtil.parseArray(message.getPayload());
assertEquals(1, values.size());
assertEquals("co2", values.getJSONObject(0).getStr("id"));
assertEquals("666", values.getJSONObject(0).getStr("value"));
}
@Test public void safeConsoleLoggingDoesNotExposeJvmStreams() {
java.io.PrintStream originalOut = System.out, originalErr = System.err;
assertEquals(7, run("System.out.println('legacy log'); System.err.println('legacy warning'); "
+ "System.out.print('value='); System.out.printf('%s=%d', 'co2', 666); return 7"));
rejected("System.out.close()");
rejected("System.err.close()");
rejected("System.out.getClass()");
rejected("System.out.class");
rejected("System.setOut(System.out)");
rejected("System.setErr(System.err)");
rejected("System.in.read()");
assertSame(originalOut, System.out); assertSame(originalErr, System.err);
}
@Test public void javaCollectionForEachCallbacksRemainSandboxed() {
assertEquals(Arrays.asList(2, 4), run("def result=[]; [1,2].forEach(value -> { result.add(value * 2) }); return result"));
System.clearProperty("fastbee.security.callback");
rejected("[1].forEach(value -> { System.setProperty('fastbee.security.callback','bad') })");
assertNull(System.getProperty("fastbee.security.callback"));
}
@Test public void annotationsCannotRunDuringCompilation() {
System.clearProperty("fastbee.security.ast");
rejected("@groovy.transform.ASTTest(value={ System.setProperty('fastbee.security.ast','executed') }) def x = 1");
assertNull(System.getProperty("fastbee.security.ast"));
rejected("@groovy.lang.Grab('invalid:artifact:0') def x = 1");
rejected("@groovy.transform.CompileStatic def x() { return 1 }; x()");
}
@Test public void commandApisAndSandboxSwitchAreDenied() {
rejected("['cmd.exe','/d','/c','echo FASTBEE_SECURITY_TEST'].execute()");
rejected("Runtime.getRuntime().exec('echo FASTBEE_SECURITY_TEST')");
rejected("new ProcessBuilder('echo','FASTBEE_SECURITY_TEST').start()");
rejected("com.fastbee.ruleEngine.security.ScriptSandbox.exit()");
rejected("org.kohsuke.groovy.sandbox.GroovyInterceptor.getApplicableInterceptors().clear()");
}
@Test public void reflectionFilesThreadsAndNestedEnginesAreDenied() {
rejected("Class.forName('java.lang.Runtime')");
rejected("''.getClass().forName('java.lang.Runtime')");
rejected("''.metaClass");
rejected("this.binding");
rejected("''.getAt('class')");
rejected("new File('fastbee-security-no-such-file').text");
rejected("new Thread({}).start()");
rejected("new GroovyShell().evaluate('1')");
rejected("def f = Class.&forName; f('java.lang.Runtime')");
}
@Test public void nestedPropertyAndArrayDataStillWork() {
assertEquals("OK", run("def x=[a:[1,2,3]]; assert x.a[1] == 2; x.a[1]=4; assert x.a.sum()==8; return 'ok'.toUpperCase()"));
}
@Test public void spiHasNoUnrestrictedFallback() {
List<ScriptExecutor> executors = new ArrayList<>();
ServiceLoader.load(ScriptExecutor.class).forEach(executors::add);
assertEquals(1, executors.size());
assertEquals(SecureGroovyScriptExecutor.class, executors.get(0).getClass());
}
@Test public void compilationDoesNotPublishAndDisabledNodesUnload() {
SecureGroovyScriptExecutor executor = new SecureGroovyScriptExecutor();
executor.compile("return 1");
assertTrue(executor.getNodeIds().isEmpty());
executor.load("Dtest", "return 2");
assertEquals(Collections.singletonList("Dtest"), executor.getNodeIds());
executor.unLoad("Dtest");
assertTrue(executor.getNodeIds().isEmpty());
}
@Test public void unregisteredExecutionFailsClosed() {
try {
SecureGroovyScriptExecutor.compileSource("return [1,2].sum()").newInstance().run();
fail("Missing interceptor accepted");
} catch (Exception expected) { }
}
@Test public void httpIntegrationRetainsExecuteMethod() throws Exception {
com.sun.net.httpserver.HttpServer server = com.sun.net.httpserver.HttpServer.create(new java.net.InetSocketAddress("127.0.0.1", 0), 0);
server.createContext("/test", exchange -> { byte[] body = "ok".getBytes(java.nio.charset.StandardCharsets.UTF_8); exchange.sendResponseHeaders(200, body.length); exchange.getResponseBody().write(body); exchange.close(); });
server.start();
try {
assertEquals("ok", run("return cn.hutool.http.HttpRequest.get('http://127.0.0.1:" + server.getAddress().getPort() + "/test').timeout(2000).execute().body()"));
} finally { server.stop(0); }
}
@Test public void originalFrontendTemplateAndLoggerAreCompatible() throws Exception {
String source;
try (java.io.InputStream input = getClass().getResourceAsStream("/default-data-flow.groovy")) {
assertNotNull(input);
source = new String(readBytes(input), java.nio.charset.StandardCharsets.UTF_8);
}
MsgContext message = MsgContext.builder().topic("/41/device/property/post").payload("{\"temperature\":23}").build();
run(source, Collections.singletonMap("msgContext", message));
assertEquals("/41/device/property/post", message.getTopic());
assertEquals("{\"temperature\":23}", message.getPayload());
try { run("msgContext.logger.getLoggerContext()", Collections.singletonMap("msgContext", message)); fail(); }
catch (SecurityException expected) { }
}
private byte[] readBytes(java.io.InputStream input) throws Exception {
java.io.ByteArrayOutputStream output = new java.io.ByteArrayOutputStream();
byte[] buffer = new byte[4096]; int size;
while ((size = input.read(buffer)) != -1) output.write(buffer, 0, size);
return output.toByteArray();
}
@Test public void unsafeConversionAndMetaprogrammingAreDenied() {
rejected("def j=JSONUtil.parseObj('{}'); j.get('x', ProcessBuilder)");
rejected("def getMetaClass() { return null }; 1");
rejected("System.setSecurityManager(null)");
rejected("def x = 'java.lang.Runtime' as Class; x.getRuntime()");
rejected("def a = ['cmd.exe']; a.('exe'+'cute')()");
rejected("class Unsafe { static x=System.setProperty('fastbee.security.ast','bad') }; 1");
}
@Test public void actualLiteflowSequentialParallelAndAsyncPathsAreProtected() throws Exception {
com.yomahub.liteflow.property.LiteflowConfig config=new com.yomahub.liteflow.property.LiteflowConfig();
com.yomahub.liteflow.core.FlowExecutor executor=new com.yomahub.liteflow.core.FlowExecutor(config);
com.yomahub.liteflow.builder.LiteFlowNodeBuilder.createScriptNode().setId("DsafeA").setLanguage("groovy").setScript("msgContext.setData('a',1)").build();
com.yomahub.liteflow.builder.LiteFlowNodeBuilder.createScriptNode().setId("DsafeB").setLanguage("groovy").setScript("msgContext.setData('b',2)").build();
com.yomahub.liteflow.builder.el.LiteFlowChainELBuilder.createChain().setChainName("securityParallel").setEL("WHEN(DsafeA,DsafeB)").build();
MsgContext context=new MsgContext();
com.yomahub.liteflow.flow.LiteflowResponse response=executor.execute2Future("securityParallel",null,context).get(10,java.util.concurrent.TimeUnit.SECONDS);
assertTrue(response.getMessage(),response.isSuccess()); assertEquals(Integer.valueOf(1),context.getData("a")); assertEquals(Integer.valueOf(2),context.getData("b"));
com.yomahub.liteflow.builder.LiteFlowNodeBuilder.createScriptNode().setId("Dunsafe").setLanguage("groovy").setScript("System.setProperty('fastbee.security.liteflow','bad')").build();
com.yomahub.liteflow.builder.el.LiteFlowChainELBuilder.createChain().setChainName("securityUnsafe").setEL("THEN(Dunsafe)").build();
System.clearProperty("fastbee.security.liteflow");
assertFalse(executor.execute2Resp("securityUnsafe",null,new MsgContext()).isSuccess());
assertNull(System.getProperty("fastbee.security.liteflow"));
com.yomahub.liteflow.builder.el.LiteFlowChainELBuilder.createChain().setChainName("securityUnsafeParallel").setEL("WHEN(DsafeA,Dunsafe)").build();
assertFalse(executor.execute2Future("securityUnsafeParallel",null,new MsgContext()).get(10,java.util.concurrent.TimeUnit.SECONDS).isSuccess());
assertNull(System.getProperty("fastbee.security.liteflow"));
}
}

View File

@@ -0,0 +1,47 @@
import cn.hutool.json.JSONArray;
import cn.hutool.json.JSONObject;
import cn.hutool.json.JSONUtil;
import cn.hutool.core.util.NumberUtil;
//系统主题
String sysTopic = '';
//系统数据格式
String sysPayload = '';
// 1. 获取主题和内容
String name = msgContext.getTopic();
Long productId = msgContext.getProductId();
String serialNumber = msgContext.getSerialNumber();
String protocolCode = msgContext.getProtocolCode();
String payload = msgContext.getPayload();
System.out.println("产品id/协议编号:" + productId + " / " + protocolCode);
// 2. 转换为系统主题 /96/D1ELV3A5TOJS/property/post
sysTopic = "/" + productId + "/" + serialNumber + "/property/post"
if("JSON".equals(protocolCode)){
// 3. 内容格式转换
JSONArray newArray = new JSONArray();
JSONObject jsonObject = JSONUtil.parseObj(payload);
jsonObject.keySet().forEach(key -> {
JSONObject newObject =new JSONObject();
newObject.put("id" , key);
newObject.put("value" , jsonObject.getStr(key));
newArray.add(newObject);
});
sysPayload = newArray.toString();
}else{
//其他协议处理
}
// 4.打印
System.out.println("新主题:" + sysTopic);
System.out.println("新内容:" + sysPayload);
// 5. 返回新的数据(必要)
msgContext.setTopic(sysTopic);
msgContext.setPayload(sysPayload);

View File

@@ -0,0 +1,19 @@
import cn.hutool.json.JSONArray;
import cn.hutool.json.JSONObject;
import cn.hutool.json.JSONUtil;
import cn.hutool.core.util.NumberUtil;
// 1. 获取主题和内容(必要)
String topic = msgContext.getTopic();
String payload = msgContext.getPayload();
// 2. 数据转换(自己处理)
msgContext.logger.debug("数据转换处理")
String NewTopic = topic;
String NewPayload = payload;
// 3. 返回新的数据(必要)
msgContext.setTopic(NewTopic);
msgContext.setPayload(NewPayload);

View File

@@ -1,6 +1,7 @@
package com.fastbee.base.session;
import com.fastbee.common.core.protocol.Message;
import com.fastbee.common.core.domain.model.MqttClientPrincipal;
import com.fastbee.common.enums.ServerType;
import com.fastbee.common.exception.ServiceException;
import com.fastbee.common.utils.DateUtils;
@@ -80,6 +81,9 @@ public class Session {
/*服务协议类型 MQTT,TCP UDP COAP*/
private ServerType serverType;
/*认证主体,记录这条连接代表哪个平台用户,供 PUBLISH 阶段做设备归属校验*/
private MqttClientPrincipal principal;
public Session(){
}

View File

@@ -0,0 +1,69 @@
package com.fastbee.mqtt.acl;
import com.alibaba.fastjson2.JSON;
import com.alibaba.fastjson2.JSONArray;
import com.alibaba.fastjson2.JSONObject;
import com.fastbee.common.core.domain.entity.SysUser;
import com.fastbee.common.core.domain.model.MqttClientPrincipal;
import com.fastbee.iot.domain.Device;
import com.fastbee.iot.domain.DeviceUser;
import com.fastbee.iot.mapper.DeviceMapper;
import com.fastbee.iot.mapper.DeviceUserMapper;
import com.fastbee.mqtt.auth.AuthService;
import org.springframework.stereotype.Component;
import javax.annotation.Resource;
import java.util.*;
/** Reuse the platform's owner, tenant and shared-model permissions, with live login state. */
@Component
public class MqttAccessControl {
@Resource private AuthService authService;
@Resource private DeviceMapper deviceMapper;
@Resource private DeviceUserMapper deviceUserMapper;
public boolean canPublish(MqttClientPrincipal principal, String topic) { return canPublish(principal, topic, null); }
public boolean canPublish(MqttClientPrincipal principal, String topic, String payload) { return allowed(principal, topic, payload, false); }
public boolean canSubscribe(MqttClientPrincipal principal, String topic) { return allowed(principal, topic, null, true); }
private boolean allowed(MqttClientPrincipal principal, String topic, String payload, boolean subscribe) {
if (principal == null || principal.getClientType() == null) return false;
if (!principal.isWeb()) return true;
try {
SysUser user = authService.currentWebUser(principal);
if (user == null || topic == null) return false;
if (user.isAdmin()) return subscribe || topic.indexOf('#') < 0 && topic.indexOf('+') < 0;
String[] parts = topic.split("/", -1);
if (parts.length < 4 || !parts[0].isEmpty() || parts[1].isEmpty() || parts[2].isEmpty()
|| parts[1].contains("+") || parts[1].contains("#") || parts[2].contains("+") || parts[2].contains("#")) return false;
if (!subscribe && (topic.contains("+") || topic.contains("#"))) return false;
Device device = deviceMapper.selectDeviceBySerialNumber(parts[2]);
if (device == null || !String.valueOf(device.getProductId()).equals(parts[1])) return false;
if (Objects.equals(device.getUserId(), user.getUserId())) return true;
if (Objects.equals(device.getTenantId(), user.getUserId()) && user.getRoles() != null
&& user.getRoles().stream().anyMatch(r -> "tenant".equals(r.getRoleKey()))) return true;
DeviceUser share = deviceUserMapper.selectDeviceUserByDeviceIdAndUserId(device.getDeviceId(), user.getUserId());
if (share == null || "2".equals(share.getDelFlag())) return false;
if (Integer.valueOf(1).equals(share.getIsOwner())) return true;
// Viewing a shared device retains its established subscription behavior.
if (subscribe) return true;
Set<String> permissions = new HashSet<>();
if (share.getPerms() != null) for (String p : share.getPerms().split(",")) permissions.add(p.trim());
String suffix = String.join("/", Arrays.copyOfRange(parts, 3, parts.length));
if ("monitor/get".equals(suffix)) return permissions.contains("monitor");
if (!"property/get".equals(suffix) && !"function/get".equals(suffix)) return false;
if (payload == null) return false;
Object body = JSON.parse(payload);
JSONArray values = body instanceof JSONArray ? (JSONArray) body : new JSONArray(Collections.singletonList(body));
if (values.isEmpty()) return false;
for (Object value : values) {
if (!(value instanceof JSONObject)) return false;
String identifier = ((JSONObject) value).getString("id");
if (identifier == null || !permissions.contains(identifier)) return false;
}
return true;
} catch (RuntimeException e) {
// Malformed topics, revoked state or unavailable authorization storage must fail closed.
return false;
}
}
}

View File

@@ -1,6 +1,10 @@
package com.fastbee.mqtt.auth;
import com.fastbee.common.constant.CacheConstants;
import com.fastbee.common.constant.Constants;
import com.fastbee.common.core.domain.entity.SysUser;
import com.fastbee.common.core.domain.model.LoginUser;
import com.fastbee.common.core.domain.model.MqttClientPrincipal;
import com.fastbee.common.core.redis.RedisCache;
import com.fastbee.common.exception.ServiceException;
import com.fastbee.common.utils.StringUtils;
@@ -33,6 +37,8 @@ public class AuthService {
private RedisCache redisCache;
@Resource
private MqttClientConfig mqttConfig;
@Resource
private com.fastbee.system.service.ISysUserService userService;
// 令牌秘钥
@Value("${token.secret}")
private String secret;
@@ -41,57 +47,96 @@ public class AuthService {
/**
* MQTT客户端认证
*
* <p>web / phone 客户端使用平台登录 JWT 作为 MQTT 密码。此处不能只做签名校验:
* 必须回查 Redis 中的登录态并取出 userId,一方面让已注销/已过期的令牌立即失效,
* 另一方面让 PUBLISH 阶段能校验该用户对目标设备的归属权限。</p>
*
* @param clientId 客户端id
* @param username 用户名
* @param password 密码
* @return 结果
* @return 认证主体,认证失败返回 null
*/
public boolean auth(String clientId, String username, String password) {
public MqttClientPrincipal authenticate(String clientId, String username, String password) {
if (StringUtils.isEmpty(clientId) || StringUtils.isEmpty(username) || StringUtils.isEmpty(password)) {
log.error("=>客户端参数缺少,clientId:{},username:{},password:{}", clientId, username, password);
return false;
log.error("=>客户端参数缺少,clientId:{}", clientId);
return null;
}
try {
if (clientId.startsWith("server")) {
// 服务端认证:配置的账号密码认证
if (mqttConfig.getUsername().equals(username) && mqttConfig.getPassword().equals(password)) {
log.info("-----------服务端mqtt认证成功,clientId:" + clientId + "---------------");
return true;
return MqttClientPrincipal.of(clientId, MqttClientPrincipal.ClientType.SERVER);
} else {
ResponseEntity response = toolService.returnUnauthorized(new MqttAuthenticationModel(clientId, username, password), "mqtt账号和密码与认证服务器配置不匹配");
log.warn("=>服务端认证失败[{}]", response.getBody());
throw new ServiceException("服务端认证失败:"+response.getBody());
throw new ServiceException("服务端认证失败:" + response.getBody());
}
} else if (clientId.startsWith("web") || clientId.startsWith("phone")) {
// web端和移动端认证:token认证
String token = password;
if (StringUtils.isNotEmpty(token) && token.startsWith(Constants.TOKEN_PREFIX)) {
token = token.replace(Constants.TOKEN_PREFIX, "");
}
try {
Claims claims = Jwts.parser().setSigningKey(secret).parseClaimsJws(token).getBody();
log.info("-----------移动端/Web端mqtt认证成功,clientId:" + clientId + "---------------");
return true;
} catch (Exception ex) {
ResponseEntity response = toolService.returnUnauthorized(new MqttAuthenticationModel(clientId, username, password), ex.getMessage());
log.warn("=>移动端/Web端mqtt认证失败[{}]",response.getBody());
throw new ServiceException("移动端/Web端mqtt认证失败:"+response.getBody());
}
return authenticateWebClient(clientId, username, password);
} else {
// 设备端认证
ResponseEntity response = toolService.clientAuth(clientId, username, password);
if (response.getStatusCodeValue() == HttpStatus.OK.value()) {
return true;
return MqttClientPrincipal.of(clientId, MqttClientPrincipal.ClientType.DEVICE);
} else {
log.warn("=>设备端认证失败");
throw new ServiceException("设备端认证失败:"+response.getBody());
throw new ServiceException("设备端认证失败:" + response.getBody());
}
}
} catch (Exception e) {
log.error("=>客户端认证失败", e);
return false;
return null;
}
}
/**
* Web / 移动端认证:校验 JWT 并解析出平台用户身份
*/
private MqttClientPrincipal authenticateWebClient(String clientId, String username, String password) {
String token = password;
if (StringUtils.isNotEmpty(token) && token.startsWith(Constants.TOKEN_PREFIX)) {
token = token.replace(Constants.TOKEN_PREFIX, "");
}
Claims claims;
try {
claims = Jwts.parser().setSigningKey(secret).parseClaimsJws(token).getBody();
} catch (Exception ex) {
ResponseEntity response = toolService.returnUnauthorized(new MqttAuthenticationModel(clientId, username, password), ex.getMessage());
log.warn("=>移动端/Web端mqtt认证失败[{}]", response.getBody());
throw new ServiceException("移动端/Web端mqtt认证失败:" + response.getBody());
}
// 回查登录态:令牌签名合法但已注销/已过期时必须拒绝
String uuid = (String) claims.get(Constants.LOGIN_USER_KEY);
if (StringUtils.isEmpty(uuid)) {
log.warn("=>移动端/Web端mqtt认证失败[令牌中缺少登录标识]");
throw new ServiceException("移动端/Web端mqtt认证失败:令牌中缺少登录标识");
}
LoginUser loginUser = redisCache.getCacheObject(CacheConstants.LOGIN_TOKEN_KEY + uuid);
if (loginUser == null || loginUser.getUser() == null) {
log.warn("=>移动端/Web端mqtt认证失败[登录态已失效],clientId:{}", clientId);
throw new ServiceException("移动端/Web端mqtt认证失败:登录态已失效,请重新登录");
}
MqttClientPrincipal principal = MqttClientPrincipal.ofWeb(clientId, loginUser.getUser().getUserId(), false);
principal.setSessionId(uuid);
SysUser user = currentWebUser(principal);
if (user == null) throw new ServiceException("登录态不可用或必须先修改默认密码");
log.info("-----------移动端/Web端mqtt认证成功,clientId:{},userId:{}---------------", clientId, user.getUserId());
principal.setAdmin(user.isAdmin());
return principal;
}
/** Check every Web operation/delivery; a valid old TCP connection is not a valid login session. */
public SysUser currentWebUser(MqttClientPrincipal principal) {
if (principal == null || !principal.isWeb() || StringUtils.isEmpty(principal.getSessionId())) return null;
LoginUser login = redisCache.getCacheObject(CacheConstants.LOGIN_TOKEN_KEY + principal.getSessionId());
if (login == null || login.getUser() == null || !java.util.Objects.equals(principal.getUserId(), login.getUser().getUserId())
|| !"0".equals(login.getUser().getStatus()) || "2".equals(login.getUser().getDelFlag())
|| com.fastbee.common.utils.PasswordPolicy.requiresChange(login)) return null;
SysUser current = userService.selectUserById(principal.getUserId());
if (current == null || !"0".equals(current.getStatus()) || "2".equals(current.getDelFlag())
|| !java.util.Objects.equals(current.getPassword(), login.getUser().getPassword())) return null;
return current;
}
}

View File

@@ -2,6 +2,7 @@ package com.fastbee.mqtt.handler;
import com.fastbee.common.constant.FastBeeConstant;
import com.fastbee.common.core.redis.RedisCache;
import com.fastbee.common.core.domain.model.MqttClientPrincipal;
import com.fastbee.common.enums.ServerType;
import com.fastbee.common.utils.DateUtils;
import com.fastbee.mqtt.annotation.Process;
@@ -126,11 +127,13 @@ public class MqttConnect implements MqttHandler {
String username = message.payload().userName();
String password = message.payload().passwordInBytes() == null ? null : new String(message.payload().passwordInBytes(), CharsetUtil.UTF_8);
/*验证失败,应答客户端*/
if (!authService.auth(clientId, username, password)) {
MqttClientPrincipal principal = authService.authenticate(clientId, username, password);
if (principal == null) {
MqttConnAckMessage connAckMessage = MqttMessageUtils.buildConntAckMessage(MqttConnectReturnCode.CONNECTION_REFUSED_BAD_USER_NAME_OR_PASSWORD, false);
ResponseManager.responseMessage(session, connAckMessage, true);
return false;
}
session.setPrincipal(principal);
return true;
}

View File

@@ -11,6 +11,7 @@ import com.fastbee.iot.ruleEngine.RuleProcess;
import com.fastbee.mq.redischannel.producer.MessageProducer;
import com.fastbee.mq.service.IDeviceReportMessageService;
import com.fastbee.mqtt.annotation.Process;
import com.fastbee.mqtt.acl.MqttAccessControl;
import com.fastbee.mqtt.handler.adapter.MqttHandler;
import com.fastbee.mqtt.manager.ClientManager;
import com.fastbee.mqtt.manager.ResponseManager;
@@ -52,20 +53,31 @@ public class MqttPublish implements MqttHandler {
@Resource
private RuleProcess ruleProcess;
@Resource
private MqttAccessControl mqttAccessControl;
@Override
public void handler(ChannelHandlerContext ctx, MqttMessage message) {
MqttPublishMessage publishMessage = (MqttPublishMessage) message;
/*获取客户端id*/
String clientId = AttributeUtils.getClientId(ctx.channel());
String topicName = publishMessage.variableHeader().topicName();
Session session = AttributeUtils.getSession(ctx.channel());
if (!mqttAccessControl.canPublish(session == null ? null : session.getPrincipal(), topicName,
publishMessage.content().toString(StandardCharsets.UTF_8))) {
// MQTT 3.1.1 has no negative PUBACK: terminate denied connections rather than silently hanging QoS1.
ctx.close();
return;
}
log.debug("=>***客户端[{}],主题[{}],推送消息[{}]", clientId, topicName,
ByteBufUtil.hexDump(publishMessage.content()));
// 以get结尾是模拟客户端数据,只转发消息
if (topicName.endsWith(FastBeeConstant.MQTT.PROPERTY_GET_SIMULATE)) {
callBack(session, publishMessage, clientId);
sendTestToMQ(publishMessage);
} else {
/*获取客户端session*/
Session session = AttributeUtils.getSession(ctx.channel());
/*发布权限校验:web端只能操作自己名下的设备*/
/*推送保留信息*/
pubRetain(publishMessage);
/*响应客户端消息到达Broker*/

View File

@@ -30,6 +30,8 @@ public class MqttSubscribe implements MqttHandler {
@Resource
private RedisCache redisCache;
@Resource
private com.fastbee.mqtt.acl.MqttAccessControl mqttAccessControl;
@Override
public void handler(ChannelHandlerContext ctx, MqttMessage message) {
@@ -40,6 +42,7 @@ public class MqttSubscribe implements MqttHandler {
public void subscribe(ChannelHandlerContext ctx, MqttSubscribeMessage message) {
/*获取session*/
Session session = AttributeUtils.getSession(ctx.channel());
if (session == null) { ctx.close(); return; }
/*获取客户端订阅的topic列表*/
List<MqttTopicSubscription> topList = message.payload().topicSubscriptions();
/*获取topicName列表*/
@@ -47,10 +50,19 @@ public class MqttSubscribe implements MqttHandler {
log.debug("=>客户端:{},订阅主题:{}", session.getClientId(), JSON.toJSONString(topicNameList));
if (!TopicsUtils.validTopicFilter(topicNameList)) {
log.error("=>订阅主题不合法:{}", JSON.toJSONString(topicNameList));
ctx.close();
return;
}
java.util.List<Integer> grants = new java.util.ArrayList<>();
java.util.List<MqttTopicSubscription> permitted = new java.util.ArrayList<>();
for (MqttTopicSubscription subscription : topList) {
boolean allowed = mqttAccessControl.canSubscribe(session.getPrincipal(), subscription.topicName());
grants.add(allowed ? subscription.qualityOfService().value() : 128);
if (allowed) permitted.add(subscription);
}
/*存储到本地topic缓存*/
topicNameList.forEach(topicName -> {
permitted.forEach(subscription -> {
String topicName = subscription.topicName();
ClientManager.push(topicName, session);
/*累计订阅数*/
redisCache.incr2(FastBeeConstant.REDIS.MESSAGE_SUBSCRIBE_TOTAL,-1L);
@@ -58,10 +70,12 @@ public class MqttSubscribe implements MqttHandler {
/*更新客户端ping*/
ClientManager.updatePing(session.getClientId());
/*应答客户端订阅成功*/
MqttSubAckMessage subAckMessage = MqttMessageUtils.buildSubAckMessage(message);
MqttSubAckMessage subAckMessage = new MqttSubAckMessage(
new MqttFixedHeader(MqttMessageType.SUBACK, false, MqttQoS.AT_MOST_ONCE, false, 0),
MqttMessageIdVariableHeader.from(message.variableHeader().messageId()), new MqttSubAckPayload(grants));
ResponseManager.responseMessage(session, subAckMessage, true);
/*客户端订阅了遗留消息主题后,推送遗留消息给客户端*/
topList.forEach(topic -> {
permitted.forEach(topic -> {
retain(topic.topicName(), session, topic.qualityOfService());
});
}

View File

@@ -53,6 +53,12 @@ public class ResponseManager {
*/
public static void publishClients(MqttPublishMessage msg, Session session) {
try {
if (session.getPrincipal() == null || session.getPrincipal().isWeb()
&& !com.fastbee.common.utils.spring.SpringUtils.getBean(com.fastbee.mqtt.acl.MqttAccessControl.class)
.canSubscribe(session.getPrincipal(), msg.variableHeader().topicName())) {
session.getHandlerContext().close();
return;
}
final Channel channel = session.getHandlerContext().channel();
MqttQoS qos = msg.fixedHeader().qosLevel();
ByteBuf sendBuf = msg.content().retainedDuplicate();

View File

@@ -109,12 +109,6 @@
<artifactId>liteflow-rule-sql</artifactId>
<version>${liteflow.version}</version>
</dependency>
<!--groovy脚本插件-->
<dependency>
<groupId>com.yomahub</groupId>
<artifactId>liteflow-script-groovy</artifactId>
<version>${liteflow.version}</version>
</dependency>
<dependency>
<groupId>com.fastbee</groupId>
<artifactId>fastbee-ruleEngine</artifactId>

View File

@@ -1,5 +1,6 @@
package com.fastbee.iot.model;
import lombok.Data;
/**
@@ -17,4 +18,12 @@ public class ProductCode {
* 协议编号
*/
private String protocolCode;
/**
* 设备归属用户id,用于校验 MQTT 客户端是否有权操作该设备
*/
private Long userId;
/**
* 设备归属租户id
*/
private Long tenantId;
}

View File

@@ -9,6 +9,7 @@ import com.fastbee.iot.service.IProductService;
import com.fastbee.iot.service.IScriptService;
import com.fastbee.ruleEngine.context.MsgContext;
import com.fastbee.ruleEngine.core.FlowLogExecutor;
import com.fastbee.ruleEngine.security.ScriptSandbox;
import com.yomahub.liteflow.builder.el.LiteFlowChainELBuilder;
import com.yomahub.liteflow.flow.LiteflowResponse;
import lombok.extern.slf4j.Slf4j;
@@ -35,6 +36,8 @@ public class RuleProcess {
private RedisCache redisCache;
@Resource
private IProductService productService;
@Resource
private ScriptSandbox scriptSandbox;
/**
* 规则引擎脚本处理
@@ -55,6 +58,7 @@ public class RuleProcess {
scriptCondition.setProductId(productCode.getProductId());
scriptCondition.setScriptEvent(event); // 事件 1=设备上报 2=平台下发 3=设备上线 4=设备下线
scriptCondition.setScriptPurpose(1); // 脚本用途:数据流=1
scriptCondition.setEnable(1);
String[] scriptIds = scriptService.selectRuleScriptIdArray(scriptCondition);
MsgContext context = MsgContext.builder()
.serialNumber(serialNumber)
@@ -73,8 +77,9 @@ public class RuleProcess {
String requestId = "script/" + script;
String el = "THEN(" + script + ")";
LiteFlowChainELBuilder.createChain().setChainName(eChainName).setEL(el).build();
// 执行规则脚本
LiteflowResponse response = flowLogExecutor.execute2RespWithRid(eChainName, null, requestId, context);
// 执行规则脚本(脚本内容来自数据库,等同于不可信代码,必须在沙箱内执行)
LiteflowResponse response = scriptSandbox.call(() ->
flowLogExecutor.execute2RespWithRid(eChainName, null, requestId, context));
if (!response.isSuccess()) {
log.error("规则脚本执行发生错误:" + response.getMessage());
}
@@ -93,11 +98,16 @@ public class RuleProcess {
String cacheKey = RedisKeyBuilder.buildDeviceMsgCacheKey(serialNumber);
if (redisCache.containsKey(cacheKey)) {
Object cacheObject = redisCache.getCacheObject(cacheKey);
return JSON.parseObject(cacheObject.toString(), ProductCode.class);
productCode = cacheObject == null ? null : JSON.parseObject(cacheObject.toString(), ProductCode.class);
// Upgrade pre-ACL cache entries instead of denying legitimate device owners.
if (productCode != null && productCode.getUserId() != null) {
return productCode;
}
}
productCode = productService.getProtocolBySerialNumber(serialNumber);
String jsonString = JSON.toJSONString(productCode);
redisCache.setCacheObject(cacheKey, jsonString);
if (productCode != null) {
redisCache.setCacheObject(cacheKey, JSON.toJSONString(productCode));
}
return productCode;
}
}

View File

@@ -0,0 +1,21 @@
package com.fastbee.iot.ruleEngine.security;
import com.fastbee.ruleEngine.security.SecureGroovyScriptExecutor;
import org.springframework.stereotype.Component;
/** The same safe compiler serves validation, database loading and execution. */
@Component
public class ScriptSecurityValidator {
public String validateLanguage(String language) {
return language != null && "groovy".equalsIgnoreCase(language.trim()) ? null : "当前仅支持 groovy 脚本";
}
public String validateScriptData(String source) {
try {
SecureGroovyScriptExecutor.compileSource(source);
return null;
} catch (RuntimeException e) {
String message = String.valueOf(e.getMessage()).replace((char) 10, ' ');
return "脚本校验失败: " + message.substring(0, Math.min(300, message.length()));
}
}
}

View File

@@ -5,16 +5,27 @@ import cn.hutool.core.util.IdUtil;
import com.fastbee.common.core.domain.AjaxResult;
import com.fastbee.common.core.domain.entity.SysRole;
import com.fastbee.common.core.domain.entity.SysUser;
import com.fastbee.common.exception.ServiceException;
import com.fastbee.common.utils.DateUtils;
import com.fastbee.common.utils.StringUtils;
import com.fastbee.iot.domain.Script;
import com.fastbee.iot.mapper.ScriptMapper;
import com.fastbee.iot.model.ScriptCondition;
import com.fastbee.iot.ruleEngine.security.ScriptSecurityValidator;
import com.fastbee.iot.service.IScriptService;
import com.yomahub.liteflow.core.FlowExecutor;
import com.yomahub.liteflow.builder.LiteFlowNodeBuilder;
import com.yomahub.liteflow.enums.NodeTypeEnum;
import com.yomahub.liteflow.flow.FlowBus;
import com.yomahub.liteflow.flow.element.Node;
import com.yomahub.liteflow.script.ScriptExecutorFactory;
import org.apache.commons.io.input.ReversedLinesFileReader;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;
import org.springframework.transaction.support.TransactionSynchronization;
import org.springframework.transaction.support.TransactionSynchronizationManager;
import com.fastbee.ruleEngine.security.SecureGroovyScriptExecutor;
import org.slf4j.LoggerFactory;
import lombok.extern.slf4j.Slf4j;
import ch.qos.logback.classic.LoggerContext;
@@ -25,8 +36,6 @@ import java.io.IOException;
import java.util.ArrayList;
import java.util.Collections;
import java.util.List;
import java.util.regex.Matcher;
import java.util.regex.Pattern;
import static com.fastbee.common.core.domain.AjaxResult.success;
import static com.fastbee.common.utils.SecurityUtils.getLoginUser;
@@ -47,6 +56,9 @@ public class ScriptServiceImpl implements IScriptService
@Resource
private FlowExecutor flowExecutor;
@Resource
private ScriptSecurityValidator scriptSecurityValidator;
/**
* 查询规则引擎脚本
*
@@ -130,10 +142,15 @@ public class ScriptServiceImpl implements IScriptService
* @return 结果
*/
@Override
@Transactional(rollbackFor = Exception.class)
public int insertRuleScript(Script ruleScript)
{
// 脚本中引用包替换为许可的包
// 保留原始源码;默认导入和安全限制由统一编译器处理
ruleScript.setScriptData(replaceAllowPackage(ruleScript.getScriptData()));
// 统一受限编译校验,成功后才允许入库
assertScriptSafe(ruleScript);
if (ruleScript.getEnable() == null) ruleScript.setEnable(1);
Class<? extends groovy.lang.Script> compiled = SecureGroovyScriptExecutor.compileSource(ruleScript.getScriptData());
// 设置脚本标识,D=数据流,A=执行动作,T=触发器,雪花算法生成唯一数
Snowflake snowflake = IdUtil.createSnowflake(1,1);
ruleScript.setScriptId("D"+snowflake.nextId());
@@ -144,24 +161,42 @@ public class ScriptServiceImpl implements IScriptService
int result = ruleScriptMapper.insertRuleScript(ruleScript);
// 动态刷新脚本
if(result==1){
ScriptExecutorFactory.loadInstance().getScriptExecutor(ruleScript.getScriptLanguage()).load(ruleScript.getScriptId(), ruleScript.getScriptData());
publishAfterCommit(ruleScript, compiled);
}
return result;
}
/**
* 脚本安全校验,未通过时直接抛出业务异常,阻止脚本入库与热加载
*/
private void assertScriptSafe(Script ruleScript) {
if (ruleScript.getScriptLanguage() != null) {
ruleScript.setScriptLanguage(ruleScript.getScriptLanguage().trim().toLowerCase(java.util.Locale.ROOT));
}
String error = scriptSecurityValidator.validateLanguage(ruleScript.getScriptLanguage());
if (error != null) {
throw new ServiceException(error);
}
if (StringUtils.isEmpty(ruleScript.getScriptType())) {
ruleScript.setScriptType(Integer.valueOf(2).equals(ruleScript.getScriptPurpose()) ? "boolean_script" : "script");
}
NodeTypeEnum nodeType = NodeTypeEnum.getEnumByCode(ruleScript.getScriptType());
if (nodeType == null || !nodeType.isScript()) {
throw new ServiceException("不支持的规则脚本类型:" + ruleScript.getScriptType());
}
error = scriptSecurityValidator.validateScriptData(ruleScript.getScriptData());
if (error != null) {
throw new ServiceException(error);
}
}
/**
* 脚本中引用包替换为许可的包
* @return
*/
private String replaceAllowPackage(String scriptData){
String header="import cn.hutool.json.JSONArray;\n" +
"import cn.hutool.json.JSONObject;\n" +
"import cn.hutool.json.JSONUtil;\n" +
"import cn.hutool.core.util.NumberUtil;\n";
// 正则替换import为许可的引用包
String pattern = "import.*[;\\n\\s]";
String data = scriptData.replaceAll(pattern, "");
return header + data;
private String replaceAllowPackage(String scriptData) {
// Preserve imports and multiline strings; the safe compiler supplies default imports.
return scriptData;
}
/**
@@ -171,15 +206,38 @@ public class ScriptServiceImpl implements IScriptService
* @return 结果
*/
@Override
@Transactional(rollbackFor = Exception.class)
public int updateRuleScript(Script ruleScript)
{
SysUser sysUser = getLoginUser().getUser();
Script existing = ruleScriptMapper.selectRuleScriptById(ruleScript.getScriptId());
if (existing == null) {
throw new ServiceException("规则脚本不存在:" + ruleScript.getScriptId());
}
// 越权校验:非管理员只能修改自己创建的脚本
if (!sysUser.isAdmin() && !sysUser.getUserId().equals(existing.getUserId())) {
throw new ServiceException("无权修改他人创建的规则脚本");
}
// 脚本中引用包替换为许可的包
if (ruleScript.getScriptData() == null) ruleScript.setScriptData(existing.getScriptData());
if (ruleScript.getScriptLanguage() == null) ruleScript.setScriptLanguage(existing.getScriptLanguage());
if (ruleScript.getEnable() == null) ruleScript.setEnable(existing.getEnable());
if (ruleScript.getScriptType() == null) ruleScript.setScriptType(existing.getScriptType());
if (ruleScript.getScriptPurpose() == null) ruleScript.setScriptPurpose(existing.getScriptPurpose());
if (ruleScript.getScriptName() == null) ruleScript.setScriptName(existing.getScriptName());
ruleScript.setScriptData(replaceAllowPackage(ruleScript.getScriptData()));
// 统一受限编译校验,成功后才允许入库
assertScriptSafe(ruleScript);
Class<? extends groovy.lang.Script> compiled = SecureGroovyScriptExecutor.compileSource(ruleScript.getScriptData());
// 归属与主键不允许通过请求体改写,避免把脚本转移给他人或改写到别的组件标识上
ruleScript.setScriptId(existing.getScriptId());
ruleScript.setUserId(null);
ruleScript.setUserName(null);
ruleScript.setUpdateTime(DateUtils.getNowDate());
int result = ruleScriptMapper.updateRuleScript(ruleScript);
// 动态刷新脚本
if(result==1){
ScriptExecutorFactory.loadInstance().getScriptExecutor(ruleScript.getScriptLanguage()).load(ruleScript.getScriptId(), ruleScript.getScriptData());
publishAfterCommit(ruleScript, compiled);
}
return result;
}
@@ -191,9 +249,20 @@ public class ScriptServiceImpl implements IScriptService
* @return 结果
*/
@Override
@Transactional(rollbackFor = Exception.class)
public int deleteRuleScriptByIds(String[] ids)
{
return ruleScriptMapper.deleteRuleScriptByIds(ids);
SysUser user = getLoginUser().getUser();
for (String id : ids) {
Script script = ruleScriptMapper.selectRuleScriptById(id);
if (script == null || !user.isAdmin() && !user.getUserId().equals(script.getUserId()))
throw new ServiceException("无权删除该规则脚本");
}
int result = ruleScriptMapper.deleteRuleScriptByIds(ids);
if (result > 0) afterCommit(() -> {
for (String id : ids) ScriptExecutorFactory.loadInstance().getScriptExecutor("groovy").unLoad(id);
});
return result;
}
/**
@@ -203,9 +272,46 @@ public class ScriptServiceImpl implements IScriptService
* @return 结果
*/
@Override
@Transactional(rollbackFor = Exception.class)
public int deleteRuleScriptById(String id)
{
return ruleScriptMapper.deleteRuleScriptById(id);
return deleteRuleScriptByIds(new String[]{id});
}
private void publishAfterCommit(Script script, Class<? extends groovy.lang.Script> compiled) {
SecureGroovyScriptExecutor executor = (SecureGroovyScriptExecutor) ScriptExecutorFactory.loadInstance().getScriptExecutor("groovy");
final String id = script.getScriptId();
final boolean enabled = Integer.valueOf(1).equals(script.getEnable());
final String source = script.getScriptData();
final String name = script.getScriptName();
final NodeTypeEnum type = NodeTypeEnum.getEnumByCode(script.getScriptType());
afterCommit(() -> {
if (!enabled) {
executor.unLoad(id);
return;
}
Node node = FlowBus.getNode(id);
if (node == null || node.getType() != type) {
// A compiled cache entry alone is not a LiteFlow node. Register new nodes
// only after commit, through the same restricted compiler/SPI.
LiteFlowNodeBuilder.createNode().setType(type).setId(id).setName(name)
.setLanguage("groovy").setScript(source).build();
} else {
// Keep source metadata aligned so a later rule reload cannot restore old code.
node.setScript(source);
node.setLanguage("groovy");
if (name != null) node.setName(name);
}
executor.install(id, compiled);
});
}
private void afterCommit(Runnable action) {
if (TransactionSynchronizationManager.isSynchronizationActive()) {
TransactionSynchronizationManager.registerSynchronization(new TransactionSynchronization() {
@Override public void afterCommit() { action.run(); }
});
} else action.run();
}
/**
@@ -215,19 +321,18 @@ public class ScriptServiceImpl implements IScriptService
*/
@Override
public AjaxResult validateScript(Script ruleScript){
// 检查安全性检查
String pattern = ".*while|for\\s*\\(|InputStream|OutputStream|Reader|Writer|File|Socket.*";
Pattern r = Pattern.compile(pattern);
Matcher m = r.matcher(ruleScript.getScriptData());
if(m.find()){
return success("验证失败,错误信息:" + "不能包含关键词for、while、Reader、Write、File、Socket等",false);
// 与保存路径使用同一编译器;验证不执行脚本,也不发布到全局节点缓存
String scriptData = replaceAllowPackage(ruleScript.getScriptData());
String language = StringUtils.isEmpty(ruleScript.getScriptLanguage()) ? "groovy" : ruleScript.getScriptLanguage();
String error = scriptSecurityValidator.validateLanguage(language);
if (error == null) {
error = scriptSecurityValidator.validateScriptData(scriptData);
}
// 热刷新脚本
try {
ScriptExecutorFactory.loadInstance().getScriptExecutor("groovy").load("validateScript", ruleScript.getScriptData());
}catch(Exception e){
return success("验证失败,错误信息:" + e.getMessage(),false);
if (error != null) {
return success("验证失败,错误信息:" + error, false);
}
return success("验证成功,脚本的实际执行情况可以查看后端日志文件",true);
// 只做编译校验,不再 load 到执行器:load 会以固定节点名覆盖全局组件注册表,
// 使任意登录用户都能往内存里塞入未落库的脚本组件
return success("验证成功,脚本的实际执行情况可以查看后端日志文件", true);
}
}

View File

@@ -209,7 +209,9 @@ PUBLIC "-//mybatis.org//DTD Mapper 3.0//EN"
<select id="getProtocolBySerialNumber" resultType="com.fastbee.iot.model.ProductCode">
select p.protocol_code as protocolCode,
p.product_id as productId
p.product_id as productId,
d.user_id as userId,
d.tenant_id as tenantId
from
iot_product p
inner join iot_device d on p.product_id = d.product_id