我们的项目使用的若依前后端分离框架,均需部署在客户提供的服务器上,运维工作也主要通过客户开放的远程方式开展。前一段上班路上老板突然打电话给我:
老板:“如果客户后续不续费了,咱们有没有办法把系统服务停止掉?”
我:“老板,要是客户一直给我们开放远程运维权限,我们就能直接操作停止服务;但如果客户把远程权限收回去了,我们就没辙了。”
老板:“那不行,这事儿必须解决,你赶紧想个办法,确保就算没有远程权限,客户不续费时,我们也能把服务停了。”
老板发了话,我只能立刻行动,开始围绕这一核心需求,梳理实现过程中需要规避的问题与具体落地思路。
一、核心需求与关键注意事项
要实现“客户不续费时可停止服务”的目标,首先要规避一系列潜在漏洞,毕竟所有程序和数据都部署在客户服务器上,客户存在篡改、破解的可能,因此以下5个关键点必须重点考量:
1. 过期时间不可存储在数据库:数据库部署在客户服务器,客户可随意修改数据库中的过期时间,导致管控失效;
2. 过期时间不可嵌入jar包:即便jar包经过加密处理,但jar包本身仍在客户服务器上,无法避免被客户破解、修改过期时间;
3. 不可仅在启动时判断过期:若系统启动后一直不重启,即便已过有效期,服务仍会持续运行,无法达到关停目的,需加入定时检测机制;
4. 不可使用若依自带定时任务:若依框架的定时任务可通过页面操作或数据库修改直接删除、关闭,客户可轻松规避检测,因此需采用Spring原生定时任务;
5. 需防范服务器时间回拨:若客户调整服务器时间,使系统时间始终小于过期时间,上述所有管控手段都会失效,需加入时间防篡改校验。
二、初始实现思路(原方案)
结合上述注意事项,我梳理出一套初始实现方案,核心是通过授权文件管控过期时间,搭配定时检测和防时间回拨机制,具体步骤如下:
1. 授权文件生成功能开发:开发授权文件生成功能,文件内包含过期时间和签名串,签名串用于防止客户篡改文件内容;
2. 授权文件部署:将生成的授权文件放置在服务器指定目录下,确保文件可被系统读取但不易被客户随意找到;
3. 启动时过期校验:系统启动时,自动读取授权文件,校验过期时间,若已过期则直接停止服务;
4. Spring原生定时任务配置:基于Spring原生定时任务,设置每天凌晨1点执行过期检测,若检测到已过期,立即停止服务;
5. Redis时间防回拨校验:定时任务启动时,若系统未过期,将当天时间存入Redis,后续检测时对比Redis中的时间与当前服务器时间,防范客户时间回拨;
6. 登录提醒功能:当授权有效期不足30天时,在用户登录系统时弹出提醒,告知客户及时续费;
方案落地后,我们在自己的服务器上进行了测试,所有功能均正常运行,看似完美解决了老板提出的需求。但在测试人员更换不同日期的授权文件进行二次测试时,一个隐藏的漏洞被意外发现。
三、漏洞发现与优化方案(新方案)
测试人员询问更换授权文件后是否需要重启系统,这让我意识到,初始方案存在两个关键漏洞,若不解决,会导致客户续费时出现服务异常:
1. 客户续费时,更换新的授权文件后,需要重启系统才能加载新的过期时间,若客户未及时重启,可能导致服务被误关停;
2. 若客户在授权到期最后一天续费,且新授权文件的过期时间从当天0点开始,而定时任务在每天凌晨1点执行,会出现“新授权已生效,但定时任务未及时加载,仍按旧授权判断过期”的时间差问题,导致服务误关停。
针对上述漏洞,我对方案进行了针对性优化,放弃了“频繁提高定时任务执行频率”的低效方案(会增加服务器负载,且大部分时间无实际意义),重点优化授权文件的加载机制,具体优化措施如下:
1. 授权文件信息记录:系统第一次读取授权文件时,同步记录文件的修改时间和文件内容(含签名串),存储在内存中;
2. 定时任务文件校验与重新加载:每天定时任务执行时,不仅检测过期时间,还会重新读取授权文件,对比当前文件的修改时间、内容与内存中记录的信息,若存在差异,立即加载新的授权文件,无需重启系统;
3. 手动刷新授权接口开发:针对授权到期最后一天续费的紧急情况,开发手动刷新授权文件接口,若出现时间差导致的异常,可通过该接口手动触发授权文件重新加载,快速解决问题;
优化完成后,再次进行多场景测试,无论是正常续费更换授权文件,还是紧急情况下的授权刷新,均能正常生效,彻底解决了初始方案的漏洞,确保授权管控功能既安全又灵活。
四、核心功能源代码
本节单独预留原方案、优化后方案的核心源代码位置,按功能模块分类,便于后续补充完善,对应前文实现思路与优化措施:
4.1 原方案(初始实现)源代码

