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

@@ -0,0 +1 @@
<settings><mirrors><mirror><id>public</id><mirrorOf>*,!jenkins-releases</mirrorOf><url>https://repo.maven.apache.org/maven2</url></mirror></mirrors></settings>

View File

@@ -0,0 +1,107 @@
{
"date": "2026-10-01",
"timezone": "Asia/Shanghai",
"artifact": "D:\\ideaProject\\fastbee\\.security-fix\\backend-verify\\springboot\\fastbee-admin\\target\\fastbee-admin.jar",
"bytes": 161967403,
"sha256": "1996143de1ae02120be3b47da6d1f6feddf5197e9ae4ddccee55d0ae0165f2f4",
"scriptProviders": [
{
"jar": "BOOT-INF/lib/fastbee-ruleEngine-2.1.0.jar",
"provider": "com.fastbee.ruleEngine.security.SecureGroovyScriptExecutor"
}
],
"obsoleteSandboxClassPresent": false,
"bundledProjectJarChecks": [
"fastbee-framework-2.1.0.jar",
"fastbee-common-2.1.0.jar",
"fastbee-system-service-2.1.0.jar",
"fastbee-quartz-2.1.0.jar",
"fastbee-generator-2.1.0.jar",
"fastbee-open-api-2.1.0.jar",
"mqtt-broker-2.1.0.jar",
"iot-server-core-2.1.0.jar",
"sip-server-2.1.0.jar",
"boot-strap-2.1.0.jar",
"fastbee-protocol-collect-2.1.0.jar",
"base-server-2.1.0.jar",
"fastbee-iot-service-2.1.0.jar",
"fastbee-ruleEngine-2.1.0.jar",
"gateway-boot-2.1.0.jar",
"fastbee-mq-2.1.0.jar",
"fastbee-mqtt-client-2.1.0.jar"
],
"adminClassChecks": 28,
"suites": [
{
"name": "com.fastbee.ruleEngine.security.SecureGroovyScriptExecutorTest",
"tests": "16",
"failures": "0",
"errors": "0",
"skipped": "0"
},
{
"name": "com.fastbee.security.SecurityRegressionTest",
"tests": "26",
"failures": "0",
"errors": "0",
"skipped": "0"
}
],
"backendTests": 42,
"frontendChecks": 32,
"build": "clean package SUCCESS",
"frontendBuild": "production build SUCCESS",
"browser": {
"environment": "local production bundle, Chromium, mocked API; no deployed services accessed",
"passed": 7,
"checks": [
"restricted admin opens only the password page without homepage requests or error toasts",
"refreshing the password page neither loops nor starts business requests",
"direct big-screen navigation is gated before its components mount",
"successful password change logs out and reaches login without mounting the homepage",
"the complete restricted-account browser flow has no uncaught page errors",
"demo account clears stale local restrictions and enters the homepage normally",
"production bundle initializes routes and homepage data successfully for the demo account"
]
},
"demoPasswordExemption": {
"storedUserName": "fastbee",
"builtInAdminExcluded": true,
"authenticationAndAuthorizationStillRequired": true
},
"isolatedBuildRoot": "D:\\ideaProject\\fastbee\\.security-fix\\backend-verify\\springboot",
"sourceSnapshotChecks": 1051,
"adminResourceChecks": [
"i18n/messages.properties",
"i18n/messages_zh_CN.properties",
"i18n/messages_en_US.properties"
],
"ruleScriptCompatibility": {
"scriptId": "D1782337038486474752",
"source": "repository database seed, tested without changing the script logic",
"input": {
"co2": 666
},
"output": [
{
"id": "co2",
"value": "666"
}
],
"safeConsoleLogging": "PASS",
"forEachCallbacksSandboxed": "PASS",
"ruleProcessToJsonDecoder": "PASS",
"invalidPayloadErrorInThreeLocales": "PASS",
"realDeviceRetest": "NOT_RUN"
},
"ideStartupRecovery": {
"scope": "original workspace target/classes plus verified package runtime dependencies; IDE temporary manifest removed; offline checks only; application not started",
"restoredCompile": "PASS",
"classpathEntries": 282,
"sourceResourceChecks": 104,
"mybatisConfiguration": "PASS",
"scriptExecutorProvider": "PASS",
"databaseSeedRuleConversion": "PASS",
"fullApplicationStartup": "NOT_RUN"
}
}

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

