diff --git a/api/security_alert.php b/api/security_alert.php new file mode 100644 index 0000000..191cb0a --- /dev/null +++ b/api/security_alert.php @@ -0,0 +1,94 @@ +&ip=<登录IP> + * + * 职责: + * 1. 用 ip9.com.cn 查询本次登录 IP 归属 + * 2. 与上次登录归属比较,不一致且非首次 → 向绑定邮箱发送提醒邮件 + * 3. 邮件内含「冻结账号」与「修改密码」两个链接 + * 4. 更新 users.last_login_location / last_login_ip + */ +require_once __DIR__ . '/../core.php'; + +header('Content-Type: application/json; charset=utf-8'); + +$action = $_GET['action'] ?? ''; +if ($action !== 'login_location_alert') { + echo json_encode(['success' => false, 'message' => '未知操作']); + exit; +} +$secret = $_GET['secret'] ?? ''; +if (!$secret || $secret !== SECRET_KEY) { + echo json_encode(['success' => false, 'message' => '密钥错误']); + exit; +} +$name = $_GET['name'] ?? ''; +$ip = $_GET['ip'] ?? ''; +if (!$name || !$ip) { + echo json_encode(['success' => false, 'message' => '参数缺失']); + exit; +} + +$db = getDB(); + +// 查询账号信息 +$stmt = $db->prepare( + "SELECT email, last_login_location, last_login_ip, frozen + FROM users WHERE player_name = :name"); +$stmt->bindValue(':name', $name, SQLITE3_TEXT); +$row = $stmt->execute()->fetchArray(SQLITE3_ASSOC); + +// 查询本次 IP 归属 +$location = queryLoginIpLocation($ip); + +if ($location === null) { + // 归属查询失败:仅更新 IP,不阻断、不提醒 + $upd = $db->prepare( + "UPDATE users SET last_login_ip = :ip WHERE player_name = :name"); + $upd->bindValue(':ip', $ip, SQLITE3_TEXT); + $upd->bindValue(':name', $name, SQLITE3_TEXT); + $upd->execute(); + echo json_encode(['success' => true, 'message' => 'IP归属查询失败,仅更新IP']); + exit; +} + +if (!$row) { + // 理论上 Java 已 syncUserOnJoin,不会走到这里 + echo json_encode(['success' => true, 'message' => '无用户记录']); + exit; +} + +$lastLoc = $row['last_login_location'] ?? ''; +$lastIp = $row['last_login_ip'] ?? ''; +$email = $row['email'] ?? ''; + +// 更新归属与 IP +$upd = $db->prepare( + "UPDATE users SET last_login_ip = :ip, + last_login_location = :loc WHERE player_name = :name"); +$upd->bindValue(':ip', $ip, SQLITE3_TEXT); +$upd->bindValue(':loc', $location, SQLITE3_TEXT); +$upd->bindValue(':name', $name, SQLITE3_TEXT); +$upd->execute(); + +// 首次登录(无任何历史记录)不提醒 +if ($lastLoc === '' && $lastIp === '') { + echo json_encode(['success' => true, 'message' => '首次登录,已记录']); + exit; +} + +// 归属变化才发送提醒邮件 +if ($lastLoc !== $location && !empty($email)) { + // 生成冻结 token 并持久化,供邮件中的冻结链接使用 + $freezeToken = bin2hex(random_bytes(16)); + $ut = $db->prepare("UPDATE users SET freeze_token = :t WHERE player_name = :name"); + $ut->bindValue(':t', $freezeToken, SQLITE3_TEXT); + $ut->bindValue(':name', $name, SQLITE3_TEXT); + $ut->execute(); + sendLocationAlertEmail($name, $email, $ip, $location, $lastLoc, $freezeToken); +} + +echo json_encode(['success' => true, 'message' => 'ok']); diff --git a/api/sync.php b/api/sync.php index d32137c..3914df9 100644 --- a/api/sync.php +++ b/api/sync.php @@ -50,85 +50,7 @@ set_exception_handler(function($e) { // debugLog() 函数已在 core.php 中定义,无需重复声明 -// ===== SMTP邮件发送函数 ===== -function smtpSendEmail($host, $port, $user, $pass, $to, $subject, $htmlBody, $headers, $useSSL = true) { - $errno = 0; - $errstr = ''; - - // 创建socket连接 - if ($useSSL) { - $socket = @fsockopen('ssl://' . $host, $port, $errno, $errstr, 30); - } else { - $socket = @fsockopen($host, $port, $errno, $errstr, 30); - } - - if (!$socket) { - throw new Exception("SMTP连接失败: $errstr ($errno)"); - } - - // 读取服务器响应 - $response = fgets($socket); - if (strpos($response, '220') !== 0) { - fclose($socket); - throw new Exception("SMTP服务器拒绝连接: $response"); - } - - // 发送EHLO - fwrite($socket, "EHLO localhost\r\n"); - $response = fread($socket, 1024); - - // 登录认证 - fwrite($socket, "AUTH LOGIN\r\n"); - $response = fgets($socket); - if (strpos($response, '334') !== 0) { - fclose($socket); - throw new Exception("SMTP认证失败: $response"); - } - - // 发送用户名 - fwrite($socket, base64_encode($user) . "\r\n"); - $response = fgets($socket); - if (strpos($response, '334') !== 0) { - fclose($socket); - throw new Exception("SMTP用户名错误: $response"); - } - - // 发送密码 - fwrite($socket, base64_encode($pass) . "\r\n"); - $response = fgets($socket); - if (strpos($response, '235') !== 0) { - fclose($socket); - throw new Exception("SMTP密码错误: $response"); - } - - // 发送邮件 - fwrite($socket, "MAIL FROM:<$user>\r\n"); - fgets($socket); - - fwrite($socket, "RCPT TO:<$to>\r\n"); - fgets($socket); - - fwrite($socket, "DATA\r\n"); - fgets($socket); - - // 发送邮件头和正文 - $emailData = "Subject: $subject\r\n"; - $emailData .= $headers; - $emailData .= "\r\n"; - $emailData .= $htmlBody; - $emailData .= "\r\n.\r\n"; - - fwrite($socket, $emailData); - $response = fgets($socket); - - // 退出 - fwrite($socket, "QUIT\r\n"); - fgets($socket); - - fclose($socket); - - return true; -} +// ===== SMTP邮件发送函数(已统一移至 core.php,通过 require_once core.php 复用,避免重复定义) ===== /** @@ -2440,6 +2362,14 @@ function completeWebLoginRequest() { // 如果验证成功,写入web_login_confirmations和web_login_verified if ($status === 'success') { + // ★ 冻结账号禁止 Web 登录(异地登录自冻结保护) + $fz = $db->prepare("SELECT frozen FROM users WHERE player_name = :name"); + $fz->bindValue(':name', $player, SQLITE3_TEXT); + $fzr = $fz->execute()->fetchArray(SQLITE3_ASSOC); + if ($fzr && (int)$fzr['frozen'] === 1) { + error('账号已被冻结,请修改密码后重试'); + } + $db->exec("CREATE TABLE IF NOT EXISTS web_login_confirmations (player_name TEXT PRIMARY KEY, confirmed_at INTEGER NOT NULL, consumed INTEGER DEFAULT 0)"); $confirmStmt = $db->prepare("INSERT OR REPLACE INTO web_login_confirmations (player_name, confirmed_at, consumed) VALUES (:player, :time, 0)"); $confirmStmt->bindValue(':player', $player, SQLITE3_TEXT); diff --git a/core.php b/core.php index 34d0f31..da59132 100644 --- a/core.php +++ b/core.php @@ -411,6 +411,24 @@ function initTables(SQLite3 $db) { total_online_time INTEGER DEFAULT 0 )"); + // ===== 风控 / 封禁相关列(安全迁移,避免 ALTER 锁表) ===== + $userCols = []; + $ci = $db->query("PRAGMA table_info(users)"); + while ($c = $ci->fetchArray(SQLITE3_ASSOC)) { + $userCols[] = $c['name']; + } + $addCols = [ + 'last_login_location' => "TEXT DEFAULT ''", + 'last_login_ip' => "TEXT DEFAULT ''", + 'frozen' => "INTEGER DEFAULT 0", + 'freeze_token' => "TEXT DEFAULT ''", + ]; + foreach ($addCols as $col => $type) { + if (!in_array($col, $userCols)) { + $db->exec("ALTER TABLE users ADD COLUMN $col $type"); + } + } + // Web登录Token表 $db->exec("CREATE TABLE IF NOT EXISTS weblogin_tokens ( player_name TEXT PRIMARY KEY, @@ -1588,3 +1606,197 @@ function validateWebAccess($webToken, $action = 'view', $password = null, $ipAdd debugLog("validateWebAccess: 验证成功", ['player' => $playerName, 'mode' => 'full']); return ['ok' => true, 'mode' => 'full', 'player' => $playerName, 'session' => $sessionToken, 'registered' => $isRegistered, 'online' => $isOnline, 'message' => '验证成功']; } + +// ===================================================================== +// 异地登录检测 / 账号冻结 公共函数(security_alert.php / freeze.php 复用) +// ===================================================================== + +/** + * SMTP 邮件发送(已从 api/sync.php 上移至此处,统一复用,避免重复定义) + */ +function smtpSendEmail($host, $port, $user, $pass, $to, $subject, $htmlBody, $headers, $useSSL = true) { + $errno = 0; + $errstr = ''; + + // 创建socket连接 + if ($useSSL) { + $socket = @fsockopen('ssl://' . $host, $port, $errno, $errstr, 30); + } else { + $socket = @fsockopen($host, $port, $errno, $errstr, 30); + } + + if (!$socket) { + throw new Exception("SMTP连接失败: $errstr ($errno)"); + } + + // 读取服务器响应 + $response = fgets($socket); + if (strpos($response, '220') !== 0) { + fclose($socket); + throw new Exception("SMTP服务器拒绝连接: $response"); + } + + // 发送EHLO + fwrite($socket, "EHLO localhost\r\n"); + $response = fread($socket, 1024); + + // 登录认证 + fwrite($socket, "AUTH LOGIN\r\n"); + $response = fgets($socket); + if (strpos($response, '334') !== 0) { + fclose($socket); + throw new Exception("SMTP认证失败: $response"); + } + + // 发送用户名 + fwrite($socket, base64_encode($user) . "\r\n"); + $response = fgets($socket); + if (strpos($response, '334') !== 0) { + fclose($socket); + throw new Exception("SMTP用户名错误: $response"); + } + + // 发送密码 + fwrite($socket, base64_encode($pass) . "\r\n"); + $response = fgets($socket); + if (strpos($response, '235') !== 0) { + fclose($socket); + throw new Exception("SMTP密码错误: $response"); + } + + // 发送邮件 + fwrite($socket, "MAIL FROM:<$user>\r\n"); + fgets($socket); + + fwrite($socket, "RCPT TO:<$to>\r\n"); + fgets($socket); + + fwrite($socket, "DATA\r\n"); + fgets($socket); + + // 发送邮件头和正文 + $emailData = "Subject: $subject\r\n"; + $emailData .= $headers; + $emailData .= "\r\n"; + $emailData .= $htmlBody; + $emailData .= "\r\n.\r\n"; + + fwrite($socket, $emailData); + $response = fgets($socket); + + // 退出 + fwrite($socket, "QUIT\r\n"); + fgets($socket); + + fclose($socket); + + return true; +} + +/** + * 查询登录 IP 归属(ip9.com.cn) + * 注意:与 admin.php 的 queryIpLocation 区分命名,避免重复定义冲突。 + * @return string|null 归属字符串(如 "广东深圳"),失败或内网返回 null + */ +function queryLoginIpLocation($ip) { + if (empty($ip) || $ip === '-' || $ip === '127.0.0.1' + || strpos($ip, '10.') === 0 || strpos($ip, '192.168.') === 0 + || !filter_var($ip, FILTER_VALIDATE_IP)) { + return null; + } + $url = 'https://ip9.com.cn/get?ip=' . urlencode($ip); + $ctx = stream_context_create([ + 'http' => [ + 'method' => 'GET', + 'timeout' => 3, + 'ignore_errors' => true, + 'header' => "Accept: application/json, text/plain, */*\r\nUser-Agent: Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/120.0.0.0 Safari/537.36\r\nReferer: https://ip9.com.cn/\r\nOrigin: https://ip9.com.cn" + ], + 'ssl' => ['verify_peer' => false, 'verify_peer_name' => false] + ]); + $response = @file_get_contents($url, false, $ctx); + if ($response === false) return null; + $response = trim($response); + if ($response === '') return null; + $data = json_decode($response, true); + if (!$data || !is_array($data)) return null; + $item = null; + if (isset($data['data'][0]) && is_array($data['data'][0])) { + $item = $data['data'][0]; + } elseif (isset($data['data']) && is_array($data['data']) && isset($data['data']['prov'])) { + $item = $data['data']; + } + if (!$item) return null; + $loc = ''; + if (isset($item['prov'])) $loc .= $item['prov']; + if (isset($item['city'])) $loc .= $item['city']; + if (isset($item['area']) && ($item['area'] !== ($item['city'] ?? '')) && ($item['area'] !== ($item['prov'] ?? ''))) { + $loc .= $item['area']; + } + $loc = trim($loc); + return $loc === '' ? null : $loc; +} + +/** + * 构建站点绝对基址(用于邮件链接) + */ +function getBaseUrl() { + $host = $_SERVER['HTTP_HOST'] ?? ''; + if ($host === '' || $host === '127.0.0.1' || $host === 'localhost') { + $host = 'caoyuan.ypshidifu.cn'; + } + // 站点统一使用 HTTPS + $base = 'https://' . $host; + $sub = trim(WEBSUB_DIR, '/'); + if ($sub !== '') $base .= '/' . $sub; + return rtrim($base, '/'); +} + +/** + * 发送异地登录提醒邮件(附带冻结 / 改密链接) + * @param string $freezeToken 已持久化到 users.freeze_token 的令牌 + */ +function sendLocationAlertEmail($name, $email, $ip, $location, $lastLoc, $freezeToken) { + $db = getDB(); + // 生成改密 token(30分钟内有效) + $resetToken = bin2hex(random_bytes(16)); + $expire = time() + 1800; + $ins = $db->prepare("INSERT INTO password_reset_tokens (token, player_name, expire_at) VALUES (:t, :n, :e)"); + $ins->bindValue(':t', $resetToken, SQLITE3_TEXT); + $ins->bindValue(':n', $name, SQLITE3_TEXT); + $ins->bindValue(':e', $expire, SQLITE3_INTEGER); + $ins->execute(); + + $base = getBaseUrl(); + $freezeLink = $base . '/freeze.php?token=' . urlencode($freezeToken); + $resetLink = $base . '/reset_password.php?token=' . urlencode($resetToken); + + $subject = '[Sdf1] 异地登录提醒:您的账号于 ' . $location . ' 登录'; + + $html = '' + . '' + . '异地登录提醒' + . '
' + . '
' + . '
' + . '

