From 2090939e8727f8a8b23d97dc640495ef5a83b2d5 Mon Sep 17 00:00:00 2001 From: youpaishidifu <1@ypshidifu.cn> Date: Thu, 22 Oct 2026 23:25:01 +0800 Subject: [PATCH] =?UTF-8?q?=E7=AC=AC47=E6=89=B9=EF=BC=9A2FA=E7=BB=91?= =?UTF-8?q?=E5=AE=9A=E7=8A=B6=E6=80=81=E4=B8=8E=E7=BD=91=E9=A1=B5=E7=AB=AF?= =?UTF-8?q?=E5=8F=8C=E5=90=91=E5=90=8C=E6=AD=A5=E2=80=94=E2=80=94users?= =?UTF-8?q?=E8=A1=A8=E6=96=B0=E5=A2=9Etwofa=5Fbound=5Fat=EF=BC=9BWebManage?= =?UTF-8?q?r=E6=96=B0=E5=A2=9Epull=5Fstate/push=5Fstate=EF=BC=9BTwoFactorM?= =?UTF-8?q?anager=E5=90=8C=E6=AD=A5=E5=90=88=E5=B9=B6=E9=80=BB=E8=BE=91(?= =?UTF-8?q?=E5=AF=BC=E5=85=A5/=E6=96=B0=E8=80=85=E8=83=9C/=E8=A7=A3?= =?UTF-8?q?=E7=BB=91=E4=BC=A0=E6=92=AD)+4=E5=86=99=E5=85=A5=E7=82=B9?= =?UTF-8?q?=E6=8E=A8=E9=80=81+=E8=BF=9B=E6=9C=8D=E9=9D=99=E9=BB=98?= =?UTF-8?q?=E5=90=8C=E6=AD=A5=EF=BC=9BMain=E8=BF=9B=E6=9C=8D=E6=8C=82?= =?UTF-8?q?=E7=82=B9?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/main/java/Sdf1_login/DatabaseManager.java | 4 + src/main/java/Sdf1_login/Main.java | 4 + .../java/Sdf1_login/TwoFactorManager.java | 168 +++++++++++++++++- src/main/java/Sdf1_login/WebManager.java | 88 +++++++++ 4 files changed, 263 insertions(+), 1 deletion(-) diff --git a/src/main/java/Sdf1_login/DatabaseManager.java b/src/main/java/Sdf1_login/DatabaseManager.java index e48aa3e..d861114 100644 --- a/src/main/java/Sdf1_login/DatabaseManager.java +++ b/src/main/java/Sdf1_login/DatabaseManager.java @@ -133,6 +133,10 @@ public class DatabaseManager { "TEXT DEFAULT ''"); safeAdd(st, "twofa_pending_at", "INTEGER DEFAULT 0"); + // ★ 第47批:绑定时间(秒)——与网页端 web_account_2fa.bound_at 对齐, + // 双向同步冲突时"取时间新者"用 + safeAdd(st, "twofa_bound_at", + "INTEGER DEFAULT 0"); // ===== 2FA 恢复代码(SHA-256 逗号连接存库,明文只发邮件,一次性有效)===== safeAdd(st, "twofa_recovery_codes", "TEXT DEFAULT ''"); diff --git a/src/main/java/Sdf1_login/Main.java b/src/main/java/Sdf1_login/Main.java index 5d9b22c..ba61d11 100644 --- a/src/main/java/Sdf1_login/Main.java +++ b/src/main/java/Sdf1_login/Main.java @@ -3181,6 +3181,10 @@ public class Main extends JavaPlugin if (!db.userExists(name)) { webManager.syncUserOnJoin(name); } + // 第47批:进服静默同步网页端 2FA 绑定状态(登录风控 isEnabled 读到同步后结果) + if (twofa != null) { + twofa.syncOnJoin(name); + } } // ★ 同IP判断:只作为开启自动登录验证的钥匙,不作为自动登录依据 diff --git a/src/main/java/Sdf1_login/TwoFactorManager.java b/src/main/java/Sdf1_login/TwoFactorManager.java index 3db942e..7a09d84 100644 --- a/src/main/java/Sdf1_login/TwoFactorManager.java +++ b/src/main/java/Sdf1_login/TwoFactorManager.java @@ -84,6 +84,13 @@ public class TwoFactorManager { /** /2fa 命令分发,恒返回 true */ public boolean handleCommand(Player p, String[] args) { + // 第47批:先异步同步网页端绑定状态再分发(网页已绑定 → 游戏内立即显示/生效, + // add 也不会对着网页绑定重复申请);网络失败静默按本地状态走 + syncWithWebThen(p, pl -> dispatchSub(pl, args)); + return true; + } + + private void dispatchSub(Player p, String[] args) { String sub = args.length >= 1 ? args[0].toLowerCase() : ""; switch (sub) { case "add": @@ -113,7 +120,6 @@ public class TwoFactorManager { sendHelp(p); break; } - return true; } public void sendHelp(Player p) { @@ -271,6 +277,10 @@ public class TwoFactorManager { plugin.getDb().setField(name, "twofa_enabled", 1); plugin.getDb().setField(name, "twofa_pending_secret", ""); plugin.getDb().setField(name, "twofa_pending_at", 0L); + markBoundNow(name); + // 第47批:绑定结果同步到网页端(网页安全卡随之显示已绑定) + pushStateAsync(name, true, pending, + System.currentTimeMillis() / 1000L); p.sendMessage("§a§l[2FA] §a二次验证绑定成功!"); p.sendMessage("§7 此后敏感操作需提供认证器动态码"); @@ -370,6 +380,9 @@ public class TwoFactorManager { plugin.getDb().setField(name, "twofa_secret", ""); plugin.getDb().setField(name, "twofa_enabled", 0); plugin.getDb().setField(name, "twofa_recovery_codes", ""); + plugin.getDb().setField(name, "twofa_bound_at", 0L); + // 第47批:解绑结果同步到网页端 + pushStateAsync(name, false, "", System.currentTimeMillis() / 1000L); plugin.getLogger().info("[2FA] 玩家 " + name + " 使用恢复代码通过验证,已自动解绑二次验证"); sendUnbindAlarm(name, "恢复代码解绑", "玩家本人(恢复代码)", @@ -653,6 +666,9 @@ public class TwoFactorManager { plugin.getDb().setField(name, "twofa_enabled", 0); // 解绑要彻底:恢复代码一并作废,避免残留可再次触发解绑/验证 plugin.getDb().setField(name, "twofa_recovery_codes", ""); + plugin.getDb().setField(name, "twofa_bound_at", 0L); + // 第47批:解绑结果同步到网页端 + pushStateAsync(name, false, "", System.currentTimeMillis() / 1000L); Player pl = Bukkit.getPlayer(uuid); String srcIp = ""; if (pl != null && pl.isOnline()) { @@ -810,6 +826,9 @@ public class TwoFactorManager { plugin.getDb().setField(target, "twofa_recovery_codes", ""); plugin.getDb().setField(target, "twofa_pending_secret", ""); plugin.getDb().setField(target, "twofa_pending_at", 0L); + plugin.getDb().setField(target, "twofa_bound_at", 0L); + // 第47批:解绑结果同步到网页端 + pushStateAsync(target, false, "", System.currentTimeMillis() / 1000L); out.sendMessage("§a[2FA] 强制解绑成功:玩家 " + target); out.sendMessage("§7 已清空 twofa_secret / twofa_enabled / " @@ -896,6 +915,153 @@ public class TwoFactorManager { return true; } + // ==================== 网页端绑定状态双向同步(第47批) ==================== + + /** + * 异步拉取网页端 2FA 状态并合并到本地,回主线程后执行后续动作。 + * 网络失败/未启用:静默按本地状态直接继续,绝不阻塞命令。 + */ + private void syncWithWebThen(Player p, java.util.function.Consumer next) { + if (plugin.webManager == null) { + next.accept(p); + return; + } + final UUID uuid = p.getUniqueId(); + final String name = p.getName(); + Bukkit.getScheduler().runTaskAsynchronously(plugin, () -> { + WebManager.TwoFaWebState web = null; + try { + web = plugin.webManager.pullTwoFactorState(name); + } catch (Exception e) { + plugin.getLogger().warning("[2FA] 网页状态拉取异常: " + e.getMessage()); + } + final WebManager.TwoFaWebState st = web; + Bukkit.getScheduler().runTask(plugin, () -> { + Player pl = Bukkit.getPlayer(uuid); + if (pl == null || !pl.isOnline()) return; + try { + applyWebState(pl, st); + } catch (Exception e) { + plugin.getLogger().warning("[2FA] 网页状态合并异常: " + e.getMessage()); + } + next.accept(pl); + }); + }); + } + + /** + * 合并网页端状态到本地(主线程)。 + * 规则:网页绑定而本地未绑定 → 导入;两端都绑定但密钥不同 → bound_at 新者胜; + * 网页已解绑且比本地绑定更新 → 本地解绑;本地新 → 反向推送覆盖网页。 + * 恢复代码不做跨端同步(网页 16 位明文 vs 游戏 6 位 SHA-256,格式不兼容,各自维护)。 + */ + private void applyWebState(Player p, WebManager.TwoFaWebState web) { + if (web == null) return; // 拉取失败:保持本地状态 + String name = p.getName(); + boolean localBound = isEnabled(name); + String localSecret = str(plugin.getDb().getField(name, "twofa_secret")); + long localBoundAt = longOf(plugin.getDb().getField(name, "twofa_bound_at")); + + if (web.bound && !web.secret.isEmpty()) { + if (!localBound) { + // 网页已绑定、本地未绑定 → 导入(修复截图场景) + plugin.getDb().setField(name, "twofa_secret", web.secret); + plugin.getDb().setField(name, "twofa_enabled", 1); + plugin.getDb().setField(name, "twofa_bound_at", web.boundAt); + plugin.getDb().setField(name, "twofa_pending_secret", ""); + plugin.getDb().setField(name, "twofa_pending_at", 0L); + p.sendMessage("§a§l[2FA] §a已同步网页端的二次验证绑定"); + p.sendMessage("§7 此后敏感操作需提供认证器动态码;网页端恢复代码在游戏内不可用"); + plugin.getLogger().info("[2FA] 已导入网页端绑定: " + name + + " (bound_at=" + web.boundAt + ")"); + } else if (!localSecret.equals(web.secret)) { + if (web.boundAt > localBoundAt) { + // 网页端换绑更新 → 覆盖本地 + plugin.getDb().setField(name, "twofa_secret", web.secret); + plugin.getDb().setField(name, "twofa_bound_at", web.boundAt); + p.sendMessage("§e§l[2FA] §e检测到网页端更新的绑定,已切换为新密钥"); + plugin.getLogger().info("[2FA] 密钥冲突取网页端(更新): " + name); + } else { + // 本地更新 → 反向推送覆盖网页 + plugin.getLogger().info("[2FA] 密钥冲突取本地(更新): " + name); + pushStateAsync(name, true, localSecret, localBoundAt); + } + } + return; + } + + // 网页端未绑定/无记录 + if (localBound) { + if (web.exists && web.updatedAt > localBoundAt) { + // 网页端主动解绑且更晚 → 本地跟随解绑 + plugin.getDb().setField(name, "twofa_secret", ""); + plugin.getDb().setField(name, "twofa_enabled", 0); + plugin.getDb().setField(name, "twofa_recovery_codes", ""); + plugin.getDb().setField(name, "twofa_bound_at", 0L); + p.sendMessage("§e§l[2FA] §e网页端已解除二次验证,游戏内同步解绑"); + plugin.getLogger().info("[2FA] 网页端解绑传播到本地: " + name); + } else if (!web.exists) { + // 网页端无记录(本地绑定未推送过)→ 推送本地绑定到网页 + pushStateAsync(name, true, localSecret, localBoundAt); + } + } + // 两端都未绑定:无需动作(网页端 pending 待确认态不导入) + } + + /** 异步推送本地绑定状态到网页端(解绑/绑定收口统一走这里) */ + private void pushStateAsync(String name, boolean bound, String secret, long boundAt) { + if (plugin.webManager == null) return; + Bukkit.getScheduler().runTaskAsynchronously(plugin, () -> { + try { + plugin.webManager.pushTwoFactorState(name, bound, secret, boundAt); + } catch (Exception e) { + plugin.getLogger().warning("[2FA] 推送绑定状态异常: " + e.getMessage()); + } + }); + } + + /** + * 玩家进服时静默同步(第47批,Main.PlayerJoinEvent 调用): + * 网页端绑定在进服即落本地,登录 IP 风控的 isEnabled() 读到的就是同步后状态。 + * 无回调、无提示(除非真的导入了网页绑定);网络失败静默跳过。 + */ + public void syncOnJoin(String playerName) { + if (plugin.webManager == null || playerName == null || playerName.isEmpty()) return; + final String name = playerName; + Bukkit.getScheduler().runTaskAsynchronously(plugin, () -> { + WebManager.TwoFaWebState web = null; + try { + web = plugin.webManager.pullTwoFactorState(name); + } catch (Exception e) { + plugin.getLogger().warning("[2FA] 进服同步拉取异常: " + e.getMessage()); + } + final WebManager.TwoFaWebState st = web; + Bukkit.getScheduler().runTask(plugin, () -> { + try { + Player pl = Bukkit.getPlayerExact(name); + if (pl == null || !pl.isOnline()) return; // 已下线:跳过(无处提示,/2fa 时还会再同步) + applyWebState(pl, st); + } catch (Exception e) { + plugin.getLogger().warning("[2FA] 进服同步合并异常: " + e.getMessage()); + } + }); + }); + } + + /** 本地绑定时刻(秒)—— 绑定完成时写入,供与网页端 bound_at 比新旧 */ + private void markBoundNow(String name) { + plugin.getDb().setField(name, "twofa_bound_at", System.currentTimeMillis() / 1000L); + } + + private static long longOf(Object v) { + if (v instanceof Number) return ((Number) v).longValue(); + try { + return Long.parseLong(String.valueOf(v).trim()); + } catch (Exception e) { + return 0L; + } + } + // ==================== 状态读取 ==================== private boolean isEnabled(Player p) { diff --git a/src/main/java/Sdf1_login/WebManager.java b/src/main/java/Sdf1_login/WebManager.java index 16c2bb4..5fa5ab4 100644 --- a/src/main/java/Sdf1_login/WebManager.java +++ b/src/main/java/Sdf1_login/WebManager.java @@ -3755,6 +3755,94 @@ public class WebManager { } } + // ==================== 2FA 绑定状态双向同步(第47批) ==================== + + /** pull_state 返回的网页端 2FA 状态快照(Java 侧不可变结果) */ + public static final class TwoFaWebState { + /** 网页端是否有 2FA 记录(含未绑定的待确认态) */ + public final boolean exists; + /** 网页端是否已完成绑定 */ + public final boolean bound; + /** 网页端 TOTP 密钥(仅 bound 时非空) */ + public final String secret; + /** 网页端绑定时间(秒) */ + public final long boundAt; + /** 网页端最后更新时间(秒) */ + public final long updatedAt; + + public TwoFaWebState(boolean exists, boolean bound, String secret, + long boundAt, long updatedAt) { + this.exists = exists; + this.bound = bound; + this.secret = secret == null ? "" : secret; + this.boundAt = boundAt; + this.updatedAt = updatedAt; + } + } + + /** + * 拉取网页端 2FA 绑定状态(api/twofa.php?action=pull_state)。 + * 网络失败/未启用返回 null —— 调用方静默保持本地状态,绝不误清。 + */ + public TwoFaWebState pullTwoFactorState(String player) { + if (!isEnabled()) return null; + try { + String url = webBaseUrl + + "/api/twofa.php?action=pull_state&player=" + + java.net.URLEncoder.encode(player, "UTF-8") + + "&secret=" + + java.net.URLEncoder.encode(secretKey, "UTF-8"); + String resp = doGet(url, 8); + if (resp == null || !resp.contains("\"success\":true")) { + plugin.getLogger().warning("[2FA] 拉取网页端状态失败: " + resp); + return null; + } + boolean exists = resp.contains("\"exists\":true"); + boolean bound = resp.contains("\"bound\":true"); + String sec = extractJsonString(resp, "secret"); + String bAt = extractJsonNumber(resp, "bound_at"); + String uAt = extractJsonNumber(resp, "updated_at"); + return new TwoFaWebState(exists, bound, sec, + bAt == null ? 0L : Long.parseLong(bAt), + uAt == null ? 0L : Long.parseLong(uAt)); + } catch (Exception e) { + plugin.getLogger().warning("[2FA] 拉取网页端状态异常: " + + e.getMessage()); + return null; + } + } + + /** + * 推送 Java 端 2FA 绑定/解绑结果(api/twofa.php?action=push_state)。 + * PHP 侧按 bound_at 时间戳决定是否采纳(网页端更新则拒绝,applied=false)。 + * + * @param boundAt 绑定时间(秒);解绑时传解绑动作发生时刻,供 PHP 判新旧 + * @return PHP 是否采纳了本次推送;网络失败返回 false + */ + public boolean pushTwoFactorState(String player, boolean bound, + String secret, long boundAt) { + if (!isEnabled()) return false; + try { + JsonObject json = new JsonObject(); + json.addProperty("secret", secretKey); + json.addProperty("player", player); + json.addProperty("bound", bound ? "1" : "0"); + json.addProperty("twofa_secret", secret == null ? "" : secret); + json.addProperty("bound_at", boundAt); + String resp = httpPost( + "api/twofa.php?action=push_state", json.toString()); + boolean ok = resp != null && resp.contains("\"success\":true"); + if (!ok) { + plugin.getLogger().warning("[2FA] 推送绑定状态被拒/失败: " + resp); + } + return ok; + } catch (Exception e) { + plugin.getLogger().warning("[2FA] 推送绑定状态异常: " + + e.getMessage()); + return false; + } + } + /** * 发送POST请求(带token) */