Upload via GUI (19 Dateien)
This commit is contained in:
+333
@@ -0,0 +1,333 @@
|
||||
'use strict';
|
||||
|
||||
/*
|
||||
* auth.js – offizieller Microsoft-Login für Minecraft (Phase 3)
|
||||
* -------------------------------------------------------------
|
||||
* Ablauf (Device-Code-Flow, wie bei Prism/MultiMC):
|
||||
* 1. Microsoft OAuth Device-Code -> MS access_token + refresh_token
|
||||
* 2. Xbox-Live-Authentifizierung -> XBL-Token + userhash
|
||||
* 3. XSTS-Autorisierung -> XSTS-Token
|
||||
* 4. Minecraft-Login -> MC access_token
|
||||
* 5. Minecraft-Profil -> UUID + Name (prüft zugleich den Besitz)
|
||||
*
|
||||
* Erfordert eine kostenlose Azure-App-ID (öffentlicher Client, Device-Flow).
|
||||
* Wir bauen KEINEN Offline-/Crack-Login – ohne echtes Konto kein Start.
|
||||
*/
|
||||
|
||||
const fs = require('fs');
|
||||
const path = require('path');
|
||||
|
||||
// Fest eingebaute Azure-App-ID des Launchers (öffentlicher Client – kein Geheimnis).
|
||||
// Jeder Nutzer meldet sich damit über SEIN eigenes Microsoft-Konto an.
|
||||
// Über die Einstellungen lässt sich bei Bedarf eine eigene ID hinterlegen.
|
||||
const DEFAULT_CLIENT_ID = '0625f4a8-dfff-4eab-8124-a573b00b0319';
|
||||
|
||||
const SCOPE = 'XboxLive.signin offline_access';
|
||||
// Manche Endpunkte (v. a. api.minecraftservices.com hinter Cloudflare) lehnen
|
||||
// Anfragen ohne User-Agent mit HTTP 403 ab -> überall mitsenden.
|
||||
const UA = 'AeroMC-Launcher/0.5';
|
||||
const DEVICECODE_URL = 'https://login.microsoftonline.com/consumers/oauth2/v2.0/devicecode';
|
||||
const TOKEN_URL = 'https://login.microsoftonline.com/consumers/oauth2/v2.0/token';
|
||||
const XBL_URL = 'https://user.auth.xboxlive.com/user/authenticate';
|
||||
const XSTS_URL = 'https://xsts.auth.xboxlive.com/xsts/authorize';
|
||||
const MC_LOGIN_URL = 'https://api.minecraftservices.com/authentication/login_with_xbox';
|
||||
const MC_PROFILE_URL = 'https://api.minecraftservices.com/minecraft/profile';
|
||||
|
||||
let USER_DATA = null;
|
||||
let SECURE = null; // optionaler Verschlüsseler (Electron safeStorage)
|
||||
|
||||
function init(userDataPath, secure) {
|
||||
USER_DATA = userDataPath;
|
||||
SECURE = secure && secure.available && secure.available() ? secure : null;
|
||||
migrateLegacy();
|
||||
if (SECURE) { try { writeStore(readStore()); } catch { /* Migration best effort */ } }
|
||||
}
|
||||
|
||||
// sensible Felder verschlüsselt ablegen (an das Windows-Benutzerkonto gebunden)
|
||||
const SENSITIVE = ['mcAccessToken', 'msRefreshToken'];
|
||||
const ENC = 'enc:v1:';
|
||||
function protect(acc) {
|
||||
if (!SECURE) return acc;
|
||||
const out = Object.assign({}, acc);
|
||||
for (const k of SENSITIVE) {
|
||||
const v = out[k];
|
||||
if (typeof v === 'string' && v && !v.startsWith(ENC)) {
|
||||
try { out[k] = ENC + SECURE.encrypt(v); } catch { /* unverschlüsselt lassen */ }
|
||||
}
|
||||
}
|
||||
return out;
|
||||
}
|
||||
function unprotect(acc) {
|
||||
const out = Object.assign({}, acc);
|
||||
for (const k of SENSITIVE) {
|
||||
const v = out[k];
|
||||
if (typeof v === 'string' && v.startsWith(ENC)) {
|
||||
try { out[k] = SECURE ? SECURE.decrypt(v.slice(ENC.length)) : null; } catch { out[k] = null; }
|
||||
}
|
||||
}
|
||||
return out;
|
||||
}
|
||||
function accountsFile() { return path.join(USER_DATA, 'accounts.json'); }
|
||||
function legacyFile() { return path.join(USER_DATA, 'account.json'); }
|
||||
|
||||
// Speicherform: { activeId, accounts: [ {profile,mcAccessToken,mcExpiresAt,msRefreshToken,updatedAt}, ... ] }
|
||||
function readStore() {
|
||||
try {
|
||||
const s = JSON.parse(fs.readFileSync(accountsFile(), 'utf8'));
|
||||
if (s && Array.isArray(s.accounts)) {
|
||||
return { activeId: s.activeId, accounts: s.accounts.map(unprotect) };
|
||||
}
|
||||
} catch { /* leer */ }
|
||||
return { activeId: null, accounts: [] };
|
||||
}
|
||||
function writeStore(s) {
|
||||
const safe = { activeId: s.activeId, accounts: (s.accounts || []).map(protect) };
|
||||
fs.writeFileSync(accountsFile(), JSON.stringify(safe, null, 2));
|
||||
}
|
||||
|
||||
// altes Einzelkonto (account.json) übernehmen
|
||||
function migrateLegacy() {
|
||||
try {
|
||||
if (fs.existsSync(accountsFile())) return;
|
||||
const old = JSON.parse(fs.readFileSync(legacyFile(), 'utf8'));
|
||||
if (old && old.profile) {
|
||||
writeStore({ activeId: old.profile.id, accounts: [old] });
|
||||
try { fs.unlinkSync(legacyFile()); } catch { /* egal */ }
|
||||
}
|
||||
} catch { /* nichts zu migrieren */ }
|
||||
}
|
||||
|
||||
function upsertAccount(acc) {
|
||||
const s = readStore();
|
||||
s.accounts = s.accounts.filter((a) => a.profile.id !== acc.profile.id);
|
||||
s.accounts.push(acc);
|
||||
s.activeId = acc.profile.id; // frisch eingeloggtes/erneuertes Konto wird aktiv
|
||||
writeStore(s);
|
||||
return acc;
|
||||
}
|
||||
|
||||
function listAccounts() {
|
||||
const s = readStore();
|
||||
return s.accounts.map((a) => ({ id: a.profile.id, name: a.profile.name, active: a.profile.id === s.activeId }));
|
||||
}
|
||||
|
||||
function setActive(id) {
|
||||
const s = readStore();
|
||||
if (s.accounts.some((a) => a.profile.id === id)) { s.activeId = id; writeStore(s); }
|
||||
return listAccounts();
|
||||
}
|
||||
|
||||
function removeAccount(id) {
|
||||
const s = readStore();
|
||||
s.accounts = s.accounts.filter((a) => a.profile.id !== id);
|
||||
if (s.activeId === id) s.activeId = s.accounts[0] ? s.accounts[0].profile.id : null;
|
||||
writeStore(s);
|
||||
return listAccounts();
|
||||
}
|
||||
|
||||
function getAccountById(id) {
|
||||
return readStore().accounts.find((a) => a.profile.id === id) || null;
|
||||
}
|
||||
function getActiveRaw() {
|
||||
const s = readStore();
|
||||
return s.accounts.find((a) => a.profile.id === s.activeId) || null;
|
||||
}
|
||||
function clearAccount() { try { fs.unlinkSync(accountsFile()); } catch { /* egal */ } }
|
||||
|
||||
async function postForm(url, params) {
|
||||
const res = await fetch(url, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/x-www-form-urlencoded', 'User-Agent': UA, Accept: 'application/json' },
|
||||
body: new URLSearchParams(params).toString(),
|
||||
});
|
||||
const data = await res.json().catch(() => ({}));
|
||||
return { status: res.status, data };
|
||||
}
|
||||
|
||||
async function postJson(url, body, headers = {}) {
|
||||
const res = await fetch(url, {
|
||||
method: 'POST',
|
||||
headers: Object.assign({ 'Content-Type': 'application/json', Accept: 'application/json', 'User-Agent': UA }, headers),
|
||||
body: JSON.stringify(body),
|
||||
});
|
||||
const data = await res.json().catch(() => ({}));
|
||||
return { status: res.status, data };
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// 1. Device-Code anfordern
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
async function requestDeviceCode(clientId) {
|
||||
if (!clientId) throw new Error('Keine Azure-Client-ID gesetzt (Einstellungen → Microsoft-Login).');
|
||||
const { status, data } = await postForm(DEVICECODE_URL, { client_id: clientId, scope: SCOPE });
|
||||
if (status !== 200) {
|
||||
throw new Error('Device-Code fehlgeschlagen: ' + (data.error_description || data.error || status));
|
||||
}
|
||||
return data; // { user_code, device_code, verification_uri, expires_in, interval, message }
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// 2. Auf Autorisierung warten (pollt Token-Endpoint)
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
async function pollForToken(clientId, deviceCode, intervalSec, onTick) {
|
||||
let interval = Math.max(intervalSec || 5, 3);
|
||||
const deadline = Date.now() + 15 * 60 * 1000;
|
||||
while (Date.now() < deadline) {
|
||||
await sleep(interval * 1000);
|
||||
const { data } = await postForm(TOKEN_URL, {
|
||||
grant_type: 'urn:ietf:params:oauth:grant-type:device_code',
|
||||
client_id: clientId,
|
||||
device_code: deviceCode,
|
||||
});
|
||||
if (data.access_token) return data; // { access_token, refresh_token, expires_in }
|
||||
if (data.error === 'authorization_pending') { onTick && onTick(); continue; }
|
||||
if (data.error === 'slow_down') { interval += 5; continue; }
|
||||
if (data.error === 'authorization_declined') throw new Error('Anmeldung abgelehnt.');
|
||||
if (data.error === 'expired_token') throw new Error('Code abgelaufen – bitte erneut anmelden.');
|
||||
throw new Error('Login-Fehler: ' + (data.error_description || data.error));
|
||||
}
|
||||
throw new Error('Zeitüberschreitung – Code nicht rechtzeitig bestätigt.');
|
||||
}
|
||||
|
||||
function sleep(ms) { return new Promise((r) => setTimeout(r, ms)); }
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// 3.–5. Xbox -> XSTS -> Minecraft -> Profil
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
async function xboxLogin(msAccessToken) {
|
||||
const { status, data } = await postJson(XBL_URL, {
|
||||
Properties: { AuthMethod: 'RPS', SiteName: 'user.auth.xboxlive.com', RpsTicket: 'd=' + msAccessToken },
|
||||
RelyingParty: 'http://auth.xboxlive.com',
|
||||
TokenType: 'JWT',
|
||||
});
|
||||
if (status !== 200 || !data.Token) {
|
||||
throw new Error('Xbox-Live-Login fehlgeschlagen (' + status + ')' + (data && data.Message ? ': ' + data.Message : '') + '.');
|
||||
}
|
||||
const uhs = data.DisplayClaims && data.DisplayClaims.xui && data.DisplayClaims.xui[0] && data.DisplayClaims.xui[0].uhs;
|
||||
return { token: data.Token, uhs };
|
||||
}
|
||||
|
||||
async function xstsAuth(xblToken) {
|
||||
const { status, data } = await postJson(XSTS_URL, {
|
||||
Properties: { SandboxId: 'RETAIL', UserTokens: [xblToken] },
|
||||
RelyingParty: 'rp://api.minecraftservices.com/',
|
||||
TokenType: 'JWT',
|
||||
});
|
||||
if (status === 401) {
|
||||
const xerr = data.XErr;
|
||||
if (xerr === 2148916233) throw new Error('Dieses Microsoft-Konto hat kein Xbox-Profil. Bitte einmal auf xbox.com anmelden.');
|
||||
if (xerr === 2148916238) throw new Error('Kinderkonto – muss einer Familie hinzugefügt werden.');
|
||||
throw new Error('XSTS-Autorisierung abgelehnt (XErr ' + xerr + ').');
|
||||
}
|
||||
if (status !== 200 || !data.Token) throw new Error('XSTS-Autorisierung fehlgeschlagen (' + status + ').');
|
||||
return { token: data.Token };
|
||||
}
|
||||
|
||||
async function minecraftLogin(uhs, xstsToken) {
|
||||
const { status, data } = await postJson(MC_LOGIN_URL, {
|
||||
identityToken: `XBL3.0 x=${uhs};${xstsToken}`,
|
||||
});
|
||||
if (status !== 200 || !data.access_token) {
|
||||
const detail = data && (data.errorMessage || data.error || data.path) ? ': ' + (data.errorMessage || data.error || data.path) : '';
|
||||
throw new Error('Minecraft-Login fehlgeschlagen (' + status + ')' + detail + '.');
|
||||
}
|
||||
return { accessToken: data.access_token, expiresIn: data.expires_in || 86400 };
|
||||
}
|
||||
|
||||
async function minecraftProfile(mcAccessToken) {
|
||||
const res = await fetch(MC_PROFILE_URL, {
|
||||
headers: { Authorization: 'Bearer ' + mcAccessToken, 'User-Agent': UA, Accept: 'application/json' },
|
||||
});
|
||||
if (res.status === 404) throw new Error('Dieses Konto besitzt kein Minecraft (Java Edition).');
|
||||
if (!res.ok) throw new Error('Profil laden fehlgeschlagen (' + res.status + ').');
|
||||
const data = await res.json();
|
||||
return { id: data.id, name: data.name }; // id = UUID ohne Bindestriche
|
||||
}
|
||||
|
||||
// vollständige Kette ab MS-Token -> gespeichertes Konto
|
||||
async function completeFromMsToken(msToken) {
|
||||
const xbl = await xboxLogin(msToken.access_token);
|
||||
const xsts = await xstsAuth(xbl.token);
|
||||
const mc = await minecraftLogin(xbl.uhs, xsts.token);
|
||||
const profile = await minecraftProfile(mc.accessToken);
|
||||
|
||||
return {
|
||||
profile, // { id, name }
|
||||
mcAccessToken: mc.accessToken,
|
||||
mcExpiresAt: Date.now() + (mc.expiresIn - 60) * 1000,
|
||||
msRefreshToken: msToken.refresh_token || null,
|
||||
updatedAt: new Date().toISOString(),
|
||||
};
|
||||
}
|
||||
|
||||
// ersetzt ein Konto in der Liste, ohne das aktive Konto zu ändern (für Token-Refresh)
|
||||
function updateAccount(acc) {
|
||||
const s = readStore();
|
||||
s.accounts = s.accounts.filter((a) => a.profile.id !== acc.profile.id);
|
||||
s.accounts.push(acc);
|
||||
writeStore(s);
|
||||
return acc;
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Öffentliche Login-Orchestrierung
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/*
|
||||
* startLogin: fordert Device-Code an und ruft danach den Callback mit dem Code.
|
||||
* Läuft dann im Hintergrund bis zur Bestätigung. Gibt am Ende das Konto zurück.
|
||||
*/
|
||||
async function login(clientId, onCode, onTick) {
|
||||
const dc = await requestDeviceCode(clientId);
|
||||
onCode && onCode({
|
||||
userCode: dc.user_code,
|
||||
verificationUri: dc.verification_uri,
|
||||
message: dc.message,
|
||||
expiresIn: dc.expires_in,
|
||||
});
|
||||
const msToken = await pollForToken(clientId, dc.device_code, dc.interval, onTick);
|
||||
const account = await completeFromMsToken(msToken);
|
||||
return upsertAccount(account); // neues Konto speichern + aktiv setzen
|
||||
}
|
||||
|
||||
// ein Konto gültig machen (Token ggf. per Refresh erneuern)
|
||||
async function refreshAccount(clientId, acc) {
|
||||
if (acc.mcAccessToken && acc.mcExpiresAt && Date.now() < acc.mcExpiresAt) return acc;
|
||||
if (acc.msRefreshToken && clientId) {
|
||||
const { data } = await postForm(TOKEN_URL, {
|
||||
grant_type: 'refresh_token',
|
||||
client_id: clientId,
|
||||
refresh_token: acc.msRefreshToken,
|
||||
scope: SCOPE,
|
||||
});
|
||||
if (data.access_token) {
|
||||
try {
|
||||
const fresh = await completeFromMsToken(data);
|
||||
if (!fresh.msRefreshToken) fresh.msRefreshToken = acc.msRefreshToken;
|
||||
return updateAccount(fresh);
|
||||
} catch { return null; }
|
||||
}
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
// aktives Konto gültig holen (null wenn kein Login)
|
||||
async function getValidAccount(clientId) {
|
||||
const acc = getActiveRaw();
|
||||
return acc ? refreshAccount(clientId, acc) : null;
|
||||
}
|
||||
// bestimmtes Konto gültig holen (für „starten als …")
|
||||
async function getValidAccountById(clientId, id) {
|
||||
const acc = getAccountById(id);
|
||||
return acc ? refreshAccount(clientId, acc) : null;
|
||||
}
|
||||
|
||||
function currentAccount() { return getActiveRaw(); }
|
||||
|
||||
module.exports = {
|
||||
init, login, getValidAccount, getValidAccountById, currentAccount, clearAccount,
|
||||
listAccounts, setActive, removeAccount, DEFAULT_CLIENT_ID,
|
||||
};
|
||||
Reference in New Issue
Block a user