mirror of
https://github.com/HMCL-dev/HMCL.git
synced 2025-09-14 06:17:47 -04:00
feat: authenticate Microsoft accounts via external browser
This commit is contained in:
parent
b39508922f
commit
5890f0c782
@ -36,6 +36,7 @@ def buildnumber = System.getenv("BUILD_NUMBER") ?: dev ?: "SNAPSHOT"
|
|||||||
if (System.getenv("BUILD_NUMBER") != null && System.getenv("BUILD_NUMBER_OFFSET") != null)
|
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()
|
buildnumber = (Integer.parseInt(System.getenv("BUILD_NUMBER")) - Integer.parseInt(System.getenv("BUILD_NUMBER_OFFSET"))).toString()
|
||||||
def versionroot = System.getenv("VERSION_ROOT") ?: "3.3"
|
def versionroot = System.getenv("VERSION_ROOT") ?: "3.3"
|
||||||
|
def microsoftAuthSecret = System.getenv("MICROSOFT_AUTH_SECRET") ?: ""
|
||||||
version = versionroot + '.' + buildnumber
|
version = versionroot + '.' + buildnumber
|
||||||
|
|
||||||
mainClassName = 'org.jackhuang.hmcl.Main'
|
mainClassName = 'org.jackhuang.hmcl.Main'
|
||||||
@ -120,10 +121,11 @@ shadowJar {
|
|||||||
classifier = null
|
classifier = null
|
||||||
|
|
||||||
manifest {
|
manifest {
|
||||||
attributes 'Created-By': 'Copyright(c) 2013-2020 huangyuhui.',
|
attributes 'Created-By': 'Copyright(c) 2013-2021 huangyuhui.',
|
||||||
'Main-Class': mainClassName,
|
'Main-Class': mainClassName,
|
||||||
'Multi-Release': 'true',
|
'Multi-Release': 'true',
|
||||||
'Implementation-Version': project.version,
|
'Implementation-Version': project.version,
|
||||||
|
'Microsoft-Auth-Secret': microsoftAuthSecret,
|
||||||
'Class-Path': 'pack200.jar',
|
'Class-Path': 'pack200.jar',
|
||||||
'Add-Opens': [
|
'Add-Opens': [
|
||||||
'java.base/java.lang',
|
'java.base/java.lang',
|
||||||
|
@ -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);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
@ -1,6 +1,6 @@
|
|||||||
/*
|
/*
|
||||||
* Hello Minecraft! Launcher
|
* 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
|
* 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
|
* 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.Account;
|
||||||
import org.jackhuang.hmcl.auth.AccountFactory;
|
import org.jackhuang.hmcl.auth.AccountFactory;
|
||||||
import org.jackhuang.hmcl.auth.AuthenticationException;
|
import org.jackhuang.hmcl.auth.AuthenticationException;
|
||||||
import org.jackhuang.hmcl.auth.authlibinjector.AuthlibInjectorAccount;
|
import org.jackhuang.hmcl.auth.authlibinjector.*;
|
||||||
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.microsoft.MicrosoftAccount;
|
import org.jackhuang.hmcl.auth.microsoft.MicrosoftAccount;
|
||||||
import org.jackhuang.hmcl.auth.microsoft.MicrosoftAccountFactory;
|
import org.jackhuang.hmcl.auth.microsoft.MicrosoftAccountFactory;
|
||||||
import org.jackhuang.hmcl.auth.microsoft.MicrosoftService;
|
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.offline.OfflineAccountFactory;
|
||||||
import org.jackhuang.hmcl.auth.yggdrasil.YggdrasilAccount;
|
import org.jackhuang.hmcl.auth.yggdrasil.YggdrasilAccount;
|
||||||
import org.jackhuang.hmcl.auth.yggdrasil.YggdrasilAccountFactory;
|
import org.jackhuang.hmcl.auth.yggdrasil.YggdrasilAccountFactory;
|
||||||
|
import org.jackhuang.hmcl.game.MicrosoftAuthenticationServer;
|
||||||
import org.jackhuang.hmcl.task.Schedulers;
|
import org.jackhuang.hmcl.task.Schedulers;
|
||||||
import org.jackhuang.hmcl.ui.account.MicrosoftAccountLoginStage;
|
|
||||||
|
|
||||||
import java.io.IOException;
|
import java.io.IOException;
|
||||||
import java.nio.file.Paths;
|
import java.nio.file.Paths;
|
||||||
@ -84,7 +78,7 @@ public final class Accounts {
|
|||||||
public static final OfflineAccountFactory FACTORY_OFFLINE = OfflineAccountFactory.INSTANCE;
|
public static final OfflineAccountFactory FACTORY_OFFLINE = OfflineAccountFactory.INSTANCE;
|
||||||
public static final YggdrasilAccountFactory FACTORY_MOJANG = YggdrasilAccountFactory.MOJANG;
|
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 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);
|
public static final List<AccountFactory<?>> FACTORIES = immutableListOf(FACTORY_OFFLINE, FACTORY_MOJANG, FACTORY_MICROSOFT, FACTORY_AUTHLIB_INJECTOR);
|
||||||
|
|
||||||
// ==== login type / account factory mapping ====
|
// ==== login type / account factory mapping ====
|
||||||
|
@ -35,7 +35,6 @@ import org.jackhuang.hmcl.setting.Profiles;
|
|||||||
import org.jackhuang.hmcl.task.Task;
|
import org.jackhuang.hmcl.task.Task;
|
||||||
import org.jackhuang.hmcl.task.TaskExecutor;
|
import org.jackhuang.hmcl.task.TaskExecutor;
|
||||||
import org.jackhuang.hmcl.ui.account.AuthlibInjectorServersPage;
|
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.animation.ContainerAnimations;
|
||||||
import org.jackhuang.hmcl.ui.construct.InputDialogPane;
|
import org.jackhuang.hmcl.ui.construct.InputDialogPane;
|
||||||
import org.jackhuang.hmcl.ui.construct.MessageDialogPane;
|
import org.jackhuang.hmcl.ui.construct.MessageDialogPane;
|
||||||
@ -151,7 +150,6 @@ public final class Controllers {
|
|||||||
Logging.LOG.info("Start initializing application");
|
Logging.LOG.info("Start initializing application");
|
||||||
|
|
||||||
Controllers.stage = stage;
|
Controllers.stage = stage;
|
||||||
MicrosoftAccountLoginStage.INSTANCE.initOwner(stage);
|
|
||||||
|
|
||||||
stage.setHeight(config().getHeight());
|
stage.setHeight(config().getHeight());
|
||||||
stageHeight.bind(stage.heightProperty());
|
stageHeight.bind(stage.heightProperty());
|
||||||
|
@ -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;
|
|
||||||
}
|
|
||||||
}
|
|
37
HMCL/src/main/resources/assets/microsoft_auth.html
Normal file
37
HMCL/src/main/resources/assets/microsoft_auth.html
Normal 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>
|
@ -12,6 +12,7 @@ dependencies {
|
|||||||
api group: 'org.jenkins-ci', name: 'constant-pool-scanner', version: '1.2'
|
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.github.steveice10', name: 'opennbt', version: '1.1'
|
||||||
api group: 'com.nqzero', name: 'permit-reflect', version: '0.3'
|
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.jetbrains', name: 'annotations', version: '16.0.3'
|
||||||
|
|
||||||
// compileOnlyApi group: 'org.openjfx', name: 'javafx-base', version: '15', classifier: 'win'
|
// compileOnlyApi group: 'org.openjfx', name: 'javafx-base', version: '15', classifier: 'win'
|
||||||
|
@ -1,6 +1,6 @@
|
|||||||
/*
|
/*
|
||||||
* Hello Minecraft! Launcher
|
* 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
|
* 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
|
* 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.JsonParseException;
|
||||||
import com.google.gson.annotations.SerializedName;
|
import com.google.gson.annotations.SerializedName;
|
||||||
import org.jackhuang.hmcl.auth.*;
|
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.JsonUtils;
|
||||||
import org.jackhuang.hmcl.util.gson.TolerableValidationException;
|
import org.jackhuang.hmcl.util.gson.TolerableValidationException;
|
||||||
import org.jackhuang.hmcl.util.gson.Validation;
|
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 org.jackhuang.hmcl.util.javafx.ObservableOptionalCache;
|
||||||
|
|
||||||
import java.io.IOException;
|
import java.io.IOException;
|
||||||
|
import java.net.HttpURLConnection;
|
||||||
import java.util.*;
|
import java.util.*;
|
||||||
import java.util.concurrent.CompletableFuture;
|
|
||||||
import java.util.concurrent.ExecutionException;
|
import java.util.concurrent.ExecutionException;
|
||||||
import java.util.concurrent.ThreadPoolExecutor;
|
import java.util.concurrent.ThreadPoolExecutor;
|
||||||
import java.util.concurrent.TimeUnit;
|
import java.util.concurrent.TimeUnit;
|
||||||
import java.util.function.Predicate;
|
|
||||||
import java.util.logging.Level;
|
import java.util.logging.Level;
|
||||||
import java.util.regex.Matcher;
|
|
||||||
import java.util.regex.Pattern;
|
import java.util.regex.Pattern;
|
||||||
|
|
||||||
|
import static java.net.HttpURLConnection.HTTP_NOT_FOUND;
|
||||||
import static java.util.Objects.requireNonNull;
|
import static java.util.Objects.requireNonNull;
|
||||||
import static org.jackhuang.hmcl.util.Lang.mapOf;
|
import static org.jackhuang.hmcl.util.Lang.mapOf;
|
||||||
import static org.jackhuang.hmcl.util.Lang.threadPool;
|
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;
|
import static org.jackhuang.hmcl.util.Pair.pair;
|
||||||
|
|
||||||
public class MicrosoftService {
|
public class MicrosoftService {
|
||||||
private static final ThreadPoolExecutor POOL = threadPool("MicrosoftProfileProperties", true, 2, 10, TimeUnit.SECONDS);
|
private static final String CLIENT_ID = "6a3728d6-27a3-4180-99bb-479895b8f88e";
|
||||||
private static final Pattern OAUTH_URL_PATTERN = Pattern.compile("^https://login\\.live\\.com/oauth20_desktop\\.srf\\?code=(.*?)&lc=(.*?)$");
|
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;
|
private final ObservableOptionalCache<String, MinecraftProfileResponse, AuthenticationException> profileRepository;
|
||||||
|
|
||||||
public MicrosoftService(WebViewCallback callback) {
|
public MicrosoftService(OAuthCallback callback) {
|
||||||
this.callback = callback;
|
this.callback = requireNonNull(callback);
|
||||||
this.profileRepository = new ObservableOptionalCache<>(
|
this.profileRepository = new ObservableOptionalCache<>(authorization -> {
|
||||||
authorization -> {
|
LOG.info("Fetching properties");
|
||||||
LOG.info("Fetching properties");
|
return getCompleteProfile(authorization);
|
||||||
return getCompleteProfile(authorization);
|
}, (uuid, e) -> LOG.log(Level.WARNING, "Failed to fetch properties of " + uuid, e), POOL);
|
||||||
},
|
|
||||||
(uuid, e) -> LOG.log(Level.WARNING, "Failed to fetch properties of " + uuid, e),
|
|
||||||
POOL);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
public ObservableOptionalCache<String, MinecraftProfileResponse, AuthenticationException> getProfileRepository() {
|
public ObservableOptionalCache<String, MinecraftProfileResponse, AuthenticationException> getProfileRepository() {
|
||||||
@ -70,62 +75,78 @@ public class MicrosoftService {
|
|||||||
}
|
}
|
||||||
|
|
||||||
public MicrosoftSession authenticate() throws AuthenticationException {
|
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 {
|
try {
|
||||||
// Microsoft OAuth Flow
|
// Microsoft OAuth Flow
|
||||||
String code = callback.show(this, urlToBeTested -> OAUTH_URL_PATTERN.matcher(urlToBeTested).find(), "https://login.live.com/oauth20_authorize.srf" +
|
OAuthSession session = callback.startServer();
|
||||||
"?client_id=00000000402b5328" +
|
callback.openBrowser(NetworkUtils.withQuery(AUTHORIZATION_URL,
|
||||||
"&response_type=code" +
|
mapOf(pair("client_id", CLIENT_ID), pair("response_type", "code"),
|
||||||
"&scope=service%3A%3Auser.auth.xboxlive.com%3A%3AMBI_SSL" +
|
pair("redirect_uri", session.getRedirectURI()), pair("scope", SCOPE),
|
||||||
"&redirect_uri=https%3A%2F%2Flogin.live.com%2Foauth20_desktop.srf")
|
pair("prompt", "select_account"))));
|
||||||
.thenApply(url -> {
|
String code = session.waitFor();
|
||||||
Matcher matcher = OAUTH_URL_PATTERN.matcher(url);
|
|
||||||
matcher.find();
|
|
||||||
return matcher.group(1);
|
|
||||||
})
|
|
||||||
.get();
|
|
||||||
|
|
||||||
// Authorization Code -> Token
|
// Authorization Code -> Token
|
||||||
String responseText = HttpRequest.POST("https://login.live.com/oauth20_token.srf").form(mapOf(
|
String responseText = HttpRequest.POST("https://login.live.com/oauth20_token.srf")
|
||||||
pair("client_id", "00000000402b5328"),
|
.form(mapOf(pair("client_id", CLIENT_ID), pair("code", code),
|
||||||
pair("code", code),
|
pair("grant_type", "authorization_code"), pair("client_secret", session.getClientSecret()),
|
||||||
pair("grant_type", "authorization_code"),
|
pair("redirect_uri", session.getRedirectURI()), pair("scope", SCOPE)))
|
||||||
pair("redirect_uri", "https://login.live.com/oauth20_desktop.srf"),
|
.getString();
|
||||||
pair("scope", "service::user.auth.xboxlive.com::MBI_SSL"))).getString();
|
LiveAuthorizationResponse response = JsonUtils.fromNonNullJson(responseText,
|
||||||
LiveAuthorizationResponse response = JsonUtils.fromNonNullJson(responseText, LiveAuthorizationResponse.class);
|
LiveAuthorizationResponse.class);
|
||||||
|
|
||||||
// Authenticate with XBox Live
|
// 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(
|
.json(mapOf(
|
||||||
pair("Properties", mapOf(
|
pair("Properties",
|
||||||
pair("AuthMethod", "RPS"),
|
mapOf(pair("AuthMethod", "RPS"), pair("SiteName", "user.auth.xboxlive.com"),
|
||||||
pair("SiteName", "user.auth.xboxlive.com"),
|
pair("RpsTicket", "d=" + response.accessToken))),
|
||||||
pair("RpsTicket", response.accessToken)
|
pair("RelyingParty", "http://auth.xboxlive.com"), pair("TokenType", "JWT")))
|
||||||
)),
|
.accept("application/json").getJson(XBoxLiveAuthenticationResponse.class);
|
||||||
pair("RelyingParty", "http://auth.xboxlive.com"),
|
|
||||||
pair("TokenType", "JWT")))
|
|
||||||
.getJson(XBoxLiveAuthenticationResponse.class);
|
|
||||||
String uhs = (String) xboxResponse.displayClaims.xui.get(0).get("uhs");
|
String uhs = (String) xboxResponse.displayClaims.xui.get(0).get("uhs");
|
||||||
|
|
||||||
// Authenticate with XSTS
|
// Authenticate Minecraft with XSTS
|
||||||
XBoxLiveAuthenticationResponse xstsResponse = HttpRequest.POST("https://xsts.auth.xboxlive.com/xsts/authorize")
|
XBoxLiveAuthenticationResponse minecraftXstsResponse = HttpRequest
|
||||||
|
.POST("https://xsts.auth.xboxlive.com/xsts/authorize")
|
||||||
.json(mapOf(
|
.json(mapOf(
|
||||||
pair("Properties", mapOf(
|
pair("Properties",
|
||||||
pair("SandboxId", "RETAIL"),
|
mapOf(pair("SandboxId", "RETAIL"),
|
||||||
pair("UserTokens", Collections.singletonList(xboxResponse.token))
|
pair("UserTokens", Collections.singletonList(xboxResponse.token)))),
|
||||||
)),
|
pair("RelyingParty", "rp://api.minecraftservices.com/"), pair("TokenType", "JWT")))
|
||||||
pair("RelyingParty", "rp://api.minecraftservices.com/"),
|
|
||||||
pair("TokenType", "JWT")))
|
|
||||||
.getJson(XBoxLiveAuthenticationResponse.class);
|
.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
|
// Authenticate with Minecraft
|
||||||
MinecraftLoginWithXBoxResponse minecraftResponse = HttpRequest.POST("https://api.minecraftservices.com/authentication/login_with_xbox")
|
MinecraftLoginWithXBoxResponse minecraftResponse = HttpRequest
|
||||||
.json(mapOf(pair("identityToken", "XBL3.0 x=" + uhs + ";" + xstsResponse.token)))
|
.POST("https://api.minecraftservices.com/authentication/login_with_xbox")
|
||||||
.getJson(MinecraftLoginWithXBoxResponse.class);
|
.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
|
// 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))
|
.authorization(String.format("%s %s", minecraftResponse.tokenType, minecraftResponse.accessToken))
|
||||||
.getJson(MinecraftStoreResponse.class);
|
.getJson(MinecraftStoreResponse.class);
|
||||||
handleErrorResponse(storeResponse);
|
handleErrorResponse(storeResponse);
|
||||||
@ -133,7 +154,8 @@ public class MicrosoftService {
|
|||||||
throw new NoCharacterException();
|
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) {
|
} catch (IOException e) {
|
||||||
throw new ServerDisconnectException(e);
|
throw new ServerDisconnectException(e);
|
||||||
} catch (InterruptedException e) {
|
} catch (InterruptedException e) {
|
||||||
@ -152,11 +174,14 @@ public class MicrosoftService {
|
|||||||
public MicrosoftSession refresh(MicrosoftSession oldSession) throws AuthenticationException {
|
public MicrosoftSession refresh(MicrosoftSession oldSession) throws AuthenticationException {
|
||||||
try {
|
try {
|
||||||
// Get the profile
|
// 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()))
|
.authorization(String.format("%s %s", oldSession.getTokenType(), oldSession.getAccessToken()))
|
||||||
.getJson(MinecraftProfileResponse.class);
|
.getJson(MinecraftProfileResponse.class);
|
||||||
handleErrorResponse(profileResponse);
|
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) {
|
} catch (IOException e) {
|
||||||
throw new ServerDisconnectException(e);
|
throw new ServerDisconnectException(e);
|
||||||
} catch (JsonParseException e) {
|
} catch (JsonParseException e) {
|
||||||
@ -166,9 +191,9 @@ public class MicrosoftService {
|
|||||||
|
|
||||||
public Optional<MinecraftProfileResponse> getCompleteProfile(String authorization) throws AuthenticationException {
|
public Optional<MinecraftProfileResponse> getCompleteProfile(String authorization) throws AuthenticationException {
|
||||||
try {
|
try {
|
||||||
return Optional.ofNullable(HttpRequest.GET(NetworkUtils.toURL("https://api.minecraftservices.com/minecraft/profile"))
|
return Optional.ofNullable(
|
||||||
.authorization(authorization)
|
HttpRequest.GET(NetworkUtils.toURL("https://api.minecraftservices.com/minecraft/profile"))
|
||||||
.getJson(MinecraftProfileResponse.class));
|
.authorization(authorization).getJson(MinecraftProfileResponse.class));
|
||||||
} catch (IOException e) {
|
} catch (IOException e) {
|
||||||
throw new ServerDisconnectException(e);
|
throw new ServerDisconnectException(e);
|
||||||
} catch (JsonParseException e) {
|
} catch (JsonParseException e) {
|
||||||
@ -182,13 +207,11 @@ public class MicrosoftService {
|
|||||||
|
|
||||||
try {
|
try {
|
||||||
HttpRequest.GET(NetworkUtils.toURL("https://api.minecraftservices.com/minecraft/profile"))
|
HttpRequest.GET(NetworkUtils.toURL("https://api.minecraftservices.com/minecraft/profile"))
|
||||||
.authorization(String.format("%s %s", tokenType, accessToken))
|
.authorization(String.format("%s %s", tokenType, accessToken)).filter((url, responseCode) -> {
|
||||||
.filter((url, responseCode) -> {
|
|
||||||
if (responseCode / 100 == 4) {
|
if (responseCode / 100 == 4) {
|
||||||
throw new ResponseCodeException(url, responseCode);
|
throw new ResponseCodeException(url, responseCode);
|
||||||
}
|
}
|
||||||
})
|
}).getString();
|
||||||
.getString();
|
|
||||||
return true;
|
return true;
|
||||||
} catch (ResponseCodeException e) {
|
} catch (ResponseCodeException e) {
|
||||||
return false;
|
return false;
|
||||||
@ -211,13 +234,44 @@ public class MicrosoftService {
|
|||||||
if (!profile.skins.isEmpty()) {
|
if (!profile.skins.isEmpty()) {
|
||||||
textures.put(TextureType.SKIN, new Texture(profile.skins.get(0).url, null));
|
textures.put(TextureType.SKIN, new Texture(profile.skins.get(0).url, null));
|
||||||
}
|
}
|
||||||
// if (!profile.capes.isEmpty()) {
|
// if (!profile.capes.isEmpty()) {
|
||||||
// textures.put(TextureType.CAPE, new Texture(profile.capes.get(0).url, null);
|
// textures.put(TextureType.CAPE, new Texture(profile.capes.get(0).url, null);
|
||||||
// }
|
// }
|
||||||
|
|
||||||
return Optional.of(textures);
|
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 {
|
private static class LiveAuthorizationResponse {
|
||||||
@SerializedName("token_type")
|
@SerializedName("token_type")
|
||||||
String tokenType;
|
String tokenType;
|
||||||
@ -245,6 +299,18 @@ public class MicrosoftService {
|
|||||||
List<Map<Object, Object>> xui;
|
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 {
|
private static class XBoxLiveAuthenticationResponse {
|
||||||
@SerializedName("IssueInstant")
|
@SerializedName("IssueInstant")
|
||||||
String issueInstant;
|
String issueInstant;
|
||||||
@ -341,8 +407,36 @@ public class MicrosoftService {
|
|||||||
public String developerMessage;
|
public String developerMessage;
|
||||||
}
|
}
|
||||||
|
|
||||||
public interface WebViewCallback {
|
public interface OAuthCallback {
|
||||||
CompletableFuture<String> show(MicrosoftService service, Predicate<String> urlTester, String initialURL);
|
/**
|
||||||
|
* 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;
|
||||||
}
|
}
|
||||||
|
|
||||||
}
|
}
|
||||||
|
@ -1,6 +1,6 @@
|
|||||||
/*
|
/*
|
||||||
* Hello Minecraft! Launcher
|
* 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
|
* 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
|
* 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 {
|
public class MicrosoftSession {
|
||||||
private final String tokenType;
|
private final String tokenType;
|
||||||
|
private final long notAfter;
|
||||||
private final String accessToken;
|
private final String accessToken;
|
||||||
private final User user;
|
private final User user;
|
||||||
private final GameProfile profile;
|
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.tokenType = tokenType;
|
||||||
this.accessToken = accessToken;
|
this.accessToken = accessToken;
|
||||||
|
this.notAfter = notAfter;
|
||||||
this.user = user;
|
this.user = user;
|
||||||
this.profile = profile;
|
this.profile = profile;
|
||||||
}
|
}
|
||||||
@ -49,6 +51,10 @@ public class MicrosoftSession {
|
|||||||
return accessToken;
|
return accessToken;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
public long getNotAfter() {
|
||||||
|
return notAfter;
|
||||||
|
}
|
||||||
|
|
||||||
public String getAuthorization() {
|
public String getAuthorization() {
|
||||||
return String.format("%s %s", getTokenType(), getAccessToken());
|
return String.format("%s %s", getTokenType(), getAccessToken());
|
||||||
}
|
}
|
||||||
@ -62,25 +68,27 @@ public class MicrosoftSession {
|
|||||||
}
|
}
|
||||||
|
|
||||||
public static MicrosoftSession fromStorage(Map<?, ?> storage) {
|
public static MicrosoftSession fromStorage(Map<?, ?> storage) {
|
||||||
UUID uuid = tryCast(storage.get("uuid"), String.class).map(UUIDTypeAdapter::fromString).orElseThrow(() -> new IllegalArgumentException("uuid is missing"));
|
UUID uuid = tryCast(storage.get("uuid"), String.class).map(UUIDTypeAdapter::fromString)
|
||||||
String name = tryCast(storage.get("displayName"), String.class).orElseThrow(() -> new IllegalArgumentException("displayName is missing"));
|
.orElseThrow(() -> new IllegalArgumentException("uuid is missing"));
|
||||||
String tokenType = tryCast(storage.get("tokenType"), String.class).orElseThrow(() -> new IllegalArgumentException("tokenType is missing"));
|
String name = tryCast(storage.get("displayName"), String.class)
|
||||||
String accessToken = tryCast(storage.get("accessToken"), String.class).orElseThrow(() -> new IllegalArgumentException("accessToken is missing"));
|
.orElseThrow(() -> new IllegalArgumentException("displayName is missing"));
|
||||||
String userId = tryCast(storage.get("userid"), String.class).orElseThrow(() -> new IllegalArgumentException("userid is missing"));
|
String tokenType = tryCast(storage.get("tokenType"), String.class)
|
||||||
return new MicrosoftSession(tokenType, accessToken, new User(userId), new GameProfile(uuid, name));
|
.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() {
|
public Map<Object, Object> toStorage() {
|
||||||
requireNonNull(profile);
|
requireNonNull(profile);
|
||||||
requireNonNull(user);
|
requireNonNull(user);
|
||||||
|
|
||||||
return mapOf(
|
return mapOf(pair("tokenType", tokenType), pair("accessToken", accessToken),
|
||||||
pair("tokenType", tokenType),
|
pair("uuid", UUIDTypeAdapter.fromUUID(profile.getId())), pair("displayName", profile.getName()),
|
||||||
pair("accessToken", accessToken),
|
pair("userid", user.id));
|
||||||
pair("uuid", UUIDTypeAdapter.fromUUID(profile.getId())),
|
|
||||||
pair("displayName", profile.getName()),
|
|
||||||
pair("userid", user.id)
|
|
||||||
);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
public AuthInfo toAuthInfo() {
|
public AuthInfo toAuthInfo() {
|
||||||
|
@ -47,6 +47,17 @@ public final class Lang {
|
|||||||
*/
|
*/
|
||||||
@SafeVarargs
|
@SafeVarargs
|
||||||
public static <K, V> Map<K, V> mapOf(Pair<K, V>... pairs) {
|
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<>();
|
Map<K, V> map = new LinkedHashMap<>();
|
||||||
for (Pair<K, V> pair : pairs)
|
for (Pair<K, V> pair : pairs)
|
||||||
map.put(pair.getKey(), pair.getValue());
|
map.put(pair.getKey(), pair.getValue());
|
||||||
|
@ -18,6 +18,7 @@
|
|||||||
package org.jackhuang.hmcl.util.io;
|
package org.jackhuang.hmcl.util.io;
|
||||||
|
|
||||||
import com.google.gson.JsonParseException;
|
import com.google.gson.JsonParseException;
|
||||||
|
import org.jackhuang.hmcl.util.Pair;
|
||||||
import org.jackhuang.hmcl.util.function.ExceptionalBiConsumer;
|
import org.jackhuang.hmcl.util.function.ExceptionalBiConsumer;
|
||||||
import org.jackhuang.hmcl.util.gson.JsonUtils;
|
import org.jackhuang.hmcl.util.gson.JsonUtils;
|
||||||
|
|
||||||
@ -30,6 +31,7 @@ import java.util.HashMap;
|
|||||||
import java.util.Map;
|
import java.util.Map;
|
||||||
|
|
||||||
import static java.nio.charset.StandardCharsets.UTF_8;
|
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.gson.JsonUtils.GSON;
|
||||||
import static org.jackhuang.hmcl.util.io.NetworkUtils.createHttpConnection;
|
import static org.jackhuang.hmcl.util.io.NetworkUtils.createHttpConnection;
|
||||||
import static org.jackhuang.hmcl.util.io.NetworkUtils.resolveConnection;
|
import static org.jackhuang.hmcl.util.io.NetworkUtils.resolveConnection;
|
||||||
@ -73,7 +75,7 @@ public abstract class HttpRequest {
|
|||||||
return this;
|
return this;
|
||||||
}
|
}
|
||||||
|
|
||||||
protected HttpURLConnection createConnection() throws IOException {
|
public HttpURLConnection createConnection() throws IOException {
|
||||||
HttpURLConnection con = createHttpConnection(url);
|
HttpURLConnection con = createHttpConnection(url);
|
||||||
con.setRequestMethod(method);
|
con.setRequestMethod(method);
|
||||||
for (Map.Entry<String, String> entry : headers.entrySet()) {
|
for (Map.Entry<String, String> entry : headers.entrySet()) {
|
||||||
@ -102,8 +104,7 @@ public abstract class HttpRequest {
|
|||||||
}
|
}
|
||||||
|
|
||||||
public <T> HttpPostRequest json(Object payload) throws JsonParseException {
|
public <T> HttpPostRequest json(Object payload) throws JsonParseException {
|
||||||
return string(payload instanceof String ? (String) payload : GSON.toJson(payload),
|
return string(payload instanceof String ? (String) payload : GSON.toJson(payload), "application/json");
|
||||||
"application/json");
|
|
||||||
}
|
}
|
||||||
|
|
||||||
public HttpPostRequest form(Map<String, String> params) {
|
public HttpPostRequest form(Map<String, String> params) {
|
||||||
@ -136,6 +137,11 @@ public abstract class HttpRequest {
|
|||||||
return GET(new URL(url));
|
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) {
|
public static HttpGetRequest GET(URL url) {
|
||||||
return new HttpGetRequest(url);
|
return new HttpGetRequest(url);
|
||||||
}
|
}
|
||||||
|
@ -1,6 +1,6 @@
|
|||||||
/*
|
/*
|
||||||
* Hello Minecraft! Launcher
|
* 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
|
* 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
|
* it under the terms of the GNU General Public License as published by
|
||||||
@ -17,14 +17,15 @@
|
|||||||
*/
|
*/
|
||||||
package org.jackhuang.hmcl.util.io;
|
package org.jackhuang.hmcl.util.io;
|
||||||
|
|
||||||
|
import org.jackhuang.hmcl.util.Pair;
|
||||||
|
|
||||||
import java.io.*;
|
import java.io.*;
|
||||||
import java.net.*;
|
import java.net.*;
|
||||||
import java.util.List;
|
import java.util.*;
|
||||||
import java.util.Locale;
|
|
||||||
import java.util.Map;
|
|
||||||
import java.util.Map.Entry;
|
import java.util.Map.Entry;
|
||||||
|
|
||||||
import static java.nio.charset.StandardCharsets.UTF_8;
|
import static java.nio.charset.StandardCharsets.UTF_8;
|
||||||
|
import static org.jackhuang.hmcl.util.Pair.pair;
|
||||||
import static org.jackhuang.hmcl.util.StringUtils.*;
|
import static org.jackhuang.hmcl.util.StringUtils.*;
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@ -32,6 +33,8 @@ import static org.jackhuang.hmcl.util.StringUtils.*;
|
|||||||
* @author huangyuhui
|
* @author huangyuhui
|
||||||
*/
|
*/
|
||||||
public final class NetworkUtils {
|
public final class NetworkUtils {
|
||||||
|
public static final String PARAMETER_SEPARATOR = "&";
|
||||||
|
public static final String NAME_VALUE_SEPARATOR = "=";
|
||||||
|
|
||||||
private NetworkUtils() {
|
private NetworkUtils() {
|
||||||
}
|
}
|
||||||
@ -48,15 +51,38 @@ public final class NetworkUtils {
|
|||||||
}
|
}
|
||||||
first = false;
|
first = false;
|
||||||
} else {
|
} else {
|
||||||
sb.append('&');
|
sb.append(PARAMETER_SEPARATOR);
|
||||||
}
|
}
|
||||||
sb.append(encodeURL(param.getKey()));
|
sb.append(encodeURL(param.getKey()));
|
||||||
sb.append('=');
|
sb.append(NAME_VALUE_SEPARATOR);
|
||||||
sb.append(encodeURL(param.getValue()));
|
sb.append(encodeURL(param.getValue()));
|
||||||
}
|
}
|
||||||
return sb.toString();
|
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 {
|
public static URLConnection createConnection(URL url) throws IOException {
|
||||||
URLConnection connection = url.openConnection();
|
URLConnection connection = url.openConnection();
|
||||||
connection.setUseCaches(false);
|
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
|
* @param location the url to be URL encoded
|
||||||
* @return encoded URL
|
* @return encoded URL
|
||||||
*/
|
*/
|
||||||
@ -81,8 +108,10 @@ public final class NetworkUtils {
|
|||||||
for (char ch : location.toCharArray()) {
|
for (char ch : location.toCharArray()) {
|
||||||
switch (ch) {
|
switch (ch) {
|
||||||
case ' ':
|
case ' ':
|
||||||
if (left) sb.append("%20");
|
if (left)
|
||||||
else sb.append('+');
|
sb.append("%20");
|
||||||
|
else
|
||||||
|
sb.append('+');
|
||||||
break;
|
break;
|
||||||
case '?':
|
case '?':
|
||||||
left = false;
|
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>
|
* @see <a href="https://github.com/curl/curl/issues/473">Issue with libcurl</a>
|
||||||
* @param conn the stupid http connection.
|
* @param conn the stupid http connection.
|
||||||
* @return manually redirected http connection.
|
* @return manually redirected http connection.
|
||||||
@ -125,8 +156,10 @@ public final class NetworkUtils {
|
|||||||
throw new IOException("Too much redirects");
|
throw new IOException("Too much redirects");
|
||||||
}
|
}
|
||||||
|
|
||||||
HttpURLConnection redirected = (HttpURLConnection) new URL(conn.getURL(), encodeLocation(newURL)).openConnection();
|
HttpURLConnection redirected = (HttpURLConnection) new URL(conn.getURL(), encodeLocation(newURL))
|
||||||
properties.forEach((key, value) -> value.forEach(element -> redirected.addRequestProperty(key, element)));
|
.openConnection();
|
||||||
|
properties
|
||||||
|
.forEach((key, value) -> value.forEach(element -> redirected.addRequestProperty(key, element)));
|
||||||
redirected.setRequestMethod(method);
|
redirected.setRequestMethod(method);
|
||||||
conn = redirected;
|
conn = redirected;
|
||||||
++redirect;
|
++redirect;
|
||||||
@ -178,7 +211,8 @@ public final class NetworkUtils {
|
|||||||
}
|
}
|
||||||
} catch (IOException e) {
|
} catch (IOException e) {
|
||||||
try (InputStream stderr = con.getErrorStream()) {
|
try (InputStream stderr = con.getErrorStream()) {
|
||||||
if (stderr == null) throw e;
|
if (stderr == null)
|
||||||
|
throw e;
|
||||||
return IOUtils.readFullyAsString(stderr, UTF_8);
|
return IOUtils.readFullyAsString(stderr, UTF_8);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
Loading…
x
Reference in New Issue
Block a user