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