第44批:玩家端账号安全双层验证(改密/2FA绑定解绑/恢复代码)+admin用户搜索卡住根因修复+sync改密列名修复

This commit is contained in:
ypsdf1
2026-10-22 18:55:26 +08:00
parent d4748c4e62
commit 71b644182c
5 changed files with 1281 additions and 41 deletions
+26 -3
View File
@@ -971,7 +971,12 @@ let lazyLoadState = {
let isFirstLoad = true; // ★ 标记是否为首次加载
function lazyLoadUsersPage(page, limit, search, onlineSet) {
if (lazyLoadState.isLoading) return;
// ★ 第44批根因修复:此前 isLoading=true 时直接 return —— 搜索请求(IP查询最长15-30秒)进行中
// 清空搜索词,新请求被静默丢弃 → 旧搜索结果滞留(10次里6次卡住的元凶)。
// 改为抢占式:中止在飞旧请求 + 代际令牌 _lazyUsersGen,旧回调全部作废。
if (window._lazyUsersCtl) { try { window._lazyUsersCtl.abort(); } catch (e) {} }
window._lazyUsersGen = (window._lazyUsersGen || 0) + 1;
const myGen = window._lazyUsersGen;
lazyLoadState.isLoading = true;
lazyLoadState.currentPage = page;
lazyLoadState.currentSearch = search;
@@ -986,7 +991,7 @@ function lazyLoadUsersPage(page, limit, search, onlineSet) {
page: page,
limit: batchSize,
_t: Date.now(), // ★ 缓存失效:每次请求带时间戳
...(search ? { search: encodeURIComponent(search) } : {})
...(search ? { search: search } : {}) // ★ 第44批:URLSearchParams 自会编码,手动 encodeURIComponent 会二次转义(%2520)
};
const queryString = new URLSearchParams(queryParams).toString();
@@ -997,6 +1002,7 @@ function lazyLoadUsersPage(page, limit, search, onlineSet) {
// 使用fetch的timeout(AbortController)
const controller = new AbortController();
window._lazyUsersCtl = controller; // ★ 第44批:挂全局,供下一个请求抢占中止
const timeoutId = setTimeout(() => controller.abort(), timeoutMs);
fetch('api/admin.php?' + queryString, {
@@ -1006,6 +1012,7 @@ function lazyLoadUsersPage(page, limit, search, onlineSet) {
})
.finally(() => clearTimeout(timeoutId))
.then(r => {
if (myGen !== window._lazyUsersGen) { console.log('[LazyLoad] 旧请求已被新请求抢占(第1段),丢弃'); return; }
clearTimeout(timeoutId);
console.log('[LazyLoad] Response status: ' + r.status);
// 先获取text再解析JSON,防止r.json()直接崩溃
@@ -1025,6 +1032,7 @@ function lazyLoadUsersPage(page, limit, search, onlineSet) {
});
})
.then(r => {
if (myGen !== window._lazyUsersGen) { console.log('[LazyLoad] 旧请求已被新请求抢占(第2段),丢弃'); return; }
console.log('[LazyLoad] ★★★ 进入第二个then块, r:', typeof r, r ? Object.keys(r).join(',') : 'null');
lazyLoadState.isLoading = false;
@@ -1244,6 +1252,7 @@ function lazyLoadUsersPage(page, limit, search, onlineSet) {
}
})
.catch(e => {
if (myGen !== window._lazyUsersGen) { console.log('[LazyLoad] 旧请求被抢占/中止,忽略:', e.name, e.message); return; }
clearTimeout(timeoutId);
lazyLoadState.isLoading = false;
document.getElementById('userLazyContainer').innerHTML = '<div class="empty">加载失败: ' + (e.name === 'AbortError' ? '请求超时,请重试' : e.message) + '</div>';
@@ -1371,13 +1380,17 @@ function updateIpLocationDisplay(ipLocationMap) {
function doLazyLoadMore() {
if (!lazyLoadState.hasMore || lazyLoadState.isLoading) return;
// ★ 第44批:进入新请求前换代,旧代回调(含被抢占的)一律作废
if (window._lazyUsersCtl) { try { window._lazyUsersCtl.abort(); } catch (e) {} }
window._lazyUsersGen = (window._lazyUsersGen || 0) + 1;
const myGen = window._lazyUsersGen;
lazyLoadState.currentPage++;
const queryParams = {
action: 'list_users_paginated',
page: lazyLoadState.currentPage,
limit: 20,
_t: Date.now(),
...(lazyLoadState.currentSearch ? { search: encodeURIComponent(lazyLoadState.currentSearch) } : {})
...(lazyLoadState.currentSearch ? { search: lazyLoadState.currentSearch } : {}) // ★ 第44批:URLSearchParams 自会编码
};
const queryString = new URLSearchParams(queryParams).toString();
@@ -1388,6 +1401,7 @@ function doLazyLoadMore() {
// 设置30秒超时(IP查询需要时间)
const controller = new AbortController();
window._lazyUsersCtl = controller; // ★ 第44批
const timeoutId = setTimeout(() => controller.abort(), 30000);
fetch('api/admin.php?' + queryString, {
@@ -1397,6 +1411,7 @@ function doLazyLoadMore() {
})
.finally(() => clearTimeout(timeoutId))
.then(r => {
if (myGen !== window._lazyUsersGen) { console.log('[LazyLoad More] 旧请求已被抢占(第1段),丢弃'); return; }
clearTimeout(timeoutId);
console.log('[LazyLoad More] Response status: ' + r.status);
return r.text().then(text => {
@@ -1412,6 +1427,7 @@ function doLazyLoadMore() {
});
})
.then(r => {
if (myGen !== window._lazyUsersGen) { console.log('[LazyLoad More] 旧请求已被抢占(第2段),丢弃'); return; }
console.log('[LazyLoad More] ★ Parsed OK, success:', r.success, 'data count:', r.data?.length);
lazyLoadState.isLoading = false;
@@ -1530,6 +1546,7 @@ function doLazyLoadMore() {
}
})
.catch(e => {
if (myGen !== window._lazyUsersGen) { console.log('[LazyLoad More] 旧请求被抢占/中止,忽略:', e.name, e.message); return; }
console.error('[LazyLoad More] ★ Error:', e.name, e.message);
clearTimeout(timeoutId);
lazyLoadState.isLoading = false;
@@ -1568,6 +1585,11 @@ function doLazyLoadSearch() {
// ===== ban/tempban 搜索:客户端筛选封禁玩家 =====
function renderBanSearchResults(type) {
// ★ 第44批:抢占在飞的懒加载请求并换代(否则旧搜索结果可能稍后覆盖封禁列表,反之亦然)
if (window._lazyUsersCtl) { try { window._lazyUsersCtl.abort(); } catch (e) {} }
window._lazyUsersGen = (window._lazyUsersGen || 0) + 1;
const myGen = window._lazyUsersGen;
lazyLoadState.isLoading = false;
const div = document.getElementById('userLazyContainer');
if (!div) return;
div.innerHTML = '<div class="empty">加载中...</div>';
@@ -1576,6 +1598,7 @@ function renderBanSearchResults(type) {
document.getElementById('userLazyLoadMore').style.display = 'none';
jsonApi('admin.php?action=list_users').then(r => {
if (myGen !== window._lazyUsersGen) { console.log('[BanSearch] 已被新请求抢占,丢弃封禁列表结果'); return; }
if (!r.success || !r.data) {
div.innerHTML = '<div class="empty">加载失败: ' + escAdmHtml(r.message || '未知错误') + '</div>';
return;
+841
View File
@@ -0,0 +1,841 @@
<?php
/**
* 玩家端账号安全 API(双层验证 · 2026-10-22 第44批)
*
* ★ 两层验证模型(任何改信息操作前强制完成):
* 第一层(强制):输入一次完整主密码(临时密码不算)→ verify_start 签发 challenge(5分钟)
* 第二层(三选一):邮箱验证码 / 认证器动态码(TOTP) / 恢复代码 → verify_step2 换 ticket(10分钟)
* 变更类操作凭 ticket 执行;ticket 一次性(totp_setup 例外:只校验不消耗,扫码确认要两次调用)。
*
* ★ Actions(全部需要 web_token,建议 POST JSON,避免密码进 access log):
* status 账号安全状态(是否绑邮箱/2FA/恢复代码/当前可用第二层方式)
* verify_start 第一层:password → challenge + methods
* send_email_code challenge 后向绑定邮箱发 6 位码(60秒间隔 + 10次/小时限频)
* verify_step2 challenge + email_code|totp_code|recovery_code → ticket
* change_password ticket + new_password → 改登录密码(顺带清临时密码)
* totp_setup ticket → 生成待确认密钥 + otpauth(不消耗 ticket)
* totp_bind ticket + code → 确认绑定,一次性返回 10 组恢复代码
* totp_unbind ticket → 解绑(恢复代码一并清空)
* recovery_list ticket → 查看恢复代码(明文)
* recovery_regenerate ticket → 重新生成恢复代码(旧的立即作废)
*
* ★ 安全与取舍(如实记录):
* - security.php 尾部有顶层 secEmergencyGate()/secRateLimitGate(),不能直接 require,
* 故 secB32 与 secTotp 系列函数以副本内置(来源 security.php:717-841 行,逻辑一字不改)。
* - 邮件发送失败绝不回显验证码(回显=只要密码就能过第二层,形同单层)。
* - 恢复代码/TOTP 密钥明文存库:「页内查看恢复代码」「展示绑定二维码」功能必须明文,
* 库文件在站点私有目录,风险在此声明。
* - 未绑邮箱且未绑 2FA 的账号没有第二层可过 → status 返回 can_verify=false 引导先绑邮箱。
* - 猜测限速:db/acct_state.json —— 密码 5次/5分、第二层 5次/5分、发码 10次/小时+60秒间隔、
* 绑定动态码 5次/5分;失败计数在验证成功时清零。
*/
// 防止任何输出污染 JSON 响应
ob_start();
if (function_exists('opcache_invalidate')) { @opcache_invalidate(__FILE__); }
require_once __DIR__ . '/../core.php';
while (ob_get_level() > 0) { @ob_end_clean(); }
ob_start();
header('Content-Type: application/json; charset=utf-8');
header('X-Content-Type-Options: nosniff');
header('Cache-Control: no-store, no-cache, must-revalidate');
if (!function_exists('error') || !function_exists('success') || !function_exists('getParam')) {
while (ob_get_level() > 0) { @ob_end_clean(); }
echo json_encode(['success' => false, 'message' => 'core.php load failed'], JSON_UNESCAPED_UNICODE);
exit;
}
$action = getParam('action', 'status');
try {
acctEnsureTables();
switch ($action) {
case 'status': acctStatus(); break;
case 'verify_start': acctVerifyStart(); break;
case 'send_email_code': acctSendEmailCode(); break;
case 'verify_step2': acctVerifyStep2(); break;
case 'change_password': acctChangePassword(); break;
case 'totp_setup': acctTotpSetup(); break;
case 'totp_bind': acctTotpBind(); break;
case 'totp_unbind': acctTotpUnbind(); break;
case 'recovery_list': acctRecoveryList(); break;
case 'recovery_regenerate': acctRecoveryRegen(); break;
default:
error('未知操作: ' . $action);
}
try { if (function_exists('walCheckpoint')) { walCheckpoint(); } } catch (\Throwable $ignored) {}
} catch (\Throwable $e) {
while (ob_get_level() > 0) { @ob_end_clean(); }
if (function_exists('debugLog')) { debugLog('[account.php] EXCEPTION: ' . $e->getMessage()); }
error('服务器内部错误: ' . $e->getMessage(), 500);
}
// ===================== TOTP 基础(副本来自 security.php 717-841 行) =====================
/** Base32 解码(含 0/1/8/9 易混字符归一,逻辑同 security.php secB32Decode) */
function acctB32Decode($s) {
$s = strtoupper((string)$s);
$s = str_replace(array('0', '1', '8', '9'), array('O', 'I', 'B', 'G'), $s);
$s = preg_replace('/[^A-Z2-7]/', '', $s);
$alphabet = 'ABCDEFGHIJKLMNOPQRSTUVWXYZ234567';
$buf = 0; $bits = 0; $out = '';
for ($i = 0; $i < strlen($s); $i++) {
$v = strpos($alphabet, $s[$i]);
if ($v === false) continue;
$buf = ($buf << 5) | $v;
$bits += 5;
if ($bits >= 8) {
$bits -= 8;
$out .= chr(($buf >> $bits) & 0xFF);
}
}
return $out;
}
function acctB32Encode($data) {
$alphabet = 'ABCDEFGHIJKLMNOPQRSTUVWXYZ234567';
$out = ''; $buf = 0; $bits = 0;
for ($i = 0; $i < strlen($data); $i++) {
$buf = ($buf << 8) | ord($data[$i]);
$bits += 8;
while ($bits >= 5) {
$bits -= 5;
$out .= $alphabet[($buf >> $bits) & 31];
}
}
if ($bits > 0) $out .= $alphabet[($buf << (5 - $bits)) & 31];
return $out;
}
/** 生成 20 字节随机密钥(Base32 无填充,认证器通用格式)。 */
function acctNewSecret() {
return acctB32Encode(random_bytes(20));
}
/** 计算某一时刻的 6 位动态码(SHA1/30秒/RFC6238)。 */
function acctTotp($secret, $time = null, $step = 30, $digits = 6) {
$key = acctB32Decode($secret);
if ($key === '') return '';
$t = ($time === null) ? time() : (int)$time;
$counter = (int)floor($t / $step);
$bin = '';
for ($i = 7; $i >= 0; $i--) {
$bin .= chr(($counter >> ($i * 8)) & 0xFF);
}
$hash = hash_hmac('sha1', $bin, $key, true);
$offset = ord($hash[19]) & 0x0F;
$code = ((ord($hash[$offset]) & 0x7F) << 24)
| (ord($hash[$offset + 1]) << 16)
| (ord($hash[$offset + 2]) << 8)
| (ord($hash[$offset + 3]));
$code = $code % (int)pow(10, $digits);
return str_pad((string)$code, $digits, '0', STR_PAD_LEFT);
}
/** 校验动态码,window=3 → 接受 ±90 秒时钟漂移(同 security.php secTotpVerify)。 */
function acctTotpVerify($secret, $code, $window = 3) {
$code = trim((string)$code);
if (!preg_match('/^\d{6}$/', $code)) return false;
if (!$secret) return false;
$now = time();
for ($i = -$window; $i <= $window; $i++) {
if (hash_equals(acctTotp($secret, $now + $i * 30), $code)) return true;
}
return false;
}
/** 认证器绑定用 otpauth 链接(label 带玩家名,便于多账号区分)。 */
function acctOtpauthUri($secret, $player) {
$label = rawurlencode('SDF1:' . $player);
$issuer = rawurlencode('SDF1');
return 'otpauth://totp/' . $label . '?secret=' . $secret
. '&issuer=' . $issuer . '&algorithm=SHA1&digits=6&period=30';
}
// ===================== 猜测限速(db/acct_state.json,文件锁) =====================
function acctStateFile() {
return __DIR__ . '/../db/acct_state.json';
}
/**
* 记录一次尝试并返回当前锁定剩余秒数(0 = 未锁定)。
* 逻辑同 security.php secThrottleFail,另加全局过期清理防状态文件膨胀。
*/
function acctThrottleFail($bucket, $key, $max, $win) {
$f = acctStateFile();
$dir = dirname($f);
if (!is_dir($dir)) { @mkdir($dir, 0755, true); }
$fp = @fopen($f, 'c+');
if (!$fp) return 0;
flock($fp, LOCK_EX);
$raw = stream_get_contents($fp);
$state = json_decode($raw, true);
if (!is_array($state)) $state = array();
$now = time();
$k = $bucket . '|' . $key;
$arr = isset($state[$k]) && is_array($state[$k]) ? $state[$k] : array();
$arr[] = $now;
$fresh = array();
foreach ($arr as $ts) { if ((int)$ts > $now - $win) $fresh[] = (int)$ts; }
$state[$k] = $fresh;
// 全量顺手清理:条目过多或整文件超过 64KB 时剔除窗口外记录
if (count($state) > 500 || @filesize($f) > 65536) {
foreach ($state as $sk => $sts) {
if (!is_array($sts)) { unset($state[$sk]); continue; }
$keep = array();
foreach ($sts as $ts) { if ((int)$ts > $now - 3600) $keep[] = (int)$ts; }
if ($keep) { $state[$sk] = $keep; } else { unset($state[$sk]); }
}
if (isset($state[$k])) { $state[$k] = $fresh; }
}
ftruncate($fp, 0); rewind($fp);
fwrite($fp, json_encode($state, JSON_UNESCAPED_UNICODE));
fflush($fp); flock($fp, LOCK_UN); fclose($fp);
if (count($fresh) >= $max) {
return max(1, ($fresh[0] + $win) - $now);
}
return 0;
}
/** 只读查询:当前是否被锁定,返回剩余秒数(0 = 未锁定)。 */
function acctThrottleLocked($bucket, $key, $max, $win) {
$f = acctStateFile();
if (!file_exists($f)) return 0;
$fp = @fopen($f, 'r');
if (!$fp) return 0;
flock($fp, LOCK_SH);
$raw = stream_get_contents($fp);
flock($fp, LOCK_UN); fclose($fp);
$state = json_decode($raw, true);
if (!is_array($state)) return 0;
$k = $bucket . '|' . $key;
if (!isset($state[$k]) || !is_array($state[$k])) return 0;
$now = time();
$fresh = array();
foreach ($state[$k] as $ts) { if ((int)$ts > $now - $win) $fresh[] = (int)$ts; }
if (count($fresh) >= $max) {
return max(1, ($fresh[0] + $win) - $now);
}
return 0;
}
/** 验证成功后清除该 key 的失败计数。 */
function acctThrottleReset($bucket, $key) {
$f = acctStateFile();
if (!file_exists($f)) return;
$fp = @fopen($f, 'r+');
if (!$fp) return;
flock($fp, LOCK_EX);
$raw = stream_get_contents($fp);
$state = json_decode($raw, true);
if (is_array($state)) {
$k = $bucket . '|' . $key;
if (isset($state[$k])) {
unset($state[$k]);
ftruncate($fp, 0); rewind($fp);
fwrite($fp, json_encode($state, JSON_UNESCAPED_UNICODE));
fflush($fp);
}
}
flock($fp, LOCK_UN); fclose($fp);
}
// ===================== 建表 =====================
function acctEnsureTables() {
$db = getDB();
// 2FA 状态:totp_secret / recovery_codes 明文存(页内查看与二维码展示必须明文,见文件头声明)
$db->exec("CREATE TABLE IF NOT EXISTS web_account_2fa (
player_name TEXT PRIMARY KEY,
totp_secret TEXT DEFAULT '',
recovery_codes TEXT DEFAULT '',
bound_at INTEGER DEFAULT 0,
updated_at INTEGER DEFAULT 0
)");
// 验证会话:challenge(第一层后,5分钟)/ ticket(两层过后,10分钟一次性)
$db->exec("CREATE TABLE IF NOT EXISTS web_account_verify (
token TEXT PRIMARY KEY,
player_name TEXT NOT NULL,
kind TEXT NOT NULL,
methods TEXT DEFAULT '',
ip TEXT DEFAULT '',
fail_count INTEGER DEFAULT 0,
used INTEGER DEFAULT 0,
expire_at INTEGER NOT NULL,
created_at INTEGER DEFAULT 0
)");
// 顺手清理过期超过 1 天的会话行
$db->exec("DELETE FROM web_account_verify WHERE expire_at < " . (time() - 86400));
}
// ===================== 公共辅助 =====================
/** 邮箱脱敏:123@456.com → xx@456.com(同 sync.php maskEmail,仅显示域名) */
function acctMaskEmail($email) {
if (empty($email)) return '';
$parts = explode('@', $email);
if (count($parts) !== 2) return 'xx@' . ($parts[0] ?? '*');
return 'xx@' . $parts[1];
}
/**
* web_token → 玩家名。缺 token / 过期 / 封禁 / 拉黑 / 未注册一律直接 error()。
* @return string player_name
*/
function acctAuthPlayer() {
$webToken = getParam('web_token');
if (!$webToken) error('缺少web_token参数,请重新登录', 401);
$info = validateTokenSilent($webToken);
if (!$info) error('登录已过期,请重新登录', 401);
$player = $info['player'];
$ban = playerWebBanReason($player);
if ($ban !== null) error('账号已被封禁:' . $ban, 403);
$blk = playerWebBlacklistReason($player);
if ($blk !== null) error('你已被拉黑,无法进行账号安全操作', 403);
if (!isPlayerRegistered($player)) error('玩家未注册', 403);
return $player;
}
/** 取主密码凭证行(只认 password_hash+salt,同 core.php 3053 行验证模板)。 */
function acctCredentials($player) {
$db = getDB();
$stmt = $db->prepare("SELECT password_hash, salt, temp_password_hash, temp_pw_expire FROM weblogin_credentials WHERE player_name = :player");
$stmt->bindValue(':player', $player, SQLITE3_TEXT);
$result = $stmt->execute();
$row = $result->fetchArray(SQLITE3_ASSOC);
return $row ? $row : null;
}
/** 第一层密码比对:只认主密码("完整密码"语义,临时密码不算)。 */
function acctPwdCheck($cred, $pwd) {
if (!$cred || !isset($cred['password_hash']) || $cred['password_hash'] === '') return false;
$saltBytes = base64_decode((string)$cred['salt'], true);
if ($saltBytes === false || $saltBytes === '') return false;
$calc = base64_encode(hash('sha256', $saltBytes . $pwd, true));
return hash_equals((string)$cred['password_hash'], $calc);
}
function acctEmailOf($player) {
$db = getDB();
$stmt = $db->prepare("SELECT email FROM users WHERE player_name = :p");
$stmt->bindValue(':p', $player, SQLITE3_TEXT);
$result = $stmt->execute();
$row = $result->fetchArray(SQLITE3_ASSOC);
return ($row && isset($row['email'])) ? (string)$row['email'] : '';
}
function acctTwoFa($player) {
$db = getDB();
$stmt = $db->prepare("SELECT * FROM web_account_2fa WHERE player_name = :p");
$stmt->bindValue(':p', $player, SQLITE3_TEXT);
$result = $stmt->execute();
$row = $result->fetchArray(SQLITE3_ASSOC);
return $row ? $row : null;
}
/** 当前实时可用的第二层方式:email / totp / recovery。 */
function acctMethods($player, $email) {
$m = array();
if ($email !== '') $m[] = 'email';
$t = acctTwoFa($player);
if ($t && (int)$t['bound_at'] > 0 && (string)$t['totp_secret'] !== '') {
$m[] = 'totp';
if ((string)$t['recovery_codes'] !== '') $m[] = 'recovery';
}
return $m;
}
/**
* 取回并校验 challenge/ticket,不通过直接 error()。
* @param string $token 会话 token
* @param string $kind challenge|ticket
* @param string $player 期望玩家名(跨账号使用会话直接拒绝)
*/
function acctLoadToken($token, $kind, $player) {
$label = ($kind === 'challenge') ? '第一层验证会话' : '验证凭据';
if (!$token) error('缺少' . $label . ',请重新开始验证', 401);
$db = getDB();
$stmt = $db->prepare("SELECT * FROM web_account_verify WHERE token = :t AND kind = :k");
$stmt->bindValue(':t', $token, SQLITE3_TEXT);
$stmt->bindValue(':k', $kind, SQLITE3_TEXT);
$result = $stmt->execute();
$row = $result->fetchArray(SQLITE3_ASSOC);
if (!$row) error($label . '不存在,请重新开始验证', 401);
if ((int)$row['used'] !== 0) error($label . '已使用,请重新开始验证', 401);
if ((int)$row['expire_at'] < time()) error($label . '已过期,请重新开始验证', 401);
if ($player !== null && $row['player_name'] !== $player) error($label . '与当前账号不匹配', 403);
return $row;
}
function acctConsumeToken($token) {
$db = getDB();
$stmt = $db->prepare("UPDATE web_account_verify SET used = 1 WHERE token = :t");
$stmt->bindValue(':t', $token, SQLITE3_TEXT);
$stmt->execute();
}
/** 签发新会话,并作废该玩家所有未使用的旧会话(同一时间只保留一个有效会话)。 */
function acctIssueToken($player, $kind, $ttl, $methodsJson, $ip) {
$db = getDB();
// 作废旧会话只标记不删除(保留审计痕迹;过期行由 acctEnsureTables 定期清理)
$upd = $db->prepare("UPDATE web_account_verify SET used = 1 WHERE player_name = :p AND used = 0");
$upd->bindValue(':p', $player, SQLITE3_TEXT);
$upd->execute();
$token = bin2hex(random_bytes(24));
$stmt = $db->prepare("INSERT INTO web_account_verify (token, player_name, kind, methods, ip, fail_count, used, expire_at, created_at)
VALUES (:t, :p, :k, :m, :ip, 0, 0, :e, :c)");
$stmt->bindValue(':t', $token, SQLITE3_TEXT);
$stmt->bindValue(':p', $player, SQLITE3_TEXT);
$stmt->bindValue(':k', $kind, SQLITE3_TEXT);
$stmt->bindValue(':m', $methodsJson, SQLITE3_TEXT);
$stmt->bindValue(':ip', (string)$ip, SQLITE3_TEXT);
$stmt->bindValue(':e', time() + (int)$ttl, SQLITE3_INTEGER);
$stmt->bindValue(':c', time(), SQLITE3_INTEGER);
$stmt->execute();
return $token;
}
function acctClientIp() {
return isset($_SERVER['REMOTE_ADDR']) ? (string)$_SERVER['REMOTE_ADDR'] : '';
}
/** 恢复代码字母表:26 字母 + 234567(32 字符);0/1/8/9/O/I 全部排除,输入按易混归一。 */
function acctRecoveryAlpha() {
return 'ABCDEFGHIJKLMNOPQRSTUVWXYZ234567';
}
function acctGenRecoveryCodes() {
$alpha = acctRecoveryAlpha();
$n = strlen($alpha);
$codes = array();
for ($i = 0; $i < 10; $i++) {
$s = '';
for ($j = 0; $j < 16; $j++) {
$s .= $alpha[random_int(0, $n - 1)];
}
$codes[] = substr($s, 0, 4) . '-' . substr($s, 4, 4) . '-' . substr($s, 8, 4) . '-' . substr($s, 12, 4);
}
return $codes;
}
/** 恢复代码归一:大写 + 易混字符归一(0→O 1→I 8→B 9→G)+ 去掉非字母数字。 */
function acctNormRecovery($s) {
$s = strtoupper((string)$s);
$s = str_replace(array('0', '1', '8', '9'), array('O', 'I', 'B', 'G'), $s);
$s = preg_replace('/[^A-Z0-9]/', '', $s);
return $s;
}
function acctSaveTwoFa($player, $secret, $codesJson, $boundAt) {
$db = getDB();
$stmt = $db->prepare("INSERT INTO web_account_2fa (player_name, totp_secret, recovery_codes, bound_at, updated_at)
VALUES (:p, :s, :r, :b, :u)
ON CONFLICT(player_name) DO UPDATE SET totp_secret = :s, recovery_codes = :r, bound_at = :b, updated_at = :u");
$stmt->bindValue(':p', $player, SQLITE3_TEXT);
$stmt->bindValue(':s', (string)$secret, SQLITE3_TEXT);
$stmt->bindValue(':r', (string)$codesJson, SQLITE3_TEXT);
$stmt->bindValue(':b', (int)$boundAt, SQLITE3_INTEGER);
$stmt->bindValue(':u', time(), SQLITE3_INTEGER);
$stmt->execute();
}
// ===================== Actions =====================
/** 账号安全状态:前端渲染面板 + 第二层可用方式清单 */
function acctStatus() {
$player = acctAuthPlayer();
$email = acctEmailOf($player);
$t = acctTwoFa($player);
$bound = $t && (int)$t['bound_at'] > 0 && (string)$t['totp_secret'] !== '';
$hasRecovery = $bound && $t && (string)$t['recovery_codes'] !== '';
$methods = acctMethods($player, $email);
$hint = '';
if (!$methods) {
$hint = '账号未绑定邮箱,也未绑定二次验证器 —— 无法完成第二层验证。请先在游戏中使用 /bindemail 绑定邮箱,否则无法修改密码等账号信息。';
}
success([
'player' => $player,
'has_email' => ($email !== ''),
'email_masked' => acctMaskEmail($email),
'twofa_bound' => (bool)$bound,
'has_recovery' => (bool)$hasRecovery,
'pending_setup' => ($t && (int)$t['bound_at'] === 0 && (string)$t['totp_secret'] !== ''),
'methods' => $methods,
'can_verify' => (bool)$methods,
'hint' => $hint,
], 'ok');
}
/** 第一层:完整主密码 → challenge(5分钟)+ 第二层可用方式快照 */
function acctVerifyStart() {
$player = acctAuthPlayer();
$pwd = (string)getParam('password', '');
if ($pwd === '') error('请输入密码');
$locked = acctThrottleLocked('pwd', $player, 5, 300);
if ($locked > 0) error('密码错误次数过多,请 ' . $locked . ' 秒后再试', 429);
$cred = acctCredentials($player);
if (!$cred) error('账号未设置密码,请先在游戏中完成注册');
if (!acctPwdCheck($cred, $pwd)) {
$remain = acctThrottleFail('pwd', $player, 5, 300);
debugLog('[account.php] 第一层密码错误', ['player' => $player, 'ip' => acctClientIp()]);
if ($remain > 0) error('密码错误(连续错误 5 次将锁定 5 分钟),' . $remain . ' 秒后可重试');
error('密码错误');
}
acctThrottleReset('pwd', $player);
$email = acctEmailOf($player);
$methods = acctMethods($player, $email);
if (!$methods) {
error('无法完成第二层验证:账号未绑定邮箱且未绑定二次验证器,请先在游戏中使用 /bindemail 绑定邮箱');
}
$challenge = acctIssueToken($player, 'challenge', 300, json_encode($methods, JSON_UNESCAPED_UNICODE), acctClientIp());
success([
'challenge' => $challenge,
'methods' => $methods,
'email_masked' => acctMaskEmail($email),
'expire_in' => 300,
], '第一层验证通过');
}
/** challenge 后发送邮箱验证码(60秒间隔 + 10次/小时;发送失败绝不回显验证码) */
function acctSendEmailCode() {
$player = acctAuthPlayer();
$challenge = (string)getParam('challenge', '');
$ch = acctLoadToken($challenge, 'challenge', $player);
$methods = json_decode((string)$ch['methods'], true);
if (!is_array($methods) || !in_array('email', $methods, true)) {
error('当前验证会话未启用邮箱验证');
}
$email = acctEmailOf($player);
if ($email === '') error('账号未绑定邮箱');
$locked = acctThrottleLocked('mail', $player, 10, 3600);
if ($locked > 0) error('发送过于频繁,请 ' . $locked . ' 秒后再试', 429);
// 60 秒发送间隔(查该玩家最近一条验证码的时间)
$db = getDB();
$stmt = $db->prepare("SELECT created_at FROM email_codes WHERE player_name = :p ORDER BY id DESC LIMIT 1");
$stmt->bindValue(':p', $player, SQLITE3_TEXT);
$result = $stmt->execute();
$last = $result->fetchArray(SQLITE3_ASSOC);
if ($last) {
$gap = time() - (int)$last['created_at'];
if ($gap < 60) error('发送过于频繁,请 ' . (60 - $gap) . ' 秒后再试', 429);
}
$smtpHost = SMTP_HOST;
$smtpPort = SMTP_PORT;
$smtpUser = SMTP_USER;
$smtpPass = SMTP_PASS;
$senderName = SMTP_SENDER_NAME;
if (empty($smtpHost) || empty($smtpUser) || empty($smtpPass)) {
error('SMTP配置不完整,无法发送邮件验证');
}
$code = str_pad((string)random_int(0, 999999), 6, '0', STR_PAD_LEFT);
$expireTime = time() + 300;
// 旧验证码全部作废:任何时刻只有最新一条有效
$upd = $db->prepare("UPDATE email_codes SET used = 1 WHERE player_name = :p AND used = 0");
$upd->bindValue(':p', $player, SQLITE3_TEXT);
$upd->execute();
$ins = $db->prepare("INSERT INTO email_codes (player_name, email, code, expire_at, used, created_at)
VALUES (:p, :e, :c, :x, 0, :t)");
$ins->bindValue(':p', $player, SQLITE3_TEXT);
$ins->bindValue(':e', $email, SQLITE3_TEXT);
$ins->bindValue(':c', $code, SQLITE3_TEXT);
$ins->bindValue(':x', $expireTime, SQLITE3_INTEGER);
$ins->bindValue(':t', time(), SQLITE3_INTEGER);
$ins->execute();
$subject = "【" . $senderName . "】账号安全验证";
$htmlBody = "<div style='font-family:Arial,sans-serif;max-width:400px;margin:0 auto;padding:20px;background:#f5f5f5;border-radius:10px'>"
. "<h2 style='color:#333;text-align:center'>🛡️ 账号安全验证</h2>"
. "<div style='background:#fff;padding:20px;border-radius:8px;text-align:center;margin:20px 0'>"
. "<p style='font-size:14px;color:#666;margin-bottom:10px'>玩家 <b>" . htmlspecialchars($player) . "</b> 的验证码:</p>"
. "<p style='font-size:32px;font-weight:bold;color:#4CAF50;letter-spacing:8px;margin:0'>" . $code . "</p>"
. "</div>"
. "<p style='font-size:12px;color:#999;text-align:center'>验证码5分钟内有效,用于修改账号信息前的第二层验证</p>"
. "<p style='font-size:12px;color:#999;text-align:center'>如果这不是您的操作,请立刻修改密码并忽略此邮件</p>"
. "</div>";
$headers = "MIME-Version: 1.0\r\n"
. "Content-type: text/html; charset=UTF-8\r\n"
. "From: " . $senderName . " <" . $smtpUser . ">\r\n";
try {
$sent = smtpSendEmail($smtpHost, $smtpPort, $smtpUser, $smtpPass, $email, $subject, $htmlBody, $headers, SMTP_USE_SSL);
} catch (\Throwable $e) {
$sent = false;
debugLog('[account.php] 邮件异常: ' . $e->getMessage(), ['player' => $player]);
}
if (!$sent) {
// 发送失败立即作废该验证码,且绝不回显(回显=密码单层即可通过)
$del = $db->prepare("UPDATE email_codes SET used = 1 WHERE player_name = :p AND code = :c");
$del->bindValue(':p', $player, SQLITE3_TEXT);
$del->bindValue(':c', $code, SQLITE3_TEXT);
$del->execute();
debugLog('[account.php] 邮件发送失败', ['player' => $player]);
error('邮件发送失败,请稍后重试');
}
acctThrottleFail('mail', $player, 10, 3600);
success([
'masked_email' => acctMaskEmail($email),
'resend_in' => 60,
'code_expire_in' => 300,
], '验证码已发送至 ' . acctMaskEmail($email));
}
/** 第二层:邮箱码 / TOTP 动态码 / 恢复代码 → ticket(10分钟,一次性) */
function acctVerifyStep2() {
$player = acctAuthPlayer();
$challenge = (string)getParam('challenge', '');
$ch = acctLoadToken($challenge, 'challenge', $player);
$emailCode = (string)getParam('email_code', '');
$totpCode = (string)getParam('totp_code', '');
$recCode = (string)getParam('recovery_code', '');
$picked = 0; $which = '';
if ($emailCode !== '') { $picked++; $which = 'email'; }
if ($totpCode !== '') { $picked++; $which = 'totp'; }
if ($recCode !== '') { $picked++; $which = 'recovery'; }
if ($picked !== 1) error('请只填写一种第二层验证方式');
$snapshot = json_decode((string)$ch['methods'], true);
if (!is_array($snapshot)) $snapshot = array();
if (!in_array($which, $snapshot, true)) error('当前验证会话未启用该验证方式');
$db = getDB();
$ok = false;
if ($which === 'email') {
// 只认未使用且未过期的最新一条(发送侧已把旧码全部作废)
$stmt = $db->prepare("SELECT id FROM email_codes WHERE player_name = :p AND code = :c AND used = 0 AND expire_at > :t ORDER BY id DESC LIMIT 1");
$stmt->bindValue(':p', $player, SQLITE3_TEXT);
$stmt->bindValue(':c', $emailCode, SQLITE3_TEXT);
$stmt->bindValue(':t', time(), SQLITE3_INTEGER);
$result = $stmt->execute();
$row = $result->fetchArray(SQLITE3_ASSOC);
if ($row) {
$upd = $db->prepare("UPDATE email_codes SET used = 1 WHERE id = :id");
$upd->bindValue(':id', (int)$row['id'], SQLITE3_INTEGER);
$upd->execute();
$ok = true;
}
} elseif ($which === 'totp') {
$t = acctTwoFa($player);
if ($t && (int)$t['bound_at'] > 0 && (string)$t['totp_secret'] !== '') {
$ok = acctTotpVerify((string)$t['totp_secret'], $totpCode);
}
} elseif ($which === 'recovery') {
$t = acctTwoFa($player);
if ($t && (int)$t['bound_at'] > 0 && (string)$t['recovery_codes'] !== '') {
$codes = json_decode((string)$t['recovery_codes'], true);
if (is_array($codes)) {
$norm = acctNormRecovery($recCode);
foreach ($codes as $idx => $one) {
if ($norm !== '' && hash_equals(acctNormRecovery($one), $norm)) {
unset($codes[$idx]);
$left = array_values($codes);
// 核销用掉的恢复代码(一次性),列表为空时存空串使 has_recovery 归 false
acctSaveTwoFa($player, (string)$t['totp_secret'],
$left ? json_encode($left, JSON_UNESCAPED_UNICODE) : '', (int)$t['bound_at']);
$ok = true;
break;
}
}
}
}
}
if (!$ok) {
$remain = acctThrottleFail('s2', $player, 5, 300);
debugLog('[account.php] 第二层验证失败', ['player' => $player, 'way' => $which, 'ip' => acctClientIp()]);
if ($remain > 0) error('验证失败(连续失败 5 次锁定 5 分钟),' . $remain . ' 秒后可重试', 429);
error('验证失败');
}
acctThrottleReset('s2', $player);
// 两层全过 → 签发 ticket(10分钟一次性);acctIssueToken 会同时作废本 challenge 与旧会话
$ticket = acctIssueToken($player, 'ticket', 600, '', acctClientIp());
success([
'ticket' => $ticket,
'expire_in' => 600,
], '第二层验证通过');
}
/** 凭 ticket 修改登录密码:新盐新哈希,顺带清临时密码(ticket 先核销后落库,防并发重复使用) */
function acctChangePassword() {
$player = acctAuthPlayer();
$ticket = (string)getParam('ticket', '');
acctLoadToken($ticket, 'ticket', $player);
$newPwd = (string)getParam('new_password', '');
$confirm = (string)getParam('confirm_password', '');
if ($newPwd === '') error('请输入新密码');
if ($confirm !== '' && $confirm !== $newPwd) error('两次输入的新密码不一致');
if (strlen($newPwd) < 6 || strlen($newPwd) > 20) error('密码长度必须为6-20位');
$cred = acctCredentials($player);
if (!$cred) error('账号未设置密码');
if (acctPwdCheck($cred, $newPwd)) error('新密码不能与当前密码相同');
// 一次性凭据核销(上面格式/新旧比对全部通过后才走到这里)
acctConsumeToken($ticket);
$newSaltBytes = random_bytes(16);
$newSalt = base64_encode($newSaltBytes);
$newHash = base64_encode(hash('sha256', $newSaltBytes . $newPwd, true));
$db = getDB();
$upd = $db->prepare("UPDATE weblogin_credentials SET password_hash = :h, salt = :s, temp_password_hash = '', temp_pw_expire = 0 WHERE player_name = :p");
$upd->bindValue(':h', $newHash, SQLITE3_TEXT);
$upd->bindValue(':s', $newSalt, SQLITE3_TEXT);
$upd->bindValue(':p', $player, SQLITE3_TEXT);
$upd->execute();
// 回读核对(不依赖 changes(),SQLite/MySQL 双模式同形)
$after = acctCredentials($player);
if (!$after || !hash_equals((string)$after['password_hash'], $newHash)) {
debugLog('[account.php] 改密写入核对失败', ['player' => $player, 'ip' => acctClientIp()]);
error('密码写入失败,请重试', 500);
}
debugLog('[account.php] 登录密码已修改', ['player' => $player, 'ip' => acctClientIp()]);
success(['message' => '密码修改成功'], '密码修改成功');
}
/**
* 生成待确认的 TOTP 密钥 + otpauth 链接。
* 不消耗 ticket —— 扫码与输码确认是两次调用,共用同一张 ticket;confirmed 前 bound_at=0 不计入可用方式。
*/
function acctTotpSetup() {
$player = acctAuthPlayer();
$ticket = (string)getParam('ticket', '');
acctLoadToken($ticket, 'ticket', $player);
$t = acctTwoFa($player);
if ($t && (int)$t['bound_at'] > 0 && (string)$t['totp_secret'] !== '') {
error('已绑定二次验证器,如需更换请先解绑');
}
$secret = acctNewSecret();
acctSaveTwoFa($player, $secret, '', 0); // 待确认:旧二维码/未确认密钥作废
success([
'secret' => $secret,
'otpauth' => acctOtpauthUri($secret, $player),
'server_time' => time(),
], '密钥已生成,请用认证器扫码后输入动态码确认');
}
/** 输 6 位动态码确认绑定 → 一次性返回 10 组恢复代码,并核销 ticket */
function acctTotpBind() {
$player = acctAuthPlayer();
$ticket = (string)getParam('ticket', '');
acctLoadToken($ticket, 'ticket', $player);
$code = (string)getParam('code', '');
if ($code === '') error('请输入认证器中的6位动态码');
$locked = acctThrottleLocked('bind', $player, 5, 300);
if ($locked > 0) error('动态码错误次数过多,请 ' . $locked . ' 秒后再试', 429);
$t = acctTwoFa($player);
if (!$t || (string)$t['totp_secret'] === '') error('请先生成绑定二维码');
if ((int)$t['bound_at'] > 0) error('已绑定二次验证器,如需更换请先解绑');
if (!acctTotpVerify((string)$t['totp_secret'], $code)) {
$remain = acctThrottleFail('bind', $player, 5, 300);
debugLog('[account.php] 绑定动态码错误', ['player' => $player, 'ip' => acctClientIp()]);
if ($remain > 0) error('动态码错误,请确认认证器时间正常(' . $remain . ' 秒后可重试)', 429);
error('动态码错误,请确认认证器时间正常');
}
acctThrottleReset('bind', $player);
$codes = acctGenRecoveryCodes();
acctSaveTwoFa($player, (string)$t['totp_secret'], json_encode($codes, JSON_UNESCAPED_UNICODE), time());
acctConsumeToken($ticket);
debugLog('[account.php] 二次验证绑定成功', ['player' => $player, 'ip' => acctClientIp()]);
success([
'recovery_codes' => $codes,
'message' => '绑定成功,恢复代码只显示这一次,请立即保存',
], '绑定成功');
}
/**
* 解绑二次验证(恢复代码一并清空)。
* 前端必须先警告:若账号未绑定邮箱,解绑后将没有任何第二层方式、无法再改账号信息。
*/
function acctTotpUnbind() {
$player = acctAuthPlayer();
$ticket = (string)getParam('ticket', '');
acctLoadToken($ticket, 'ticket', $player);
$t = acctTwoFa($player);
if (!$t || (int)$t['bound_at'] <= 0) error('当前未绑定二次验证器');
acctSaveTwoFa($player, '', '', 0);
acctConsumeToken($ticket);
debugLog('[account.php] 二次验证已解绑', ['player' => $player, 'ip' => acctClientIp()]);
success(['message' => '已解绑二次验证器,恢复代码同时作废'], '已解绑');
}
/** 查看恢复代码(明文返回 —— 页内查看功能要求;核销 ticket) */
function acctRecoveryList() {
$player = acctAuthPlayer();
$ticket = (string)getParam('ticket', '');
acctLoadToken($ticket, 'ticket', $player);
$t = acctTwoFa($player);
if (!$t || (int)$t['bound_at'] <= 0 || (string)$t['recovery_codes'] === '') {
error('尚未生成恢复代码');
}
$codes = json_decode((string)$t['recovery_codes'], true);
if (!is_array($codes)) $codes = array();
acctConsumeToken($ticket);
debugLog('[account.php] 恢复代码被查看', ['player' => $player, 'ip' => acctClientIp()]);
success(['recovery_codes' => $codes, 'updated_at' => (int)$t['updated_at']], 'ok');
}
/** 重新生成恢复代码:旧的立即作废,新代码只返回这一次(核销 ticket) */
function acctRecoveryRegen() {
$player = acctAuthPlayer();
$ticket = (string)getParam('ticket', '');
acctLoadToken($ticket, 'ticket', $player);
$t = acctTwoFa($player);
if (!$t || (int)$t['bound_at'] <= 0 || (string)$t['totp_secret'] === '') {
error('请先绑定二次验证器');
}
$codes = acctGenRecoveryCodes();
acctSaveTwoFa($player, (string)$t['totp_secret'], json_encode($codes, JSON_UNESCAPED_UNICODE), (int)$t['bound_at']);
acctConsumeToken($ticket);
debugLog('[account.php] 恢复代码已重新生成', ['player' => $player, 'ip' => acctClientIp()]);
success(['recovery_codes' => $codes], '恢复代码已重新生成,旧代码全部作废');
}
+2 -2
View File
@@ -4845,7 +4845,7 @@ function changePassword() {
}
$db = getDB();
$stmt = $db->prepare("SELECT password_hash, password_salt FROM weblogin_credentials WHERE player_name = :player");
$stmt = $db->prepare("SELECT password_hash, salt, temp_password_hash, temp_pw_expire FROM weblogin_credentials WHERE player_name = :player");
$stmt->bindValue(':player', $player, SQLITE3_TEXT);
$result = $stmt->execute();
$row = $result->fetchArray(SQLITE3_ASSOC);
@@ -4855,7 +4855,7 @@ function changePassword() {
}
// 验证旧密码
$saltBytes = base64_decode($row['password_salt'], true);
$saltBytes = base64_decode($row['salt'], true);
if ($saltBytes === false || $saltBytes === '') {
error('密码盐值无效');
}
+408 -38
View File
@@ -316,6 +316,7 @@ if ($currentVersion !== $BUILD_VERSION) {
<div class="sidebar-item" data-page="groups" onclick="switchPage('groups')">👥 用户组</div>
<div class="sidebar-item" data-page="ticket" onclick="switchPage('ticket')">📋 工单系统</div>
<div class="sidebar-item" data-page="sn" onclick="switchPage('sn')">🔢 SN设备管理</div>
<div class="sidebar-item" data-page="account" onclick="switchPage('account')">🛡️ 账号安全</div>
</div>
<div class="content" id="content">
@@ -369,6 +370,7 @@ if ($currentVersion !== $BUILD_VERSION) {
</div>
</div>
<script src="qrcode.min.js?v=<?php echo filemtime(__DIR__ . '/qrcode.min.js'); ?>"></script>
<script>
// ★ 人机验证挂件HTML:由 config.php 的 CAPTCHA_PROVIDER 单点决定 CF/VA(全项目只用一家,不混用)。
// 未启用(off / 密钥未配)时为空串,前端不挂件、服务端同步放行,
@@ -2462,7 +2464,7 @@ if ($currentVersion !== $BUILD_VERSION) {
}
}
// ===== 账号 =====
// ===== 账号(2026-10-22 第44批:账号信息 + 账号安全双卡) =====
function renderAccount(el) {
if (IS_PREVIEW || (!AUTHENTICATED && NEED_PASSWORD)) {
el.innerHTML = `<div class="card"><h2>账号信息</h2><div style="text-align:center;padding:40px 20px"><div style="font-size:48px;margin-bottom:16px">🔒</div><p style="color:var(--dim);font-size:14px;margin-bottom:20px">游客模式下无法查看账号信息</p><p style="color:var(--dim);font-size:12px">请先登录游戏获取Web访问令牌</p></div></div>`;
@@ -2476,24 +2478,96 @@ if ($currentVersion !== $BUILD_VERSION) {
<div class="card">
<h2>账号信息</h2>
<div id="accountResult" style="margin-top:16px"></div>
</div>
<div class="card">
<h2 style="display:flex;align-items:center;justify-content:space-between">🛡️ 账号安全
<button class="btn" style="font-size:12px;padding:4px 10px" onclick="acctRefreshStatus()">🔄 刷新</button>
</h2>
<p style="color:var(--dim);font-size:12px;margin:4px 0 12px">修改任何账号信息前需完成两层验证:① 输入一次完整登录密码(强制)→ ② 邮箱验证码 / 认证器动态码 / 恢复代码(三选一)。</p>
<div id="accountSecurity" style="margin-top:4px"><div style="color:var(--dim);font-size:13px;padding:12px 0">加载中...</div></div>
</div>`;
doQueryAccount();
acctRefreshStatus();
}
async function doQueryAccount() {
if (!currentPlayer) return;
const res = await api('register.php', {action: 'query', player: currentPlayer, token: TOKEN});
const div = document.getElementById('accountResult');
if (!div) return;
if (res.success) {
const d = res.data;
const regTime = d.register_time ? new Date(d.register_time * 1000).toLocaleString() : '未知';
const loginTime = d.last_login_time ? new Date(d.last_login_time * 1000).toLocaleString() : '未知';
div.innerHTML = `<table class="table"><tr><td style="color:var(--dim)">用户名</td><td>${d.player_name}</td></tr><tr><td style="color:var(--dim)">注册时间</td><td>${regTime}</td></tr><tr><td style="color:var(--dim)">最后登录</td><td>${loginTime}</td></tr><tr><td style="color:var(--dim)">积分</td><td>${d.points || 0}</td></tr><tr><td style="color:var(--dim)">礼包阶段</td><td>${d.gift_stage || 0}</td></tr><tr><td style="color:var(--dim)">在线时长</td><td>${Math.floor((d.total_online_time||0)/3600)}小时</td></tr></table>`;
} else {
div.innerHTML = `<div class="card" style="border-color:var(--red)">${res.message}</div>`;
div.innerHTML = `<div class="card" style="border-color:var(--red)">${escapeHtml(res.message || '查询失败')}</div>`;
}
}
// ===== 账号安全状态(api/account.php?action=status) =====
let _acctStatus = null;
async function acctRefreshStatus() {
const box = document.getElementById('accountSecurity');
if (!box) return;
box.innerHTML = '<div style="color:var(--dim);font-size:13px;padding:12px 0">加载中...</div>';
const retry = '<button class="btn" style="font-size:12px;padding:2px 8px;margin-left:6px" onclick="acctRefreshStatus()">重试</button>';
try {
const res = await apiPost('account.php', {action: 'status'});
if (!res.success) {
box.innerHTML = `<div style="color:var(--red);font-size:13px;padding:8px 0">${escapeHtml(res.message || '加载失败')}${retry}</div>`;
return;
}
_acctStatus = res.data;
box.innerHTML = acctStatusHtml(res.data);
} catch (e) {
box.innerHTML = `<div style="color:var(--red);font-size:13px;padding:8px 0">连接失败: ${escapeHtml(e.message)}${retry}</div>`;
}
}
function acctRow(label, valueHtml, actionHtml) {
return `<div style="display:flex;align-items:center;justify-content:space-between;gap:12px;padding:12px 0;border-bottom:1px solid var(--border)">
<div style="min-width:0"><div style="font-size:13px;color:var(--dim)">${label}</div><div style="font-size:14px;margin-top:2px;word-break:break-all">${valueHtml}</div></div>
<div style="flex-shrink:0;display:flex;gap:8px;align-items:center">${actionHtml || ''}</div>
</div>`;
}
function acctStatusHtml(d) {
let html = '';
if (!d.can_verify) {
html += `<div style="background:rgba(239,68,68,0.08);border:1px solid rgba(239,68,68,0.35);border-radius:8px;padding:10px 12px;font-size:12.5px;color:var(--red);margin-bottom:10px">⚠️ ${escapeHtml(d.hint || '当前无法完成第二层验证')}</div>`;
}
html += acctRow('登录密码', '已设置(6-20位)',
`<button class="btn btn-primary" style="font-size:12px;padding:6px 12px" onclick="startAccountVerify('change_password','修改登录密码')">修改</button>`);
html += acctRow('绑定邮箱', d.has_email ? escapeHtml(d.email_masked) : '<span style="color:var(--red)">未绑定</span>',
d.has_email ? '<span style="font-size:12px;color:var(--dim)">第二层验证可用</span>'
: '<span style="font-size:12px;color:var(--dim)">请先游戏中 /bindemail</span>');
const twofaVal = d.twofa_bound
? '✅ 已绑定认证器(TOTP)'
: (d.pending_setup ? '⏳ 密钥已生成但未确认' : '<span style="color:var(--dim)">未绑定</span>');
let twofaBtns = '';
if (d.twofa_bound) {
twofaBtns += `<button class="btn btn-dim" style="font-size:12px;padding:6px 12px" onclick="startAccountVerify('totp_unbind','解绑二次验证')">解绑</button>`;
} else if (d.can_verify) {
twofaBtns += `<button class="btn btn-primary" style="font-size:12px;padding:6px 12px" onclick="startAccountVerify('totp_setup','绑定二次验证器')">绑定</button>`;
}
html += acctRow('二次验证器', twofaVal, twofaBtns);
const recVal = d.has_recovery
? '已生成(未用完)'
: (d.twofa_bound ? '<span style="color:var(--dim)">未生成</span>' : '<span style="color:var(--dim)">绑定认证器后生成</span>');
let recBtns = '';
if (d.has_recovery) {
recBtns += `<button class="btn" style="font-size:12px;padding:6px 12px" onclick="startAccountVerify('recovery_list','查看恢复代码')">查看</button>`;
}
if (d.twofa_bound || d.has_recovery) {
recBtns += `<button class="btn btn-yellow" style="font-size:12px;padding:6px 12px" onclick="startAccountVerify('recovery_regenerate','重新生成恢复代码')">${d.has_recovery ? '重新生成' : '生成'}</button>`;
}
html += acctRow('恢复代码', recVal, recBtns);
html += `<p style="color:var(--dim);font-size:12px;margin-top:12px;line-height:1.6">💡 恢复代码是认证器丢失时的备用第二层验证方式(每组一次性使用)。修改密码、绑定/解绑认证器、查看/重生成恢复代码,均需先完成两层验证。</p>`;
return html;
}
// ===== 通用 =====
function toast(msg, type = 'success') {
const t = document.createElement('div');
@@ -2738,51 +2812,347 @@ if ($currentVersion !== $BUILD_VERSION) {
document.getElementById('modalOverlay').style.display = 'flex';
}
// ===== 修改密码 =====
function showChangePasswordModal() {
closeModal();
document.getElementById('modalTitle').textContent = '修改密码';
document.getElementById('modalBody').innerHTML = `
<p style="color:var(--dim);font-size:13px;margin-bottom:12px">修改您的登录密码</p>
<div class="row"><label>当前密码</label><input type="password" id="changePwdOld" style="width:100%;padding:8px 12px;background:var(--bg);border:1px solid var(--border);border-radius:6px;color:var(--text);font-size:14px"></div>
<div class="row"><label>新密码</label><input type="password" id="changePwdNew" placeholder="6-20位" style="width:100%;padding:8px 12px;background:var(--bg);border:1px solid var(--border);border-radius:6px;color:var(--text);font-size:14px"></div>
<div class="row"><label>确认新密码</label><input type="password" id="changePwdConfirm" style="width:100%;padding:8px 12px;background:var(--bg);border:1px solid var(--border);border-radius:6px;color:var(--text);font-size:14px"></div>
<div id="changePwdError" style="color:var(--red);font-size:12px;margin-top:4px"></div>`;
document.getElementById('modalConfirm').textContent = '修改密码';
document.getElementById('modalConfirm').onclick = () => doChangePassword();
document.getElementById('modalOverlay').style.display = 'flex';
}
async function doChangePassword() {
const oldPwd = document.getElementById('changePwdOld').value.trim();
const newPwd = document.getElementById('changePwdNew').value.trim();
const confirmPwd = document.getElementById('changePwdConfirm').value.trim();
if (!oldPwd || !newPwd || !confirmPwd) { document.getElementById('changePwdError').textContent = '请填写所有字段'; return; }
if (newPwd !== confirmPwd) { document.getElementById('changePwdError').textContent = '两次输入的密码不一致'; return; }
if (newPwd.length < 6 || newPwd.length > 20) { document.getElementById('changePwdError').textContent = '密码长度必须为6-20位'; return; }
document.getElementById('changePwdError').textContent = '处理中...';
try {
const url = new URL(API + 'sync.php', location.href);
url.searchParams.set('action', 'change_password');
// ===== 双层验证向导(2026-10-22 第44批 · api/account.php) =====
// 流程:完整密码(第一层) → 邮箱码/动态码/恢复代码(第二层) → ticket(10分钟一次性) → 执行动作
async function apiPost(endpoint, params = {}) {
const url = new URL(API + endpoint, location.href);
const res = await fetch(url, {
method: 'POST',
headers: {'Content-Type': 'application/json'},
body: JSON.stringify({player: currentPlayer, old_password: oldPwd, new_password: newPwd})
body: JSON.stringify(params)
});
const data = await res.json();
if (data.success) {
document.getElementById('changePwdError').innerHTML = '<span style="color:var(--green)">密码修改成功,请重新登录</span>';
setTimeout(() => { closeModal(); location.reload(); }, 2000);
} else {
document.getElementById('changePwdError').textContent = data.message || '修改失败';
return await res.json();
}
let _acctState = null;
let _lastRecoveryCodes = [];
function startAccountVerify(action, label) {
if (IS_PREVIEW || !TOKEN || !currentPlayer) { showToast('请先登录游戏后再操作', 'error'); return; }
_acctState = { action: action, label: label || action, challenge: null, methods: [], method: null, ticket: null, emailMasked: '' };
acctStepPassword();
}
function acctModalOpen(title) {
closeModal();
document.getElementById('modalTitle').textContent = title;
document.getElementById('modalOverlay').style.display = 'flex';
}
function acctModalSet(html, confirmText, onConfirm) {
document.getElementById('modalBody').innerHTML = html;
const btn = document.getElementById('modalConfirm');
btn.textContent = confirmText;
btn.disabled = false;
btn.onclick = onConfirm;
}
function acctErr(msg) {
const e = document.getElementById('acctErr');
if (e) e.textContent = msg || '';
}
// ---- 第一步:完整登录密码(强制层) ----
function acctStepPassword() {
acctModalOpen('🛡️ ' + _acctState.label);
acctModalSet(`
<div style="font-size:13px;color:var(--dim);margin-bottom:10px">第一步(必填):输入一次完整登录密码</div>
<input type="password" id="acctPwd" placeholder="当前登录密码" autocomplete="current-password" style="width:100%;box-sizing:border-box;padding:10px 12px;background:var(--bg);border:1px solid var(--border);border-radius:6px;color:var(--text);font-size:14px" onkeydown="if(event.key==='Enter')document.getElementById('modalConfirm').click()">
<div id="acctErr" style="color:var(--red);font-size:12px;min-height:16px;margin-top:8px"></div>`,
'下一步 →', acctDoStep1);
setTimeout(() => { const p = document.getElementById('acctPwd'); if (p) p.focus(); }, 120);
}
async function acctDoStep1() {
const st = _acctState;
const pwd = document.getElementById('acctPwd') ? document.getElementById('acctPwd').value : '';
if (!pwd) { acctErr('请输入密码'); return; }
const btn = document.getElementById('modalConfirm');
btn.disabled = true; acctErr('验证中...');
try {
const res = await apiPost('account.php', {action: 'verify_start', password: pwd});
btn.disabled = false;
if (!res.success) { acctErr(res.message || '密码错误'); return; }
st.challenge = res.data.challenge;
st.methods = res.data.methods || [];
st.emailMasked = res.data.email_masked || '';
if (!st.methods.length) { acctErr('没有可用的第二层验证方式'); return; }
acctStep2(st.methods[0]);
} catch (e) {
document.getElementById('changePwdError').textContent = '连接失败: ' + e.message;
btn.disabled = false;
acctErr('连接失败: ' + e.message);
}
}
// ---- 第二步:三选一(邮箱码 / 动态码 / 恢复代码) ----
function acctStep2(method) {
const st = _acctState;
const methods = st.methods || [];
if (!method) method = st.method || methods[0];
st.method = method;
const names = { email: '📧 邮箱验证码', totp: '📱 认证器动态码', recovery: '🔑 恢复代码' };
let tabs = '';
if (methods.length > 1) {
tabs = '<div style="display:flex;gap:8px;margin-bottom:12px;flex-wrap:wrap">';
for (const m of methods) {
const act = m === method;
tabs += `<button class="btn${act ? ' btn-primary' : ''}" style="font-size:12px;padding:6px 10px" onclick="acctStep2('${m}')">${names[m] || m}</button>`;
}
tabs += '</div>';
}
let body = `<div style="font-size:13px;color:var(--dim);margin-bottom:8px">第二步(三选一):${names[method] || method}</div>${tabs}`;
if (method === 'email') {
body += `
<div style="font-size:12.5px;color:var(--dim);margin-bottom:8px">验证码将发送至 ${escapeHtml(st.emailMasked || '绑定邮箱')}(5分钟有效)</div>
<div style="display:flex;gap:8px;margin-bottom:10px">
<button class="btn" id="acctSendBtn" onclick="acctSendCode()" style="flex-shrink:0">发送验证码</button>
<input type="text" id="acctEmailCode" maxlength="6" placeholder="6位验证码" inputmode="numeric" autocomplete="off" style="flex:1;min-width:0;padding:8px 12px;background:var(--bg);border:1px solid var(--border);border-radius:6px;color:var(--text);font-size:15px;letter-spacing:4px" onkeydown="if(event.key==='Enter')document.getElementById('modalConfirm').click()">
</div>`;
} else if (method === 'totp') {
body += `
<div style="font-size:12.5px;color:var(--dim);margin-bottom:8px">打开认证器 App(Google/Microsoft Authenticator 等),输入当前显示的 6 位动态码</div>
<input type="text" id="acctTotpCode" maxlength="6" placeholder="6位动态码" inputmode="numeric" autocomplete="off" style="width:100%;box-sizing:border-box;padding:10px 12px;background:var(--bg);border:1px solid var(--border);border-radius:6px;color:var(--text);font-size:16px;letter-spacing:6px;text-align:center" onkeydown="if(event.key==='Enter')document.getElementById('modalConfirm').click()">`;
} else {
body += `
<div style="font-size:12.5px;color:var(--dim);margin-bottom:8px">输入一组恢复代码(形如 XXXX-XXXX-XXXX-XXXX,横线可省略),每组只能用一次</div>
<input type="text" id="acctRecCode" placeholder="XXXX-XXXX-XXXX-XXXX" autocomplete="off" style="width:100%;box-sizing:border-box;padding:10px 12px;background:var(--bg);border:1px solid var(--border);border-radius:6px;color:var(--text);font-size:14px;letter-spacing:1px;font-family:monospace" onkeydown="if(event.key==='Enter')document.getElementById('modalConfirm').click()">`;
}
body += '<div id="acctErr" style="color:var(--red);font-size:12px;min-height:16px;margin-top:8px"></div>';
acctModalSet(body, '验证并继续 →', acctDoStep2);
}
async function acctSendCode() {
const st = _acctState;
const btn = document.getElementById('acctSendBtn');
if (btn) { btn.disabled = true; btn.textContent = '发送中...'; }
try {
const res = await apiPost('account.php', {action: 'send_email_code', challenge: st.challenge});
if (!res.success) {
if (btn) { btn.disabled = false; btn.textContent = '发送验证码'; }
acctErr(res.message || '发送失败');
return;
}
acctErr('');
showToast(res.message || '验证码已发送', 'success');
acctCountdown(60);
} catch (e) {
if (btn) { btn.disabled = false; btn.textContent = '发送验证码'; }
acctErr('发送失败: ' + e.message);
}
}
function acctCountdown(sec) {
const btn = document.getElementById('acctSendBtn');
if (sec <= 0) { if (btn) { btn.disabled = false; btn.textContent = '重新发送'; } return; }
if (btn) { btn.disabled = true; btn.textContent = '重新发送(' + sec + 's)'; }
setTimeout(() => acctCountdown(sec - 1), 1000);
}
async function acctDoStep2() {
const st = _acctState;
const m = st.method;
const btn = document.getElementById('modalConfirm');
let code = '';
if (m === 'email') code = (document.getElementById('acctEmailCode') || {}).value || '';
else if (m === 'totp') code = (document.getElementById('acctTotpCode') || {}).value || '';
else code = (document.getElementById('acctRecCode') || {}).value || '';
code = (code || '').trim();
if (!code) { acctErr(m === 'email' ? '请先获取并输入邮箱验证码' : '请输入验证码'); return; }
btn.disabled = true; acctErr('验证中...');
const payload = { action: 'verify_step2', challenge: st.challenge };
if (m === 'email') payload.email_code = code;
else if (m === 'totp') payload.totp_code = code;
else payload.recovery_code = code;
try {
const res = await apiPost('account.php', payload);
btn.disabled = false;
if (!res.success) { acctErr(res.message || '验证失败'); return; }
st.ticket = res.data.ticket;
await acctRunAction();
} catch (e) {
btn.disabled = false;
acctErr('连接失败: ' + e.message);
}
}
// ---- 两层验证通过后的动作执行器(持 ticket 分发) ----
async function acctRunAction() {
const st = _acctState;
const ticket = st.ticket;
try {
if (st.action === 'change_password') { acctPromptNewPassword(ticket); return; }
if (st.action === 'totp_setup') { await acctFlowTotp(ticket); return; }
if (st.action === 'totp_unbind') {
const hasEmail = _acctStatus && _acctStatus.has_email;
const warn = hasEmail
? '确认解绑二次验证器?\n解绑后恢复代码将全部作废,第二层验证只剩邮箱验证码。'
: '⚠️ 账号未绑定邮箱!\n解绑后将没有任何第二层验证方式,届时将无法再修改密码等账号信息(需先在游戏中 /bindemail 绑定邮箱)。确认仍要解绑?';
if (!(await showConfirm(warn))) { closeModal(); return; }
const res = await apiPost('account.php', {action: 'totp_unbind', ticket: ticket});
if (!res.success) { showToast(res.message || '解绑失败', 'error'); return; }
closeModal();
showToast('已解绑二次验证器', 'success');
acctRefreshStatus();
return;
}
if (st.action === 'recovery_list') {
const res = await apiPost('account.php', {action: 'recovery_list', ticket: ticket});
if (!res.success) { showToast(res.message || '获取失败', 'error'); return; }
acctShowRecoveryCodes(res.data.recovery_codes || [], '以下为当前有效的恢复代码(未用完的):');
return;
}
if (st.action === 'recovery_regenerate') {
if (!(await showConfirm('重新生成恢复代码?\n旧的恢复代码将全部立即作废。'))) { closeModal(); return; }
const res = await apiPost('account.php', {action: 'recovery_regenerate', ticket: ticket});
if (!res.success) { showToast(res.message || '生成失败', 'error'); return; }
acctShowRecoveryCodes(res.data.recovery_codes || [], '新的恢复代码已生成(旧代码全部作废),只显示这一次:');
return;
}
showToast('未知操作: ' + st.action, 'error');
} catch (e) {
showToast('请求失败: ' + e.message, 'error');
}
}
// ---- 修改登录密码(两层验证通过后进入) ----
function acctPromptNewPassword(ticket) {
acctModalOpen('🛡️ 修改登录密码');
acctModalSet(`
<div style="font-size:13px;color:var(--dim);margin-bottom:10px">两层验证已通过,请设置新密码</div>
<div class="row"><label>新密码</label><input type="password" id="acctNewPwd" placeholder="6-20位" autocomplete="new-password" style="width:100%;box-sizing:border-box;padding:8px 12px;background:var(--bg);border:1px solid var(--border);border-radius:6px;color:var(--text);font-size:14px"></div>
<div class="row"><label>确认新密码</label><input type="password" id="acctNewPwd2" placeholder="再输入一次" autocomplete="new-password" style="width:100%;box-sizing:border-box;padding:8px 12px;background:var(--bg);border:1px solid var(--border);border-radius:6px;color:var(--text);font-size:14px" onkeydown="if(event.key==='Enter')document.getElementById('modalConfirm').click()"></div>
<div id="acctErr" style="color:var(--red);font-size:12px;min-height:16px"></div>`,
'确认修改', () => acctDoChangePassword(ticket));
setTimeout(() => { const p = document.getElementById('acctNewPwd'); if (p) p.focus(); }, 120);
}
async function acctDoChangePassword(ticket) {
const p1 = (document.getElementById('acctNewPwd') || {}).value || '';
const p2 = (document.getElementById('acctNewPwd2') || {}).value || '';
if (!p1 || !p2) { acctErr('请填写两遍新密码'); return; }
if (p1 !== p2) { acctErr('两次输入的密码不一致'); return; }
if (p1.length < 6 || p1.length > 20) { acctErr('密码长度必须为6-20位'); return; }
const btn = document.getElementById('modalConfirm');
btn.disabled = true; acctErr('提交中...');
try {
const res = await apiPost('account.php', {action: 'change_password', ticket: ticket, new_password: p1, confirm_password: p2});
btn.disabled = false;
if (!res.success) { acctErr(res.message || '修改失败'); return; }
closeModal();
showToast('✅ 密码修改成功', 'success');
try { notifyJavaSync(); } catch (e) {}
acctRefreshStatus();
} catch (e) {
btn.disabled = false;
acctErr('连接失败: ' + e.message);
}
}
// ---- 绑定二次验证器:生成密钥 → 扫码 → 动态码确认 → 一次性返回恢复代码 ----
async function acctFlowTotp(ticket) {
acctModalOpen('🛡️ 绑定二次验证器');
acctModalSet('<div style="color:var(--dim);font-size:13px;padding:12px 0">正在生成密钥...</div>', '关闭', () => closeModal());
let res;
try {
res = await apiPost('account.php', {action: 'totp_setup', ticket: ticket});
} catch (e) {
acctModalSet('<div style="color:var(--red);font-size:13px">连接失败: ' + escapeHtml(e.message) + '</div>', '关闭', () => closeModal());
return;
}
if (!res.success) {
acctModalSet('<div style="color:var(--red);font-size:13px">' + escapeHtml(res.message || '生成失败') + '</div>', '关闭', () => closeModal());
return;
}
const secret = res.data.secret, otpauth = res.data.otpauth;
acctModalSet(`
<div style="font-size:13px;color:var(--dim);margin-bottom:8px">① 用认证器 App 扫描下方二维码(或手动输入密钥)</div>
<div id="acctQr" style="text-align:center;padding:6px 0;min-height:12px"></div>
<div style="display:flex;gap:8px;align-items:center;margin:6px 0 14px">
<input id="acctSecret" readonly value="${escapeHtml(secret)}" style="flex:1;min-width:0;padding:6px 10px;background:var(--bg);border:1px solid var(--border);border-radius:6px;color:var(--text);font-family:monospace;font-size:13px" onclick="this.select()">
<button class="btn" style="flex-shrink:0;font-size:12px;padding:6px 10px" onclick="acctCopy(document.getElementById('acctSecret').value)">复制密钥</button>
</div>
<div style="font-size:13px;color:var(--dim);margin-bottom:8px">② 输入认证器显示的 6 位动态码完成确认</div>
<input type="text" id="acctBindCode" maxlength="6" placeholder="6位动态码" inputmode="numeric" autocomplete="off" style="width:100%;box-sizing:border-box;padding:10px 12px;background:var(--bg);border:1px solid var(--border);border-radius:6px;color:var(--text);font-size:16px;letter-spacing:6px;text-align:center" onkeydown="if(event.key==='Enter')document.getElementById('modalConfirm').click()">
<div id="acctErr" style="color:var(--red);font-size:12px;min-height:16px;margin-top:8px"></div>`,
'确认绑定 →', () => acctDoBind(ticket));
acctRenderQr(otpauth);
}
function acctRenderQr(text) {
const box = document.getElementById('acctQr');
if (!box) return;
if (typeof QRCode === 'undefined') {
box.innerHTML = '<div style="color:var(--red);font-size:12px;padding:6px">二维码组件未加载,请手动复制下方密钥录入认证器</div>';
return;
}
box.innerHTML = '';
try {
new QRCode(box, {text: text, width: 180, height: 180, colorDark: '#000000', colorLight: '#ffffff', correctLevel: QRCode.CorrectLevel.M});
} catch (e) {
box.innerHTML = '<div style="color:var(--red);font-size:12px">二维码渲染失败,请手动复制下方密钥</div>';
}
}
async function acctDoBind(ticket) {
const code = ((document.getElementById('acctBindCode') || {}).value || '').trim();
if (!/^\d{6}$/.test(code)) { acctErr('请输入6位动态码'); return; }
const btn = document.getElementById('modalConfirm');
btn.disabled = true; acctErr('确认中...');
try {
const res = await apiPost('account.php', {action: 'totp_bind', ticket: ticket, code: code});
btn.disabled = false;
if (!res.success) { acctErr(res.message || '动态码错误'); return; }
acctShowRecoveryCodes(res.data.recovery_codes || [], '✅ 绑定成功!以下恢复代码只显示这一次,请立即保存:');
acctRefreshStatus();
} catch (e) {
btn.disabled = false;
acctErr('连接失败: ' + e.message);
}
}
// ---- 恢复代码展示(明文,一次性查看/生成结果) ----
function acctShowRecoveryCodes(codes, title) {
_lastRecoveryCodes = codes.slice();
acctModalOpen('🔑 恢复代码');
let list = '';
for (const c of codes) {
list += `<div style="font-family:monospace;font-size:14px;letter-spacing:1px;padding:7px 10px;background:var(--bg);border:1px solid var(--border);border-radius:6px;text-align:center">${escapeHtml(c)}</div>`;
}
acctModalSet(`
<div style="font-size:13px;color:var(--red);margin-bottom:10px">⚠️ ${escapeHtml(title)}</div>
<div style="display:grid;grid-template-columns:1fr 1fr;gap:6px;margin-bottom:12px">${list || '<div style="color:var(--dim);font-size:13px">(无)</div>'}</div>
<button class="btn" style="width:100%;font-size:13px" onclick="acctCopy(_lastRecoveryCodes.join(String.fromCharCode(10)))">📋 复制全部</button>
<div style="color:var(--dim);font-size:12px;margin-top:10px;line-height:1.6">每组恢复代码只能使用一次,用后自动作废。请离线保存,勿截图外传。</div>`,
'我已保存', () => { closeModal(); acctRefreshStatus(); });
}
async function acctCopy(text) {
try {
if (navigator.clipboard && navigator.clipboard.writeText) {
await navigator.clipboard.writeText(text);
} else {
const ta = document.createElement('textarea');
ta.value = text;
document.body.appendChild(ta);
ta.select();
document.execCommand('copy');
document.body.removeChild(ta);
}
showToast('已复制到剪贴板', 'success');
} catch (e) {
showToast('复制失败,请手动选择复制', 'error');
}
}
// ===== 修改密码(旧入口已并入双层验证流程,保留函数名兼容旧调用点) =====
function showChangePasswordModal() {
startAccountVerify('change_password', '修改登录密码');
}
async function doChangePassword() {
showChangePasswordModal();
}
// ===== 重置密码(后端判断有无邮箱) =====
async function doResetPassword() {
const player = document.getElementById('resetPlayer').value.trim();
+6
View File
File diff suppressed because one or more lines are too long