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; case 'bind_email_start': acctBindEmailStart(); break; case 'bind_email_poll': acctBindEmailPoll(); break; case 'bind_email_confirm': acctBindEmailConfirm(); 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)); // ★第53批:网页绑定邮箱请求表(schema 必须与 sync.php checkPendingWebEmailBindRequests 完全一致) $db->exec("CREATE TABLE IF NOT EXISTS web_email_bind_requests ( id INTEGER PRIMARY KEY AUTOINCREMENT, player_name TEXT NOT NULL, email TEXT NOT NULL, ip_address TEXT DEFAULT '', status TEXT DEFAULT 'pending', created_at INTEGER NOT NULL, expire_at INTEGER NOT NULL, approved_at INTEGER DEFAULT 0, processed_at INTEGER DEFAULT 0, fail_reason TEXT DEFAULT '' )"); } // ===================== 公共辅助 ===================== /** 邮箱脱敏: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 = saltDecode((string)$cred['salt']); // ★第48批:容错解盐(core.php saltDecode) if ($saltBytes === false || $saltBytes === '') return false; $calc = base64_encode(hash('sha256', $saltBytes . $pwd, true)); return hash_equals(b64Clean((string)$cred['password_hash']), $calc); // ★第52批:存量 \/ 脏值容错 } 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 = '账号未绑定邮箱,也未绑定二次验证器 —— 无法完成第二层验证。可在下方点击「网页绑定邮箱」,游戏内批准后即可完成绑定。'; } 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) { // ★第53批:不再硬拦截——密码第一层已过,返回导航标记让前端直接进入「网页绑定邮箱」定向导 jsonResponse(['success' => false, 'message' => '账号未绑定邮箱且未绑定二次验证器,请先完成邮箱绑定', 'need_bind_email' => true], 409); } $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 = "
玩家 " . htmlspecialchars($player) . " 的验证码:
" . "" . $code . "
" . "验证码5分钟内有效,用于修改账号信息前的第二层验证
" . "如果这不是您的操作,请立刻修改密码并忽略此邮件
" . "