前后端分离框架(若依框架实现软件授权:客户改系统时间想白嫖?直接自动停服)

前后端分离框架(若依框架实现软件授权:客户改系统时间想白嫖?直接自动停服)
若依框架实现软件授权:客户改系统时间想白嫖?直接自动停服

我们的项目使用的若依前后端分离框架,均需部署在客户提供的服务器上,运维工作也主要通过客户开放的远程方式开展。前一段上班路上老板突然打电话给我:

老板:“如果客户后续不续费了,咱们有没有办法把系统服务停止掉?”

我:“老板,要是客户一直给我们开放远程运维权限,我们就能直接操作停止服务;但如果客户把远程权限收回去了,我们就没辙了。”

老板:“那不行,这事儿必须解决,你赶紧想个办法,确保就算没有远程权限,客户不续费时,我们也能把服务停了。”

老板发了话,我只能立刻行动,开始围绕这一核心需求,梳理实现过程中需要规避的问题与具体落地思路。

一、核心需求与关键注意事项

要实现“客户不续费时可停止服务”的目标,首先要规避一系列潜在漏洞,毕竟所有程序和数据都部署在客户服务器上,客户存在篡改、破解的可能,因此以下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应用 #技术复盘

文章版权声明:除非注明,否则均为边学边练网络文章,版权归原作者所有