⚠️ 异地登录提醒

' + . '

Sdf1 Minecraft 账号安全中心

' + . '
' + . '

亲爱的 ' . htmlspecialchars($name) . ':

' + . '

我们检测到您的账号于 ' . htmlspecialchars($location) . ' 发起了登录,' + . '而您上一次的登录地点为 ' . htmlspecialchars($lastLoc ?: '未知') . '。如果该登录并非您本人操作,账号可能已泄露。

' + . '
' + . '本次登录 IP:' . htmlspecialchars($ip) . '
登录时间:' . date('Y-m-d H:i:s') . '
' + . '

如确认存在风险,请点击下方按钮冻结账号(将立即封禁游戏登录,需改密后解冻):

' + . '
立即冻结账号
' + . '

如确认是本人操作,也建议您定期修改密码以保障安全:

' + . '
修改密码
' + . '

本邮件由系统自动发送,请勿直接回复。若您未触发任何登录,请尽快冻结账号并修改密码。

' + . '
'; + + $headers = "From: " . SMTP_SENDER_NAME . " <" . SMTP_USER . ">\r\n" + . "Reply-To: " . SMTP_USER . "\r\n" + . "MIME-Version: 1.0\r\n" + . "Content-Type: text/html; charset=UTF-8\r\n"; + smtpSendEmail(SMTP_HOST, SMTP_PORT, SMTP_USER, SMTP_PASS, $email, $subject, $html, $headers, SMTP_USE_SSL); +} diff --git a/freeze.php b/freeze.php new file mode 100644 index 0000000..ec4b6de --- /dev/null +++ b/freeze.php @@ -0,0 +1,104 @@ +prepare( + "SELECT player_name, frozen FROM users WHERE freeze_token = :t"); +$stmt->bindValue(':t', $token, SQLITE3_TEXT); +$row = $stmt->execute()->fetchArray(SQLITE3_ASSOC); + +if (!$row) { + die('链接无效或已使用'); +} + +$player = $row['player_name']; + +if ($row['frozen'] == 1) { + // 已冻结,直接展示已冻结页 + $success = true; +} else { + // 执行冻结 + $db->exec("UPDATE users SET frozen = 1, freeze_token = '' WHERE player_name = " + . SQLite3::escapeString($player)); + // 通知 Java 插件封禁该玩家(Bukkit 原生 ban) + $now = time(); + $stmt2 = $db->prepare( + "INSERT INTO web_admin_changes + (change_type, target_id, target_name, change_data, created_at) + VALUES ('freeze', 0, :name, '{}', :now)"); + $stmt2->bindValue(':name', $player, SQLITE3_TEXT); + $stmt2->bindValue(':now', $now, SQLITE3_INTEGER); + $stmt2->execute(); + $success = true; +} + +// 取一个有效改密 token 构造「去改密」链接 +$stmt3 = $db->prepare( + "SELECT token FROM password_reset_tokens + WHERE player_name = :name AND expire_at > :now + ORDER BY expire_at DESC LIMIT 1"); +$stmt3->bindValue(':name', $player, SQLITE3_TEXT); +$stmt3->bindValue(':now', time(), SQLITE3_INTEGER); +$r3 = $stmt3->execute()->fetchArray(SQLITE3_ASSOC); +if ($r3) { + $resetLink = getBaseUrl() . '/reset_password.php?token=' . $r3['token']; +} +?> + + + + + + 账号已冻结 - Sdf1 Minecraft + + + +
+

