Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
a2c92d9150 | ||
|
|
325d363f4b | ||
|
|
74e2c60d60 | ||
|
|
ad17f9ccba | ||
|
|
b65562850f | ||
|
|
956ee0e940 | ||
|
|
f7f47e6178 | ||
|
|
7b62075dc7 | ||
|
|
d7d51cbb6b | ||
|
|
6eaa883849 | ||
|
|
8a693e6417 | ||
|
|
8718dc6e87 | ||
|
|
51f56b9e18 | ||
|
|
0b3a8debdd | ||
|
|
340726205c | ||
|
|
ba73ebfdb2 | ||
|
|
5e9bd5f6bf | ||
|
|
474b912afe | ||
|
|
5b705d0af2 | ||
|
|
7525b8f6b5 | ||
|
|
2090939e87 | ||
|
|
14c288a77c | ||
|
|
c92e79160c | ||
|
|
a21e95df68 | ||
|
|
0b5422395c | ||
|
|
b7b564a091 | ||
|
|
c5bd3d58f5 |
@@ -146,6 +146,7 @@ public class BanBroadcastManager implements Listener {
|
||||
} catch (Exception ignored) {}
|
||||
long cut0 = resolveStartCutoff();
|
||||
File bf0 = bannedPlayersFile();
|
||||
ensureTimestampFile();
|
||||
plugin.getLogger().info("[封禁播报] 已启动,模式=" + modeName()
|
||||
+ " 间隔=" + minInterval + "~" + maxInterval + "s 每页" + pageSize + "条"
|
||||
+ " 起点=" + (cut0 > 0 ? new SimpleDateFormat("yyyy-MM-dd").format(new Date(cut0)) : "不限")
|
||||
@@ -497,37 +498,95 @@ public class BanBroadcastManager implements Listener {
|
||||
if (!startCutoffAuto) return startCutoffMs;
|
||||
if (autoCutoffMs < 0) {
|
||||
autoCutoffMs = 0;
|
||||
long folderCreated = 0;
|
||||
try {
|
||||
File df = plugin.getDataFolder();
|
||||
if (df != null && df.exists()) {
|
||||
long t = java.nio.file.Files.readAttributes(df.toPath(),
|
||||
java.nio.file.attribute.BasicFileAttributes.class)
|
||||
.creationTime().toMillis();
|
||||
if (t > 0) folderCreated = t;
|
||||
long tsFile = readTimestampFile();
|
||||
if (tsFile > 0) {
|
||||
autoCutoffMs = tsFile;
|
||||
} else {
|
||||
long folderCreated = 0;
|
||||
try {
|
||||
File df = plugin.getDataFolder();
|
||||
if (df != null && df.exists()) {
|
||||
long t = java.nio.file.Files.readAttributes(df.toPath(),
|
||||
java.nio.file.attribute.BasicFileAttributes.class)
|
||||
.creationTime().toMillis();
|
||||
if (t > 0) folderCreated = t;
|
||||
}
|
||||
} catch (Exception e) {
|
||||
warn("读取插件数据目录创建时间失败,本次不过滤历史封禁: "
|
||||
+ e.getMessage());
|
||||
}
|
||||
} catch (Exception e) {
|
||||
warn("读取插件数据目录创建时间失败,本次不过滤历史封禁: "
|
||||
+ e.getMessage());
|
||||
}
|
||||
if (folderCreated > 0) {
|
||||
long cap = System.currentTimeMillis() - MAX_AUTO_CUTOFF_WINDOW_MS;
|
||||
autoCutoffMs = Math.min(folderCreated, cap);
|
||||
if (folderCreated > cap) {
|
||||
warn("插件数据目录创建时间 " + fmtCutoff(folderCreated)
|
||||
+ " 晚于180天前(目录可能被重建/迁移),"
|
||||
+ "自动起点回退为 " + fmtCutoff(cap)
|
||||
+ ",近半年内的生效封禁照常播报"
|
||||
+ "(可在 ban/规则配置.md 手动填「播报起点」精确控制)");
|
||||
if (folderCreated > 0) {
|
||||
long cap = System.currentTimeMillis() - MAX_AUTO_CUTOFF_WINDOW_MS;
|
||||
autoCutoffMs = Math.min(folderCreated, cap);
|
||||
if (folderCreated > cap) {
|
||||
warn("插件数据目录创建时间 " + fmtCutoff(folderCreated)
|
||||
+ " 晚于180天前(目录可能被重建/迁移),"
|
||||
+ "自动起点回退为 " + fmtCutoff(cap)
|
||||
+ ",近半年内的生效封禁照常播报"
|
||||
+ "(可在 ban/规则配置.md 手动填「播报起点」精确控制)");
|
||||
}
|
||||
}
|
||||
if (autoCutoffMs <= 0) {
|
||||
warn("取不到插件数据目录创建时间,历史封禁过滤已关闭"
|
||||
+ "(可在 ban/规则配置.md 里手动填「播报起点」锁定时间)");
|
||||
}
|
||||
}
|
||||
if (autoCutoffMs <= 0) {
|
||||
warn("取不到插件数据目录创建时间,历史封禁过滤已关闭"
|
||||
+ "(可在 ban/规则配置.md 里手动填「播报起点」锁定时间)");
|
||||
}
|
||||
}
|
||||
return autoCutoffMs;
|
||||
}
|
||||
/** ★ 第57批:读插件数据目录中的时间戳文件(10位数字无后缀)
|
||||
* 优先级:用户配置 > 时间戳文件 > 插件数据目录创建时间
|
||||
*/
|
||||
private long readTimestampFile() {
|
||||
try {
|
||||
File df = plugin.getDataFolder();
|
||||
if (df == null) return 0;
|
||||
File[] files = df.listFiles();
|
||||
if (files == null) return 0;
|
||||
// ★ 第57批续:多份时间戳文件取最小值(用户手动放的固定值 < 插件启动创建的当前秒值)
|
||||
long minTs = -1;
|
||||
for (File f : files) {
|
||||
String name = f.getName();
|
||||
if (name.length() == 10 && name.chars().allMatch(Character::isDigit)) {
|
||||
long ts = Long.parseLong(name);
|
||||
if (ts > 0 && (minTs < 0 || ts < minTs)) minTs = ts;
|
||||
}
|
||||
}
|
||||
if (minTs > 0) return minTs * 1000L;
|
||||
} catch (Exception e) {
|
||||
warn("读时间戳文件失败: " + e.getMessage());
|
||||
}
|
||||
return 0;
|
||||
}
|
||||
|
||||
/** ★ 第57批:插件启动时创建时间戳文件(如果不存在)
|
||||
* 文件名 = 当前时间戳(秒),内容为空
|
||||
*/
|
||||
private void ensureTimestampFile() {
|
||||
try {
|
||||
File df = plugin.getDataFolder();
|
||||
if (df == null || !df.exists()) return;
|
||||
// ★ 第57批续:目录里已有任意时间戳文件就跳过,不重复创建
|
||||
File[] files = df.listFiles();
|
||||
if (files != null) {
|
||||
for (File f : files) {
|
||||
String name = f.getName();
|
||||
if (name.length() == 10 && name.chars().allMatch(Character::isDigit)) {
|
||||
return; // 已有时间戳文件,不创建
|
||||
}
|
||||
}
|
||||
}
|
||||
long tsSec = System.currentTimeMillis() / 1000L;
|
||||
File tsFile = new File(df, String.valueOf(tsSec));
|
||||
if (!tsFile.exists()) {
|
||||
java.nio.file.Files.createFile(tsFile.toPath());
|
||||
plugin.getLogger().info("[封禁播报] 已创建时间戳文件: " + tsFile.getName());
|
||||
}
|
||||
} catch (Exception e) {
|
||||
warn("创建时间戳文件失败: " + e.getMessage());
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
/** 28批:自动播报起点最多回溯 180 天(约半年)——数据目录重建时起点不得过新 */
|
||||
private static final long MAX_AUTO_CUTOFF_WINDOW_MS = 180L * 24 * 3600 * 1000;
|
||||
|
||||
@@ -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 ''");
|
||||
@@ -146,6 +150,20 @@ public class DatabaseManager {
|
||||
} catch (SQLException ignored) {
|
||||
}
|
||||
|
||||
// ★ 第52批:存量脏凭证一次性清洗——PHP json_encode 把 base64 中的
|
||||
// / 转义成 \/,历史解析层漏还原曾把反斜杠写进库(hash 脏 → 密码恒错)。
|
||||
// base64 合法字符永不含反斜杠(char92),删除即还原正确值;WHERE 带
|
||||
// instr 条件,无脏值时为近似 no-op,可每次启动安全重跑。
|
||||
try {
|
||||
st.executeUpdate("UPDATE users SET password_hash=REPLACE(password_hash, char(92), '') "
|
||||
+ "WHERE instr(password_hash, char(92))>0");
|
||||
st.executeUpdate("UPDATE users SET password_salt=REPLACE(password_salt, char(92), '') "
|
||||
+ "WHERE instr(password_salt, char(92))>0");
|
||||
st.executeUpdate("UPDATE users SET temp_password=REPLACE(temp_password, char(92), '') "
|
||||
+ "WHERE instr(temp_password, char(92))>0");
|
||||
} catch (SQLException ignored) {
|
||||
}
|
||||
|
||||
// ===== 背包备份表:安全迁移 =====
|
||||
// 检查旧表是否存在(用 inventory_data 列判断)
|
||||
boolean hasOldTable = false;
|
||||
@@ -744,8 +762,22 @@ public class DatabaseManager {
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* ★ 第52批:base64 凭证白名单清洗。
|
||||
* PHP json_encode 默认把 base64 中的 / 转义成 \/,历史解析层漏还原
|
||||
* 会把反斜杠写进库(hash 脏值 → 游戏/网页两端密码恒错)。
|
||||
* base64 合法字符永不含反斜杠,先还原 \/ 再按白名单清洗即得正确值;
|
||||
* 写库与比对两侧同款清洗,保证对称。
|
||||
*/
|
||||
public static String b64Clean(String v) {
|
||||
if (v == null) return null;
|
||||
return v.replace("\\/", "/").replaceAll("[^A-Za-z0-9+/=]", "");
|
||||
}
|
||||
|
||||
public void createUser(String name, String hash,
|
||||
String salt) {
|
||||
hash = b64Clean(hash);
|
||||
salt = b64Clean(salt);
|
||||
try {
|
||||
PreparedStatement ps = db.prepareStatement(
|
||||
"INSERT INTO users "
|
||||
@@ -770,6 +802,8 @@ public class DatabaseManager {
|
||||
* 更新玩家密码凭证(salt + hash),用于Web重新注册时同步
|
||||
*/
|
||||
public void updatePassword(String name, String hash, String salt) {
|
||||
hash = b64Clean(hash);
|
||||
salt = b64Clean(salt);
|
||||
try {
|
||||
PreparedStatement ps = db.prepareStatement(
|
||||
"UPDATE users SET password_hash = ?, password_salt = ? WHERE player_name = ?");
|
||||
@@ -831,7 +865,8 @@ public class DatabaseManager {
|
||||
rs.getString("password_hash");
|
||||
rs.close();
|
||||
ps.close();
|
||||
return hash.equals(mainHash);
|
||||
// ★ 第52批:比对前双侧清洗(存量脏 hash 容错,见 b64Clean)
|
||||
return b64Clean(hash).equals(b64Clean(mainHash));
|
||||
} else {
|
||||
rs.close();
|
||||
ps.close();
|
||||
@@ -870,7 +905,7 @@ public class DatabaseManager {
|
||||
ps.close();
|
||||
if (tempHash != null
|
||||
&& !tempHash.isEmpty()
|
||||
&& mainHash.equals(tempHash)
|
||||
&& b64Clean(mainHash).equals(b64Clean(tempHash))
|
||||
&& System.currentTimeMillis() < expire
|
||||
&& used != 1) {
|
||||
return "temp";
|
||||
@@ -919,7 +954,7 @@ public class DatabaseManager {
|
||||
ps.close();
|
||||
if (tempHash != null
|
||||
&& !tempHash.isEmpty()
|
||||
&& hash.equals(tempHash)
|
||||
&& b64Clean(hash).equals(b64Clean(tempHash))
|
||||
&& used != 1) {
|
||||
return "temp";
|
||||
}
|
||||
|
||||
@@ -646,14 +646,21 @@ public class GarbageManager implements Listener {
|
||||
// r == n 即「本轮结束时倾倒垃圾」(下一轮才真正倒)。
|
||||
final int nextRound = roundNow + 1; // 清理后开启的新一轮
|
||||
final int r = ((nextRound - 1) % n) + 1;
|
||||
if (r < n) {
|
||||
broadcast("§7[垃圾站] §f当前是第 §e" + r
|
||||
+ " §f轮,第 §e" + n
|
||||
+ " §f轮倒垃圾,还剩 §e" + (n - r)
|
||||
+ " §f轮倾倒垃圾");
|
||||
} else {
|
||||
broadcast("§7[垃圾站] §f当前是第 §e" + n
|
||||
+ " §f轮,本轮结束时倾倒垃圾");
|
||||
// ★ 第45批:保留只有1轮(n==1)时
|
||||
// 每一轮都是倒垃圾轮,「本轮结束时
|
||||
// 倾倒垃圾」无增量信息 → 不播报;
|
||||
// 仅保留>1才提示轮次进度(用户
|
||||
// 2026-10-22 截图反馈)。
|
||||
if (n > 1) {
|
||||
if (r < n) {
|
||||
broadcast("§7[垃圾站] §f当前是第 §e" + r
|
||||
+ " §f轮,第 §e" + n
|
||||
+ " §f轮倒垃圾,还剩 §e" + (n - r)
|
||||
+ " §f轮倾倒垃圾");
|
||||
} else {
|
||||
broadcast("§7[垃圾站] §f当前是第 §e" + n
|
||||
+ " §f轮,本轮结束时倾倒垃圾");
|
||||
}
|
||||
}
|
||||
|
||||
// 打印下次清理时间
|
||||
|
||||
@@ -12,6 +12,13 @@ public class LoginManager {
|
||||
this.plugin = plugin;
|
||||
}
|
||||
|
||||
/** ★第48批#244:/login 失败自动对账重试的每人限流(值=上次对账触发时刻) */
|
||||
private final java.util.Map<String, Long> pwSyncRetryAt =
|
||||
new java.util.concurrent.ConcurrentHashMap<>();
|
||||
/** ★第48批#244:正在对账重试中的玩家(防递归) */
|
||||
private final java.util.Set<String> pwSyncRetrying =
|
||||
java.util.concurrent.ConcurrentHashMap.newKeySet();
|
||||
|
||||
public boolean handleRegister(Player p,
|
||||
String[] args) {
|
||||
String name = p.getName();
|
||||
@@ -69,6 +76,10 @@ public class LoginManager {
|
||||
// ★ 记录Java手动登录:5分钟内重连可直接放行(检查点1)
|
||||
if (plugin.webManager != null) {
|
||||
plugin.webManager.recordJavaLogin(name, plugin.getPlayerIP(p));
|
||||
// ★ 第57批:/login 成功后推送 login_verified=1 给 PHP
|
||||
Bukkit.getScheduler().runTaskAsynchronously(plugin, () -> {
|
||||
plugin.webManager.pushLoginVerified(name);
|
||||
});
|
||||
}
|
||||
p.setAllowFlight(false);
|
||||
p.setFlying(false);
|
||||
@@ -191,6 +202,8 @@ public class LoginManager {
|
||||
if (matchMain) {
|
||||
// ★ IP风控双验证第一步:只认密码,不解冻、不恢复背包
|
||||
if (markDualStep1(p, false)) return true;
|
||||
// ★第48批#249:已绑定2FA → 密码过后强制第二关 /2fa code
|
||||
if (requireTwoFaIfEnabled(p, false)) return true;
|
||||
finishPasswordLogin(p, false);
|
||||
return true;
|
||||
}
|
||||
@@ -202,11 +215,17 @@ public class LoginManager {
|
||||
if (pwdResult != null) {
|
||||
// ★ IP风控双验证第一步:只认密码,不解冻、不恢复背包
|
||||
if (markDualStep1(p, true)) return true;
|
||||
// ★第48批#249:已绑定2FA → 密码过后强制第二关 /2fa code
|
||||
if (requireTwoFaIfEnabled(p, true)) return true;
|
||||
finishPasswordLogin(p, true);
|
||||
return true;
|
||||
}
|
||||
|
||||
|
||||
// ★第54批:密码不匹配 → 先同步账号数据(拉回PHP侧凭证入库为主密码),
|
||||
// 15秒/人限流;同步完成后主线程自动重校验(以Java本地login.db为准),仍未过才计失败次数
|
||||
if (maybeRetryAfterWebSync(p, pwd)) return true;
|
||||
|
||||
// ★ 密码错误,记录风控
|
||||
int remaining = plugin.getRiskControl()
|
||||
.recordFailAttempt(p);
|
||||
@@ -251,6 +270,67 @@ public class LoginManager {
|
||||
return true;
|
||||
}
|
||||
|
||||
/**
|
||||
* ★第48批#249:已绑定 2FA(twofa_enabled=1)的玩家,密码通过后不直接放行,
|
||||
* 转入「密码 + 2FA」双验证第二步(等 /2fa code)。
|
||||
* 仅在该玩家当前不在双验证流程时生效(IP风控已 mark 的走 markDualStep1)。
|
||||
*
|
||||
* @return true = 已进入双验证流程,本轮 /login 到此为止
|
||||
*/
|
||||
private boolean requireTwoFaIfEnabled(Player p, boolean tempPassword) {
|
||||
TwoFactorManager twofa = plugin.getTwofa();
|
||||
String name = p.getName();
|
||||
if (twofa == null) return false;
|
||||
if (twofa.isDualVerify(p)) return false; // IP风控路径已处理
|
||||
if (!plugin.getDb().userExists(name)) return false;
|
||||
if (!twofa.isEnabled(name)) return false; // 未绑定2FA:维持原单步登录
|
||||
// 密码这一步确实对了 → 建立双验证状态并记录进度(不解冻、不恢复背包)
|
||||
twofa.markDualVerify(p, plugin.getPlayerIP(p));
|
||||
twofa.onDualPasswordPassed(p, tempPassword);
|
||||
// 重置「密码错误次数」风控(密码对了,但仍未完成登录)
|
||||
plugin.getRiskControl().onLoginSuccess(name);
|
||||
p.sendMessage("§a§l[双重验证] §a第一步密码验证通过(尚未解冻)");
|
||||
p.sendMessage("§7第二步: 执行 §f/2fa code <动态码|恢复代码>");
|
||||
p.sendMessage("§c两步全部通过后才会解冻并恢复背包");
|
||||
return true;
|
||||
}
|
||||
|
||||
/**
|
||||
* ★第54批:密码校验失败时先同步账号数据(拉回PHP侧凭证入库)再自动重试一次。
|
||||
* 15秒/人限流;重试仍失败走正常风控计数。
|
||||
*
|
||||
* @return true = 本轮 /login 已交给异步对账流程处理(调用方直接 return)
|
||||
*/
|
||||
private boolean maybeRetryAfterWebSync(Player p, String pwd) {
|
||||
if (plugin.webManager == null) return false;
|
||||
String name = p.getName();
|
||||
if (pwSyncRetrying.contains(name)) return false; // 重试轮失败:不再触发
|
||||
long now = System.currentTimeMillis();
|
||||
Long last = pwSyncRetryAt.get(name);
|
||||
if (last != null && now - last < 15000L) return false; // 15秒/人限流
|
||||
pwSyncRetryAt.put(name, now);
|
||||
pwSyncRetrying.add(name);
|
||||
p.sendMessage("§e§l[登录] §e正在同步账号数据…");
|
||||
Bukkit.getScheduler().runTaskAsynchronously(plugin, () -> {
|
||||
try {
|
||||
plugin.webManager.pushWebLoginCredentials(); // 含 alignGate 拉回
|
||||
} catch (Throwable t) {
|
||||
plugin.getLogger().warning("[登录] 对账重试同步异常: " + t.getMessage());
|
||||
}
|
||||
Bukkit.getScheduler().runTask(plugin, () -> {
|
||||
try {
|
||||
Player online = Bukkit.getPlayerExact(name);
|
||||
if (online == null || !online.isOnline()) return;
|
||||
// 主线程重新走一次完整登录校验(含2FA/风控前置)
|
||||
handleLogin(online, new String[]{pwd});
|
||||
} finally {
|
||||
pwSyncRetrying.remove(name);
|
||||
}
|
||||
});
|
||||
});
|
||||
return true;
|
||||
}
|
||||
|
||||
/**
|
||||
* 密码登录成功的统一收口:解冻 + 恢复背包 + 记录登录信息。
|
||||
* 调用点:/login 主密码、/login 临时密码、IP风控双验证第二步通过后。
|
||||
@@ -263,6 +343,10 @@ public class LoginManager {
|
||||
// ★ 记录Java手动登录:5分钟内重连可直接放行(检查点1)
|
||||
if (plugin.webManager != null) {
|
||||
plugin.webManager.recordJavaLogin(name, plugin.getPlayerIP(p));
|
||||
// ★ 第57批续3:/login 成功后推送 login_verified=1 给 PHP(统一收口)
|
||||
Bukkit.getScheduler().runTaskAsynchronously(plugin, () -> {
|
||||
plugin.webManager.pushLoginVerified(name);
|
||||
});
|
||||
}
|
||||
plugin.getDb().setLoggedIn(name, true);
|
||||
if (!tempPassword) {
|
||||
|
||||
@@ -141,6 +141,9 @@ public class Main extends JavaPlugin
|
||||
private final Map<String, Long> ipAutoLogin =
|
||||
new HashMap<>();
|
||||
private static final long IP_SKIP_MS = 300000L;
|
||||
// ★ 第56批:/webbind 批准密码验证失败计数(玩家名 -> 失败次数)
|
||||
// ★ 第57批续5: /webbind approve 改聊天框输入密码(玩家名 -> 请求ID)
|
||||
private final ConcurrentHashMap<String, String> pendingWebbindApprove = new ConcurrentHashMap<>();
|
||||
private SalesStatsManager salesStats;
|
||||
|
||||
private final Map<UUID, Long> lastActivity =
|
||||
@@ -2983,6 +2986,25 @@ public class Main extends JavaPlugin
|
||||
Player p = e.getPlayer();
|
||||
String name = p.getName();
|
||||
String ip = getPlayerIP(p);
|
||||
// ★ 第57批:插件管理员登录 → CLI 通知待确认的封禁缺口
|
||||
if (webManager != null && webManager.hasBanPending() && isAdmin(p)) {
|
||||
p.sendMessage(net.kyori.adventure.text.Component.text()
|
||||
.append(net.kyori.adventure.text.Component.text("§c§l[封禁-缺口] §f检测到 "))
|
||||
.append(net.kyori.adventure.text.Component.text(String.valueOf(webManager.getBanPendingCount()))
|
||||
.color(net.kyori.adventure.text.format.NamedTextColor.RED))
|
||||
.append(net.kyori.adventure.text.Component.text(" 条封禁数据缺口待确认"))
|
||||
.build());
|
||||
p.sendMessage(net.kyori.adventure.text.Component.text()
|
||||
.append(net.kyori.adventure.text.Component.text("§a[点击拉回封禁] §7").clickEvent(
|
||||
net.kyori.adventure.text.event.ClickEvent.runCommand("/sdf1_login confirm ban")))
|
||||
.append(net.kyori.adventure.text.Component.text("§7= /sdf1_login confirm ban"))
|
||||
.build());
|
||||
p.sendMessage(net.kyori.adventure.text.Component.text()
|
||||
.append(net.kyori.adventure.text.Component.text("§c[点击确认解封] §7").clickEvent(
|
||||
net.kyori.adventure.text.event.ClickEvent.runCommand("/sdf1_login cancel ban")))
|
||||
.append(net.kyori.adventure.text.Component.text("§7= /sdf1_login cancel ban"))
|
||||
.build());
|
||||
}
|
||||
|
||||
// ===== 公告计分板(第35批)=====
|
||||
// 延后 1 秒:避免与登录/权限流程抢计分板
|
||||
@@ -3181,6 +3203,10 @@ public class Main extends JavaPlugin
|
||||
if (!db.userExists(name)) {
|
||||
webManager.syncUserOnJoin(name);
|
||||
}
|
||||
// 第47批:进服静默同步网页端 2FA 绑定状态(登录风控 isEnabled 读到同步后结果)
|
||||
if (twofa != null) {
|
||||
twofa.syncOnJoin(name);
|
||||
}
|
||||
}
|
||||
|
||||
// ★ 同IP判断:只作为开启自动登录验证的钥匙,不作为自动登录依据
|
||||
@@ -6231,6 +6257,57 @@ public class Main extends JavaPlugin
|
||||
return;
|
||||
}
|
||||
|
||||
// ★ 第57批续5: /webbind approve 密码输入拦截
|
||||
String pendingBindReq = pendingWebbindApprove.get(p.getName());
|
||||
if (pendingBindReq != null) {
|
||||
e.setCancelled(true);
|
||||
String input = msg.trim();
|
||||
// 拒绝选项
|
||||
if (input.equalsIgnoreCase("拒绝") || input.equalsIgnoreCase("reject")) {
|
||||
pendingWebbindApprove.remove(p.getName());
|
||||
p.sendMessage("§e已拒绝,网页端直接返回");
|
||||
webManager.submitWebEmailBindResult(pendingBindReq, p.getName(),
|
||||
"reject", "", status -> {});
|
||||
return;
|
||||
}
|
||||
// ★ 第57批续10:接入统一风控(RiskControlManager),与 /login 完全一致
|
||||
// 密码错5次 → Bukkit真实封禁5分钟 + 踢出 + 报警邮件(不再仅提示)
|
||||
if (riskControl != null && riskControl.isBanned(p.getName())) {
|
||||
int rem2 = riskControl.getBanRemainingSeconds(p.getName());
|
||||
pendingWebbindApprove.remove(p.getName());
|
||||
p.sendMessage("§c§l[风控] §f密码错误次数过多,账号已被临时封禁 §e"
|
||||
+ rem2 + " §f秒");
|
||||
return;
|
||||
}
|
||||
String pwdSalt2 = (String) db.getField(p.getName(), "password_salt");
|
||||
String pwdHash2 = PasswordUtils.hash(input, pwdSalt2);
|
||||
String pwdResult2 = db.checkPasswordWithFallback(p.getName(), pwdHash2);
|
||||
if (pwdResult2 == null) {
|
||||
int remaining2 = riskControl != null
|
||||
? riskControl.recordFailAttempt(p) : 4;
|
||||
if (remaining2 < 0) {
|
||||
// 已达阈值:triggerBan 已真实封禁并踢出
|
||||
return;
|
||||
}
|
||||
p.sendMessage("§c密码错误");
|
||||
if (remaining2 > 0) {
|
||||
p.sendMessage("§c§l[风控] §f剩余 §e" + remaining2 + " §f次尝试机会");
|
||||
} else {
|
||||
p.sendMessage("§c§l[风控] §f连续5次失败,已被临时封禁");
|
||||
pendingWebbindApprove.remove(p.getName());
|
||||
}
|
||||
return;
|
||||
}
|
||||
// 验证通过,重置风控计数
|
||||
if (riskControl != null) riskControl.onLoginSuccess(p.getName());
|
||||
String reqId2 = pendingBindReq;
|
||||
pendingWebbindApprove.remove(p.getName());
|
||||
p.sendMessage("§e正在提交批准…");
|
||||
webManager.submitWebEmailBindResult(reqId2, p.getName(),
|
||||
"approve", "", true, status -> {});
|
||||
return;
|
||||
}
|
||||
|
||||
// ★ 2FA 解绑确认输入拦截(30秒 confirm 窗口,
|
||||
// 输入 confirm=解绑,其它任意内容/超时=取消)
|
||||
if (twofa != null && twofa.isRemoveConfirming(p)) {
|
||||
@@ -9549,6 +9626,10 @@ public class Main extends JavaPlugin
|
||||
// ★ 未绑定邮箱:登录成功后提示
|
||||
if (loggedIn.contains(p.getName())) {
|
||||
remindEmailBindIfNeeded(p);
|
||||
// ★ 第57批续6:/login成功后重新推送weblogin token(onJoin时推送的token login_verified=0,Web端不认)
|
||||
if (webManager != null) {
|
||||
webManager.autoGenerateWebLoginToken(p);
|
||||
}
|
||||
}
|
||||
|
||||
} catch (Exception e) {
|
||||
@@ -9606,6 +9687,65 @@ public class Main extends JavaPlugin
|
||||
return true;
|
||||
}
|
||||
|
||||
// ★ 第53批:网页端绑邮箱 · 游戏内批准/拒绝(/webbind approve|reject <id>)
|
||||
if (cmdName.equals("webbind")) {
|
||||
if (!(sender instanceof Player)) {
|
||||
sender.sendMessage("§c仅玩家可用");
|
||||
return true;
|
||||
}
|
||||
final Player wp = (Player) sender;
|
||||
if (isFrozen(wp)) {
|
||||
wp.sendMessage("§c§l[登录] §f你还未登录,无法使用该命令。请先使用 §e/l <密码> §f登录");
|
||||
return true;
|
||||
}
|
||||
if (args.length < 2
|
||||
|| (!args[0].equalsIgnoreCase("approve")
|
||||
&& !args[0].equalsIgnoreCase("reject"))) {
|
||||
wp.sendMessage("§c用法: /webbind approve|reject <id> [密码]");
|
||||
return true;
|
||||
}
|
||||
final String bindReqId = args[1];
|
||||
if (!bindReqId.matches("[0-9]+")) {
|
||||
wp.sendMessage("§c无效的请求ID");
|
||||
return true;
|
||||
}
|
||||
if (webManager == null) {
|
||||
wp.sendMessage("§cWeb通信未启用,无法处理");
|
||||
return true;
|
||||
}
|
||||
final boolean bindApprove = args[0].equalsIgnoreCase("approve");
|
||||
// ★ 第56批:approve 时需要密码(Java端直接验证,不再依赖PHP复核)
|
||||
if (bindApprove) {
|
||||
// ★ 第57批续5: 改聊天框输入密码(不再要求命令行参数密码)
|
||||
// ★ 第57批续10:接入统一风控,封禁期内直接拒绝
|
||||
if (riskControl != null && riskControl.isBanned(wp.getName())) {
|
||||
int rem = riskControl.getBanRemainingSeconds(wp.getName());
|
||||
wp.sendMessage("§c§l[风控] §f密码错误次数过多,账号已被临时封禁 §e"
|
||||
+ rem + " §f秒,请稍后再试");
|
||||
return true;
|
||||
}
|
||||
pendingWebbindApprove.put(wp.getName(), bindReqId);
|
||||
wp.sendMessage("§e请在聊天框输入密码完成验证");
|
||||
wp.sendMessage("§7还是输入「拒绝」拒绝本次绑定");
|
||||
return true;
|
||||
}
|
||||
// reject 不需要密码
|
||||
wp.sendMessage("§e正在提交拒绝…");
|
||||
webManager.submitWebEmailBindResult(bindReqId, wp.getName(),
|
||||
"reject", "", status -> {
|
||||
if (status == null) {
|
||||
wp.sendMessage("§c提交失败,请稍后重试");
|
||||
} else if ("rejected".equals(status)) {
|
||||
wp.sendMessage("§e已拒绝,网页端将直接返回,不做任何处置");
|
||||
} else if ("expired".equals(status)) {
|
||||
wp.sendMessage("§c请求已超时,未做任何处置");
|
||||
} else {
|
||||
wp.sendMessage("§7请求当前状态: §f" + status + " §7(重复操作已忽略)");
|
||||
}
|
||||
});
|
||||
return true;
|
||||
}
|
||||
|
||||
// 绑定邮箱
|
||||
if (cmdName.equals("绑定邮箱")) {
|
||||
// ★ 3.0 安全修复:邮箱绑定必须登录后才能执行(未登录越权绑邮箱 + 任意收件人 SMTP 滥发)
|
||||
@@ -9852,6 +9992,13 @@ public class Main extends JavaPlugin
|
||||
return true;
|
||||
}
|
||||
String target = args[1];
|
||||
// ★ 第57批续3:/del ban 拦截(ban 不作为待删除玩家账号的名字)
|
||||
if (target.equalsIgnoreCase("ban")) {
|
||||
sender.sendMessage("§cban 是封禁缺口确认命令,不是玩家账号名");
|
||||
sender.sendMessage("§7确认拉回封禁:§a/sdf1_login confirm ban");
|
||||
sender.sendMessage("§7确认解封:§a/sdf1_login cancel ban");
|
||||
return true;
|
||||
}
|
||||
if (!db.userExists(target)) {
|
||||
String twin = db.findSimilarUserNameIgnoreCase(target);
|
||||
sender.sendMessage(
|
||||
@@ -9910,6 +10057,23 @@ public class Main extends JavaPlugin
|
||||
return true;
|
||||
}
|
||||
|
||||
// ★ 第57批:confirm ban 确认封禁缺口拉回
|
||||
if (sub.equals("confirm") && args.length >= 2 && args[1].equalsIgnoreCase("ban")) {
|
||||
if (!isAdmin(sender)) { sender.sendMessage("\u00a7c权限不足"); return true; }
|
||||
if (webManager == null) { sender.sendMessage("\u00a7cWeb通信未初始化"); return true; }
|
||||
if (!webManager.hasBanPending()) { sender.sendMessage("§c当前没有待确认的封禁缺口"); return true; }
|
||||
webManager.confirmBanGap(sender);
|
||||
return true;
|
||||
}
|
||||
// ★ 第57批:cancel ban 确认解封(清空PHP封禁数据,不写回banned-players.json)
|
||||
if (sub.equals("cancel") && args.length >= 2 && args[1].equalsIgnoreCase("ban")) {
|
||||
if (!isAdmin(sender)) { sender.sendMessage("\u00a7c权限不足"); return true; }
|
||||
if (webManager == null) { sender.sendMessage("\u00a7cWeb通信未初始化"); return true; }
|
||||
if (!webManager.hasBanPending()) { sender.sendMessage("§c当前没有待确认的封禁缺口"); return true; }
|
||||
webManager.cancelBanGap(sender);
|
||||
return true;
|
||||
}
|
||||
|
||||
if (sub.equals("confirm")) {
|
||||
if (!isAdmin(sender)) {
|
||||
sender.sendMessage("§c权限不足");
|
||||
|
||||
@@ -550,8 +550,8 @@ public class PVPArenaManager implements Listener {
|
||||
w.setPVP(true);
|
||||
// ★ 和平模式:排除敌对生物干扰PVP
|
||||
w.setDifficulty(Difficulty.PEACEFUL);
|
||||
w.setGameRule(GameRule.DO_DAYLIGHT_CYCLE, false);
|
||||
w.setGameRule(GameRule.DO_WEATHER_CYCLE, false);
|
||||
w.setGameRule(GameRules.ADVANCE_TIME, false);
|
||||
w.setGameRule(GameRules.ADVANCE_WEATHER, false);
|
||||
w.setTime(6000);
|
||||
// ★ 不常驻出生点区块(与 WorldCreator.keepSpawnInMemory(false) 一致),避免同步加载区块卡主线程
|
||||
w.setKeepSpawnInMemory(false);
|
||||
|
||||
@@ -182,10 +182,10 @@ public class PVPTestManager implements Listener {
|
||||
// 世界规则:常昼 / 无天气 / 关闭自然刷怪(只有我们生成的练习生物)/ PVP 开启
|
||||
// ★ KEEP_INVENTORY=false:死亡掉落由事件处理器清空掉落物+复活时还原,避免gamerule与事件冲突
|
||||
w.setPVP(true);
|
||||
w.setGameRule(GameRule.DO_DAYLIGHT_CYCLE, false);
|
||||
w.setGameRule(GameRule.DO_WEATHER_CYCLE, false);
|
||||
w.setGameRule(GameRule.DO_MOB_SPAWNING, false);
|
||||
w.setGameRule(GameRule.KEEP_INVENTORY, false);
|
||||
w.setGameRule(GameRules.ADVANCE_TIME, false);
|
||||
w.setGameRule(GameRules.ADVANCE_WEATHER, false);
|
||||
w.setGameRule(GameRules.SPAWN_MONSTERS, false);
|
||||
w.setGameRule(GameRules.KEEP_INVENTORY, false);
|
||||
w.setTime(6000);
|
||||
w.setKeepSpawnInMemory(false);
|
||||
|
||||
@@ -895,7 +895,7 @@ public class PVPTestManager implements Listener {
|
||||
// 抵消皮革帽自带的1点护甲,使其不增加玩家对练习生物的伤害减免(纯用于挡太阳)
|
||||
hm.addAttributeModifier(Attribute.ARMOR,
|
||||
new AttributeModifier(
|
||||
"pvp_test_no_armor", -1.0,
|
||||
new NamespacedKey(plugin, "pvp_test_no_armor"), -1.0,
|
||||
AttributeModifier.Operation.ADD_NUMBER));
|
||||
hat.setItemMeta(hm);
|
||||
}
|
||||
|
||||
@@ -88,23 +88,18 @@ public class RiskControlManager {
|
||||
public int recordFailAttempt(Player player) {
|
||||
String name = player.getName();
|
||||
int maxAttempts = getMaxFailAttempts();
|
||||
|
||||
// 已被封禁,直接返回
|
||||
if (isBanned(name)) {
|
||||
return -1;
|
||||
}
|
||||
|
||||
int current = failAttempts
|
||||
.getOrDefault(name, 0) + 1;
|
||||
failAttempts.put(name, current);
|
||||
|
||||
int remaining = Math.max(0, maxAttempts - current);
|
||||
|
||||
// 达到阈值 → 触发封禁
|
||||
if (current >= maxAttempts) {
|
||||
triggerBan(player);
|
||||
}
|
||||
|
||||
return remaining;
|
||||
}
|
||||
|
||||
@@ -117,16 +112,13 @@ public class RiskControlManager {
|
||||
long durationMs = getBanDurationMs();
|
||||
long until = System.currentTimeMillis() + durationMs;
|
||||
banUntil.put(name, until);
|
||||
|
||||
String ip = plugin.getPlayerIP(player);
|
||||
int banMinutes = (int) (durationMs / 60000);
|
||||
|
||||
plugin.getLogger().warning(
|
||||
"[风控] 玩家 " + name
|
||||
+ " 密码错误达到阈值("
|
||||
+ getMaxFailAttempts() + "次)"
|
||||
+ ",封禁 " + banMinutes + " 分钟");
|
||||
|
||||
// ★ 真实封禁:调用Bukkit BanList API阻止玩家重新加入服务器
|
||||
String banReason = "§c§l[安全风控] 密码连续错误"
|
||||
+ getMaxFailAttempts() + "次,"
|
||||
@@ -145,7 +137,6 @@ public class RiskControlManager {
|
||||
}
|
||||
// 踢出玩家(无论BanList是否成功,立即踢出)
|
||||
player.kickPlayer(banReason);
|
||||
|
||||
// 异步发送报警邮件
|
||||
sendBanAlertEmail(name, ip, banMinutes);
|
||||
}
|
||||
@@ -164,7 +155,6 @@ public class RiskControlManager {
|
||||
+ " 未绑定邮箱,跳过报警邮件");
|
||||
return;
|
||||
}
|
||||
|
||||
String subject =
|
||||
"[安全警告] 您的账号已被临时封禁";
|
||||
String body = "尊敬的玩家 " + playerName + ":\n\n"
|
||||
@@ -187,7 +177,6 @@ public class RiskControlManager {
|
||||
+ "如果非本人操作,建议您立即修改密码"
|
||||
+ "(使用 /sdf1_login pw)。\n\n"
|
||||
+ "--Sdf1_login 安全系统";
|
||||
|
||||
Bukkit.getScheduler().runTaskAsynchronously(plugin, () -> {
|
||||
try {
|
||||
plugin.getEmail().sendBody(
|
||||
|
||||
@@ -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<Player> 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) {
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -1,5 +1,5 @@
|
||||
name: Sdf1_login
|
||||
version: 3.01
|
||||
version: 3.11
|
||||
main: Sdf1_login.Main
|
||||
api-version: '1.21'
|
||||
commands:
|
||||
@@ -72,6 +72,9 @@ commands:
|
||||
绑定邮箱:
|
||||
description: 绑定邮箱
|
||||
usage: /绑定邮箱
|
||||
webbind:
|
||||
description: 网页邮箱绑定批准/拒绝
|
||||
usage: /webbind <approve|reject> <id>
|
||||
chat:
|
||||
description: 聊天过滤管理
|
||||
usage: /chat
|
||||
|
||||
Reference in New Issue
Block a user