Files
Sdf1_login/api/account.php
T

1130 lines
50 KiB
PHP
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
<?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;
case 'bind_email_start': acctBindEmailStart(); break;
case 'bind_email_poll': acctBindEmailPoll(); break;
case 'bind_email_confirm': acctBindEmailConfirm(); break;
case 'bind_email_send_code': acctBindEmailSendCode(); break;
case 'get_email_suffix_config': acctGetEmailSuffixConfig(); 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 = "<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('两次输入的新密码不一致');
// ★第48批#245:与游戏 PasswordUtils.validate 同一把尺子(6-20位+大写+小写+数字)
$ruleErr = pwdRuleError($newPwd);
if ($ruleErr !== null) error($ruleErr);
$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(b64Clean((string)$after['password_hash']), $newHash)) { // ★第52批:回读值清洗后比对
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], '恢复代码已重新生成,旧代码全部作废');
}
// ★ 第57批续4:获取邮箱后缀配置(供前端展示可绑定的邮箱类型)
function acctGetEmailSuffixConfig() {
$player = acctAuthPlayer();
$db = getDB();
try {
$cfgRow = $db->query("SELECT mode, suffix_list FROM email_suffix_config WHERE id = 1")->fetchArray(SQLITE3_ASSOC);
} catch (\Throwable $e) {
success(['email_mode' => '默认', 'email_suffixes' => []]);
return;
}
if (!$cfgRow) {
success(['email_mode' => '默认', 'email_suffixes' => []]);
return;
}
$mode = (string)$cfgRow['mode'];
$suffixList = (string)$cfgRow['suffix_list'];
if ($mode === '默认' || $suffixList === '') {
success(['email_mode' => '默认', 'email_suffixes' => []]);
return;
}
$suffixes = preg_split('/[,;\n]+/', $suffixList);
$suffixes = array_map('trim', $suffixes);
$suffixes = array_map('strtolower', $suffixes);
$suffixes = array_filter($suffixes, function ($s) { return $s !== ''; });
success(['email_mode' => $mode, 'email_suffixes' => array_values($suffixes)]);
}
// ===================== 第53批:网页端绑定邮箱(游戏内批准流) =====================
//
// 流程:bind_email_start 建 60 秒请求(要求玩家在线)→ Java 定时器A 拉取并游戏内发
// clickable 通知(/webbind approve|reject <id>)→ sync.php web_email_bind_result 回写
// approved/rejected/offline/expired → bind_email_poll 前端轮询 → approved 后
// bind_email_confirm 复核登录密码才写 users.email。
// 防盗绑三重:①游戏内本人批准 ②批准后重验登录密码 ③IP 连错 5 次封 5 分钟。
/** 玩家当前是否在线:online_players 有行 且 Java 心跳(online_player_hb)<=120秒(Java 死了通知发不出,等同不在线) */
function acctPlayerOnlineNow($player) {
$db = getDB();
try {
$db->exec("CREATE TABLE IF NOT EXISTS online_players (player_name TEXT PRIMARY KEY, login_time INTEGER DEFAULT 0)");
$db->exec("CREATE TABLE IF NOT EXISTS online_player_hb (id INTEGER PRIMARY KEY DEFAULT 1, last_seen INTEGER DEFAULT 0)");
$hb = $db->query("SELECT last_seen FROM online_player_hb WHERE id = 1")->fetchArray(SQLITE3_ASSOC);
if (!$hb || time() - (int)$hb['last_seen'] > 120) return false;
$stmt = $db->prepare("SELECT 1 FROM online_players WHERE player_name = :p");
$stmt->bindValue(':p', $player, SQLITE3_TEXT);
return ($stmt->execute()->fetchArray() !== false);
} catch (\Throwable $e) {
@error_log("[acctPlayerOnlineNow] error: " . $e->getMessage());
return false;
}
}
/** 发起绑定:校验邮箱格式 + 在线 → 建 60 秒请求(同玩家旧未终结请求全部作废) */
function acctBindEmailStart() {
$player = acctAuthPlayer();
$email = strtolower(trim((string)getParam('email', '')));
if ($email === '') error('请输入邮箱地址');
if (!filter_var($email, FILTER_VALIDATE_EMAIL)) error('邮箱格式不正确');
// ★ 第56批:邮箱后缀校验(与Java端ConfigManager一致)
if (!checkEmailSuffix($email)) {
$mode = '默认';
$cfgDb = getDB();
$cfgRow = $cfgDb->query("SELECT mode FROM email_suffix_config WHERE id = 1")->fetchArray(SQLITE3_ASSOC);
if ($cfgRow) $mode = (string)$cfgRow['mode'];
if ($mode === '白名单') error('该邮箱后缀不在允许列表中');
else if ($mode === '黑名单') error('该邮箱后缀被禁止使用');
else error('该邮箱后缀不可用');
}
$cur = acctEmailOf($player);
if ($cur !== '') error('账号已绑定邮箱,无需重复绑定');
if (!acctPlayerOnlineNow($player)) {
error('你当前未在线或未登录游戏,请先登录游戏后再发起绑定(需在游戏内点击批准)');
}
$db = getDB();
$now = time();
// 同一玩家任何时刻只允许一条活跃请求(含已批准未输密码的,重新发起即作废旧的)
$void = $db->prepare("UPDATE web_email_bind_requests SET status = 'expired', processed_at = :t WHERE player_name = :p AND status IN ('pending','notified','approved')");
$void->bindValue(':t', $now, SQLITE3_INTEGER);
$void->bindValue(':p', $player, SQLITE3_TEXT);
$void->execute();
$ins = $db->prepare("INSERT INTO web_email_bind_requests (player_name, email, ip_address, status, created_at, expire_at, processed_at)
VALUES (:p, :e, :ip, 'pending', :c, :x, 0)");
$ins->bindValue(':p', $player, SQLITE3_TEXT);
$ins->bindValue(':e', $email, SQLITE3_TEXT);
$ins->bindValue(':ip', acctClientIp(), SQLITE3_TEXT);
$ins->bindValue(':c', $now, SQLITE3_INTEGER);
$ins->bindValue(':x', $now + 60, SQLITE3_INTEGER); // CLI通知1分钟时效
$ins->execute();
$id = (int)$db->lastInsertRowID();
debugLog('[account.php] 发起网页绑邮箱', ['player' => $player, 'id' => $id, 'ip' => acctClientIp()]);
// ★ 第57批续4:返回后缀配置,供前端展示可绑定的邮箱类型
$mode = '默认'; $suffixList = '';
try {
$cfgRow = $db->query("SELECT mode, suffix_list FROM email_suffix_config WHERE id = 1")->fetchArray(SQLITE3_ASSOC);
if ($cfgRow) {
$mode = (string)$cfgRow['mode'];
$suffixList = (string)$cfgRow['suffix_list'];
}
} catch (\Throwable $_e) {}
$suffixes = array_filter(array_map('trim', preg_split('/[,;\n]+/', $suffixList)));
$suffixes = array_map(function ($s) { return strtolower($s); }, $suffixes);
success(['id' => $id, 'expire_in' => 60, 'email_mode' => $mode, 'email_suffixes' => array_values($suffixes)], '请求已发送,等待游戏内批准');
}
/** 前端轮询请求状态(1秒一次):pending/notified/approved/rejected/offline/expired/completed/failed */
function acctBindEmailPoll() {
$player = acctAuthPlayer();
$id = (int)getParam('id', 0);
if ($id <= 0) error('缺少请求id');
$db = getDB();
$stmt = $db->prepare("SELECT id, player_name, status, created_at, expire_at, approved_at, fail_reason FROM web_email_bind_requests WHERE id = :id");
$stmt->bindValue(':id', $id, SQLITE3_INTEGER);
$row = $stmt->execute()->fetchArray(SQLITE3_ASSOC);
if (!$row) error('请求不存在');
if ((string)$row['player_name'] !== $player) error('无权查看该请求');
$now = time();
$status = (string)$row['status'];
$left = 0;
// 惰性超时:不等 Java 拉,前端轮询到就地终结(批准时效60秒)
if (in_array($status, array('pending', 'notified'), true) && $now > (int)$row['expire_at']) {
$status = 'expired';
}
// 批准后密码窗口 180 秒,超窗即折返终结
if ($status === 'approved') {
$left = max(0, ((int)$row['approved_at'] + 180) - $now);
if ($left <= 0) $status = 'expired';
} elseif (in_array($status, array('pending', 'notified'), true)) {
$left = max(0, (int)$row['expire_at'] - $now);
}
if ($status === 'expired' && (string)$row['status'] !== 'expired') {
$upd = $db->prepare("UPDATE web_email_bind_requests SET status = 'expired', processed_at = :t WHERE id = :id AND status IN ('pending','notified','approved')");
$upd->bindValue(':t', $now, SQLITE3_INTEGER);
$upd->bindValue(':id', $id, SQLITE3_INTEGER);
$upd->execute();
}
success([
'id' => $id,
'status' => $status,
'left' => $left,
'fail_reason' => (string)$row['fail_reason'],
], 'ok');
}
/** 第57批续7:向刚绑定的邮箱发送验证码(Java端已验密码,PHP不重复验) */
function acctBindEmailSendCode() {
$player = acctAuthPlayer();
$id = (int)getParam('id', 0);
if ($id <= 0) error('缺少请求id');
$db = getDB();
$stmt = $db->prepare("SELECT id, player_name, email, status FROM web_email_bind_requests WHERE id = :id");
$stmt->bindValue(':id', $id, SQLITE3_INTEGER);
$row = $stmt->execute()->fetchArray(SQLITE3_ASSOC);
if (!$row) error('请求不存在');
if ((string)$row['player_name'] !== $player) error('无权操作该请求');
if ((string)$row['status'] !== 'completed') error('绑定尚未完成,无法发送验证码');
$email = (string)$row['email'];
if ($email === '' || !filter_var($email, FILTER_VALIDATE_EMAIL)) error('邮箱数据异常');
// 复用 acctSendEmailCode 的发送逻辑(限频 + SMTP)
$locked = acctThrottleLocked('mail', $player, 10, 3600);
if ($locked > 0) error('发送过于频繁,请 ' . $locked . ' 秒后再试', 429);
$last = $db->query("SELECT created_at FROM email_codes WHERE player_name = '" . $db->escapeString($player) . "' ORDER BY id DESC LIMIT 1")->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'>验证码:</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></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();
error('邮件发送失败,请稍后重试');
}
acctThrottleFail('mail', $player, 10, 3600);
success(['masked_email' => acctMaskEmail($email), 'resend_in' => 60, 'code_expire_in' => 300], '验证码已发送至 ' . acctMaskEmail($email));
}
/** 终态确认:验证邮箱验证码 → 完成绑定 */
/** 终态确认:验证邮箱验证码 → 完成绑定 */
function acctBindEmailConfirm() {
$player = acctAuthPlayer();
$id = (int)getParam('id', 0);
$emailCode = (string)getParam('email_code', '');
if ($id <= 0) error('缺少请求id');
if ($emailCode === '' || strlen($emailCode) !== 6) error('请输入6位验证码');
$db = getDB();
$stmt = $db->prepare("SELECT id, player_name, email, status FROM web_email_bind_requests WHERE id = :id");
$stmt->bindValue(':id', $id, SQLITE3_INTEGER);
$row = $stmt->execute()->fetchArray(SQLITE3_ASSOC);
if (!$row) error('请求不存在');
if ((string)$row['player_name'] !== $player) error('无权操作该请求');
if ((string)$row['status'] !== 'completed') error('绑定尚未完成');
// 验证邮箱验证码(只认未使用且未过期的最新一条)
$codeStmt = $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");
$codeStmt->bindValue(':p', $player, SQLITE3_TEXT);
$codeStmt->bindValue(':c', $emailCode, SQLITE3_TEXT);
$codeStmt->bindValue(':t', time(), SQLITE3_INTEGER);
$codeRow = $codeStmt->execute()->fetchArray(SQLITE3_ASSOC);
if (!$codeRow) error('验证码错误或已过期');
// 核销验证码
$usedUpd = $db->prepare("UPDATE email_codes SET used = 1 WHERE id = :id");
$usedUpd->bindValue(':id', (int)$codeRow['id'], SQLITE3_INTEGER);
$usedUpd->execute();
$email = (string)$row['email'];
debugLog('[account.php] 邮箱验证码验证通过', ['player' => $player, 'id' => $id]);
success(['id' => $id, 'status' => 'completed', 'email_masked' => acctMaskEmail($email)], '邮箱绑定验证通过');
}