@Componentpublic class LicenseManager { private static final Logger log = LoggerFactory.getLogger(LicenseManager.class); // 授权文件路径(和jar包同级) private static final String LICENSE_FILE = "./license.lic"; // 签名密钥(和生成时保持一致) private static final String SECRET_KEY = "ruoyi-2024"; // Redis防回拨key private static final String REDIS_PROOF_KEY = "license:proof:date"; @Autowired(required = false) private StringRedisTemplate redisTemplate; private LocalDate expiryDate; private boolean valid = false; private long lastFileCheck = 0; // ==================== 1. 启动检查 ==================== @PostConstruct public void initCheck() { log.info("========================================="); log.info("开始授权校验..."); log.info("授权文件路径:{}", new File(LICENSE_FILE).getAbsolutePath()); try { // 1.1 检查文件是否存在 File file = new File(LICENSE_FILE); if (!file.exists()) { log.error("授权文件不存在:{}", LICENSE_FILE); kill("授权文件不存在"); return; } // 1.2 读取并解密授权文件 String encrypted = new String(Files.readAllBytes(file.toPath())).trim(); String plainText = AesDecryptUtils.decrypt(encrypted); if (plainText == null) { log.error("授权文件解密失败"); kill("授权文件格式错误"); return; } log.info("授权文件解密成功:{}", plainText); // 1.3 解析日期和签名 String[] parts = plainText.split("\\|"); if (parts.length != 2) { log.error("授权文件格式错误,应为:日期|签名"); kill("授权文件格式错误"); return; } String dateStr = parts[0]; String signature = parts[1]; // 1.4 验证签名 String expectedSign = DigestUtils.md5DigestAsHex( (dateStr + SECRET_KEY).getBytes() ); if (!expectedSign.equals(signature)) { log.error("签名验证失败"); log.error("预期签名:{}", expectedSign); log.error("实际签名:{}", signature); kill("授权文件签名验证失败"); return; } // 1.5 解析过期日期 expiryDate = LocalDate.parse(dateStr); // 1.6 检查是否过期 LocalDate now = LocalDate.now(); if (now.isAfter(expiryDate)) { log.error("系统已过期!过期日期:{},当前日期:{}", expiryDate, now); kill("系统已过期"); return; } // 1.7 检查时间是否被回拨 checkTimeRollback(); valid = true; log.info("✅ 授权校验通过,有效期至:{}", expiryDate); log.info("剩余有效期:{}天", ChronoUnit.DAYS.between(now, expiryDate)); // 1.8 记录文件修改时间 lastFileCheck = file.lastModified(); // 1.9 启动文件监控 startFileMonitor(); } catch (Exception e) { log.error("授权校验异常", e); kill("校验异常:" + e.getMessage()); } } // ==================== 2. 定时检查(每天2次)==================== @Scheduled(cron = "0 0 1 * * ?") // 凌晨2点 public void scheduledCheck() { if (!valid) return; log.info("执行定时授权检查..."); try { LocalDate now = LocalDate.now(); if (now.isAfter(expiryDate)) { log.error("定时检查:系统已过期(过期日期:{})", expiryDate); kill("定时检查触发"); } else { log.info("定时检查通过,剩余有效期:{}天", ChronoUnit.DAYS.between(now, expiryDate)); } } catch (Exception e) { log.error("定时检查异常", e); } } // ==================== 3. 时间回拨检查 ==================== private void checkTimeRollback() { if (redisTemplate == null) { log.warn("Redis未配置,跳过时间回拨检查"); return; } try { String today = LocalDate.now().toString(); String yesterday = redisTemplate.opsForValue().get(REDIS_PROOF_KEY); // 如果昨天记录的时间比今天还大,说明时间被回拨了 if (yesterday != null && LocalDate.parse(yesterday).isAfter(LocalDate.now())) { log.error("检测到时间回拨!昨天记录:{},今天实际:{}", yesterday, today); kill("时间回拨"); return; } // 记录今天的时间 redisTemplate.opsForValue().set(REDIS_PROOF_KEY, today, 30, TimeUnit.DAYS); } catch (Exception e) { log.error("Redis时间检查异常", e); } } // ==================== 4. 文件监控 ==================== private void startFileMonitor() { Thread monitor = new Thread(() -> { while (valid) { try { Thread.sleep(60 * 60 * 1000); // 每小时检查一次 File file = new File(LICENSE_FILE); long lastModified = file.lastModified(); if (lastModified != lastFileCheck) { log.warn("检测到授权文件发生变化,重新校验..."); // 重新读取校验 String encrypted = new String(Files.readAllBytes(file.toPath())).trim(); String plainText = AesDecryptUtils.decrypt(encrypted); if (plainText == null) { kill("授权文件被篡改"); return; } String[] parts = plainText.split("\\|"); if (parts.length != 2) { kill("授权文件格式错误"); return; } String dateStr = parts[0]; String signature = parts[1]; String expectedSign = DigestUtils.md5DigestAsHex( (dateStr + SECRET_KEY).getBytes() ); if (!expectedSign.equals(signature)) { kill("授权文件签名失效"); return; } LocalDate newExpiry = LocalDate.parse(dateStr); if (LocalDate.now().isAfter(newExpiry)) { kill("授权文件已过期"); return; } expiryDate = newExpiry; lastFileCheck = lastModified; log.info("授权文件重新校验通过,新有效期:{}", expiryDate); } } catch (InterruptedException e) { break; } catch (Exception e) { log.error("文件监控异常", e); } } }); monitor.setName("license-file-monitor"); monitor.setDaemon(true); monitor.start(); log.info("授权文件监控已启动"); } // ==================== 5. 杀进程 ==================== private void kill(String reason) { log.error("=========================================="); log.error("系统授权失效,执行终止!"); log.error("授权文件:{}", LICENSE_FILE); log.error("过期日期:{}", expiryDate); log.error("当前日期:{}", LocalDate.now()); log.error("触发原因:{}", reason); log.error("=========================================="); // 写入日志文件 try { Files.write( Paths.get("./license_kill.log"), (LocalDateTime.now() + " 终止原因:" + reason + "\n").getBytes(), java.nio.file.StandardOpenOption.CREATE, java.nio.file.StandardOpenOption.APPEND ); } catch (Exception e) { // ignore } valid = false; // 30秒后强制退出 new Thread(() -> { try { Thread.sleep(30000); } catch (Exception e) {} Runtime.getRuntime().halt(1); }).start(); System.exit(-1); } // ==================== 6. 对外接口 ==================== /** * 登录检查 */ public void checkLogin() { if (!valid) { throw new RuntimeException("系统授权异常,请联系供应商"); } if (LocalDate.now().isAfter(expiryDate)) { throw new RuntimeException("系统已过期,请联系供应商"); } }}4.2 优化后方案(新方案)源代码
@Slf4j@Componentpublic class LicenseManager { // 授权文件路径(和jar包同级) private static final String LICENSE_FILE = "./license.lic"; // 签名密钥(和生成时保持一致) private static final String SECRET_KEY = "sty-license-2026"; // Redis防回拨key private static final String REDIS_PROOF_KEY = "license:proof:date"; @Autowired(required = false) private StringRedisTemplate redisTemplate; private LocalDate expiryDate; private boolean valid = false; private long lastFileCheck = 0; private String lastFileHash = null; // 记录文件内容哈希 // ==================== 1. 启动检查 ==================== @PostConstruct public void initCheck() { log.info("========================================="); log.info("开始授权校验..."); log.info("授权文件路径:{}", new File(LICENSE_FILE).getAbsolutePath()); try { // 1.1 检查文件是否存在 File file = new File(LICENSE_FILE); if (!file.exists()) { log.error("授权文件不存在:{}", LICENSE_FILE); kill("授权文件不存在"); return; } // 1.2 读取并解密授权文件 String encrypted = new String(Files.readAllBytes(file.toPath())).trim(); String plainText = AesDecryptUtils.decrypt(encrypted); if (plainText == null) { log.error("授权文件解密失败"); kill("授权文件格式错误"); return; } log.info("授权文件解密成功:{}", plainText); //log.info("授权文件解密成功"); // 1.3 解析日期和签名 String[] parts = plainText.split("\\|"); if (parts.length != 2) { log.error("授权文件格式错误,应为:日期|签名"); kill("授权文件格式错误"); return; } String dateStr = parts[0]; String signature = parts[1]; // 1.4 验证签名 String expectedSign = DigestUtils.md5DigestAsHex( (dateStr + SECRET_KEY).getBytes() ); if (!expectedSign.equals(signature)) { log.error("签名验证失败"); log.error("预期签名:{}", expectedSign); log.error("实际签名:{}", signature); kill("授权文件签名验证失败"); return; } // 1.5 解析过期日期 expiryDate = LocalDate.parse(dateStr); // 1.6 检查是否过期 LocalDate now = LocalDate.now(); if (now.compareTo(expiryDate) >= 0) { log.error("系统已过期!过期日期:{},当前日期:{}", expiryDate, now); kill("系统已过期"); return; } // 1.7 检查时间是否被回拨 checkTimeRollback(); valid = true; log.info("✅ 授权校验通过,有效期至:{}", expiryDate); log.info("剩余有效期:{}天", ChronoUnit.DAYS.between(now, expiryDate)); // 1.8 记录文件修改时间和内容哈希 lastFileCheck = file.lastModified(); lastFileHash = DigestUtils.md5DigestAsHex(Files.readAllBytes(file.toPath())); } catch (Exception e) { log.error("授权校验异常", e); kill("校验异常:" + e.getMessage()); } } // ==================== 2. 定时检查(每天 1 次)==================== @Scheduled(cron = "0 0 1 * * ?") // 凌晨 1 点 public void scheduledCheck() { log.info("执行定时授权检查..."); try { // 2.1 检查授权文件是否被更新 File file = new File(LICENSE_FILE); if (file.exists()) { long currentLastModified = file.lastModified(); String currentHash = DigestUtils.md5DigestAsHex(Files.readAllBytes(file.toPath())); // 如果文件修改时间或内容发生变化,重新加载授权文件 if (currentLastModified != lastFileCheck || !currentHash.equals(lastFileHash)) { log.info("检测到授权文件已更新,重新加载授权校验..."); loadLicenseFile(currentLastModified, currentHash, true); return; } } // 2.2 如果没有更新,执行常规检查 if (!valid) { log.error("授权未通过,跳过定时检查"); return; } LocalDate now = LocalDate.now(); if (now.compareTo(expiryDate) >= 0) { log.error("定时检查:系统已过期(过期日期:{})", expiryDate); kill("定时检查触发"); } else { log.info("定时检查通过,剩余有效期:{}天", ChronoUnit.DAYS.between(now, expiryDate)); } } catch (Exception e) { log.error("定时检查异常", e); } } /** * 对外公开的授权文件刷新方法(供 Controller 调用) * @return 刷新结果 true-成功,false-失败 */ public boolean reloadLicenseFromFile() { try { log.info("开始手动刷新授权文件..."); File file = new File(LICENSE_FILE); if (!file.exists()) { log.error("授权文件不存在:{}", LICENSE_FILE); return false; } // 检查文件是否发生变化 long currentLastModified = file.lastModified(); String currentHash = DigestUtils.md5DigestAsHex(Files.readAllBytes(file.toPath())); if (currentLastModified == lastFileCheck && currentHash.equals(lastFileHash)) { log.info("授权文件未发生变化,无需刷新"); log.info("当前授权有效期至:{}", expiryDate); log.info("剩余有效期:{}天", ChronoUnit.DAYS.between(LocalDate.now(), expiryDate)); return true; // 文件没变化,但也算成功 } // 执行重新加载(不触发 kill) return loadLicenseFile(currentLastModified, currentHash, false); } catch (Exception e) { log.error("手动刷新授权文件异常", e); return false; } } /** * 通用授权文件加载方法 * @param newLastModified 新文件最后修改时间 * @param newHash 新文件内容哈希 * @param shouldKill 验证失败时是否终止系统 * @return 加载结果 true-成功,false-失败 */ private boolean loadLicenseFile(long newLastModified, String newHash, boolean shouldKill) { try { // 读取并解密授权文件 String encrypted = new String(Files.readAllBytes(Paths.get(LICENSE_FILE))).trim(); String plainText = AesDecryptUtils.decrypt(encrypted); if (plainText == null) { log.error("授权文件解密失败"); if (shouldKill) { kill("授权文件格式错误"); } else { valid = false; } return false; } log.info("授权文件解密成功"); // 解析日期和签名 String[] parts = plainText.split("\\|"); if (parts.length != 2) { log.error("授权文件格式错误,应为:日期 | 签名"); if (shouldKill) { kill("授权文件格式错误"); } else { valid = false; } return false; } String dateStr = parts[0]; String signature = parts[1]; // 验证签名 String expectedSign = DigestUtils.md5DigestAsHex( (dateStr + SECRET_KEY).getBytes() ); if (!expectedSign.equals(signature)) { log.error("授权文件签名验证失败"); if (shouldKill) { kill("授权文件签名验证失败"); } else { valid = false; } return false; } // 解析过期日期 expiryDate = LocalDate.parse(dateStr); // 检查是否过期 LocalDate now = LocalDate.now(); if (now.compareTo(expiryDate) >= 0) { log.error("授权文件已过期!过期日期:{},当前日期:{}", expiryDate, now); if (shouldKill) { kill("授权文件已过期"); } else { valid = false; } return false; } // 更新文件记录信息 lastFileCheck = newLastModified; lastFileHash = newHash; valid = true; log.info("✅ 授权文件加载成功,有效期至:{}", expiryDate); log.info("剩余有效期:{}天", ChronoUnit.DAYS.between(now, expiryDate)); return true; } catch (Exception e) { log.error("加载授权文件异常", e); if (shouldKill) { kill("加载授权文件异常:" + e.getMessage()); } else { valid = false; } return false; } } // ==================== 3. 时间回拨检查 ==================== private void checkTimeRollback() { if (redisTemplate == null) { log.warn("Redis未配置,跳过时间回拨检查"); return; } try { String today = LocalDate.now().toString(); String yesterday = redisTemplate.opsForValue().get(REDIS_PROOF_KEY); // 如果昨天记录的时间比今天还大,说明时间被回拨了 if (yesterday != null && LocalDate.parse(yesterday).isAfter(LocalDate.now())) { log.error("检测到时间回拨!昨天记录:{},今天实际:{}", yesterday, today); kill("时间回拨"); return; } // 记录今天的时间 redisTemplate.opsForValue().set(REDIS_PROOF_KEY, today, 30, TimeUnit.DAYS); } catch (Exception e) { log.error("Redis时间检查异常", e); } } // ==================== 5. 杀进程 ==================== private void kill(String reason) { log.error("=========================================="); log.error("系统授权失效,执行终止!"); log.error("授权文件:{}", LICENSE_FILE); log.error("过期日期:{}", expiryDate); log.error("当前日期:{}", LocalDate.now()); log.error("触发原因:{}", reason); log.error("=========================================="); DateTimeFormatter formatter = DateTimeFormatter.ofPattern("yyyy-MM-dd HH:mm:ss"); String now = LocalDateTime.now().format(formatter); // 写入日志文件 try { Files.write( Paths.get("./logs/license_kill.log"), (now + " 终止原因:" + reason + "\n").getBytes(), java.nio.file.StandardOpenOption.CREATE, java.nio.file.StandardOpenOption.APPEND ); } catch (Exception e) { // ignore } valid = false; // 30秒后强制退出 new Thread(() -> { try { Thread.sleep(30000); } catch (Exception e) {} Runtime.getRuntime().halt(1); }).start(); System.exit(-1); } // ==================== 6. 对外接口 ==================== /** * 登录检查 */ public void checkLogin() { if (!valid) { throw new RuntimeException("系统授权异常,请联系软件供应商"); } if (LocalDate.now().compareTo(expiryDate) >= 0) { throw new RuntimeException("系统已过期,请联系软件供应商"); } }}五、系统流程图
5.1 原方案(初始实现)系统流程图
5.2 新方案(优化后)系统流程图
六、总结
本次授权管控功能的实现,从最初的需求拆解、注意事项梳理,到初始方案落地、漏洞发现与优化,全程围绕“安全、可靠、灵活”的核心目标。核心难点在于,所有管控逻辑都需规避“客户篡改数据、破解程序”的风险,同时兼顾客户续费时的使用体验,避免出现服务误关停的情况。
通过授权文件+Spring定时任务+Redis时间校验的组合方式,既解决了客户不续费时的服务关停问题,又通过优化授权文件加载机制,解决了续费场景下的漏洞,最终实现了管控需求与用户体验的平衡。此次复盘也让我深刻意识到,技术实现不仅要满足核心需求,更要考虑到各种边界场景,多维度测试才能避免隐藏漏洞,确保功能稳定落地。
#若依框架 #前后端分离 #Java开发 #Spring定时任务 #授权管控 #项目运维 #Redis应用 #技术复盘