✅ 账号已冻结

+

为防范账号被盗用,玩家 的账号已被临时冻结。

+
玩家:
+

冻结期间将无法进入游戏。如需解冻,请修改密码,系统将自动解除冻结。

+ + 前往修改密码并解冻 + +

未找到有效的改密链接,请返回异地登录提醒邮件点击「修改密码」。

+ +
+ + diff --git a/reset_password.php b/reset_password.php index 5064ade..6c9765a 100644 --- a/reset_password.php +++ b/reset_password.php @@ -55,14 +55,22 @@ if ($_SERVER['REQUEST_METHOD'] === 'POST') { // 更新密码凭证 storeWebLoginCredentials($player, $passwordHash, $salt); - + + // ★ 解冻:清除 frozen 标记并通知 Java 解除 Bukkit 原生封禁 + $db->exec("UPDATE users SET frozen = 0, freeze_token = '' WHERE player_name = " . SQLite3::escapeString($player)); + $now = time(); + $unf = $db->prepare("INSERT INTO web_admin_changes (change_type, target_id, target_name, change_data, created_at) VALUES ('unfreeze', 0, :name, '{}', :now)"); + $unf->bindValue(':name', $player, SQLITE3_TEXT); + $unf->bindValue(':now', $now, SQLITE3_INTEGER); + $unf->execute(); + // 删除重置token $stmt = $db->prepare("DELETE FROM password_reset_tokens WHERE token = :token"); $stmt->bindValue(':token', $token, SQLITE3_TEXT); $stmt->execute(); $success = true; - $message = '密码重置成功!请在游戏中使用新密码登录'; + $message = '密码重置成功!账号已解冻,请在游戏中使用新密码登录'; } } ?>