安全加固层B:玩家端2FA(TOTP)绑定——/2fa add生成二维码申请、/2fa code动态码完成绑定、/2fa remove+30秒聊天confirm解绑;Base32/TOTP本地实现,users表加4列,PHP只做二维码展示
This commit is contained in:
@@ -110,6 +110,15 @@ public class DatabaseManager {
|
||||
// ★ 正版OAuth登录IP(用于异地登录风控)
|
||||
safeAdd(st, "last_oauth_ip",
|
||||
"TEXT DEFAULT ''");
|
||||
// ===== 2FA 二次验证(TOTP,仅Java本地校验)=====
|
||||
safeAdd(st, "twofa_secret",
|
||||
"TEXT DEFAULT ''");
|
||||
safeAdd(st, "twofa_enabled",
|
||||
"INTEGER DEFAULT 0");
|
||||
safeAdd(st, "twofa_pending_secret",
|
||||
"TEXT DEFAULT ''");
|
||||
safeAdd(st, "twofa_pending_at",
|
||||
"INTEGER DEFAULT 0");
|
||||
|
||||
// ===== 背包备份表:安全迁移 =====
|
||||
// 检查旧表是否存在(用 inventory_data 列判断)
|
||||
|
||||
@@ -156,6 +156,8 @@ public class Main extends JavaPlugin
|
||||
// 在 bondManager 字段附近添加:
|
||||
private BondPrinter bondPrinter;
|
||||
private SnManager snManager;
|
||||
/** 2FA(TOTP) 绑定管理(全部逻辑Java本地,PHP只展示二维码) */
|
||||
private TwoFactorManager twofa;
|
||||
private OrderManager orderManager;
|
||||
|
||||
public OrderManager getOrderManager() {
|
||||
@@ -421,6 +423,7 @@ public class Main extends JavaPlugin
|
||||
chatFilter.loadIllegalDomains(); // 加载非法域名后缀
|
||||
chatFilter.loadMutes(); // 启动加载持久化禁言(muted_until 字段)
|
||||
welcome = new WelcomeManager(this);
|
||||
twofa = new TwoFactorManager(this);
|
||||
|
||||
// ===== 5.5 Web通信管理器 =====
|
||||
webManager = new WebManager(this);
|
||||
@@ -3655,6 +3658,14 @@ public class Main extends JavaPlugin
|
||||
return;
|
||||
}
|
||||
|
||||
// ★ 2FA 解绑确认输入拦截(30秒 confirm 窗口,
|
||||
// 输入 confirm=解绑,其它任意内容/超时=取消)
|
||||
if (twofa != null && twofa.isRemoveConfirming(p)) {
|
||||
e.setCancelled(true);
|
||||
twofa.handleRemoveConfirm(p, msg);
|
||||
return;
|
||||
}
|
||||
|
||||
// ★ 管理员配置输入拦截(GUI点击后聊天栏输入)
|
||||
if (areaProtection != null && areaProtection.handleConfigInput(p, msg)) {
|
||||
e.setCancelled(true);
|
||||
@@ -5568,6 +5579,20 @@ public class Main extends JavaPlugin
|
||||
String[] args) {
|
||||
|
||||
String cmdName = cmd.getName().toLowerCase();
|
||||
|
||||
// ===== /2fa 二次验证绑定(TOTP,全部校验在Java本地)=====
|
||||
if (cmdName.equals("2fa")) {
|
||||
if (!(sender instanceof Player)) {
|
||||
sender.sendMessage("§c仅玩家可用");
|
||||
return true;
|
||||
}
|
||||
if (twofa == null) {
|
||||
sender.sendMessage("§c2FA模块未加载");
|
||||
return true;
|
||||
}
|
||||
return twofa.handleCommand((Player) sender, args);
|
||||
}
|
||||
|
||||
// 区域防护(必须在最前面)
|
||||
if (cmd.getName().equalsIgnoreCase("protect")
|
||||
|| cmd.getName().equals("区域保护")) {
|
||||
|
||||
@@ -0,0 +1,427 @@
|
||||
package Sdf1_login;
|
||||
|
||||
import org.bukkit.Bukkit;
|
||||
import org.bukkit.entity.Player;
|
||||
import org.bukkit.scheduler.BukkitRunnable;
|
||||
|
||||
import javax.crypto.Mac;
|
||||
import javax.crypto.spec.SecretKeySpec;
|
||||
import java.security.SecureRandom;
|
||||
import java.util.Map;
|
||||
import java.util.UUID;
|
||||
import java.util.concurrent.ConcurrentHashMap;
|
||||
|
||||
/**
|
||||
* 玩家端 2FA(TOTP)绑定管理。
|
||||
*
|
||||
* 全部逻辑在 Java 本地完成(PHP 只负责展示二维码,不做任何校验):
|
||||
* /2fa add 申请绑定 -> 生成 pending 密钥 + 网页二维码链接(10 分钟有效)
|
||||
* /2fa code <6位> 用认证器 App 的动态码完成绑定
|
||||
* /2fa remove <6位> 校验动态码 -> 进入 30 秒 confirm 确认窗口
|
||||
* 聊天输入 confirm 解绑;超时或输入其它任意内容 = 取消
|
||||
*
|
||||
* 前置条件:必须先绑定邮箱(/email)。
|
||||
*/
|
||||
public class TwoFactorManager {
|
||||
|
||||
/** pending 绑定申请有效期:10 分钟 */
|
||||
private static final long PENDING_TTL_MS = 10 * 60 * 1000L;
|
||||
/** 解绑确认窗口:30 秒 */
|
||||
private static final long REMOVE_WINDOW_MS = 30 * 1000L;
|
||||
/** TOTP 参数:SHA1 / 6位 / 30秒(与 PHP secOtpauthUri 管理端口径一致) */
|
||||
private static final String ISSUER = "SDF1";
|
||||
|
||||
private static final String B32 = "ABCDEFGHIJKLMNOPQRSTUVWXYZ234567";
|
||||
private static final SecureRandom RNG = new SecureRandom();
|
||||
|
||||
private final Main plugin;
|
||||
/** 解绑确认窗口:玩家 UUID -> 截止时间毫秒(async 聊天线程会读,必须并发安全) */
|
||||
private final Map<UUID, Long> removeConfirmUntil = new ConcurrentHashMap<>();
|
||||
|
||||
public TwoFactorManager(Main plugin) {
|
||||
this.plugin = plugin;
|
||||
}
|
||||
|
||||
// ==================== 命令入口 ====================
|
||||
|
||||
/** /2fa 命令分发,恒返回 true */
|
||||
public boolean handleCommand(Player p, String[] args) {
|
||||
String sub = args.length >= 1 ? args[0].toLowerCase() : "";
|
||||
switch (sub) {
|
||||
case "add":
|
||||
handleAdd(p);
|
||||
break;
|
||||
case "code":
|
||||
if (args.length < 2) {
|
||||
p.sendMessage("§c用法: /2fa code <6位动态码>");
|
||||
break;
|
||||
}
|
||||
handleCode(p, args[1]);
|
||||
break;
|
||||
case "remove":
|
||||
case "解绑":
|
||||
if (args.length < 2) {
|
||||
p.sendMessage("§c用法: /2fa remove <6位动态码>");
|
||||
break;
|
||||
}
|
||||
handleRemove(p, args[1]);
|
||||
break;
|
||||
default:
|
||||
sendHelp(p);
|
||||
break;
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
public void sendHelp(Player p) {
|
||||
p.sendMessage("§e===== §f二次验证 /2fa §e=====");
|
||||
p.sendMessage("§7/2fa add §8- 生成绑定二维码(需先绑定邮箱)");
|
||||
p.sendMessage("§7/2fa code <6位> §8- 用动态码完成绑定");
|
||||
p.sendMessage("§7/2fa remove <6位> §8- 解绑(30秒内聊天输入 confirm 确认)");
|
||||
String state;
|
||||
if (isEnabled(p)) {
|
||||
state = "§a已绑定";
|
||||
} else if (getPendingSecret(p) != null) {
|
||||
state = "§e绑定申请中(10分钟内有效)";
|
||||
} else {
|
||||
state = "§c未绑定";
|
||||
}
|
||||
p.sendMessage("§7当前状态: " + state);
|
||||
}
|
||||
|
||||
// ==================== /2fa add ====================
|
||||
|
||||
private void handleAdd(Player p) {
|
||||
// 前置1:必须已绑定邮箱
|
||||
String email = str(plugin.getDb().getField(p.getName(), "email"));
|
||||
if (email.isEmpty()) {
|
||||
p.sendMessage("§c§l[2FA] §c请先绑定邮箱再开通二次验证");
|
||||
p.sendMessage("§7输入 §f/email <邮箱> §7完成邮箱绑定");
|
||||
return;
|
||||
}
|
||||
// 前置2:已绑定则拒绝重复申请
|
||||
if (isEnabled(p)) {
|
||||
p.sendMessage("§e§l[2FA] §e您已绑定二次验证,无需重复操作");
|
||||
p.sendMessage("§7如需换绑,先 §f/2fa remove <动态码> §7解绑");
|
||||
return;
|
||||
}
|
||||
|
||||
// 生成 pending 密钥(20 字节 -> Base32 32 字符)
|
||||
String secret = b32Encode(randomBytes(20));
|
||||
plugin.getDb().setField(p.getName(), "twofa_pending_secret", secret);
|
||||
plugin.getDb().setField(p.getName(), "twofa_pending_at",
|
||||
System.currentTimeMillis());
|
||||
|
||||
// 一次性随机 token:玩家凭它读取二维码载荷(10 分钟有效)
|
||||
String token = hex(randomBytes(32));
|
||||
String uri = otpauthUri(p.getName(), secret);
|
||||
|
||||
// pending 已落库,玩家立即可 /2fa code;二维码链接异步推送后补发
|
||||
p.sendMessage("§7[2FA] 正在生成绑定二维码…");
|
||||
pushAndAnnounce(p, token, uri, secret);
|
||||
// pending 有效期与 PHP 侧载荷一致,10 分钟后需重新 /2fa add
|
||||
}
|
||||
|
||||
/**
|
||||
* 异步推送给 PHP 展示端(网络耗时不可控,绝不能在主线程等),
|
||||
* 结果回主线程补发聊天消息:成功给二维码页 URL,失败降级手动录入。
|
||||
*/
|
||||
private void pushAndAnnounce(Player p, String token, String uri,
|
||||
String secret) {
|
||||
final UUID uuid = p.getUniqueId();
|
||||
final String name = p.getName();
|
||||
if (plugin.webManager == null) {
|
||||
announceFallback(p, name, secret);
|
||||
return;
|
||||
}
|
||||
Bukkit.getScheduler().runTaskAsynchronously(plugin, () -> {
|
||||
String pageUrl = null;
|
||||
try {
|
||||
pageUrl = plugin.webManager.pushTwoFactorQr(
|
||||
token, name, uri);
|
||||
} catch (Exception e) {
|
||||
plugin.getLogger().warning("[2FA] 推送二维码异常: "
|
||||
+ e.getMessage());
|
||||
}
|
||||
final String url = pageUrl;
|
||||
Bukkit.getScheduler().runTask(plugin, () -> {
|
||||
Player pl = Bukkit.getPlayer(uuid);
|
||||
if (pl == null || !pl.isOnline()) return;
|
||||
if (url != null) {
|
||||
pl.sendMessage("§a§l[2FA] §a已生成绑定二维码(10 分钟内有效)");
|
||||
pl.sendMessage("§e 浏览器打开: §b" + url);
|
||||
pl.sendMessage("§7 扫码添加到认证器 App 后,回游戏执行:");
|
||||
pl.sendMessage("§e /2fa code <6位动态码> §7完成绑定");
|
||||
} else {
|
||||
plugin.getLogger().info(
|
||||
"[2FA] 二维码推送失败,降级手动录入: " + name);
|
||||
announceFallback(pl, name, secret);
|
||||
}
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
/** 推送失败降级:打印 Base32 密钥供手动录入 */
|
||||
private void announceFallback(Player p, String name, String secret) {
|
||||
p.sendMessage("§e§l[2FA] §e网页二维码推送失败,改用手动录入");
|
||||
p.sendMessage("§7 认证器 App 选择「手动输入密钥」,账号名填 §f" + name);
|
||||
p.sendMessage("§e 密钥: §f" + secret);
|
||||
p.sendMessage("§7 录入后执行 §f/2fa code <6位动态码> §7完成绑定");
|
||||
}
|
||||
|
||||
// ==================== /2fa code(完成绑定) ====================
|
||||
|
||||
private void handleCode(Player p, String code) {
|
||||
code = code.trim();
|
||||
if (!code.matches("\\d{6}")) {
|
||||
p.sendMessage("§c动态码为 6 位数字");
|
||||
return;
|
||||
}
|
||||
String pending = getPendingSecret(p);
|
||||
if (pending == null) {
|
||||
p.sendMessage("§c没有待完成的绑定申请,请先执行 /2fa add");
|
||||
return;
|
||||
}
|
||||
if (isPendingExpired(p)) {
|
||||
plugin.getDb().setField(p.getName(), "twofa_pending_secret", "");
|
||||
plugin.getDb().setField(p.getName(), "twofa_pending_at", 0L);
|
||||
p.sendMessage("§c绑定申请已过期(10分钟),请重新执行 /2fa add");
|
||||
return;
|
||||
}
|
||||
if (!verifyCode(pending, code)) {
|
||||
p.sendMessage("§c动态码错误,请核对认证器 App 上的 6 位数字");
|
||||
return;
|
||||
}
|
||||
|
||||
// 绑定成功:pending 转正
|
||||
plugin.getDb().setField(p.getName(), "twofa_secret", pending);
|
||||
plugin.getDb().setField(p.getName(), "twofa_enabled", 1);
|
||||
plugin.getDb().setField(p.getName(), "twofa_pending_secret", "");
|
||||
plugin.getDb().setField(p.getName(), "twofa_pending_at", 0L);
|
||||
|
||||
p.sendMessage("§a§l[2FA] §a二次验证绑定成功!");
|
||||
p.sendMessage("§7 此后敏感操作需提供认证器动态码");
|
||||
p.sendMessage("§c ★ 换手机/卸载 App 前请先 /2fa remove 解绑,"
|
||||
+ "否则动态码将永久丢失");
|
||||
}
|
||||
|
||||
// ==================== /2fa remove(进入 30 秒 confirm 窗口) ====================
|
||||
|
||||
private void handleRemove(Player p, String code) {
|
||||
code = code.trim();
|
||||
if (!code.matches("\\d{6}")) {
|
||||
p.sendMessage("§c动态码为 6 位数字");
|
||||
return;
|
||||
}
|
||||
if (!isEnabled(p)) {
|
||||
p.sendMessage("§c您尚未绑定二次验证");
|
||||
return;
|
||||
}
|
||||
String secret = str(plugin.getDb().getField(p.getName(), "twofa_secret"));
|
||||
if (secret.isEmpty() || !verifyCode(secret, code)) {
|
||||
p.sendMessage("§c动态码错误,请核对认证器 App 上的 6 位数字");
|
||||
return;
|
||||
}
|
||||
|
||||
// 进入确认窗口
|
||||
final UUID uuid = p.getUniqueId();
|
||||
final long deadline = System.currentTimeMillis() + REMOVE_WINDOW_MS;
|
||||
removeConfirmUntil.put(uuid, deadline);
|
||||
|
||||
p.sendMessage("§c§l[2FA] §c解绑确认");
|
||||
p.sendMessage("§7 请在 §e30秒 §7内聊天输入: §fconfirm");
|
||||
p.sendMessage("§c 输入其它任何内容或超时 = 取消解绑(动态码保持有效)");
|
||||
|
||||
// 30 秒超时自动取消
|
||||
new BukkitRunnable() {
|
||||
@Override
|
||||
public void run() {
|
||||
Long d = removeConfirmUntil.get(uuid);
|
||||
if (d != null && d == deadline) {
|
||||
removeConfirmUntil.remove(uuid);
|
||||
Player pl = Bukkit.getPlayer(uuid);
|
||||
if (pl != null && pl.isOnline()) {
|
||||
pl.sendMessage("§7[2FA] 解绑确认已超时,操作取消");
|
||||
}
|
||||
}
|
||||
}
|
||||
}.runTaskLater(plugin, REMOVE_WINDOW_MS / 50L);
|
||||
}
|
||||
|
||||
/** onChat 调用:该玩家是否处于解绑确认窗口 */
|
||||
public boolean isRemoveConfirming(Player p) {
|
||||
Long d = removeConfirmUntil.get(p.getUniqueId());
|
||||
if (d == null) return false;
|
||||
if (System.currentTimeMillis() > d) {
|
||||
removeConfirmUntil.remove(p.getUniqueId());
|
||||
return false;
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
/** onChat 调用:消费确认窗口内的一条聊天输入(调用方需先 setCancelled) */
|
||||
public void handleRemoveConfirm(Player p, String msg) {
|
||||
Long deadline = removeConfirmUntil.remove(p.getUniqueId());
|
||||
if (deadline == null) return;
|
||||
if (System.currentTimeMillis() > deadline) {
|
||||
p.sendMessage("§7[2FA] 解绑确认已超时,操作取消");
|
||||
return;
|
||||
}
|
||||
if (msg != null && msg.trim().equals("confirm")) {
|
||||
// DB 写回主线程(本方法运行在异步聊天线程)
|
||||
final String name = p.getName();
|
||||
final UUID uuid = p.getUniqueId();
|
||||
Bukkit.getScheduler().runTask(plugin, () -> {
|
||||
plugin.getDb().setField(name, "twofa_secret", "");
|
||||
plugin.getDb().setField(name, "twofa_enabled", 0);
|
||||
Player pl = Bukkit.getPlayer(uuid);
|
||||
if (pl != null && pl.isOnline()) {
|
||||
pl.sendMessage("§a§l[2FA] §a二次验证已解绑");
|
||||
pl.sendMessage("§7 账号回到仅密码保护状态,建议尽快重新绑定");
|
||||
}
|
||||
});
|
||||
} else {
|
||||
p.sendMessage("§7[2FA] 输入不正确,解绑已取消(二次验证保持绑定)");
|
||||
}
|
||||
}
|
||||
|
||||
// ==================== 状态读取 ====================
|
||||
|
||||
private boolean isEnabled(Player p) {
|
||||
Object v = plugin.getDb().getField(p.getName(), "twofa_enabled");
|
||||
if (v instanceof Number) return ((Number) v).intValue() != 0;
|
||||
return "1".equals(String.valueOf(v));
|
||||
}
|
||||
|
||||
/** 返回未过期的 pending 密钥;无申请或已过期返回 null */
|
||||
private String getPendingSecret(Player p) {
|
||||
String secret = str(plugin.getDb()
|
||||
.getField(p.getName(), "twofa_pending_secret"));
|
||||
if (secret.isEmpty()) return null;
|
||||
Object t = plugin.getDb().getField(p.getName(), "twofa_pending_at");
|
||||
long at = (t instanceof Number) ? ((Number) t).longValue() : 0L;
|
||||
if (at <= 0 || System.currentTimeMillis() - at > PENDING_TTL_MS) {
|
||||
return null;
|
||||
}
|
||||
return secret;
|
||||
}
|
||||
|
||||
private boolean isPendingExpired(Player p) {
|
||||
Object t = plugin.getDb().getField(p.getName(), "twofa_pending_at");
|
||||
long at = (t instanceof Number) ? ((Number) t).longValue() : 0L;
|
||||
return at <= 0
|
||||
|| System.currentTimeMillis() - at > PENDING_TTL_MS;
|
||||
}
|
||||
|
||||
private static String str(Object v) {
|
||||
return v == null ? "" : String.valueOf(v).trim();
|
||||
}
|
||||
|
||||
// ==================== Base32 (RFC4648,无 padding,容错 0/1/8/9) ====================
|
||||
|
||||
static String b32Encode(byte[] data) {
|
||||
StringBuilder sb = new StringBuilder();
|
||||
int buffer = 0, bitsLeft = 0;
|
||||
for (byte b : data) {
|
||||
buffer = (buffer << 8) | (b & 0xff);
|
||||
bitsLeft += 8;
|
||||
while (bitsLeft >= 5) {
|
||||
sb.append(B32.charAt((buffer >> (bitsLeft - 5)) & 0x1f));
|
||||
bitsLeft -= 5;
|
||||
}
|
||||
}
|
||||
if (bitsLeft > 0) {
|
||||
sb.append(B32.charAt((buffer << (5 - bitsLeft)) & 0x1f));
|
||||
}
|
||||
return sb.toString();
|
||||
}
|
||||
|
||||
static byte[] b32Decode(String s) {
|
||||
String t = s.trim().toUpperCase()
|
||||
.replace(" ", "").replace("-", "").replace("=", "")
|
||||
.replace('0', 'O').replace('1', 'I')
|
||||
.replace('8', 'B').replace('9', 'G');
|
||||
java.io.ByteArrayOutputStream out = new java.io.ByteArrayOutputStream();
|
||||
int buffer = 0, bitsLeft = 0;
|
||||
for (int i = 0; i < t.length(); i++) {
|
||||
int idx = B32.indexOf(t.charAt(i));
|
||||
if (idx < 0) continue; // 忽略非法字符
|
||||
buffer = (buffer << 5) | idx;
|
||||
bitsLeft += 5;
|
||||
if (bitsLeft >= 8) {
|
||||
out.write((buffer >> (bitsLeft - 8)) & 0xff);
|
||||
bitsLeft -= 8;
|
||||
}
|
||||
}
|
||||
return out.toByteArray();
|
||||
}
|
||||
|
||||
// ==================== TOTP (RFC 6238, HMAC-SHA1, 6位, 30秒) ====================
|
||||
|
||||
static String totp(String base32Secret, long timeMs) {
|
||||
try {
|
||||
byte[] key = b32Decode(base32Secret);
|
||||
long counter = timeMs / 1000L / 30L;
|
||||
byte[] msg = new byte[8];
|
||||
for (int i = 7; i >= 0; i--) {
|
||||
msg[i] = (byte) (counter & 0xff);
|
||||
counter >>= 8;
|
||||
}
|
||||
Mac mac = Mac.getInstance("HmacSHA1");
|
||||
mac.init(new SecretKeySpec(key, "HmacSHA1"));
|
||||
byte[] hash = mac.doFinal(msg);
|
||||
int offset = hash[hash.length - 1] & 0x0f;
|
||||
int binary = ((hash[offset] & 0x7f) << 24)
|
||||
| ((hash[offset + 1] & 0xff) << 16)
|
||||
| ((hash[offset + 2] & 0xff) << 8)
|
||||
| (hash[offset + 3] & 0xff);
|
||||
return String.format("%06d", binary % 1_000_000);
|
||||
} catch (Exception e) {
|
||||
return "";
|
||||
}
|
||||
}
|
||||
|
||||
/** 校验动态码:当前周期 ±1 个周期(容网络/时钟 30~60 秒偏差) */
|
||||
static boolean verifyCode(String base32Secret, String code) {
|
||||
if (base32Secret == null || base32Secret.isEmpty()) return false;
|
||||
long now = System.currentTimeMillis();
|
||||
for (int i = -1; i <= 1; i++) {
|
||||
if (totp(base32Secret, now + i * 30_000L).equals(code)) {
|
||||
return true;
|
||||
}
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
/** otpauth://totp/SDF1:<玩家>?secret=..&issuer=SDF1&algorithm=SHA1&digits=6&period=30 */
|
||||
static String otpauthUri(String player, String base32Secret) {
|
||||
try {
|
||||
String label = java.net.URLEncoder
|
||||
.encode(ISSUER + ":" + player, "UTF-8");
|
||||
String issuer = java.net.URLEncoder.encode(ISSUER, "UTF-8");
|
||||
return "otpauth://totp/" + label
|
||||
+ "?secret=" + base32Secret
|
||||
+ "&issuer=" + issuer
|
||||
+ "&algorithm=SHA1&digits=6&period=30";
|
||||
} catch (java.io.UnsupportedEncodingException e) {
|
||||
return "otpauth://totp/" + ISSUER + ":" + player
|
||||
+ "?secret=" + base32Secret + "&issuer=" + ISSUER
|
||||
+ "&algorithm=SHA1&digits=6&period=30";
|
||||
}
|
||||
}
|
||||
|
||||
static byte[] randomBytes(int n) {
|
||||
byte[] b = new byte[n];
|
||||
RNG.nextBytes(b);
|
||||
return b;
|
||||
}
|
||||
|
||||
static String hex(byte[] b) {
|
||||
StringBuilder sb = new StringBuilder(b.length * 2);
|
||||
for (byte x : b) {
|
||||
sb.append(Character.forDigit((x >> 4) & 0xf, 16));
|
||||
sb.append(Character.forDigit(x & 0xf, 16));
|
||||
}
|
||||
return sb.toString();
|
||||
}
|
||||
}
|
||||
@@ -2830,6 +2830,37 @@ public class WebManager {
|
||||
return doPostWithRetry(urlStr, jsonBody, maxRetries);
|
||||
}
|
||||
|
||||
/**
|
||||
* 推送 2FA 二维码载荷给 PHP 展示端(PHP 只存+渲染,不做任何 2FA 校验)
|
||||
*
|
||||
* @param token 玩家读取载荷用的一次性随机 token
|
||||
* @param player 玩家名
|
||||
* @param otpauthUri otpauth://totp/... 完整载荷
|
||||
* @return 成功返回玩家可访问的二维码页 URL;未启用/失败返回 null
|
||||
*/
|
||||
public String pushTwoFactorQr(String token, String player,
|
||||
String otpauthUri) {
|
||||
if (!isEnabled()) return null;
|
||||
try {
|
||||
JsonObject json = new JsonObject();
|
||||
json.addProperty("secret", secretKey);
|
||||
json.addProperty("token", token);
|
||||
json.addProperty("player", player);
|
||||
json.addProperty("otpauth", otpauthUri);
|
||||
String resp = httpPost(
|
||||
"api/twofa.php?action=push_qr", json.toString());
|
||||
if (resp != null && resp.contains("\"success\":true")) {
|
||||
return webBaseUrl + "/twofa_qr.php?t=" + token;
|
||||
}
|
||||
plugin.getLogger().warning("[2FA] PHP拒绝二维码载荷: " + resp);
|
||||
return null;
|
||||
} catch (Exception e) {
|
||||
plugin.getLogger().warning("[2FA] 推送二维码失败: "
|
||||
+ e.getMessage());
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 发送POST请求(带token)
|
||||
*/
|
||||
|
||||
@@ -63,6 +63,9 @@ commands:
|
||||
email:
|
||||
description: 绑定邮箱
|
||||
usage: /email <邮箱>
|
||||
2fa:
|
||||
description: 二次验证(TOTP)绑定
|
||||
usage: /2fa add|code <6位>|remove <6位>
|
||||
绑定:
|
||||
description: 绑定邮箱(中文)
|
||||
usage: /绑定 <邮箱>
|
||||
|
||||
Reference in New Issue
Block a user