View File

@@ -5,6 +5,7 @@
"author": "kerwincui",
"license": "AGPL3.0",
"scripts": {
"test:security": "node tests/security-regression.cjs",
"dev": "set NODE_OPTIONS=--openssl-legacy-provider && vue-cli-service serve --open",
"build:prod": "set NODE_OPTIONS=--openssl-legacy-provider && vue-cli-service build --report",
"build:stage": "set NODE_OPTIONS=--openssl-legacy-provider && vue-cli-service build --mode staging",

View File

@@ -0,0 +1,101 @@
<template>
<el-dialog
:title="$t('forceChangePwdTitle')"
:visible="visible"
width="460px"
append-to-body
:show-close="false"
:close-on-click-modal="false"
:close-on-press-escape="false"
>
<el-alert :title="$t('forceChangePwdTip')" type="warning" :closable="false" show-icon style="margin-bottom: 16px" />
<el-form ref="form" :model="form" :rules="rules" label-width="80px">
<el-form-item :label="$t('oldPassword')" prop="oldPassword">
<el-input v-model="form.oldPassword" :placeholder="$t('system.user.profile.oldPasswordPlaceholder')" type="password" show-password />
</el-form-item>
<el-form-item :label="$t('newPassword')" prop="newPassword">
<el-input v-model="form.newPassword" :placeholder="$t('system.user.profile.newPasswordPlaceholder')" type="password" show-password />
</el-form-item>
<el-form-item :label="$t('confirmPassword')" prop="confirmPassword">
<el-input v-model="form.confirmPassword" :placeholder="$t('system.user.profile.confirmPasswordPlaceholder')" type="password" show-password />
</el-form-item>
</el-form>
<div slot="footer" class="dialog-footer">
<el-button type="primary" :loading="loading" @click="submit">{{ $t('forceChangePwdConfirm') }}</el-button>
</div>
</el-dialog>
</template>
<script>
import { updateUserPwd } from '@/api/system/user';
export default {
name: 'ForceChangePwd',
data() {
const equalToPassword = (rule, value, callback) => {
if (this.form.newPassword !== value) {
callback(new Error(this.$t('system.user.profile.pwdNotMatch')));
} else {
callback();
}
};
return {
loading: false,
form: {
oldPassword: undefined,
newPassword: undefined,
confirmPassword: undefined,
},
rules: {
oldPassword: [{ required: true, message: this.$t('system.user.profile.oldPasswordRequired'), trigger: 'blur' }],
newPassword: [
{ required: true, message: this.$t('system.user.profile.newPasswordRequired'), trigger: 'blur' },
{ min: 6, max: 20, message: this.$t('system.user.profile.passwordLength'), trigger: 'blur' },
{
trigger: 'blur',
validator: (rule, value, callback) => {
var passwordreg = /(?![A-Z]*$)(?![a-z]*$)(?![0-9]*$)(?![^a-zA-Z0-9]*$)/;
if (!passwordreg.test(value)) {
callback(new Error(this.$t('system.user.profile.passwordStrength')));
} else {
callback();
}
},
},
],
confirmPassword: [
{ required: true, message: this.$t('system.user.profile.confirmPasswordRequired'), trigger: 'blur' },
{ required: true, validator: equalToPassword, trigger: 'blur' },
],
},
};
},
computed: {
visible() {
return this.$store.state.user.needChangePwd;
},
},
methods: {
submit() {
this.$refs.form.validate((valid) => {
if (!valid) {
return;
}
this.loading = true;
updateUserPwd({ oldPassword: this.form.oldPassword, newPassword: this.form.newPassword })
.then(() => {
this.$modal.msgSuccess(this.$t('updateSuccessReLogin'));
// Stay on this isolated page until logout has completed.
return this.$store.dispatch('LogOut').then(() => {
location.href = '/login';
});
})
.catch(() => {})
.finally(() => {
this.loading = false;
});
});
},
},
};
</script>

View File

@@ -132,5 +132,8 @@
"batchCancelAuth": "Batch Cancel Authorization",
"cancelAuth": "Cancel Authorization",
"clean": "Clear",
"cleanSuccess": "Clear Successful"
"cleanSuccess": "Clear Successful",
"forceChangePwdTitle": "Change the default password",
"forceChangePwdTip": "This account is still using the factory default password, which is public and trivially guessable. To keep the platform and its devices safe, set a new password now. You will need to sign in again afterwards.",
"forceChangePwdConfirm": "Change password and sign in again"
}

