From 5890f0c782fffcbe5de1f3cca529081a53d09ae3 Mon Sep 17 00:00:00 2001 From: huanghongxun Date: Sun, 22 Aug 2021 20:35:30 +0800 Subject: [PATCH] feat: authenticate Microsoft accounts via external browser --- HMCL/build.gradle | 4 +- .../game/MicrosoftAuthenticationServer.java | 117 +++++++++ .../org/jackhuang/hmcl/setting/Accounts.java | 14 +- .../org/jackhuang/hmcl/ui/Controllers.java | 2 - .../account/MicrosoftAccountLoginStage.java | 72 ------ .../main/resources/assets/microsoft_auth.html | 37 +++ HMCLCore/build.gradle | 1 + .../hmcl/auth/microsoft/MicrosoftService.java | 240 ++++++++++++------ .../hmcl/auth/microsoft/MicrosoftSession.java | 38 +-- .../java/org/jackhuang/hmcl/util/Lang.java | 11 + .../jackhuang/hmcl/util/io/HttpRequest.java | 12 +- .../jackhuang/hmcl/util/io/NetworkUtils.java | 60 ++++- 12 files changed, 419 insertions(+), 189 deletions(-) create mode 100644 HMCL/src/main/java/org/jackhuang/hmcl/game/MicrosoftAuthenticationServer.java delete mode 100644 HMCL/src/main/java/org/jackhuang/hmcl/ui/account/MicrosoftAccountLoginStage.java create mode 100644 HMCL/src/main/resources/assets/microsoft_auth.html diff --git a/HMCL/build.gradle b/HMCL/build.gradle index 714fe2b0f..2007f38a7 100644 --- a/HMCL/build.gradle +++ b/HMCL/build.gradle @@ -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', diff --git a/HMCL/src/main/java/org/jackhuang/hmcl/game/MicrosoftAuthenticationServer.java b/HMCL/src/main/java/org/jackhuang/hmcl/game/MicrosoftAuthenticationServer.java new file mode 100644 index 000000000..0946192ed --- /dev/null +++ b/HMCL/src/main/java/org/jackhuang/hmcl/game/MicrosoftAuthenticationServer.java @@ -0,0 +1,117 @@ +/* + * Hello Minecraft! Launcher + * Copyright (C) 2021 huangyuhui 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 . + */ +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 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 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); + } + } +} diff --git a/HMCL/src/main/java/org/jackhuang/hmcl/setting/Accounts.java b/HMCL/src/main/java/org/jackhuang/hmcl/setting/Accounts.java index 8e23c34ed..e6030a767 100644 --- a/HMCL/src/main/java/org/jackhuang/hmcl/setting/Accounts.java +++ b/HMCL/src/main/java/org/jackhuang/hmcl/setting/Accounts.java @@ -1,6 +1,6 @@ /* * Hello Minecraft! Launcher - * Copyright (C) 2020 huangyuhui and contributors + * Copyright (C) 2021 huangyuhui 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> FACTORIES = immutableListOf(FACTORY_OFFLINE, FACTORY_MOJANG, FACTORY_MICROSOFT, FACTORY_AUTHLIB_INJECTOR); // ==== login type / account factory mapping ==== diff --git a/HMCL/src/main/java/org/jackhuang/hmcl/ui/Controllers.java b/HMCL/src/main/java/org/jackhuang/hmcl/ui/Controllers.java index 288cb2a79..bf1e0a56d 100644 --- a/HMCL/src/main/java/org/jackhuang/hmcl/ui/Controllers.java +++ b/HMCL/src/main/java/org/jackhuang/hmcl/ui/Controllers.java @@ -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()); diff --git a/HMCL/src/main/java/org/jackhuang/hmcl/ui/account/MicrosoftAccountLoginStage.java b/HMCL/src/main/java/org/jackhuang/hmcl/ui/account/MicrosoftAccountLoginStage.java deleted file mode 100644 index 35dcbb8b3..000000000 --- a/HMCL/src/main/java/org/jackhuang/hmcl/ui/account/MicrosoftAccountLoginStage.java +++ /dev/null @@ -1,72 +0,0 @@ -/* - * Hello Minecraft! Launcher - * Copyright (C) 2020 huangyuhui 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 . - */ -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 future; - Predicate 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 show(MicrosoftService service, Predicate urlTester, String initialURL) { - Platform.runLater(() -> { - COOKIE_MANAGER.getCookieStore().removeAll(); - - webEngine.load(initialURL); - show(); - }); - this.future = new CompletableFuture<>(); - this.urlTester = urlTester; - return future; - } -} diff --git a/HMCL/src/main/resources/assets/microsoft_auth.html b/HMCL/src/main/resources/assets/microsoft_auth.html new file mode 100644 index 000000000..ae0ca3da9 --- /dev/null +++ b/HMCL/src/main/resources/assets/microsoft_auth.html @@ -0,0 +1,37 @@ + + + + + + + Hello Minecraft! Launcher + + + +
+ 你可以关闭本标签页了 +
+ + + + + + \ No newline at end of file diff --git a/HMCLCore/build.gradle b/HMCLCore/build.gradle index 57259915d..5aad792f0 100644 --- a/HMCLCore/build.gradle +++ b/HMCLCore/build.gradle @@ -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' diff --git a/HMCLCore/src/main/java/org/jackhuang/hmcl/auth/microsoft/MicrosoftService.java b/HMCLCore/src/main/java/org/jackhuang/hmcl/auth/microsoft/MicrosoftService.java index 5c66dcbc0..fc027d181 100644 --- a/HMCLCore/src/main/java/org/jackhuang/hmcl/auth/microsoft/MicrosoftService.java +++ b/HMCLCore/src/main/java/org/jackhuang/hmcl/auth/microsoft/MicrosoftService.java @@ -1,6 +1,6 @@ /* * Hello Minecraft! Launcher - * Copyright (C) 2020 huangyuhui and contributors + * Copyright (C) 2021 huangyuhui 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 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 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 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> 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 show(MicrosoftService service, Predicate 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; } } diff --git a/HMCLCore/src/main/java/org/jackhuang/hmcl/auth/microsoft/MicrosoftSession.java b/HMCLCore/src/main/java/org/jackhuang/hmcl/auth/microsoft/MicrosoftSession.java index bf3e808e4..87d3375e2 100644 --- a/HMCLCore/src/main/java/org/jackhuang/hmcl/auth/microsoft/MicrosoftSession.java +++ b/HMCLCore/src/main/java/org/jackhuang/hmcl/auth/microsoft/MicrosoftSession.java @@ -1,6 +1,6 @@ /* * Hello Minecraft! Launcher - * Copyright (C) 2020 huangyuhui and contributors + * Copyright (C) 2021 huangyuhui 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 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() { diff --git a/HMCLCore/src/main/java/org/jackhuang/hmcl/util/Lang.java b/HMCLCore/src/main/java/org/jackhuang/hmcl/util/Lang.java index 748983c5b..44ad3aaa0 100644 --- a/HMCLCore/src/main/java/org/jackhuang/hmcl/util/Lang.java +++ b/HMCLCore/src/main/java/org/jackhuang/hmcl/util/Lang.java @@ -47,6 +47,17 @@ public final class Lang { */ @SafeVarargs public static Map mapOf(Pair... pairs) { + return mapOf(Arrays.asList(pairs)); + } + + /** + * Construct a mutable map by given key-value pairs. + * @param pairs entries in the new map + * @param the type of keys + * @param the type of values + * @return the map which contains data in {@code pairs}. + */ + public static Map mapOf(Iterable> pairs) { Map map = new LinkedHashMap<>(); for (Pair pair : pairs) map.put(pair.getKey(), pair.getValue()); diff --git a/HMCLCore/src/main/java/org/jackhuang/hmcl/util/io/HttpRequest.java b/HMCLCore/src/main/java/org/jackhuang/hmcl/util/io/HttpRequest.java index a9f47c4cf..95a882d56 100644 --- a/HMCLCore/src/main/java/org/jackhuang/hmcl/util/io/HttpRequest.java +++ b/HMCLCore/src/main/java/org/jackhuang/hmcl/util/io/HttpRequest.java @@ -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 entry : headers.entrySet()) { @@ -102,8 +104,7 @@ public abstract class HttpRequest { } public 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 params) { @@ -136,6 +137,11 @@ public abstract class HttpRequest { return GET(new URL(url)); } + @SafeVarargs + public static HttpGetRequest GET(String url, Pair... query) throws MalformedURLException { + return GET(new URL(NetworkUtils.withQuery(url, mapOf(query)))); + } + public static HttpGetRequest GET(URL url) { return new HttpGetRequest(url); } diff --git a/HMCLCore/src/main/java/org/jackhuang/hmcl/util/io/NetworkUtils.java b/HMCLCore/src/main/java/org/jackhuang/hmcl/util/io/NetworkUtils.java index 72ae5525a..5e1d7d450 100644 --- a/HMCLCore/src/main/java/org/jackhuang/hmcl/util/io/NetworkUtils.java +++ b/HMCLCore/src/main/java/org/jackhuang/hmcl/util/io/NetworkUtils.java @@ -1,6 +1,6 @@ /* * Hello Minecraft! Launcher - * Copyright (C) 2020 huangyuhui and contributors + * Copyright (C) 2021 huangyuhui 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> parseQuery(URI uri) { + return parseQuery(uri.getRawQuery()); + } + + public static List> parseQuery(String queryParameterString) { + List> 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 Curl + * @see Curl * @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 Issue with libcurl * @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); } }