Files
Sdf1_login/api/minecraft_auth.php
T

997 lines
39 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
/**
* Minecraft正版验证 - Microsoft OAuth代理
*
* 流程:
* 1. Java调用 create_session → 生成授权URL → 返回给Java
* 2. 玩家在浏览器打开URL → Microsoft登录 → 回调到本文件callback
* 3. callback处理授权码 → 交换token → 获取Minecraft profile → 存入SQLite
* 4. Java轮询 check_session → 返回验证状态和profile
*
* Actions:
* create_session - 创建验证会话,返回授权URL
* check_session - 检查会话状态(Java轮询)
* callback - Microsoft OAuth回调(浏览器重定向)
*/
header('Content-Type: application/json; charset=utf-8');
header('Access-Control-Allow-Origin: *');
// 加载密钥配置
require_once __DIR__ . '/pay_secrets.php';
// 第四层:全局限流(2026-09-30)
require_once __DIR__ . '/../security.php';
// ====================================================================
// ★ IP黑名单拦截(与 core.php 的 enforceIpBlacklist 同源数据)
// 本文件不引 core.php(自成体系),故内联一份同等逻辑:
// 被封IP访问必须携带游戏内 /web 签发的有效 token,否则 403;
// Java 插件的 secret 调用与任何异常一律放行(fail-open)。
// ====================================================================
require_once __DIR__ . '/../config.php'; // 取 SECRET_KEY(require_once,不会重复定义)
function mcAuthIpBlacklisted() {
if (PHP_SAPI === 'cli') return false;
$ip = $_SERVER['REMOTE_ADDR'] ?? '';
if ($ip === '') return false;
// 放行通道:Java插件 secret
if (isset($_REQUEST['secret']) && $_REQUEST['secret'] === SECRET_KEY) return false;
try {
$path = dirname(__DIR__) . '/db/web.db';
if (!file_exists($path)) return false;
$w = new PDO('sqlite:' . $path);
$w->setAttribute(PDO::ATTR_ERRMODE, PDO::ERRMODE_EXCEPTION);
$st = $w->prepare("SELECT 1 FROM web_ip_blacklist WHERE ip = ? AND (expire = 0 OR expire > ?) LIMIT 1");
$st->execute([$ip, time()]);
if (!$st->fetch()) return false; // 未被封 → 放行
// 被封 → 必须持游戏内 /web 签发的有效token
$token = isset($_REQUEST['web_token']) ? (string)$_REQUEST['web_token'] : '';
if ($token === '') return true;
$t = $w->prepare("SELECT created_at, expire_seconds FROM weblogin_tokens WHERE web_token = ? LIMIT 1");
$t->execute([$token]);
$row = $t->fetch(PDO::FETCH_ASSOC);
return !($row && (time() - (int)$row['created_at']) <= (int)$row['expire_seconds']);
} catch (\Throwable $e) {
return false; // 任何异常 fail-open,避免误伤
}
}
if (mcAuthIpBlacklisted()) {
http_response_code(403);
echo json_encode(['success' => false, 'blocked_ip' => true,
'message' => '该IP已被服务器封禁,需在游戏内执行 /web 获取授权token后才能访问'], JSON_UNESCAPED_UNICODE);
exit;
}
// ====================================================================
// 工具函数
// ====================================================================
function success($data = null) {
// ★ 清空所有输出缓冲,保证响应体是纯JSON(否则前端 JSON.parse 失败会误报"服务器响应异常")
while (ob_get_level() > 0) { @ob_end_clean(); }
echo json_encode(['success' => true, 'data' => $data], JSON_UNESCAPED_UNICODE);
exit;
}
function error($msg, $code = 400) {
// ★ 清空所有输出缓冲,保证响应体是纯JSON
while (ob_get_level() > 0) { @ob_end_clean(); }
http_response_code($code);
echo json_encode(['success' => false, 'error' => $msg], JSON_UNESCAPED_UNICODE);
exit;
}
function getParam($key, $default = '') {
return isset($_GET[$key]) ? trim($_GET[$key]) : $default;
}
function postParam($key, $default = '') {
return isset($_POST[$key]) ? trim($_POST[$key]) : $default;
}
/**
* 获取SQLite数据库连接(orders.db)
*/
function getDb() {
$possiblePaths = [
dirname(__DIR__) . '/data/orders.db',
dirname(__DIR__) . '/orders.db',
dirname(__DIR__) . '/../data/orders.db',
dirname(__DIR__) . '/../orders.db',
];
foreach ($possiblePaths as $path) {
if (file_exists($path)) {
$db = new PDO('sqlite:' . $path);
$db->setAttribute(PDO::ATTR_ERRMODE, PDO::ERRMODE_EXCEPTION);
$db->exec('PRAGMA journal_mode=WAL');
return $db;
}
}
// 都不存在,创建在第一个路径
$dir = dirname($possiblePaths[0]);
if (!is_dir($dir)) @mkdir($dir, 0755, true);
$db = new PDO('sqlite:' . $possiblePaths[0]);
$db->setAttribute(PDO::ATTR_ERRMODE, PDO::ERRMODE_EXCEPTION);
$db->exec('PRAGMA journal_mode=WAL');
return $db;
}
/**
* 确保mc_auth_sessions表存在
*/
function ensureTable($db) {
$db->exec("CREATE TABLE IF NOT EXISTS mc_auth_sessions (
session_id TEXT PRIMARY KEY,
player_name TEXT NOT NULL,
status TEXT NOT NULL DEFAULT 'pending',
mc_uuid TEXT DEFAULT '',
mc_username TEXT DEFAULT '',
mc_access_token TEXT DEFAULT '',
ip_address TEXT DEFAULT '',
created_at INTEGER NOT NULL,
verified_at INTEGER DEFAULT 0,
expires_at INTEGER NOT NULL
)");
}
/**
* 生成随机session_id
*/
function generateSessionId() {
return bin2hex(random_bytes(16));
}
// ====================================================================
// Microsoft OAuth 工具函数
// ====================================================================
/**
* 向Microsoft发送POST请求
*/
function msPost($url, $data, $headers = []) {
// 检查调用者是否已指定Content-Type
$hasContentType = false;
foreach ($headers as $h) {
if (stripos($h, 'Content-Type:') === 0) {
$hasContentType = true;
break;
}
}
$defaultHeaders = ['Accept: application/json'];
if (!$hasContentType) {
$defaultHeaders[] = 'Content-Type: application/x-www-form-urlencoded';
}
$ch = curl_init($url);
curl_setopt_array($ch, [
CURLOPT_POST => true,
CURLOPT_POSTFIELDS => is_array($data) ? http_build_query($data) : $data,
CURLOPT_RETURNTRANSFER => true,
CURLOPT_TIMEOUT => 30,
CURLOPT_SSL_VERIFYPEER => true,
CURLOPT_HTTPHEADER => array_merge($defaultHeaders, $headers),
]);
$response = curl_exec($ch);
$httpCode = curl_getinfo($ch, CURLINFO_HTTP_CODE);
$error = curl_error($ch);
curl_close($ch);
// 详细日志记录
$log = date('Y-m-d H:i:s') . "\n";
$log .= "URL: $url\n";
if (is_array($data)) {
$log .= "Data: " . http_build_query($data) . "\n";
} else {
$log .= "Data: $data\n";
}
$log .= "HTTP Code: $httpCode\n";
$log .= "cURL Error: " . ($error ?: 'none') . "\n";
$log .= "Response: " . substr($response, 0, 500) . "\n";
$log .= str_repeat('-', 80) . "\n";
@file_put_contents(__DIR__ . '/debug_minecraft.log', $log, FILE_APPEND);
if ($error) {
return ['error' => "cURL error: $error"];
}
return ['code' => $httpCode, 'body' => json_decode($response, true)];
}
/**
* 向Microsoft发送GET请求
*/
function msGet($url, $headers = []) {
$ch = curl_init($url);
curl_setopt_array($ch, [
CURLOPT_RETURNTRANSFER => true,
CURLOPT_TIMEOUT => 30,
CURLOPT_SSL_VERIFYPEER => true,
CURLOPT_HTTPHEADER => array_merge([
'Accept: application/json',
], $headers),
]);
$response = curl_exec($ch);
$httpCode = curl_getinfo($ch, CURLINFO_HTTP_CODE);
$error = curl_error($ch);
curl_close($ch);
// 详细日志记录
$log = date('Y-m-d H:i:s') . "\n";
$log .= "GET URL: $url\n";
$log .= "HTTP Code: $httpCode\n";
$log .= "cURL Error: " . ($error ?: 'none') . "\n";
$log .= "Response: " . substr($response, 0, 500) . "\n";
$log .= str_repeat('-', 80) . "\n";
@file_put_contents(__DIR__ . '/debug_minecraft.log', $log, FILE_APPEND);
if ($error) {
return ['error' => "cURL error: $error"];
}
return ['code' => $httpCode, 'body' => json_decode($response, true)];
}
// ====================================================================
// 核心OAuth流程
// ====================================================================
/**
* 步骤1: 生成Microsoft授权URL
* ★ 使用Minecraft官方启动器的公共Client ID(00000000402b5328)
* 此Client ID自带Xbox Live和Minecraft API权限,无需申请
* 缺点:redirect_uri必须是login.live.com的桌面端页面,玩家需手动复制授权码
*/
function getAuthUrl($sessionId) {
$clientId = '00000000402b5328'; // Minecraft官方启动器公共客户端ID
// 桌面端专用redirect_uri(Microsoft展示授权码的页面)
$redirectUri = 'https://login.live.com/oauth20_desktop.srf';
$scope = 'XboxLive.signin offline_access';
$params = http_build_query([
'client_id' => $clientId,
'response_type' => 'code',
'scope' => $scope,
'redirect_uri' => $redirectUri,
'state' => $sessionId,
'prompt' => 'select_account',
]);
return "https://login.live.com/oauth20_authorize.srf?$params";
}
/**
* 步骤2: 用授权码交换Access Token
* ★ 公共客户端(00000000402b5328)不需要client_secret
* redirect_uri必须与授权时一致
*/
/**
* 将Microsoft返回的OAuth错误映射为中文友好提示
*/
function mapMsTokenError($msErr, $msDesc = '') {
$msDesc = trim($msDesc);
if ($msErr === 'invalid_grant') {
return '授权码已失效(可能已过期,或已被使用过一次)。请重新点击"打开Microsoft登录"获取新的授权码,并在短时间内完成粘贴提交,不要重复使用旧授权码。';
}
if ($msErr === 'invalid_client') {
return 'OAuth客户端验证失败,请联系管理员检查 MS_CLIENT_ID 配置。';
}
if ($msErr === 'invalid_request') {
return '请求参数有误(' . ($msDesc ?: 'invalid_request') . '),请联系管理员。';
}
return 'Token兑换失败: ' . $msErr . ($msDesc ? '(' . $msDesc . ')' : '');
}
function exchangeToken($code) {
$clientId = '00000000402b5328'; // Minecraft公共客户端ID
// 桌面端redirect_uri(与getAuthUrl一致)
$redirectUri = 'https://login.live.com/oauth20_desktop.srf';
// ★ 关键修复:对code做一次urldecode归一化。
// 前端JS提取code后会encodeURIComponent(! → %21),
// 若此处不解码直接交给http_build_query,PHP会再次编码成 %2521(双重编码),
// 导致Microsoft收到失真的授权码 → invalid_grant 400。
// 做一次urldecode保证最终只编码一次。urldecode对已是明文的内容是幂等的,安全。
$code = urldecode($code);
$result = msPost('https://login.live.com/oauth20_token.srf', [
'client_id' => $clientId,
'code' => $code,
'grant_type' => 'authorization_code',
'redirect_uri' => $redirectUri,
'scope' => 'XboxLive.signin offline_access',
]);
if (isset($result['error'])) {
return ['error' => 'Token exchange failed: ' . $result['error']];
}
if ($result['code'] !== 200) {
$body = $result['body'] ?? [];
$msErr = $body['error'] ?? 'unknown';
$msDesc = $body['error_description'] ?? '';
return ['error' => mapMsTokenError($msErr, $msDesc), 'ms_error' => $msErr];
}
$body = $result['body'];
if (!isset($body['access_token'])) {
return ['error' => 'No access_token in response', 'response' => $body];
}
return [
'access_token' => $body['access_token'],
'refresh_token' => $body['refresh_token'] ?? '',
];
}
/**
* 步骤3: 获取Xbox Live Token
* ★ RpsTicket格式确认:必须用 "d= {token}" 前缀(来自日志验证)
*/
function getXboxToken($msAccessToken) {
$rpsTicket = 'd=' . $msAccessToken;
$log = date('Y-m-d H:i:s') . "\n";
$log .= "=== Xbox Token (d=前缀) ===\n";
$result = msPost('https://user.auth.xboxlive.com/user/authenticate', json_encode([
'Properties' => [
'AuthMethod' => 'RPS',
'SiteName' => 'user.auth.xboxlive.com',
'RpsTicket' => $rpsTicket,
],
'RelyingParty' => 'http://auth.xboxlive.com',
'TokenType' => 'JWT',
]), [
'Content-Type: application/json',
'Accept: application/json',
]);
$log .= "HTTP Code: " . ($result['code'] ?? 'N/A') . "\n";
$log .= "Response: " . json_encode($result['body'] ?? $result['error'] ?? 'N/A') . "\n";
$log .= str_repeat('-', 80) . "\n";
@file_put_contents(__DIR__ . '/debug_minecraft.log', $log, FILE_APPEND);
if (isset($result['error'])) {
return ['error' => 'Xbox auth failed: ' . $result['error']];
}
if ($result['code'] !== 200) {
return ['error' => "Xbox auth HTTP {$result['code']}: " . json_encode($result['body'] ?? [])];
}
$body = $result['body'];
if (!isset($body['Token'])) {
return ['error' => 'No Token in response: ' . json_encode($body)];
}
return [
'token' => $body['Token'],
'uhs' => $body['IssueClaims']['uhs'] ?? ($body['DisplayClaims']['xui'][0]['uhs'] ?? ''),
];
}
/**
* 步骤4: 获取XSTS Token
*/
function getXstsToken($xboxToken, $uhs) {
$result = msPost('https://xsts.auth.xboxlive.com/xsts/authorize', json_encode([
'Properties' => [
'SandboxId' => 'RETAIL',
'UserTokens' => [$xboxToken],
],
'RelyingParty' => 'rp://api.minecraftservices.com/',
'TokenType' => 'JWT',
]), [
'Content-Type: application/json',
'Accept: application/json',
]);
if (isset($result['error'])) {
return ['error' => 'XSTS auth failed: ' . $result['error']];
}
if ($result['code'] !== 200) {
$body = $result['body'] ?? [];
$errCode = $body['XErr'] ?? 'unknown';
return ['error' => "XSTS auth HTTP {$result['code']}, XErr: {$errCode}", 'response' => $body];
}
$body = $result['body'];
if (!isset($body['Token'])) {
return ['error' => 'No XSTS token', 'response' => $body];
}
return [
'token' => $body['Token'],
'uhs' => $body['IssueClaims']['uhs'] ?? $uhs,
];
}
/**
* 步骤5: 获取Minecraft Access Token
*/
function getMinecraftToken($xstsToken, $uhs) {
$result = msPost('https://api.minecraftservices.com/authentication/login_with_xbox', json_encode([
'identityToken' => "XBL3.0 x={$uhs};{$xstsToken}",
]), [
'Content-Type: application/json',
'Accept: application/json',
]);
if (isset($result['error'])) {
return ['error' => 'MC token failed: ' . $result['error']];
}
if ($result['code'] !== 200) {
return ['error' => 'MC token HTTP ' . $result['code'], 'response' => $result['body']];
}
$body = $result['body'];
if (!isset($body['access_token'])) {
return ['error' => 'No MC access_token', 'response' => $body];
}
return ['access_token' => $body['access_token']];
}
/**
* 步骤6: 获取Minecraft Profile
*/
function getMinecraftProfile($mcAccessToken) {
$result = msGet('https://api.minecraftservices.com/minecraft/profile', [
'Authorization: Bearer ' . $mcAccessToken,
]);
if (isset($result['error'])) {
return ['error' => 'MC profile failed: ' . $result['error']];
}
if ($result['code'] === 404) {
return ['error' => 'No Minecraft profile found (account may not own the game)'];
}
if ($result['code'] !== 200) {
return ['error' => 'MC profile HTTP ' . $result['code'], 'response' => $result['body']];
}
$body = $result['body'];
if (!isset($body['id']) || !isset($body['name'])) {
return ['error' => 'Invalid MC profile', 'response' => $body];
}
// 格式化UUID(加横杠)
$rawUuid = $body['id'];
$formattedUuid = substr($rawUuid, 0, 8) . '-'
. substr($rawUuid, 8, 4) . '-'
. substr($rawUuid, 12, 4) . '-'
. substr($rawUuid, 16, 4) . '-'
. substr($rawUuid, 20, 12);
return [
'uuid' => $formattedUuid,
'name' => $body['name'],
'skins' => $body['skins'] ?? [],
];
}
/**
* 完整OAuth流程(从授权码到Minecraft Profile)
*/
function completeAuthFlow($code) {
// 步骤2: 交换MS Token
$tokenResult = exchangeToken($code);
if (isset($tokenResult['error'])) return $tokenResult;
// 步骤3: Xbox Live Token
$xboxResult = getXboxToken($tokenResult['access_token']);
if (isset($xboxResult['error'])) return $xboxResult;
// 步骤4: XSTS Token
$xstsResult = getXstsToken($xboxResult['token'], $xboxResult['uhs']);
if (isset($xstsResult['error'])) return $xstsResult;
// 步骤5: Minecraft Token
$mcResult = getMinecraftToken($xstsResult['token'], $xstsResult['uhs']);
if (isset($mcResult['error'])) return $mcResult;
// 步骤6: Minecraft Profile
$profileResult = getMinecraftProfile($mcResult['access_token']);
if (isset($profileResult['error'])) return $profileResult;
return [
'uuid' => $profileResult['uuid'],
'name' => $profileResult['name'],
'access_token' => $mcResult['access_token'],
];
}
// ====================================================================
// Action处理
// ====================================================================
// ★ 检测Microsoft OAuth回调(带code+state,没有action参数)
$code = getParam('code');
$state = getParam('state');
$msError = getParam('error');
if (!empty($code) && !empty($state)) {
// 自动路由到callback处理
$action = 'callback';
} elseif (!empty($msError)) {
// Microsoft返回错误(如invalid_scope)→ 路由到callback显示错误
$action = 'callback';
} else {
$action = getParam('action');
}
$db = getDb();
ensureTable($db);
switch ($action) {
// ===== Java调用:创建验证会话 =====
case 'create_session': {
$player = getParam('player');
$secret = getParam('secret');
$ip = $_SERVER['REMOTE_ADDR'] ?? '';
if (empty($player)) error('缺少player参数');
if ($secret !== MC_AUTH_SECRET) error('密钥错误', 403);
// 检查MS_CLIENT_ID是否配置(公共客户端不需要secret)
if (empty(MS_CLIENT_ID)) {
error('Microsoft OAuth未配置:缺少MS_CLIENT_ID,请在pay_secrets.php中设置');
}
// 清理该玩家的旧会话
$stmt = $db->prepare("DELETE FROM mc_auth_sessions WHERE player_name = ? AND status != 'verified'");
$stmt->execute([$player]);
// 创建新会话
$sessionId = generateSessionId();
$now = time();
$expiresAt = $now + 600; // 10分钟过期
$stmt = $db->prepare("INSERT INTO mc_auth_sessions (session_id, player_name, status, ip_address, created_at, expires_at) VALUES (?, ?, 'pending', ?, ?, ?)");
$stmt->execute([$sessionId, $player, $ip, $now, $expiresAt]);
// 生成授权URL
$authUrl = getAuthUrl($sessionId);
// ★ 动态构建paste_url:基于当前请求的域名和脚本路径,不硬编码
$scheme = (!empty($_SERVER['HTTPS']) && $_SERVER['HTTPS'] !== 'off') ? 'https' : 'http';
$host = $_SERVER['HTTP_HOST'] ?? $_SERVER['SERVER_NAME'] ?? '';
$scriptPath = $_SERVER['SCRIPT_NAME'] ?? '/api/minecraft_auth.php';
$pasteUrl = $scheme . '://' . $host . $scriptPath . '?action=paste_code_page&session_id=' . $sessionId;
success([
'session_id' => $sessionId,
'auth_url' => $authUrl,
'paste_url' => $pasteUrl,
'expires_in' => 600,
]);
break;
}
// ===== Java调用:检查会话状态 =====
case 'check_session': {
$sessionId = getParam('session_id');
$secret = getParam('secret');
if (empty($sessionId)) error('缺少session_id参数');
if ($secret !== MC_AUTH_SECRET) error('密钥错误', 403);
// 查询会话
$stmt = $db->prepare("SELECT * FROM mc_auth_sessions WHERE session_id = ?");
$stmt->execute([$sessionId]);
$session = $stmt->fetch(PDO::FETCH_ASSOC);
if (!$session) {
error('会话不存在');
}
// 检查是否过期
if ($session['status'] === 'pending' && time() > $session['expires_at']) {
$stmt = $db->prepare("UPDATE mc_auth_sessions SET status = 'expired' WHERE session_id = ?");
$stmt->execute([$sessionId]);
$session['status'] = 'expired';
}
$result = [
'status' => $session['status'],
'player_name' => $session['player_name'],
];
if ($session['status'] === 'verified') {
$result['mc_uuid'] = $session['mc_uuid'];
$result['mc_username'] = $session['mc_username'];
$result['verified_at'] = $session['verified_at'];
} elseif ($session['status'] === 'failed') {
$result['error'] = '验证失败,请重试';
}
success($result);
break;
}
// ===== 粘贴页/前端轮询:查询会话真实状态(仅凭session_id)=====
// 用途:前端提交响应异常(非JSON/网关错误)时回查真实结果,避免"已验证通过却提示响应异常"的误导
case 'status': {
$sessionId = getParam('session_id');
if (empty($sessionId)) error('缺少session_id参数');
$stmt = $db->prepare("SELECT * FROM mc_auth_sessions WHERE session_id = ?");
$stmt->execute([$sessionId]);
$session = $stmt->fetch(PDO::FETCH_ASSOC);
if (!$session) error('会话不存在或已过期');
$result = [
'status' => $session['status'],
'player_name' => $session['player_name'],
];
if ($session['status'] === 'verified') {
$result['mc_uuid'] = $session['mc_uuid'];
$result['mc_username'] = $session['mc_username'];
}
success($result);
break;
}
// ===== 浏览器回调:处理Microsoft OAuth =====
case 'callback': {
$code = getParam('code');
$state = getParam('state'); // session_id
$msError = getParam('error');
// Microsoft返回错误
if ($msError) {
header('Content-Type: text/html; charset=utf-8');
$errorDesc = getParam('error_description', 'Microsoft登录被拒绝');
if ($state) {
$stmt = $db->prepare("UPDATE mc_auth_sessions SET status = 'failed' WHERE session_id = ?");
$stmt->execute([$state]);
}
echo "<!DOCTYPE html><html><head><meta charset='utf-8'><title>验证失败</title>
<style>body{font-family:sans-serif;display:flex;justify-content:center;align-items:center;height:100vh;margin:0;background:#1a1a2e;color:#fff;}
.card{background:#16213e;padding:40px;border-radius:16px;text-align:center;max-width:400px;}
.icon{font-size:48px;margin-bottom:16px;}</style></head>
<body><div class='card'><div class='icon'>❌</div><h2>验证失败</h2><p>" . htmlspecialchars($errorDesc) . "</p>
<p style='color:#888;font-size:14px;'>请关闭此页面并返回游戏重试</p></div></body></html>";
exit;
}
if (empty($code) || empty($state)) {
header('Content-Type: text/html; charset=utf-8');
echo "<!DOCTYPE html><html><head><meta charset='utf-8'><title>参数错误</title></head><body><h1>参数错误</h1></body></html>";
exit;
}
// 查询会话
$stmt = $db->prepare("SELECT * FROM mc_auth_sessions WHERE session_id = ?");
$stmt->execute([$state]);
$session = $stmt->fetch(PDO::FETCH_ASSOC);
if (!$session) {
header('Content-Type: text/html; charset=utf-8');
echo "<!DOCTYPE html><html><head><meta charset='utf-8'><title>会话不存在</title></head><body><h1>会话不存在或已过期</h1></body></html>";
exit;
}
if (time() > $session['expires_at']) {
$stmt = $db->prepare("UPDATE mc_auth_sessions SET status = 'expired' WHERE session_id = ?");
$stmt->execute([$state]);
header('Content-Type: text/html; charset=utf-8');
echo "<!DOCTYPE html><html><head><meta charset='utf-8'><title>会话过期</title></head><body><h1>会话已过期,请返回游戏重新发起验证</h1></body></html>";
exit;
}
// 执行完整OAuth流程
$result = completeAuthFlow($code);
if (isset($result['error'])) {
// 验证失败
$stmt = $db->prepare("UPDATE mc_auth_sessions SET status = 'failed' WHERE session_id = ?");
$stmt->execute([$state]);
header('Content-Type: text/html; charset=utf-8');
echo "<!DOCTYPE html><html><head><meta charset='utf-8'><title>验证失败</title>
<style>body{font-family:sans-serif;display:flex;justify-content:center;align-items:center;height:100vh;margin:0;background:#1a1a2e;color:#fff;}
.card{background:#16213e;padding:40px;border-radius:16px;text-align:center;max-width:400px;}
.icon{font-size:48px;margin-bottom:16px;}</style></head>
<body><div class='card'><div class='icon'>❌</div><h2>验证失败</h2><p>" . htmlspecialchars($result['error']) . "</p>
<p style='color:#888;font-size:14px;'>请关闭此页面并返回游戏重试</p></div></body></html>";
exit;
}
// 验证成功!
$stmt = $db->prepare("UPDATE mc_auth_sessions SET status = 'verified', mc_uuid = ?, mc_username = ?, mc_access_token = ?, verified_at = ? WHERE session_id = ?");
$stmt->execute([$result['uuid'], $result['name'], $result['access_token'], time(), $state]);
// 显示成功页面
$mcName = htmlspecialchars($result['name']);
$mcUuid = htmlspecialchars($result['uuid']);
header('Content-Type: text/html; charset=utf-8');
echo "<!DOCTYPE html><html><head><meta charset='utf-8'><title>验证成功</title>
<style>body{font-family:sans-serif;display:flex;justify-content:center;align-items:center;height:100vh;margin:0;background:#1a1a2e;color:#fff;}
.card{background:#16213e;padding:40px;border-radius:16px;text-align:center;max-width:400px;}
.icon{font-size:48px;margin-bottom:16px;}
.name{font-size:24px;font-weight:bold;color:#4ade80;margin:8px 0;}
.uuid{font-size:12px;color:#888;word-break:break-all;}</style></head>
<body><div class='card'>
<div class='icon'>✅</div>
<h2>正版验证成功!</h2>
<div class='name'>$mcName</div>
<div class='uuid'>$mcUuid</div>
<p style='margin-top:20px;color:#4ade80;'>请关闭此页面返回游戏</p>
<p style='color:#888;font-size:14px;'>你的正版身份已验证,将自动登录服务器</p>
</div></body></html>";
exit;
}
// ===== 玩家手动粘贴授权码(公共客户端流程) =====
case 'verify_code': {
// 兼容JSON和表单两种POST方式
$rawInput = file_get_contents('php://input');
$json = json_decode($rawInput, true);
$sessionId = postParam('session_id') ?: ($json['session_id'] ?? '');
$code = postParam('code') ?: ($json['code'] ?? '');
$secret = postParam('secret') ?: ($json['secret'] ?? '');
// ★ 详细调试日志
$debugLine = date('Y-m-d H:i:s') . " [verify_code] 来源: " . (postParam('code') !== '' ? 'POST表单' : 'JSON体') . "\n";
$debugLine .= " Raw input(前300): " . substr($rawInput, 0, 300) . "\n";
$debugLine .= " Session: $sessionId\n";
$debugLine .= " Code(pre-extract,前200): " . substr($code, 0, 200) . " (len=" . strlen($code) . ")\n";
$debugLine .= " Secret: " . substr($secret, 0, 30) . "\n";
@file_put_contents(__DIR__ . '/debug_minecraft.log', $debugLine, FILE_APPEND);
if (empty($sessionId)) error('缺少session_id参数');
if (empty($code)) error('缺少code参数(授权码)');
// 自动从URL中提取code参数(兼容 ?code= & #code= & 直接粘贴code)
if (strpos($code, 'code=') !== false || strpos($code, 'http') === 0 || strpos($code, '#') !== false) {
if (preg_match('/[#?&]code=([^&]+)/', $code, $m)) {
$code = urldecode($m[1]);
}
}
// ★ 提取后日志
$debugLine2 = " Code(post-extract,前200): " . substr($code, 0, 200) . " (len=" . strlen($code) . ")\n";
@file_put_contents(__DIR__ . '/debug_minecraft.log', $debugLine2, FILE_APPEND);
// 密钥验证:Java调用需要secret,浏览器表单用session_id自身认证
if (!empty($secret)) {
if ($secret !== MC_AUTH_SECRET) {
$debugLine3 = " ★ 密钥不匹配! 收到: " . substr($secret, 0, 30) . " 期望: " . substr(MC_AUTH_SECRET, 0, 30) . "\n";
@file_put_contents(__DIR__ . '/debug_minecraft.log', $debugLine3, FILE_APPEND);
error('密钥错误', 403);
}
}
// 查询会话
$stmt = $db->prepare("SELECT * FROM mc_auth_sessions WHERE session_id = ?");
$stmt->execute([$sessionId]);
$session = $stmt->fetch(PDO::FETCH_ASSOC);
if (!$session) {
$debugLine4 = " ★ 会话不存在! sessionId=$sessionId\n";
@file_put_contents(__DIR__ . '/debug_minecraft.log', $debugLine4, FILE_APPEND);
error('会话不存在或已过期');
}
if ($session['status'] !== 'pending') {
$debugLine4 = " ★ 会话状态异常: " . $session['status'] . " (期望pending)\n";
@file_put_contents(__DIR__ . '/debug_minecraft.log', $debugLine4, FILE_APPEND);
error('会话状态异常: ' . $session['status']);
}
if (time() > $session['expires_at']) {
$stmt = $db->prepare("UPDATE mc_auth_sessions SET status = 'expired' WHERE session_id = ?");
$stmt->execute([$sessionId]);
error('会话已过期,请重新发起验证');
}
// 执行完整OAuth流程
$debugLine5 = " → 调用 completeAuthFlow, code前50: " . substr($code, 0, 50) . "\n";
@file_put_contents(__DIR__ . '/debug_minecraft.log', $debugLine5, FILE_APPEND);
$result = completeAuthFlow($code);
$debugLine6 = " ← completeAuthFlow返回: " . (isset($result['error']) ? 'ERROR: ' . $result['error'] : 'SUCCESS uuid=' . ($result['uuid'] ?? '?') . ' name=' . ($result['name'] ?? '?')) . "\n";
$debugLine6 .= str_repeat('-', 80) . "\n";
@file_put_contents(__DIR__ . '/debug_minecraft.log', $debugLine6, FILE_APPEND);
if (isset($result['error'])) {
$stmt = $db->prepare("UPDATE mc_auth_sessions SET status = 'failed' WHERE session_id = ?");
$stmt->execute([$sessionId]);
error('验证失败: ' . $result['error']);
}
// 验证成功
$stmt = $db->prepare("UPDATE mc_auth_sessions SET status = 'verified', mc_uuid = ?, mc_username = ?, mc_access_token = ?, verified_at = ? WHERE session_id = ?");
$stmt->execute([$result['uuid'], $result['name'], $result['access_token'], time(), $sessionId]);
success([
'status' => 'verified',
'mc_uuid' => $result['uuid'],
'mc_username' => $result['name'],
]);
break;
}
// ===== Java调用:直接验证(用于验证Minecraft账号是否正版) =====
case 'verify_premium': {
$player = getParam('player');
$secret = getParam('secret');
if (empty($player)) error('缺少player参数');
if ($secret !== MC_AUTH_SECRET) error('密钥错误', 403);
// 通过Mojang API验证用户名是否对应正版账号
$url = "https://api.mojang.com/users/profiles/minecraft/" . urlencode($player);
$result = msGet($url);
if (isset($result['error'])) {
error('Mojang API请求失败: ' . $result['error']);
}
if ($result['code'] === 200 && isset($result['body']['id'])) {
$rawUuid = $result['body']['id'];
$formattedUuid = substr($rawUuid, 0, 8) . '-'
. substr($rawUuid, 8, 4) . '-'
. substr($rawUuid, 12, 4) . '-'
. substr($rawUuid, 16, 4) . '-'
. substr($rawUuid, 20, 12);
success([
'is_premium' => true,
'uuid' => $formattedUuid,
'name' => $result['body']['name'] ?? $player,
]);
} else {
success([
'is_premium' => false,
'uuid' => null,
'name' => $player,
]);
}
break;
}
// ===== 网页粘贴授权码页面(公共客户端流程) =====
case 'paste_code_page': {
header('Content-Type: text/html; charset=utf-8');
$sessionId = getParam('session_id');
if (empty($sessionId)) {
echo "<!DOCTYPE html><html><head><meta charset='utf-8'><title>参数错误</title></head><body><h1>缺少session_id参数</h1></body></html>";
exit;
}
// 验证session存在
$stmt = $db->prepare("SELECT * FROM mc_auth_sessions WHERE session_id = ?");
$stmt->execute([$sessionId]);
$session = $stmt->fetch(PDO::FETCH_ASSOC);
if (!$session) {
echo "<!DOCTYPE html><html><head><meta charset='utf-8'><title>会话不存在</title></head><body><h1>会话不存在或已过期</h1></body></html>";
exit;
}
$playerName = htmlspecialchars($session['player_name']);
$authUrl = htmlspecialchars(getAuthUrl($sessionId));
echo <<<HTML
<!DOCTYPE html>
<html lang="zh-CN">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width,initial-scale=1">
<title>Minecraft正版验证 - 粘贴授权码</title>
<style>
*{margin:0;padding:0;box-sizing:border-box}
body{font-family:-apple-system,BlinkMacSystemFont,'Segoe UI',sans-serif;display:flex;justify-content:center;align-items:center;min-height:100vh;background:#0f0f23;color:#e0e0e0}
.card{background:#1a1a3e;padding:40px;border-radius:16px;text-align:center;max-width:420px;width:90%;box-shadow:0 8px 32px rgba(0,0,0,0.4)}
h2{margin-bottom:8px;color:#4ade80}
.player{color:#888;font-size:14px;margin-bottom:24px}
.steps{text-align:left;background:#0f0f23;border-radius:8px;padding:16px;margin-bottom:24px;font-size:14px;line-height:1.8}
.steps li{margin-left:20px;color:#bbb}
.steps li b{color:#e0e0e0}
.input-group{margin-bottom:16px}
input[type=text]{width:100%;padding:14px 16px;font-size:16px;font-family:monospace;background:#0f0f23;border:2px solid #333;border-radius:8px;color:#4ade80;text-align:center;letter-spacing:2px;outline:none;transition:border-color 0.2s}
input[type=text]:focus{border-color:#4ade80}
button{width:100%;padding:14px;font-size:16px;font-weight:bold;background:#4ade80;color:#0f0f23;border:none;border-radius:8px;cursor:pointer;transition:all 0.2s}
button:hover{background:#22c55e;transform:translateY(-1px)}
button:disabled{opacity:0.5;cursor:not-allowed;transform:none}
.msg{margin-top:16px;font-size:14px;min-height:20px}
.msg.ok{color:#4ade80}
.msg.err{color:#f87171}
</style>
</head>
<body>
<div class="card">
<h2>🎮 正版验证</h2>
<p class="player">玩家: <b>$playerName</b></p>
<div style="margin-bottom:20px">
<a href="$authUrl" target="_blank" style="display:inline-block;padding:12px 24px;background:#4ade80;color:#0f0f23;text-decoration:none;border-radius:8px;font-weight:bold;font-size:16px">点击打开Microsoft登录</a>
</div>
<ol class="steps">
<li>点击上方<b>"打开Microsoft登录"</b>按钮</li>
<li>登录Microsoft账号</li>
<li>登录成功后,<b>复制浏览器地址栏的完整URL</b></li>
<li>将URL<b>粘贴</b>到下方输入框(会自动提取授权码)</li>
<li>点击"提交验证"</li>
</ol>
<div class="input-group">
<input type="text" id="codeInput" placeholder="粘贴完整URL或授权码" autofocus autocomplete="off">
</div>
<button id="submitBtn" onclick="submitCode()">提交验证</button>
<div class="msg" id="msg"></div>
</div>
<script>
var sessionId="{$sessionId}";
var submitting=false;
function extractCode(input){
input=input.trim();
var m=input.match(/[#?&]code=([^&]+)/);
if(m) return decodeURIComponent(m[1]);
if(input.indexOf('http')===-1 && input.indexOf('code=')===-1) return input;
try{
var u=new URL(input);
var p=new URLSearchParams(u.search);
if(p.has('code'))return p.get('code');
var ph=new URLSearchParams(u.hash.replace(/^#/,''));
if(ph.has('code'))return p.get('code');
}catch(e){}
return input;
}
function enableBtn(){var btn=document.getElementById('submitBtn');btn.disabled=false;btn.textContent='提交验证';}
function showRealStatus(httpStatus,raw){
// ★ 提交响应异常时回查会话真实状态:已验证就如实告知"已通过",不再误导为"服务器响应异常"
var x2=new XMLHttpRequest();
x2.open('GET','minecraft_auth.php?action=status&session_id='+encodeURIComponent(sessionId),true);
x2.onreadystatechange=function(){
if(x2.readyState!==4)return;
var st=null;
try{var d2=JSON.parse(x2.responseText); if(d2.success&&d2.data)st=d2.data.status;}catch(e){}
var rawPrev=String(raw||'').replace(/\s+/g,' ').substring(0,160);
if(st==='verified'){showMsg('✅ 服务器已验证通过(提交时响应异常已自动忽略),请关闭此页面返回游戏','ok');}
else if(st==='failed'){showMsg('❌ 验证失败,请重新提交 (HTTP '+httpStatus+')','err');enableBtn();}
else if(st==='expired'){showMsg('❌ 会话已过期,请回游戏重新发起验证','err');enableBtn();}
else if(st==='pending'){showMsg('⚠️ 响应异常(HTTP '+httpStatus+'),服务器仍在验证中,请稍后再次点击提交…','err');enableBtn();}
else{showMsg('❌ 服务器响应异常(HTTP '+httpStatus+'): '+rawPrev,'err');enableBtn();}
};
x2.send();
}
function doSubmit(code){
if(submitting)return;
submitting=true;
var btn=document.getElementById('submitBtn');
btn.disabled=true;btn.textContent='验证中...';
var xhr=new XMLHttpRequest();
xhr.open('POST','minecraft_auth.php?action=verify_code',true);
xhr.setRequestHeader('Content-Type','application/x-www-form-urlencoded');
xhr.onreadystatechange=function(){
if(xhr.readyState===4){
submitting=false;
var body='';
try{body=xhr.responseText||'';}catch(e){}
try{var d=JSON.parse(body);
if(d.success){showMsg('✅ 验证成功!请关闭此页面返回游戏','ok');}
else{showMsg('❌ '+(d.error||d.message||'验证失败'),'err');enableBtn();}
}catch(e){showRealStatus(xhr.status,body);}
}};
xhr.send('session_id='+encodeURIComponent(sessionId)+'&code='+encodeURIComponent(code));
}
function submitCode(){
var raw=document.getElementById('codeInput').value.trim();
if(!raw){showMsg('请输入授权码或URL','err');return}
var code=extractCode(raw);
if(!code){showMsg('无法从输入中提取授权码','err');return}
doSubmit(code);
}
document.getElementById('codeInput').addEventListener('paste',function(){
var el=this;
setTimeout(function(){
var raw=el.value.trim();
if(raw.indexOf('login.live.com')!==-1||raw.indexOf('login.microsoftonline.com')!==-1){
var code=extractCode(raw);
if(code&&code.length>10){doSubmit(code);}
}
},100);
});
function showMsg(t,c){var m=document.getElementById('msg');m.textContent=t;m.className='msg '+c;}
</script>
</body>
</html>
HTML;
exit;
}
default:
error('未知action: ' . $action);
}