View File

@@ -25,11 +25,24 @@ const i18n = new VueI18n({
locale.use(Element, {
i18n: (key, value) => i18n.t(key, value),
});
listLanguage({ pageNum: 1, pageSize: 1000 }).then((res) => {
res.rows.forEach((row) => {
if (langs[row.language]) langs[row.language].language = row.langName;
});
});
let languageNamesLoaded = false;
let languageNamesRequest;
function loadLanguageNames() {
if (languageNamesLoaded) return Promise.resolve();
if (!languageNamesRequest) {
languageNamesRequest = listLanguage({ pageNum: 1, pageSize: 1000 }).then((res) => {
res.rows.forEach((row) => {
if (langs[row.language]) langs[row.language].language = row.langName;
});
languageNamesLoaded = true;
}).catch(() => {
// Built-in translations remain available if optional display names cannot load.
}).finally(() => {
languageNamesRequest = undefined;
});
}
return languageNamesRequest;
}
function getJson(contexts) {
let json = {};
@@ -40,4 +53,4 @@ function getJson(contexts) {
}
// 导出langs 给切换语言的时候用
export { i18n, langs };
export { i18n, langs, loadLanguageNames };

View File

@@ -132,5 +132,8 @@
"batchCancelAuth": "批量取消授权",
"cancelAuth": "取消授权",
"clean": "清空",
"cleanSuccess": "清空成功"
"cleanSuccess": "清空成功",
"forceChangePwdTitle": "请修改初始密码",
"forceChangePwdTip": "检测到当前账号仍在使用出厂默认口令,该口令已公开且极易被爆破。为保障平台与设备安全,请立即设置新密码,修改后需要重新登录。",
"forceChangePwdConfirm": "确认修改并重新登录"
}

View File

@@ -5,6 +5,8 @@ import NProgress from 'nprogress'
import 'nprogress/nprogress.css'
import { getToken } from '@/utils/auth'
import { isRelogin } from '@/utils/request'
import { PASSWORD_CHANGE_ROUTE } from '@/utils/passwordChange'
import { loadLanguageNames } from '@/lang'
NProgress.configure({ showSpinner: false })
@@ -24,10 +26,18 @@ router.beforeEach((to, from, next) => {
// 判断当前用户是否已拉取完user_info信息
store.dispatch('GetInfo').then(() => {
isRelogin.show = false
// Resolve the server-owned password state before mounting any business page.
if (store.state.user.needChangePwd) {
if (to.path === PASSWORD_CHANGE_ROUTE) next()
else next({ path: PASSWORD_CHANGE_ROUTE, replace: true })
return
}
store.dispatch('GenerateRoutes').then(accessRoutes => {
// 根据roles权限生成可访问的路由表
router.addRoutes(accessRoutes) // 动态添加可访问路由表
next({ ...to, replace: true }) // hack方法 确保addRoutes已完成
next(to.path === PASSWORD_CHANGE_ROUTE
? { path: '/index', replace: true }
: { ...to, replace: true }) // hack方法 确保addRoutes已完成
})
}).catch(err => {
store.dispatch('LogOut').then(() => {
@@ -35,6 +45,11 @@ router.beforeEach((to, from, next) => {
next({ path: '/' })
})
})
} else if (store.state.user.needChangePwd) {
if (to.path === PASSWORD_CHANGE_ROUTE) next()
else next({ path: PASSWORD_CHANGE_ROUTE, replace: true })
} else if (to.path === PASSWORD_CHANGE_ROUTE) {
next({ path: '/index', replace: true })
} else {
next()
}
@@ -53,4 +68,6 @@ router.beforeEach((to, from, next) => {
router.afterEach(() => {
NProgress.done()
// Avoid the eager language-list request before GetInfo has resolved the login state.
if (!getToken() || !store.state.user.needChangePwd) loadLanguageNames()
})

View File

@@ -53,6 +53,11 @@ export const constantRoutes = [
component: () => import('@/views/login'),
hidden: true,
},
{
path: '/change-password',
component: () => import('@/components/ForceChangePwd'),
hidden: true,
},
{
path: '/register',
component: () => import('@/views/register'),

View File

@@ -3,6 +3,8 @@ import { getToken, setToken, removeToken } from '@/utils/auth'
import { isHttp, isEmpty } from "@/utils/validate"
import defAva from '@/assets/images/profile.jpg'
const NEED_CHANGE_PWD_KEY = 'needChangePwd'
const user = {
state: {
token: getToken(),
@@ -10,7 +12,9 @@ const user = {
name: '',
avatar: '',
roles: [],
permissions: []
permissions: [],
// 出厂默认口令未修改标记,登录接口下发,持久化以覆盖刷新场景
needChangePwd: localStorage.getItem(NEED_CHANGE_PWD_KEY) === 'true'
},
mutations: {
@@ -31,6 +35,14 @@ const user = {
},
SET_PERMISSIONS: (state, permissions) => {
state.permissions = permissions
},
SET_NEED_CHANGE_PWD: (state, needChangePwd) => {
state.needChangePwd = needChangePwd
if (needChangePwd) {
localStorage.setItem(NEED_CHANGE_PWD_KEY, 'true')
} else {
localStorage.removeItem(NEED_CHANGE_PWD_KEY)
}
}
},
@@ -45,6 +57,7 @@ const user = {
login(username, password, code, uuid).then(res => {
setToken(res.token)
commit('SET_TOKEN', res.token)
commit('SET_NEED_CHANGE_PWD', res.needChangePwd === true)
resolve()
}).catch(error => {
reject(error)
@@ -56,6 +69,7 @@ const user = {
GetInfo({ commit, state }) {
return new Promise((resolve, reject) => {
getInfo().then(res => {
commit('SET_NEED_CHANGE_PWD', res.needChangePwd === true)
const user = res.user
let avatar = user.avatar || ""
if (!isHttp(avatar)) {
@@ -84,6 +98,7 @@ const user = {
commit('SET_TOKEN', '')
commit('SET_ROLES', [])
commit('SET_PERMISSIONS', [])
commit('SET_NEED_CHANGE_PWD', false)
removeToken()
resolve()
}).catch(error => {
@@ -96,6 +111,7 @@ const user = {
FedLogOut({ commit }) {
return new Promise(resolve => {
commit('SET_TOKEN', '')
commit('SET_NEED_CHANGE_PWD', false)
removeToken()
resolve()
})

View File

@@ -0,0 +1,14 @@
// UX coordination only. The backend remains authoritative for access control.
export const PASSWORD_CHANGE_ROUTE = '/change-password';
export function isPasswordChangeResponse(data, status) {
return !!data && data.needChangePwd === true && (status === 403 || data.code === 403);
}
export function allowedWhileChangingPassword(config) {
const method = (config.method || 'get').toUpperCase();
const path = (config.url || '').split(/[?#]/)[0];
return (method === 'GET' && ['/getInfo', '/getRouters', '/system/user/profile'].includes(path))
|| (method === 'PUT' && path === '/system/user/profile/updatePwd')
|| (method === 'POST' && path === '/logout');
}

View File

@@ -1,6 +1,8 @@
import axios from 'axios';
import { Notification, MessageBox, Message, Loading } from 'element-ui';
import store from '@/store';
import router from '@/router';
import { PASSWORD_CHANGE_ROUTE, allowedWhileChangingPassword, isPasswordChangeResponse } from '@/utils/passwordChange';
import { getToken } from '@/utils/auth';
import errorCode from '@/utils/errorCode';
import { tansParams, blobValidate } from '@/utils/ruoyi';
@@ -10,6 +12,20 @@ import { saveAs } from 'file-saver';
let downloadLoadingInstance;
// 是否显示重新登录
export let isRelogin = { show: false };
let passwordChangeNavigation;
function requirePasswordChange(message) {
if (getToken()) {
store.commit('SET_NEED_CHANGE_PWD', true);
if (!passwordChangeNavigation && router.currentRoute.path !== PASSWORD_CHANGE_ROUTE) {
passwordChangeNavigation = Promise.resolve(router.replace({ path: PASSWORD_CHANGE_ROUTE }))
.catch(() => {}).finally(() => { passwordChangeNavigation = undefined; });
}
}
const error = new axios.Cancel(message || '请先修改出厂默认密码');
error.needChangePwd = true;
return Promise.reject(error);
}
axios.defaults.headers['Content-Type'] = 'application/json;charset=utf-8';
// 创建axios实例
@@ -26,6 +42,10 @@ service.interceptors.request.use(
config.headers.language = store.state.settings.language;
// 是否需要设置 token
const isToken = (config.headers || {}).isToken === false;
if (getToken() && !isToken && store.state.user.needChangePwd && !allowedWhileChangingPassword(config)) {
// Stop background work locally; never disguise a rejected request as success.
return requirePasswordChange();
}
// 是否需要防止数据重复提交
const isRepeatSubmit = (config.headers || {}).repeatSubmit === false;
if (getToken() && !isToken) {
@@ -65,13 +85,14 @@ service.interceptors.request.use(
},
(error) => {
console.log(error);
Promise.reject(error);
return Promise.reject(error);
}
);
// 响应拦截器
service.interceptors.response.use(
(res) => {
if (isPasswordChangeResponse(res.data, res.status)) return requirePasswordChange(res.data.msg);
// 未设置状态码则默认成功状态
const code = res.data.code || 200;
// 获取错误信息,优先使用服务端消息
@@ -111,6 +132,11 @@ service.interceptors.response.use(
}
},
(error) => {
if (axios.isCancel(error)) return Promise.reject(error);
const response = error.response;
if (response && isPasswordChangeResponse(response.data, response.status)) {
return requirePasswordChange(response.data.msg);
}
console.log('err' + error);
let { message } = error;
if (message == 'Network Error') {
@@ -153,6 +179,10 @@ export function download(url, params, filename, config) {
downloadLoadingInstance.close();
})
.catch((r) => {
if (axios.isCancel(r)) {
downloadLoadingInstance.close();
return;
}
console.error(r);
Message.error('下载文件出现错误,请联系管理员!');
downloadLoadingInstance.close();

View File

@@ -0,0 +1,127 @@
const fs = require('fs');
const path = require('path');
const vm = require('vm');
const assert = require('assert');
const axios = require('axios');
const root = path.resolve(__dirname, '..');
const source = file => fs.readFileSync(path.join(root, file), 'utf8');
const withoutImports = js => js.replace(/^import .*$/gm, '');
module.exports = async function passwordChangeFlowTests() {
let checks = 0;
const check = (label, fn) => { fn(); checks++; console.log('PASS ' + label); };
const helper = {module:{exports:{}}};
vm.runInNewContext(source('src/utils/passwordChange.js').replace(/^export /gm, '')
+ '\nmodule.exports={PASSWORD_CHANGE_ROUTE,isPasswordChangeResponse,allowedWhileChangingPassword};',helper);
const helpers = helper.module.exports;
check('only explicit mandatory-password responses activate the gate', () => {
assert(helpers.isPasswordChangeResponse({needChangePwd:true},403));
assert(helpers.isPasswordChangeResponse({needChangePwd:true,code:403},200));
assert(!helpers.isPasswordChangeResponse({needChangePwd:true,code:200},200));
assert(!helpers.isPasswordChangeResponse({code:403},403));
});
check('password gate preserves essential endpoints with exact HTTP methods', () => {
assert(helpers.allowedWhileChangingPassword({url:'/getInfo?refresh=1',method:'get'}));
assert(helpers.allowedWhileChangingPassword({url:'/system/user/profile/updatePwd',method:'put'}));
assert(helpers.allowedWhileChangingPassword({url:'/logout',method:'post'}));
assert(!helpers.allowedWhileChangingPassword({url:'/system/user/profile/updatePwd',method:'get'}));
assert(!helpers.allowedWhileChangingPassword({url:'/iot/device/statistic',method:'get'}));
});
let required=false, token='local-test-token', navigationCount=0, releaseNavigation;
const messages=[], notifications=[];
const state={user:{needChangePwd:false},settings:{language:'zh-CN'}};
const router={currentRoute:{path:'/index'},replace:() => {
navigationCount++;
return new Promise(resolve => { releaseNavigation=resolve; });
}};
const store={state,commit:(name,value)=>{assert.strictEqual(name,'SET_NEED_CHANGE_PWD');state.user.needChangePwd=value;required=value;},dispatch:()=>Promise.resolve()};
const Message=options=>messages.push(options);
Message.error=msg=>messages.push(msg);
let closed=0;
const requestSandbox={module:{exports:{}},axios,router,store,getToken:()=>token,...helpers,
Notification:{error:options=>notifications.push(options)},Message,MessageBox:{confirm:()=>Promise.reject(new Error('unused'))},
Loading:{service:()=>({close:()=>{closed++;}})},errorCode:{default:'error'},
tansParams:()=>'',blobValidate:()=>true,cache:{session:{getJSON:()=>null,setJSON:()=>{}}},saveAs:()=>{},
process:{env:{VUE_APP_BASE_API:'/prod-api'}},console:{log:()=>{},warn:()=>{},error:()=>{}},location:{href:''}};
const requestJs=withoutImports(source('src/utils/request.js')).replace('export default service;', 'module.exports={service,download};').replace(/^export /gm,'');
vm.runInNewContext(requestJs,requestSandbox,{filename:'request.js'});
const {service,download}=requestSandbox.module.exports;
let adapterCalls=0;
service.defaults.adapter=async config=>{adapterCalls++;return {status:200,data:{code:200,value:'ok'},config,request:{responseType:''}};};
let result=await service.get('/iot/device/statistic');
check('normal homepage requests still reach the server',()=>assert(result.value==='ok' && adapterCalls===1));
state.user.needChangePwd=true;
await assert.rejects(service.get('/iot/device/statistic'),err=>axios.isCancel(err)&&err.needChangePwd===true);
check('restricted homepage requests never reach the network or show errors',()=>assert(adapterCalls===1 && messages.length===0 && notifications.length===0));
await service.get('/getInfo');
await service.put('/system/user/profile/updatePwd',{oldPassword:'old',newPassword:'new'});
await service.post('/logout');
check('getInfo password update and logout remain available',()=>assert.strictEqual(adapterCalls,4));
await service.get('/captchaImage',{headers:{isToken:false}});
check('anonymous login prerequisites are not blocked by a stale flag',()=>assert.strictEqual(adapterCalls,5));
await download('/iot/device/export',{},'test.xlsx');
check('blocked downloads close loading without a second error toast',()=>assert(closed===1 && messages.length===0 && adapterCalls===5));
releaseNavigation(); await new Promise(resolve=>setImmediate(resolve)); navigationCount=0;
state.user.needChangePwd=false;
service.defaults.adapter=async config=>{throw Object.assign(new Error('Request failed with status code 403'),{config,response:{status:403,data:{code:403,needChangePwd:true,msg:'change password'}}});};
const responses=await Promise.allSettled(Array.from({length:6},()=>service.get('/iot/device/statistic')));
check('concurrent mandatory-password 403s produce one redirect and no toasts',()=> {
assert(responses.every(r=>r.status==='rejected'&&axios.isCancel(r.reason)));
assert(required && navigationCount===1 && messages.length===0 && notifications.length===0);
});
releaseNavigation(); await new Promise(resolve=>setImmediate(resolve)); navigationCount=0;
state.user.needChangePwd=false;required=false;
service.defaults.adapter=async config=>{throw Object.assign(new Error('Request failed with status code 403'),{config,response:{status:403,data:{code:403,msg:'forbidden'}}});};
await assert.rejects(service.get('/iot/device/statistic'));
check('ordinary authorization errors still show their original error',()=>assert(messages.length===1 && navigationCount===0 && !required));
service.defaults.adapter=async config=>({status:200,data:{code:403,msg:'forbidden'},config,request:{responseType:''}});
await assert.rejects(service.get('/iot/device/statistic'));
check('ordinary application-level 403 notifications remain visible',()=>assert.strictEqual(notifications.length,1));
service.defaults.adapter=async config=>({status:200,data:{code:403,needChangePwd:true},config,request:{responseType:''}});
await assert.rejects(service.get('/iot/device/statistic'),err=>axios.isCancel(err));
check('application-level mandatory-password responses use the same quiet gate',()=>assert(required && messages.length===1 && notifications.length===1 && navigationCount===1));
releaseNavigation(); await new Promise(resolve=>setImmediate(resolve));
let guard,afterEach,serverRequires=true,generated=0,languageLoads=0;
let roles=[];state.user.needChangePwd=false; token='local-test-token';
const guardStore={state,getters:{get roles(){return roles;}},dispatch:async name=>{
if(name==='GetInfo'){state.user.needChangePwd=serverRequires;roles=['general'];}
if(name==='GenerateRoutes'){generated++;return [];}
if(name==='LogOut'){token='';roles=[];state.user.needChangePwd=false;}
}};
const guardSandbox={router:{beforeEach:fn=>{guard=fn;},afterEach:fn=>{afterEach=fn;},addRoutes:()=>{}},store:guardStore,
Message:{error:()=>{}},NProgress:{configure:()=>{},start:()=>{},done:()=>{}},getToken:()=>token,isRelogin:{show:false},
PASSWORD_CHANGE_ROUTE:helpers.PASSWORD_CHANGE_ROUTE,loadLanguageNames:()=>{languageLoads++;}};
vm.runInNewContext(withoutImports(source('src/permission.js')),guardSandbox,{filename:'permission.js'});
const navigate=path=>new Promise(resolve=>guard({path,fullPath:path,meta:{}},{path:'/'},resolve));
result=await navigate('/index');
check('refresh with a deleted browser flag redirects before generating business routes',()=>assert(result.path==='/change-password' && generated===0));
result=await navigate('/change-password');
check('the isolated password page is reachable without a redirect loop',()=>assert.strictEqual(result,undefined));
result=await navigate('/bigScreen');
check('direct business routes cannot mount while a password change is required',()=>assert.strictEqual(result.path,'/change-password'));
afterEach();check('restricted navigation does not request optional language metadata',()=>assert.strictEqual(languageLoads,0));
state.user.needChangePwd=false;serverRequires=false;roles=[];
result=await navigate('/index');
check('normal and demo sessions retain route generation and homepage navigation',()=>assert(result.path==='/index' && generated===1));
afterEach();check('normal navigation still loads configured language names',()=>assert.strictEqual(languageLoads,1));
result=await navigate('/change-password');
check('unrestricted users do not get trapped on the password page',()=>assert.strictEqual(result.path,'/index'));
token='';result=await navigate('/change-password');
check('anonymous access to the password page follows the normal login flow',()=>assert.strictEqual(result,'/login?redirect=/change-password'));
const context=function(){return {};};context.keys=()=>[];
const fakeRequire={context:()=>context};
let languageCalls=0;
const languageSandbox={module:{exports:{}},Vue:{use:()=>{}},Element:{},VueI18n:function(options){this.locale=options.locale;this.t=k=>k;},
locale:{use:()=>{}},enLocale:{},zhLocale:{},defaultSettings:{language:'zh-CN'},require:fakeRequire,
listLanguage:async()=>{languageCalls++;return {rows:[{language:'en-US',langName:'Custom English'}]};}};
vm.runInNewContext(withoutImports(source('src/lang/index.js')).replace('export { i18n, langs, loadLanguageNames };','module.exports={langs,loadLanguageNames};'),languageSandbox);
check('importing translations no longer starts an eager HTTP request',()=>assert.strictEqual(languageCalls,0));
const languageModule=languageSandbox.module.exports;
await Promise.all([languageModule.loadLanguageNames(),languageModule.loadLanguageNames()]);
await languageModule.loadLanguageNames();
check('language metadata is loaded once and preserves custom names',()=>assert(languageCalls===1 && languageModule.langs['en-US'].language==='Custom English'));
return checks;
};

View File

@@ -0,0 +1,62 @@
const fs = require('fs');
const path = require('path');
const vm = require('vm');
const assert = require('assert');
const compiler = require('vue-template-compiler');
const root = path.resolve(__dirname, '..');
let checks = 0;
function check(label, fn) { fn(); checks++; console.log('PASS ' + label); }
const source = p => fs.readFileSync(path.join(root, p), 'utf8');
async function main() {
for (const file of ['src/components/ForceChangePwd/index.vue', 'src/layout/index.vue']) {
const sfc = compiler.parseComponent(source(file));
check('compile ' + file, () => assert.deepStrictEqual(compiler.compile(sfc.template.content).errors, []));
}
for (const locale of ['zh-CN', 'en-US']) {
const messages = JSON.parse(source('src/lang/' + locale + '/common.json'));
check(locale + ' password prompt translations', () => assert(messages.forceChangePwdTitle && messages.forceChangePwdTip && messages.forceChangePwdConfirm));
}
const storage = new Map();
let info = {user: {userId: 42, userName: 'owner'}, roles: ['general'], permissions: [], needChangePwd: true};
const sandbox = {
module: {exports: {}}, process: {env: {}}, defAva: 'avatar',
localStorage: {getItem: k => storage.get(k), setItem: (k,v) => storage.set(k,v), removeItem: k => storage.delete(k)},
getToken: () => 'local-test-token', setToken: () => {}, removeToken: () => {},
getInfo: () => Promise.resolve(info), login: () => Promise.resolve({token:'test-token',needChangePwd:true}), logout: () => Promise.resolve(),
isHttp: () => false, isEmpty: value => !value,
};
const js = source('src/store/modules/user.js').replace(/^import .*$/gm, '').replace('export default user', 'module.exports = user');
vm.runInNewContext(js, sandbox, {filename: 'user.js'});
const store = sandbox.module.exports;
const commit = (name, value) => store.mutations[name](store.state, value);
const context = {commit, state: store.state};
check('missing client flag initially false', () => assert.strictEqual(store.state.needChangePwd, false));
await store.actions.GetInfo(context);
check('server restores mandatory change after localStorage removal', () => assert.strictEqual(store.state.needChangePwd, true));
info = {...info, needChangePwd: false};
await store.actions.GetInfo(context);
check('normal password does not display forced prompt', () => assert.strictEqual(store.state.needChangePwd, false));
await store.actions.Login(context, {username:'owner',password:'test'});
check('login response enables mandatory change', () => assert.strictEqual(store.state.needChangePwd, true));
await store.actions.LogOut(context);
check('logout clears token and prompt state', () => assert.strictEqual(store.state.token + ':' + store.state.needChangePwd, ':false'));
let submitted = null, loggedOut = false;
const componentSandbox = {module:{exports:{}}, location:{href:''}, updateUserPwd: input => {submitted=input; return Promise.resolve();}};
const componentScript = compiler.parseComponent(source('src/components/ForceChangePwd/index.vue')).script.content
.replace(/^import .*$/gm, '').replace('export default', 'module.exports =');
vm.runInNewContext(componentScript, componentSandbox, {filename:'ForceChangePwd.vue'});
const component = componentSandbox.module.exports;
const instance = {$t:k=>k, $refs:{form:{validate:cb=>cb(true)}}, $modal:{msgSuccess:()=>{}},
$store:{state:{user:{needChangePwd:true}}, commit:()=>{}, dispatch:()=>{loggedOut=true;return Promise.resolve();}}};
Object.assign(instance,component.data.call(instance));
instance.form.oldPassword='old'; instance.form.newPassword='NewPassword!'; instance.form.confirmPassword='NewPassword!';
component.methods.submit.call(instance);
await new Promise(resolve => setImmediate(resolve));
check('password form posts old and new password', () => assert(submitted.oldPassword==='old' && submitted.newPassword==='NewPassword!'));
check('successful change logs out and returns to login', () => assert(loggedOut && !instance.loading && componentSandbox.location.href==='/login'));
checks += await require('./password-change-flow.cjs')();
console.log('SECURITY_FRONTEND_PASS checks=' + checks);
}
main().catch(error => {console.error(error);process.exitCode=1;});