feat: authenticate Microsoft accounts via external browser

This commit is contained in:
huanghongxun 2021-08-22 20:35:30 +08:00
parent b39508922f
commit 5890f0c782
12 changed files with 419 additions and 189 deletions

View File

@ -36,6 +36,7 @@ def buildnumber = System.getenv("BUILD_NUMBER") ?: dev ?: "SNAPSHOT"
if (System.getenv("BUILD_NUMBER") != null && System.getenv("BUILD_NUMBER_OFFSET") != null)
buildnumber = (Integer.parseInt(System.getenv("BUILD_NUMBER")) - Integer.parseInt(System.getenv("BUILD_NUMBER_OFFSET"))).toString()
def versionroot = System.getenv("VERSION_ROOT") ?: "3.3"
def microsoftAuthSecret = System.getenv("MICROSOFT_AUTH_SECRET") ?: ""
version = versionroot + '.' + buildnumber
mainClassName = 'org.jackhuang.hmcl.Main'
@ -120,10 +121,11 @@ shadowJar {
classifier = null
manifest {
attributes 'Created-By': 'Copyright(c) 2013-2020 huangyuhui.',
attributes 'Created-By': 'Copyright(c) 2013-2021 huangyuhui.',
'Main-Class': mainClassName,
'Multi-Release': 'true',
'Implementation-Version': project.version,
'Microsoft-Auth-Secret': microsoftAuthSecret,
'Class-Path': 'pack200.jar',
'Add-Opens': [
'java.base/java.lang',

View File

@ -0,0 +1,117 @@
/*
* Hello Minecraft! Launcher
* Copyright (C) 2021 huangyuhui <huanghongxun2008@126.com> and contributors
*
* This program is free software: you can redistribute it and/or modify
* it under the terms of the GNU General Public License as published by
* the Free Software Foundation, either version 3 of the License, or
* (at your option) any later version.
*
* This program is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU General Public License for more details.
*
* You should have received a copy of the GNU General Public License
* along with this program. If not, see <https://www.gnu.org/licenses/>.
*/
package org.jackhuang.hmcl.game;
import fi.iki.elonen.NanoHTTPD;
import org.jackhuang.hmcl.auth.AuthenticationException;
import org.jackhuang.hmcl.auth.microsoft.MicrosoftService;
import org.jackhuang.hmcl.ui.FXUtils;
import org.jackhuang.hmcl.util.Logging;
import org.jackhuang.hmcl.util.io.IOUtils;
import org.jackhuang.hmcl.util.io.JarUtils;
import org.jackhuang.hmcl.util.io.NetworkUtils;
import java.io.IOException;
import java.util.Map;
import java.util.concurrent.CompletableFuture;
import java.util.concurrent.ExecutionException;
import java.util.logging.Level;
import static org.jackhuang.hmcl.util.Lang.mapOf;
import static org.jackhuang.hmcl.util.Lang.thread;
public class MicrosoftAuthenticationServer extends NanoHTTPD implements MicrosoftService.OAuthSession {
private final int port;
private final CompletableFuture<String> future = new CompletableFuture<>();
private MicrosoftAuthenticationServer(int port) {
super(port);
this.port = port;
}
@Override
public String getRedirectURI() {
return String.format("http://localhost:%d/auth-response", port);
}
@Override
public String getClientSecret() {
return System.getProperty("hmcl.microsoft.auth.secret",
JarUtils.thisJar().flatMap(JarUtils::getManifest).map(manifest -> manifest.getMainAttributes().getValue("Microsoft-Auth-Secret")).orElse(""));
}
@Override
public String waitFor() throws InterruptedException, ExecutionException {
return future.get();
}
@Override
public Response serve(IHTTPSession session) {
if (session.getMethod() != Method.GET || !"/auth-response".equals(session.getUri())) {
return newFixedLengthResponse(Response.Status.NOT_FOUND, MIME_HTML, "");
}
Map<String, String> query = mapOf(NetworkUtils.parseQuery(session.getQueryParameterString()));
if (query.containsKey("code")) {
future.complete(query.get("code"));
} else {
future.completeExceptionally(new AuthenticationException("failed to authenticate"));
}
String html;
try {
html = IOUtils.readFullyAsString(MicrosoftAuthenticationServer.class.getResourceAsStream("/assets/microsoft_auth.html"));
} catch (IOException e) {
Logging.LOG.log(Level.SEVERE, "Failed to load html");
return newFixedLengthResponse(Response.Status.INTERNAL_ERROR, MIME_HTML, "");
}
thread(() -> {
try {
Thread.sleep(1000);
stop();
} catch (InterruptedException e) {
Logging.LOG.log(Level.SEVERE, "Failed to sleep for 1 second");
}
});
return newFixedLengthResponse(html);
}
public static class Factory implements MicrosoftService.OAuthCallback {
@Override
public MicrosoftService.OAuthSession startServer() throws IOException {
IOException exception = null;
for (int port : new int[]{29111, 29112, 29113, 29114, 29115}) {
try {
MicrosoftAuthenticationServer server = new MicrosoftAuthenticationServer(port);
server.start(NanoHTTPD.SOCKET_READ_TIMEOUT, false);
return server;
} catch (IOException e) {
exception = e;
}
}
throw exception;
}
@Override
public void openBrowser(String url) throws IOException {
// TODO: error!
FXUtils.openLink(url);
}
}
}

View File

@ -1,6 +1,6 @@
/*
* Hello Minecraft! Launcher
* Copyright (C) 2020 huangyuhui <huanghongxun2008@126.com> and contributors
* Copyright (C) 2021 huangyuhui <huanghongxun2008@126.com> and contributors
*
* This program is free software: you can redistribute it and/or modify
* it under the terms of the GNU General Public License as published by
@ -27,13 +27,7 @@ import org.jackhuang.hmcl.Metadata;
import org.jackhuang.hmcl.auth.Account;
import org.jackhuang.hmcl.auth.AccountFactory;
import org.jackhuang.hmcl.auth.AuthenticationException;
import org.jackhuang.hmcl.auth.authlibinjector.AuthlibInjectorAccount;
import org.jackhuang.hmcl.auth.authlibinjector.AuthlibInjectorAccountFactory;
import org.jackhuang.hmcl.auth.authlibinjector.AuthlibInjectorArtifactInfo;
import org.jackhuang.hmcl.auth.authlibinjector.AuthlibInjectorArtifactProvider;
import org.jackhuang.hmcl.auth.authlibinjector.AuthlibInjectorDownloader;
import org.jackhuang.hmcl.auth.authlibinjector.AuthlibInjectorServer;
import org.jackhuang.hmcl.auth.authlibinjector.SimpleAuthlibInjectorArtifactProvider;
import org.jackhuang.hmcl.auth.authlibinjector.*;
import org.jackhuang.hmcl.auth.microsoft.MicrosoftAccount;
import org.jackhuang.hmcl.auth.microsoft.MicrosoftAccountFactory;
import org.jackhuang.hmcl.auth.microsoft.MicrosoftService;
@ -41,8 +35,8 @@ import org.jackhuang.hmcl.auth.offline.OfflineAccount;
import org.jackhuang.hmcl.auth.offline.OfflineAccountFactory;
import org.jackhuang.hmcl.auth.yggdrasil.YggdrasilAccount;
import org.jackhuang.hmcl.auth.yggdrasil.YggdrasilAccountFactory;
import org.jackhuang.hmcl.game.MicrosoftAuthenticationServer;
import org.jackhuang.hmcl.task.Schedulers;
import org.jackhuang.hmcl.ui.account.MicrosoftAccountLoginStage;
import java.io.IOException;
import java.nio.file.Paths;
@ -84,7 +78,7 @@ public final class Accounts {
public static final OfflineAccountFactory FACTORY_OFFLINE = OfflineAccountFactory.INSTANCE;
public static final YggdrasilAccountFactory FACTORY_MOJANG = YggdrasilAccountFactory.MOJANG;
public static final AuthlibInjectorAccountFactory FACTORY_AUTHLIB_INJECTOR = new AuthlibInjectorAccountFactory(AUTHLIB_INJECTOR_DOWNLOADER, Accounts::getOrCreateAuthlibInjectorServer);
public static final MicrosoftAccountFactory FACTORY_MICROSOFT = new MicrosoftAccountFactory(new MicrosoftService(MicrosoftAccountLoginStage.INSTANCE));
public static final MicrosoftAccountFactory FACTORY_MICROSOFT = new MicrosoftAccountFactory(new MicrosoftService(new MicrosoftAuthenticationServer.Factory()));
public static final List<AccountFactory<?>> FACTORIES = immutableListOf(FACTORY_OFFLINE, FACTORY_MOJANG, FACTORY_MICROSOFT, FACTORY_AUTHLIB_INJECTOR);
// ==== login type / account factory mapping ====

View File

@ -35,7 +35,6 @@ import org.jackhuang.hmcl.setting.Profiles;
import org.jackhuang.hmcl.task.Task;
import org.jackhuang.hmcl.task.TaskExecutor;
import org.jackhuang.hmcl.ui.account.AuthlibInjectorServersPage;
import org.jackhuang.hmcl.ui.account.MicrosoftAccountLoginStage;
import org.jackhuang.hmcl.ui.animation.ContainerAnimations;
import org.jackhuang.hmcl.ui.construct.InputDialogPane;
import org.jackhuang.hmcl.ui.construct.MessageDialogPane;
@ -151,7 +150,6 @@ public final class Controllers {
Logging.LOG.info("Start initializing application");
Controllers.stage = stage;
MicrosoftAccountLoginStage.INSTANCE.initOwner(stage);
stage.setHeight(config().getHeight());
stageHeight.bind(stage.heightProperty());

View File

@ -1,72 +0,0 @@
/*
* Hello Minecraft! Launcher
* Copyright (C) 2020 huangyuhui <huanghongxun2008@126.com> and contributors
*
* This program is free software: you can redistribute it and/or modify
* it under the terms of the GNU General Public License as published by
* the Free Software Foundation, either version 3 of the License, or
* (at your option) any later version.
*
* This program is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU General Public License for more details.
*
* You should have received a copy of the GNU General Public License
* along with this program. If not, see <https://www.gnu.org/licenses/>.
*/
package org.jackhuang.hmcl.ui.account;
import javafx.application.Platform;
import javafx.stage.Modality;
import org.jackhuang.hmcl.auth.microsoft.MicrosoftService;
import org.jackhuang.hmcl.ui.WebStage;
import java.util.concurrent.CompletableFuture;
import java.util.function.Predicate;
import static org.jackhuang.hmcl.Launcher.COOKIE_MANAGER;
public class MicrosoftAccountLoginStage extends WebStage implements MicrosoftService.WebViewCallback {
public static final MicrosoftAccountLoginStage INSTANCE = new MicrosoftAccountLoginStage();
CompletableFuture<String> future;
Predicate<String> urlTester;
public MicrosoftAccountLoginStage() {
super(600, 600);
initModality(Modality.APPLICATION_MODAL);
titleProperty().bind(webEngine.titleProperty());
webEngine.locationProperty().addListener((observable, oldValue, newValue) -> {
if (urlTester != null && urlTester.test(newValue)) {
future.complete(newValue);
hide();
}
});
showingProperty().addListener((observable, oldValue, newValue) -> {
if (!newValue) {
if (future != null) {
future.completeExceptionally(new InterruptedException());
}
future = null;
urlTester = null;
}
});
}
@Override
public CompletableFuture<String> show(MicrosoftService service, Predicate<String> urlTester, String initialURL) {
Platform.runLater(() -> {
COOKIE_MANAGER.getCookieStore().removeAll();
webEngine.load(initialURL);
show();
});
this.future = new CompletableFuture<>();
this.urlTester = urlTester;
return future;
}
}

View File

@ -0,0 +1,37 @@
<!--
Hello Minecraft! Launcher
Copyright (C) 2021 huangyuhui <huanghongxun2008@126.com> and contributors
This program is free software: you can redistribute it and/or modify
it under the terms of the GNU General Public License as published by
the Free Software Foundation, either version 3 of the License, or
(at your option) any later version.
This program is distributed in the hope that it will be useful,
but WITHOUT ANY WARRANTY; without even the implied warranty of
MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
GNU General Public License for more details.
You should have received a copy of the GNU General Public License
along with this program. If not, see <https://www.gnu.org/licenses/>.
-->
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<title>Hello Minecraft! Launcher</title>
</head>
<body>
<div>
你可以关闭本标签页了
</div>
<script>
window.close()
</script>
</body>
</html>

View File

@ -12,6 +12,7 @@ dependencies {
api group: 'org.jenkins-ci', name: 'constant-pool-scanner', version: '1.2'
api group: 'com.github.steveice10', name: 'opennbt', version: '1.1'
api group: 'com.nqzero', name: 'permit-reflect', version: '0.3'
api group: 'org.nanohttpd', name: 'nanohttpd', version: '2.3.1'
compileOnlyApi group: 'org.jetbrains', name: 'annotations', version: '16.0.3'
// compileOnlyApi group: 'org.openjfx', name: 'javafx-base', version: '15', classifier: 'win'

View File

@ -1,6 +1,6 @@
/*
* Hello Minecraft! Launcher
* Copyright (C) 2020 huangyuhui <huanghongxun2008@126.com> and contributors
* Copyright (C) 2021 huangyuhui <huanghongxun2008@126.com> and contributors
*
* This program is free software: you can redistribute it and/or modify
* it under the terms of the GNU General Public License as published by
@ -20,7 +20,9 @@ package org.jackhuang.hmcl.auth.microsoft;
import com.google.gson.JsonParseException;
import com.google.gson.annotations.SerializedName;
import org.jackhuang.hmcl.auth.*;
import org.jackhuang.hmcl.auth.yggdrasil.*;
import org.jackhuang.hmcl.auth.yggdrasil.RemoteAuthenticationException;
import org.jackhuang.hmcl.auth.yggdrasil.Texture;
import org.jackhuang.hmcl.auth.yggdrasil.TextureType;
import org.jackhuang.hmcl.util.gson.JsonUtils;
import org.jackhuang.hmcl.util.gson.TolerableValidationException;
import org.jackhuang.hmcl.util.gson.Validation;
@ -30,16 +32,15 @@ import org.jackhuang.hmcl.util.io.ResponseCodeException;
import org.jackhuang.hmcl.util.javafx.ObservableOptionalCache;
import java.io.IOException;
import java.net.HttpURLConnection;
import java.util.*;
import java.util.concurrent.CompletableFuture;
import java.util.concurrent.ExecutionException;
import java.util.concurrent.ThreadPoolExecutor;
import java.util.concurrent.TimeUnit;
import java.util.function.Predicate;
import java.util.logging.Level;
import java.util.regex.Matcher;
import java.util.regex.Pattern;
import static java.net.HttpURLConnection.HTTP_NOT_FOUND;
import static java.util.Objects.requireNonNull;
import static org.jackhuang.hmcl.util.Lang.mapOf;
import static org.jackhuang.hmcl.util.Lang.threadPool;
@ -47,22 +48,26 @@ import static org.jackhuang.hmcl.util.Logging.LOG;
import static org.jackhuang.hmcl.util.Pair.pair;
public class MicrosoftService {
private static final ThreadPoolExecutor POOL = threadPool("MicrosoftProfileProperties", true, 2, 10, TimeUnit.SECONDS);
private static final Pattern OAUTH_URL_PATTERN = Pattern.compile("^https://login\\.live\\.com/oauth20_desktop\\.srf\\?code=(.*?)&lc=(.*?)$");
private static final String CLIENT_ID = "6a3728d6-27a3-4180-99bb-479895b8f88e";
private static final String AUTHORIZATION_URL = "https://login.live.com/oauth20_authorize.srf";
private static final String ACCESS_TOKEN_URL = "https://login.live.com/oauth20_token.srf";
private static final String SCOPE = "XboxLive.signin offline_access";
private static final int[] PORTS = { 29111, 29112, 29113, 29114, 29115 };
private static final ThreadPoolExecutor POOL = threadPool("MicrosoftProfileProperties", true, 2, 10,
TimeUnit.SECONDS);
private static final Pattern OAUTH_URL_PATTERN = Pattern
.compile("^https://login\\.live\\.com/oauth20_desktop\\.srf\\?code=(.*?)&lc=(.*?)$");
private final WebViewCallback callback;
private final OAuthCallback callback;
private final ObservableOptionalCache<String, MinecraftProfileResponse, AuthenticationException> profileRepository;
public MicrosoftService(WebViewCallback callback) {
this.callback = callback;
this.profileRepository = new ObservableOptionalCache<>(
authorization -> {
LOG.info("Fetching properties");
return getCompleteProfile(authorization);
},
(uuid, e) -> LOG.log(Level.WARNING, "Failed to fetch properties of " + uuid, e),
POOL);
public MicrosoftService(OAuthCallback callback) {
this.callback = requireNonNull(callback);
this.profileRepository = new ObservableOptionalCache<>(authorization -> {
LOG.info("Fetching properties");
return getCompleteProfile(authorization);
}, (uuid, e) -> LOG.log(Level.WARNING, "Failed to fetch properties of " + uuid, e), POOL);
}
public ObservableOptionalCache<String, MinecraftProfileResponse, AuthenticationException> getProfileRepository() {
@ -70,62 +75,78 @@ public class MicrosoftService {
}
public MicrosoftSession authenticate() throws AuthenticationException {
requireNonNull(callback);
// Example URL:
// https://login.live.com/oauth20_authorize.srf?response_type=code&client_id=6a3728d6-27a3-4180-99bb-479895b8f88e&redirect_uri=http://localhost:29111/auth-response&scope=XboxLive.signin+offline_access&state=612fd24a2447427383e8b222b597db66&prompt=select_account
try {
// Microsoft OAuth Flow
String code = callback.show(this, urlToBeTested -> OAUTH_URL_PATTERN.matcher(urlToBeTested).find(), "https://login.live.com/oauth20_authorize.srf" +
"?client_id=00000000402b5328" +
"&response_type=code" +
"&scope=service%3A%3Auser.auth.xboxlive.com%3A%3AMBI_SSL" +
"&redirect_uri=https%3A%2F%2Flogin.live.com%2Foauth20_desktop.srf")
.thenApply(url -> {
Matcher matcher = OAUTH_URL_PATTERN.matcher(url);
matcher.find();
return matcher.group(1);
})
.get();
OAuthSession session = callback.startServer();
callback.openBrowser(NetworkUtils.withQuery(AUTHORIZATION_URL,
mapOf(pair("client_id", CLIENT_ID), pair("response_type", "code"),
pair("redirect_uri", session.getRedirectURI()), pair("scope", SCOPE),
pair("prompt", "select_account"))));
String code = session.waitFor();
// Authorization Code -> Token
String responseText = HttpRequest.POST("https://login.live.com/oauth20_token.srf").form(mapOf(
pair("client_id", "00000000402b5328"),
pair("code", code),
pair("grant_type", "authorization_code"),
pair("redirect_uri", "https://login.live.com/oauth20_desktop.srf"),
pair("scope", "service::user.auth.xboxlive.com::MBI_SSL"))).getString();
LiveAuthorizationResponse response = JsonUtils.fromNonNullJson(responseText, LiveAuthorizationResponse.class);
String responseText = HttpRequest.POST("https://login.live.com/oauth20_token.srf")
.form(mapOf(pair("client_id", CLIENT_ID), pair("code", code),
pair("grant_type", "authorization_code"), pair("client_secret", session.getClientSecret()),
pair("redirect_uri", session.getRedirectURI()), pair("scope", SCOPE)))
.getString();
LiveAuthorizationResponse response = JsonUtils.fromNonNullJson(responseText,
LiveAuthorizationResponse.class);
// Authenticate with XBox Live
XBoxLiveAuthenticationResponse xboxResponse = HttpRequest.POST("https://user.auth.xboxlive.com/user/authenticate")
XBoxLiveAuthenticationResponse xboxResponse = HttpRequest
.POST("https://user.auth.xboxlive.com/user/authenticate")
.json(mapOf(
pair("Properties", mapOf(
pair("AuthMethod", "RPS"),
pair("SiteName", "user.auth.xboxlive.com"),
pair("RpsTicket", response.accessToken)
)),
pair("RelyingParty", "http://auth.xboxlive.com"),
pair("TokenType", "JWT")))
.getJson(XBoxLiveAuthenticationResponse.class);
pair("Properties",
mapOf(pair("AuthMethod", "RPS"), pair("SiteName", "user.auth.xboxlive.com"),
pair("RpsTicket", "d=" + response.accessToken))),
pair("RelyingParty", "http://auth.xboxlive.com"), pair("TokenType", "JWT")))
.accept("application/json").getJson(XBoxLiveAuthenticationResponse.class);
String uhs = (String) xboxResponse.displayClaims.xui.get(0).get("uhs");
// Authenticate with XSTS
XBoxLiveAuthenticationResponse xstsResponse = HttpRequest.POST("https://xsts.auth.xboxlive.com/xsts/authorize")
// Authenticate Minecraft with XSTS
XBoxLiveAuthenticationResponse minecraftXstsResponse = HttpRequest
.POST("https://xsts.auth.xboxlive.com/xsts/authorize")
.json(mapOf(
pair("Properties", mapOf(
pair("SandboxId", "RETAIL"),
pair("UserTokens", Collections.singletonList(xboxResponse.token))
)),
pair("RelyingParty", "rp://api.minecraftservices.com/"),
pair("TokenType", "JWT")))
pair("Properties",
mapOf(pair("SandboxId", "RETAIL"),
pair("UserTokens", Collections.singletonList(xboxResponse.token)))),
pair("RelyingParty", "rp://api.minecraftservices.com/"), pair("TokenType", "JWT")))
.getJson(XBoxLiveAuthenticationResponse.class);
String minecraftXstsUhs = (String) minecraftXstsResponse.displayClaims.xui.get(0).get("uhs");
if (!Objects.equals(uhs, minecraftXstsUhs)) {
throw new ServerResponseMalformedException("uhs mismatched");
}
// Authenticate XBox with XSTS
XBoxLiveAuthenticationResponse xboxXstsResponse = HttpRequest
.POST("https://xsts.auth.xboxlive.com/xsts/authorize")
.json(mapOf(
pair("Properties",
mapOf(pair("SandboxId", "RETAIL"),
pair("UserTokens", Collections.singletonList(xboxResponse.token)))),
pair("RelyingParty", "http://xboxlive.com"), pair("TokenType", "JWT")))
.getJson(XBoxLiveAuthenticationResponse.class);
String xboxXstsUhs = (String) xboxXstsResponse.displayClaims.xui.get(0).get("uhs");
if (!Objects.equals(uhs, xboxXstsUhs)) {
throw new ServerResponseMalformedException("uhs mismatched");
}
getXBoxProfile(uhs, xboxXstsResponse.token);
// Authenticate with Minecraft
MinecraftLoginWithXBoxResponse minecraftResponse = HttpRequest.POST("https://api.minecraftservices.com/authentication/login_with_xbox")
.json(mapOf(pair("identityToken", "XBL3.0 x=" + uhs + ";" + xstsResponse.token)))
.getJson(MinecraftLoginWithXBoxResponse.class);
MinecraftLoginWithXBoxResponse minecraftResponse = HttpRequest
.POST("https://api.minecraftservices.com/authentication/login_with_xbox")
.json(mapOf(pair("identityToken", "XBL3.0 x=" + uhs + ";" + minecraftXstsResponse.token)))
.accept("application/json").getJson(MinecraftLoginWithXBoxResponse.class);
long notAfter = minecraftResponse.expiresIn * 1000L + System.currentTimeMillis();
// Checking Game Ownership
MinecraftStoreResponse storeResponse = HttpRequest.GET("https://api.minecraftservices.com/entitlements/mcstore")
MinecraftStoreResponse storeResponse = HttpRequest
.GET("https://api.minecraftservices.com/entitlements/mcstore")
.authorization(String.format("%s %s", minecraftResponse.tokenType, minecraftResponse.accessToken))
.getJson(MinecraftStoreResponse.class);
handleErrorResponse(storeResponse);
@ -133,7 +154,8 @@ public class MicrosoftService {
throw new NoCharacterException();
}
return new MicrosoftSession(minecraftResponse.tokenType, minecraftResponse.accessToken, new MicrosoftSession.User(minecraftResponse.username), null);
return new MicrosoftSession(minecraftResponse.tokenType, minecraftResponse.accessToken, notAfter,
new MicrosoftSession.User(minecraftResponse.username), null);
} catch (IOException e) {
throw new ServerDisconnectException(e);
} catch (InterruptedException e) {
@ -152,11 +174,14 @@ public class MicrosoftService {
public MicrosoftSession refresh(MicrosoftSession oldSession) throws AuthenticationException {
try {
// Get the profile
MinecraftProfileResponse profileResponse = HttpRequest.GET(NetworkUtils.toURL("https://api.minecraftservices.com/minecraft/profile"))
MinecraftProfileResponse profileResponse = HttpRequest
.GET(NetworkUtils.toURL("https://api.minecraftservices.com/minecraft/profile"))
.authorization(String.format("%s %s", oldSession.getTokenType(), oldSession.getAccessToken()))
.getJson(MinecraftProfileResponse.class);
handleErrorResponse(profileResponse);
return new MicrosoftSession(oldSession.getTokenType(), oldSession.getAccessToken(), oldSession.getUser(), new MicrosoftSession.GameProfile(profileResponse.id, profileResponse.name));
return new MicrosoftSession(oldSession.getTokenType(), oldSession.getAccessToken(),
oldSession.getNotAfter(), oldSession.getUser(),
new MicrosoftSession.GameProfile(profileResponse.id, profileResponse.name));
} catch (IOException e) {
throw new ServerDisconnectException(e);
} catch (JsonParseException e) {
@ -166,9 +191,9 @@ public class MicrosoftService {
public Optional<MinecraftProfileResponse> getCompleteProfile(String authorization) throws AuthenticationException {
try {
return Optional.ofNullable(HttpRequest.GET(NetworkUtils.toURL("https://api.minecraftservices.com/minecraft/profile"))
.authorization(authorization)
.getJson(MinecraftProfileResponse.class));
return Optional.ofNullable(
HttpRequest.GET(NetworkUtils.toURL("https://api.minecraftservices.com/minecraft/profile"))
.authorization(authorization).getJson(MinecraftProfileResponse.class));
} catch (IOException e) {
throw new ServerDisconnectException(e);
} catch (JsonParseException e) {
@ -182,13 +207,11 @@ public class MicrosoftService {
try {
HttpRequest.GET(NetworkUtils.toURL("https://api.minecraftservices.com/minecraft/profile"))
.authorization(String.format("%s %s", tokenType, accessToken))
.filter((url, responseCode) -> {
.authorization(String.format("%s %s", tokenType, accessToken)).filter((url, responseCode) -> {
if (responseCode / 100 == 4) {
throw new ResponseCodeException(url, responseCode);
}
})
.getString();
}).getString();
return true;
} catch (ResponseCodeException e) {
return false;
@ -211,13 +234,44 @@ public class MicrosoftService {
if (!profile.skins.isEmpty()) {
textures.put(TextureType.SKIN, new Texture(profile.skins.get(0).url, null));
}
// if (!profile.capes.isEmpty()) {
// textures.put(TextureType.CAPE, new Texture(profile.capes.get(0).url, null);
// }
// if (!profile.capes.isEmpty()) {
// textures.put(TextureType.CAPE, new Texture(profile.capes.get(0).url, null);
// }
return Optional.of(textures);
}
private static void getXBoxProfile(String uhs, String xstsToken) throws IOException {
HttpRequest.GET("https://profile.xboxlive.com/users/me/profile/settings",
pair("settings", "GameDisplayName,AppDisplayName,AppDisplayPicRaw,GameDisplayPicRaw,"
+ "PublicGamerpic,ShowUserAsAvatar,Gamerscore,Gamertag,ModernGamertag,ModernGamertagSuffix,"
+ "UniqueModernGamertag,AccountTier,TenureLevel,XboxOneRep,"
+ "PreferredColor,Location,Bio,Watermarks," + "RealName,RealNameOverride,IsQuarantined"))
.contentType("application/json").accept("application/json")
.authorization(String.format("XBL3.0 x=%s;%s", uhs, xstsToken)).header("x-xbl-contract-version", "3")
.getString();
}
private static MinecraftProfileResponse getMinecraftProfile(String tokenType, String accessToken)
throws IOException, AuthenticationException {
HttpURLConnection conn = HttpRequest.GET("https://api.minecraftservices.com/minecraft/profile")
.contentType("application/json").authorization(String.format("%s %s", tokenType, accessToken))
.createConnection();
int responseCode = conn.getResponseCode();
if (responseCode == HTTP_NOT_FOUND) {
throw new NoCharacterException();
}
String result = NetworkUtils.readData(conn);
return JsonUtils.fromNonNullJson(result, MinecraftProfileResponse.class);
}
/**
* Error response: {"error":"invalid_grant","error_description":"The provided
* value for the 'redirect_uri' is not valid. The value must exactly match the
* redirect URI used to obtain the authorization
* code.","correlation_id":"??????"}
*/
private static class LiveAuthorizationResponse {
@SerializedName("token_type")
String tokenType;
@ -245,6 +299,18 @@ public class MicrosoftService {
List<Map<Object, Object>> xui;
}
/**
*
* Success Response: { "IssueInstant":"2020-12-07T19:52:08.4463796Z",
* "NotAfter":"2020-12-21T19:52:08.4463796Z", "Token":"token", "DisplayClaims":{
* "xui":[ { "uhs":"userhash" } ] } }
*
* Error response: { "Identity":"0", "XErr":2148916238, "Message":"",
* "Redirect":"https://start.ui.xboxlive.com/AddChildToFamily" }
*
* XErr Candidates: 2148916233 = missing XBox account 2148916238 = child account
* not linked to a family
*/
private static class XBoxLiveAuthenticationResponse {
@SerializedName("IssueInstant")
String issueInstant;
@ -341,8 +407,36 @@ public class MicrosoftService {
public String developerMessage;
}
public interface WebViewCallback {
CompletableFuture<String> show(MicrosoftService service, Predicate<String> urlTester, String initialURL);
public interface OAuthCallback {
/**
* Start OAuth callback server at localhost.
*
* @throws IOException if an I/O error occurred.
*/
OAuthSession startServer() throws IOException;
/**
* Open browser
*
* @param url OAuth url.
*/
void openBrowser(String url) throws IOException;
}
public interface OAuthSession {
String getRedirectURI();
String getClientSecret() throws IOException;
/**
* Wait for authentication
*
* @return authentication code
* @throws InterruptedException if interrupted
* @throws ExecutionException if an I/O error occurred.
*/
String waitFor() throws InterruptedException, ExecutionException;
}
}

View File

@ -1,6 +1,6 @@
/*
* Hello Minecraft! Launcher
* Copyright (C) 2020 huangyuhui <huanghongxun2008@126.com> and contributors
* Copyright (C) 2021 huangyuhui <huanghongxun2008@126.com> and contributors
*
* This program is free software: you can redistribute it and/or modify
* it under the terms of the GNU General Public License as published by
@ -30,13 +30,15 @@ import static org.jackhuang.hmcl.util.Pair.pair;
public class MicrosoftSession {
private final String tokenType;
private final long notAfter;
private final String accessToken;
private final User user;
private final GameProfile profile;
public MicrosoftSession(String tokenType, String accessToken, User user, GameProfile profile) {
public MicrosoftSession(String tokenType, String accessToken, long notAfter, User user, GameProfile profile) {
this.tokenType = tokenType;
this.accessToken = accessToken;
this.notAfter = notAfter;
this.user = user;
this.profile = profile;
}
@ -49,6 +51,10 @@ public class MicrosoftSession {
return accessToken;
}
public long getNotAfter() {
return notAfter;
}
public String getAuthorization() {
return String.format("%s %s", getTokenType(), getAccessToken());
}
@ -62,25 +68,27 @@ public class MicrosoftSession {
}
public static MicrosoftSession fromStorage(Map<?, ?> storage) {
UUID uuid = tryCast(storage.get("uuid"), String.class).map(UUIDTypeAdapter::fromString).orElseThrow(() -> new IllegalArgumentException("uuid is missing"));
String name = tryCast(storage.get("displayName"), String.class).orElseThrow(() -> new IllegalArgumentException("displayName is missing"));
String tokenType = tryCast(storage.get("tokenType"), String.class).orElseThrow(() -> new IllegalArgumentException("tokenType is missing"));
String accessToken = tryCast(storage.get("accessToken"), String.class).orElseThrow(() -> new IllegalArgumentException("accessToken is missing"));
String userId = tryCast(storage.get("userid"), String.class).orElseThrow(() -> new IllegalArgumentException("userid is missing"));
return new MicrosoftSession(tokenType, accessToken, new User(userId), new GameProfile(uuid, name));
UUID uuid = tryCast(storage.get("uuid"), String.class).map(UUIDTypeAdapter::fromString)
.orElseThrow(() -> new IllegalArgumentException("uuid is missing"));
String name = tryCast(storage.get("displayName"), String.class)
.orElseThrow(() -> new IllegalArgumentException("displayName is missing"));
String tokenType = tryCast(storage.get("tokenType"), String.class)
.orElseThrow(() -> new IllegalArgumentException("tokenType is missing"));
String accessToken = tryCast(storage.get("accessToken"), String.class)
.orElseThrow(() -> new IllegalArgumentException("accessToken is missing"));
Long notAfter = tryCast(storage.get("notAfter"), Long.class).orElse(0L);
String userId = tryCast(storage.get("userid"), String.class)
.orElseThrow(() -> new IllegalArgumentException("userid is missing"));
return new MicrosoftSession(tokenType, accessToken, notAfter, new User(userId), new GameProfile(uuid, name));
}
public Map<Object, Object> toStorage() {
requireNonNull(profile);
requireNonNull(user);
return mapOf(
pair("tokenType", tokenType),
pair("accessToken", accessToken),
pair("uuid", UUIDTypeAdapter.fromUUID(profile.getId())),
pair("displayName", profile.getName()),
pair("userid", user.id)
);
return mapOf(pair("tokenType", tokenType), pair("accessToken", accessToken),
pair("uuid", UUIDTypeAdapter.fromUUID(profile.getId())), pair("displayName", profile.getName()),
pair("userid", user.id));
}
public AuthInfo toAuthInfo() {

View File

@ -47,6 +47,17 @@ public final class Lang {
*/
@SafeVarargs
public static <K, V> Map<K, V> mapOf(Pair<K, V>... pairs) {
return mapOf(Arrays.asList(pairs));
}
/**
* Construct a mutable map by given key-value pairs.
* @param pairs entries in the new map
* @param <K> the type of keys
* @param <V> the type of values
* @return the map which contains data in {@code pairs}.
*/
public static <K, V> Map<K, V> mapOf(Iterable<Pair<K, V>> pairs) {
Map<K, V> map = new LinkedHashMap<>();
for (Pair<K, V> pair : pairs)
map.put(pair.getKey(), pair.getValue());

View File

@ -18,6 +18,7 @@
package org.jackhuang.hmcl.util.io;
import com.google.gson.JsonParseException;
import org.jackhuang.hmcl.util.Pair;
import org.jackhuang.hmcl.util.function.ExceptionalBiConsumer;
import org.jackhuang.hmcl.util.gson.JsonUtils;
@ -30,6 +31,7 @@ import java.util.HashMap;
import java.util.Map;
import static java.nio.charset.StandardCharsets.UTF_8;
import static org.jackhuang.hmcl.util.Lang.mapOf;
import static org.jackhuang.hmcl.util.gson.JsonUtils.GSON;
import static org.jackhuang.hmcl.util.io.NetworkUtils.createHttpConnection;
import static org.jackhuang.hmcl.util.io.NetworkUtils.resolveConnection;
@ -73,7 +75,7 @@ public abstract class HttpRequest {
return this;
}
protected HttpURLConnection createConnection() throws IOException {
public HttpURLConnection createConnection() throws IOException {
HttpURLConnection con = createHttpConnection(url);
con.setRequestMethod(method);
for (Map.Entry<String, String> entry : headers.entrySet()) {
@ -102,8 +104,7 @@ public abstract class HttpRequest {
}
public <T> HttpPostRequest json(Object payload) throws JsonParseException {
return string(payload instanceof String ? (String) payload : GSON.toJson(payload),
"application/json");
return string(payload instanceof String ? (String) payload : GSON.toJson(payload), "application/json");
}
public HttpPostRequest form(Map<String, String> params) {
@ -136,6 +137,11 @@ public abstract class HttpRequest {
return GET(new URL(url));
}
@SafeVarargs
public static HttpGetRequest GET(String url, Pair<String, String>... query) throws MalformedURLException {
return GET(new URL(NetworkUtils.withQuery(url, mapOf(query))));
}
public static HttpGetRequest GET(URL url) {
return new HttpGetRequest(url);
}

View File

@ -1,6 +1,6 @@
/*
* Hello Minecraft! Launcher
* Copyright (C) 2020 huangyuhui <huanghongxun2008@126.com> and contributors
* Copyright (C) 2021 huangyuhui <huanghongxun2008@126.com> and contributors
*
* This program is free software: you can redistribute it and/or modify
* it under the terms of the GNU General Public License as published by
@ -17,14 +17,15 @@
*/
package org.jackhuang.hmcl.util.io;
import org.jackhuang.hmcl.util.Pair;
import java.io.*;
import java.net.*;
import java.util.List;
import java.util.Locale;
import java.util.Map;
import java.util.*;
import java.util.Map.Entry;
import static java.nio.charset.StandardCharsets.UTF_8;
import static org.jackhuang.hmcl.util.Pair.pair;
import static org.jackhuang.hmcl.util.StringUtils.*;
/**
@ -32,6 +33,8 @@ import static org.jackhuang.hmcl.util.StringUtils.*;
* @author huangyuhui
*/
public final class NetworkUtils {
public static final String PARAMETER_SEPARATOR = "&";
public static final String NAME_VALUE_SEPARATOR = "=";
private NetworkUtils() {
}
@ -48,15 +51,38 @@ public final class NetworkUtils {
}
first = false;
} else {
sb.append('&');
sb.append(PARAMETER_SEPARATOR);
}
sb.append(encodeURL(param.getKey()));
sb.append('=');
sb.append(NAME_VALUE_SEPARATOR);
sb.append(encodeURL(param.getValue()));
}
return sb.toString();
}
public static List<Pair<String, String>> parseQuery(URI uri) {
return parseQuery(uri.getRawQuery());
}
public static List<Pair<String, String>> parseQuery(String queryParameterString) {
List<Pair<String, String>> result = new ArrayList<>();
try (Scanner scanner = new Scanner(queryParameterString)) {
scanner.useDelimiter("&");
while (scanner.hasNext()) {
String[] nameValue = scanner.next().split(NAME_VALUE_SEPARATOR);
if (nameValue.length <= 0 || nameValue.length > 2) {
throw new IllegalArgumentException("bad query string");
}
String name = decodeURL(nameValue[0]);
String value = nameValue.length == 2 ? decodeURL(nameValue[1]) : null;
result.add(pair(name, value));
}
}
return result;
}
public static URLConnection createConnection(URL url) throws IOException {
URLConnection connection = url.openConnection();
connection.setUseCaches(false);
@ -71,7 +97,8 @@ public final class NetworkUtils {
}
/**
* @see <a href="https://github.com/curl/curl/blob/3f7b1bb89f92c13e69ee51b710ac54f775aab320/lib/transfer.c#L1427-L1461">Curl</a>
* @see <a href=
* "https://github.com/curl/curl/blob/3f7b1bb89f92c13e69ee51b710ac54f775aab320/lib/transfer.c#L1427-L1461">Curl</a>
* @param location the url to be URL encoded
* @return encoded URL
*/
@ -81,8 +108,10 @@ public final class NetworkUtils {
for (char ch : location.toCharArray()) {
switch (ch) {
case ' ':
if (left) sb.append("%20");
else sb.append('+');
if (left)
sb.append("%20");
else
sb.append('+');
break;
case '?':
left = false;
@ -100,7 +129,9 @@ public final class NetworkUtils {
}
/**
* This method is a work-around that aims to solve problem when "Location" in stupid server's response is not encoded.
* This method is a work-around that aims to solve problem when "Location" in
* stupid server's response is not encoded.
*
* @see <a href="https://github.com/curl/curl/issues/473">Issue with libcurl</a>
* @param conn the stupid http connection.
* @return manually redirected http connection.
@ -125,8 +156,10 @@ public final class NetworkUtils {
throw new IOException("Too much redirects");
}
HttpURLConnection redirected = (HttpURLConnection) new URL(conn.getURL(), encodeLocation(newURL)).openConnection();
properties.forEach((key, value) -> value.forEach(element -> redirected.addRequestProperty(key, element)));
HttpURLConnection redirected = (HttpURLConnection) new URL(conn.getURL(), encodeLocation(newURL))
.openConnection();
properties
.forEach((key, value) -> value.forEach(element -> redirected.addRequestProperty(key, element)));
redirected.setRequestMethod(method);
conn = redirected;
++redirect;
@ -178,7 +211,8 @@ public final class NetworkUtils {
}
} catch (IOException e) {
try (InputStream stderr = con.getErrorStream()) {
if (stderr == null) throw e;
if (stderr == null)
throw e;
return IOUtils.readFullyAsString(stderr, UTF_8);
}
}