mirror of
https://gitee.com/beecue/fastbee.git
synced 2026-11-06 17:09:11 +08:00
fix(规则脚本): 规则引擎 Groovy 脚本注入导致远程代码执行漏洞修复
This commit is contained in:
1
.security-fix/settings.xml
Normal file
1
.security-fix/settings.xml
Normal 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>
|
||||
107
.security-fix/verification.json
Normal file
107
.security-fix/verification.json
Normal 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"
|
||||
}
|
||||
}
|
||||
@@ -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>
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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();
|
||||
}
|
||||
|
||||
@@ -102,3 +102,9 @@ xss:
|
||||
enabled: true # 过滤开关
|
||||
excludes: /system/notice # 排除链接(多个用逗号分隔)
|
||||
urlPatterns: /system/*,/monitor/*,/tool/* # 匹配链接
|
||||
|
||||
# 规则脚本统一使用受限编译器和业务能力白名单,禁止关闭安全边界。
|
||||
script:
|
||||
sandbox:
|
||||
enabled: true
|
||||
allow-network: true # 保留既有 HTTP 集成;设为 false 禁止脚本主动 HTTP 调用
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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(); }
|
||||
}
|
||||
}
|
||||
@@ -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>
|
||||
@@ -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() {
|
||||
}
|
||||
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
|
||||
@@ -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 用户名
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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)
|
||||
{
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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); }
|
||||
}
|
||||
@@ -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));
|
||||
}
|
||||
}
|
||||
@@ -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); }
|
||||
}
|
||||
@@ -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(); }
|
||||
}
|
||||
@@ -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(); }
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1 @@
|
||||
com.fastbee.ruleEngine.security.SecureGroovyScriptExecutor
|
||||
@@ -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"));
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
@@ -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);
|
||||
@@ -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(){
|
||||
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
|
||||
@@ -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*/
|
||||
|
||||
@@ -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());
|
||||
});
|
||||
}
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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()));
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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",
|
||||
|
||||
101
vue/src/components/ForceChangePwd/index.vue
Normal file
101
vue/src/components/ForceChangePwd/index.vue
Normal 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>
|
||||
@@ -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"
|
||||
}
|
||||
|
||||
@@ -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 };
|
||||
|
||||
@@ -132,5 +132,8 @@
|
||||
"batchCancelAuth": "批量取消授权",
|
||||
"cancelAuth": "取消授权",
|
||||
"clean": "清空",
|
||||
"cleanSuccess": "清空成功"
|
||||
"cleanSuccess": "清空成功",
|
||||
"forceChangePwdTitle": "请修改初始密码",
|
||||
"forceChangePwdTip": "检测到当前账号仍在使用出厂默认口令,该口令已公开且极易被爆破。为保障平台与设备安全,请立即设置新密码,修改后需要重新登录。",
|
||||
"forceChangePwdConfirm": "确认修改并重新登录"
|
||||
}
|
||||
|
||||
@@ -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()
|
||||
})
|
||||
|
||||
@@ -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'),
|
||||
|
||||
@@ -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()
|
||||
})
|
||||
|
||||
14
vue/src/utils/passwordChange.js
Normal file
14
vue/src/utils/passwordChange.js
Normal 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');
|
||||
}
|
||||
@@ -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();
|
||||
|
||||
127
vue/tests/password-change-flow.cjs
Normal file
127
vue/tests/password-change-flow.cjs
Normal 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;
|
||||
};
|
||||
62
vue/tests/security-regression.cjs
Normal file
62
vue/tests/security-regression.cjs
Normal 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;});
|
||||
Reference in New Issue
Block a user