Files
Sdf1_login/api/minecraft_auth.php
T

601 lines
20 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';
// ====================================================================
// 工具函数
// ====================================================================
function success($data = null) {
echo json_encode(['success' => true, 'data' => $data], JSON_UNESCAPED_UNICODE);
exit;
}
function error($msg, $code = 400) {
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 = []) {
$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([
'Content-Type: application/x-www-form-urlencoded',
'Accept: application/json',
], $headers),
]);
$response = curl_exec($ch);
$httpCode = curl_getinfo($ch, CURLINFO_HTTP_CODE);
$error = curl_error($ch);
curl_close($ch);
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);
if ($error) {
return ['error' => "cURL error: $error"];
}
return ['code' => $httpCode, 'body' => json_decode($response, true)];
}
// ====================================================================
// 核心OAuth流程
// ====================================================================
/**
* 步骤1: 生成Microsoft授权URL
*/
function getAuthUrl($sessionId) {
$clientId = MS_CLIENT_ID;
$redirectUri = MS_REDIRECT_URI;
// ★ 使用Microsoft Graph的User.Read scope(不依赖Xbox Live API权限)
// Xbox Live token交换会在后端用此token完成
$scope = 'User.Read offline_access';
$params = http_build_query([
'client_id' => $clientId,
'response_type' => 'code',
'scope' => $scope,
'redirect_uri' => $redirectUri,
'state' => $sessionId,
]);
return "https://login.live.com/oauth20_authorize.srf?$params";
}
/**
* 步骤2: 用授权码交换Access Token
*/
function exchangeToken($code) {
$result = msPost('https://login.live.com/oauth20_token.srf', [
'client_id' => MS_CLIENT_ID,
'client_secret' => MS_CLIENT_SECRET,
'code' => $code,
'grant_type' => 'authorization_code',
'redirect_uri' => MS_REDIRECT_URI,
]);
if (isset($result['error'])) {
return ['error' => 'Token exchange failed: ' . $result['error']];
}
if ($result['code'] !== 200) {
return ['error' => 'Token exchange HTTP ' . $result['code']];
}
$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
*/
function getXboxToken($msAccessToken) {
$result = msPost('https://user.auth.xboxlive.com/user/authenticate', json_encode([
'Properties' => [
'AuthMethod' => 'RPS',
'SiteName' => 'user.auth.xboxlive.com',
'RpsTicket' => $msAccessToken,
],
'RelyingParty' => 'http://auth.xboxlive.com',
'TokenType' => 'JWT',
]), [
'Content-Type: application/json',
'Accept: application/json',
]);
if (isset($result['error'])) {
return ['error' => 'Xbox auth failed: ' . $result['error']];
}
if ($result['code'] !== 200) {
return ['error' => 'Xbox auth HTTP ' . $result['code']];
}
$body = $result['body'];
if (!isset($body['Token'])) {
return ['error' => 'No Xbox token', 'response' => $body];
}
return [
'token' => $body['Token'],
'uhs' => $body['IssueClaims']['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_SECRET是否配置
if (empty(MS_CLIENT_SECRET)) {
error('Microsoft OAuth未配置:缺少MS_CLIENT_SECRET,请在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);
success([
'session_id' => $sessionId,
'auth_url' => $authUrl,
'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;
}
// ===== 浏览器回调:处理Microsoft OAuth =====
case 'callback': {
$code = getParam('code');
$state = getParam('state'); // session_id
$msError = getParam('error');
// Microsoft返回错误
if ($msError) {
$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)) {
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) {
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]);
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]);
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']);
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;
}
// ===== 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;
}
default:
error('未知action: ' . $action);
}