feat(microsoft): WIP: use device code grant flow instead.

This commit is contained in:
huanghongxun
2021-10-23 00:01:55 +08:00
parent 843b29eff0
commit 5a9e8683e4
12 changed files with 456 additions and 198 deletions

View File

@@ -19,7 +19,9 @@ package org.jackhuang.hmcl.game;
import fi.iki.elonen.NanoHTTPD; import fi.iki.elonen.NanoHTTPD;
import org.jackhuang.hmcl.auth.AuthenticationException; import org.jackhuang.hmcl.auth.AuthenticationException;
import org.jackhuang.hmcl.auth.microsoft.MicrosoftService; import org.jackhuang.hmcl.auth.OAuth;
import org.jackhuang.hmcl.event.Event;
import org.jackhuang.hmcl.event.EventManager;
import org.jackhuang.hmcl.ui.FXUtils; import org.jackhuang.hmcl.ui.FXUtils;
import org.jackhuang.hmcl.util.Logging; import org.jackhuang.hmcl.util.Logging;
import org.jackhuang.hmcl.util.StringUtils; import org.jackhuang.hmcl.util.StringUtils;
@@ -39,7 +41,7 @@ import static org.jackhuang.hmcl.util.Lang.mapOf;
import static org.jackhuang.hmcl.util.Lang.thread; import static org.jackhuang.hmcl.util.Lang.thread;
import static org.jackhuang.hmcl.util.i18n.I18n.i18n; import static org.jackhuang.hmcl.util.i18n.I18n.i18n;
public final class MicrosoftAuthenticationServer extends NanoHTTPD implements MicrosoftService.OAuthSession { public final class OAuthServer extends NanoHTTPD implements OAuth.Session {
private final int port; private final int port;
private final CompletableFuture<String> future = new CompletableFuture<>(); private final CompletableFuture<String> future = new CompletableFuture<>();
@@ -47,7 +49,7 @@ public final class MicrosoftAuthenticationServer extends NanoHTTPD implements Mi
private String idToken; private String idToken;
private MicrosoftAuthenticationServer(int port) { private OAuthServer(int port) {
super(port); super(port);
this.port = port; this.port = port;
@@ -102,7 +104,7 @@ public final class MicrosoftAuthenticationServer extends NanoHTTPD implements Mi
String html; String html;
try { try {
html = IOUtils.readFullyAsString(MicrosoftAuthenticationServer.class.getResourceAsStream("/assets/microsoft_auth.html"), StandardCharsets.UTF_8) html = IOUtils.readFullyAsString(OAuthServer.class.getResourceAsStream("/assets/microsoft_auth.html"), StandardCharsets.UTF_8)
.replace("%close-page%", i18n("account.methods.microsoft.close_page")); .replace("%close-page%", i18n("account.methods.microsoft.close_page"));
} catch (IOException e) { } catch (IOException e) {
Logging.LOG.log(Level.SEVERE, "Failed to load html"); Logging.LOG.log(Level.SEVERE, "Failed to load html");
@@ -119,10 +121,12 @@ public final class MicrosoftAuthenticationServer extends NanoHTTPD implements Mi
return newFixedLengthResponse(Response.Status.OK, "text/html; charset=UTF-8", html); return newFixedLengthResponse(Response.Status.OK, "text/html; charset=UTF-8", html);
} }
public static class Factory implements MicrosoftService.OAuthCallback { public static class Factory implements OAuth.Callback {
public final EventManager<GrantDeviceCodeEvent> onGrantDeviceCode = new EventManager<>();
public final EventManager<OpenBrowserEvent> onOpenBrowser = new EventManager<>();
@Override @Override
public MicrosoftService.OAuthSession startServer() throws IOException, AuthenticationException { public OAuth.Session startServer() throws IOException, AuthenticationException {
if (StringUtils.isBlank(getClientId())) { if (StringUtils.isBlank(getClientId())) {
throw new MicrosoftAuthenticationNotSupportedException(); throw new MicrosoftAuthenticationNotSupportedException();
} }
@@ -130,7 +134,7 @@ public final class MicrosoftAuthenticationServer extends NanoHTTPD implements Mi
IOException exception = null; IOException exception = null;
for (int port : new int[]{29111, 29112, 29113, 29114, 29115}) { for (int port : new int[]{29111, 29112, 29113, 29114, 29115}) {
try { try {
MicrosoftAuthenticationServer server = new MicrosoftAuthenticationServer(port); OAuthServer server = new OAuthServer(port);
server.start(NanoHTTPD.SOCKET_READ_TIMEOUT, true); server.start(NanoHTTPD.SOCKET_READ_TIMEOUT, true);
return server; return server;
} catch (IOException e) { } catch (IOException e) {
@@ -140,10 +144,17 @@ public final class MicrosoftAuthenticationServer extends NanoHTTPD implements Mi
throw exception; throw exception;
} }
@Override
public void grantDeviceCode(String userCode, String verificationURI) {
onGrantDeviceCode.fireEvent(new GrantDeviceCodeEvent(this, userCode, verificationURI));
}
@Override @Override
public void openBrowser(String url) throws IOException { public void openBrowser(String url) throws IOException {
lastlyOpenedURL = url; lastlyOpenedURL = url;
FXUtils.openLink(url); FXUtils.openLink(url);
onOpenBrowser.fireEvent(new OpenBrowserEvent(this, url));
} }
@Override @Override
@@ -160,6 +171,38 @@ public final class MicrosoftAuthenticationServer extends NanoHTTPD implements Mi
} }
public static class GrantDeviceCodeEvent extends Event {
private final String userCode;
private final String verificationUri;
public GrantDeviceCodeEvent(Object source, String userCode, String verificationUri) {
super(source);
this.userCode = userCode;
this.verificationUri = verificationUri;
}
public String getUserCode() {
return userCode;
}
public String getVerificationUri() {
return verificationUri;
}
}
public static class OpenBrowserEvent extends Event {
private final String url;
public OpenBrowserEvent(Object source, String url) {
super(source);
this.url = url;
}
public String getUrl() {
return url;
}
}
public static class MicrosoftAuthenticationNotSupportedException extends AuthenticationException { public static class MicrosoftAuthenticationNotSupportedException extends AuthenticationException {
} }
} }

View File

@@ -34,7 +34,7 @@ import org.jackhuang.hmcl.auth.offline.OfflineAccountFactory;
import org.jackhuang.hmcl.auth.yggdrasil.RemoteAuthenticationException; import org.jackhuang.hmcl.auth.yggdrasil.RemoteAuthenticationException;
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.game.OAuthServer;
import org.jackhuang.hmcl.task.Schedulers; import org.jackhuang.hmcl.task.Schedulers;
import org.jackhuang.hmcl.util.skin.InvalidSkinException; import org.jackhuang.hmcl.util.skin.InvalidSkinException;
@@ -75,12 +75,12 @@ public final class Accounts {
} }
} }
public static final MicrosoftService.OAuthCallback MICROSOFT_OAUTH_CALLBACK = new MicrosoftAuthenticationServer.Factory(); public static final OAuthServer.Factory OAUTH_CALLBACK = new OAuthServer.Factory();
public static final OfflineAccountFactory FACTORY_OFFLINE = new OfflineAccountFactory(AUTHLIB_INJECTOR_DOWNLOADER); public static final OfflineAccountFactory FACTORY_OFFLINE = new OfflineAccountFactory(AUTHLIB_INJECTOR_DOWNLOADER);
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(MICROSOFT_OAUTH_CALLBACK)); public static final MicrosoftAccountFactory FACTORY_MICROSOFT = new MicrosoftAccountFactory(new MicrosoftService(OAUTH_CALLBACK));
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 ====
@@ -374,7 +374,7 @@ public final class Accounts {
return i18n("account.methods.microsoft.error.no_character"); return i18n("account.methods.microsoft.error.no_character");
} else if (exception instanceof MicrosoftService.NoXuiException) { } else if (exception instanceof MicrosoftService.NoXuiException) {
return i18n("account.methods.microsoft.error.add_family_probably"); return i18n("account.methods.microsoft.error.add_family_probably");
} else if (exception instanceof MicrosoftAuthenticationServer.MicrosoftAuthenticationNotSupportedException) { } else if (exception instanceof OAuthServer.MicrosoftAuthenticationNotSupportedException) {
return i18n("account.methods.microsoft.snapshot"); return i18n("account.methods.microsoft.snapshot");
} else if (exception instanceof OAuthAccount.WrongAccountException) { } else if (exception instanceof OAuthAccount.WrongAccountException) {
return i18n("account.failed.wrong_account"); return i18n("account.failed.wrong_account");

View File

@@ -20,7 +20,7 @@ package org.jackhuang.hmcl.setting;
import com.google.gson.annotations.SerializedName; import com.google.gson.annotations.SerializedName;
import javafx.beans.property.ObjectProperty; import javafx.beans.property.ObjectProperty;
import javafx.beans.property.SimpleObjectProperty; import javafx.beans.property.SimpleObjectProperty;
import org.jackhuang.hmcl.auth.microsoft.MicrosoftService; import org.jackhuang.hmcl.auth.OAuth;
import org.jackhuang.hmcl.task.Schedulers; import org.jackhuang.hmcl.task.Schedulers;
import org.jackhuang.hmcl.task.Task; import org.jackhuang.hmcl.task.Task;
import org.jackhuang.hmcl.util.gson.JsonUtils; import org.jackhuang.hmcl.util.gson.JsonUtils;
@@ -57,11 +57,11 @@ public final class HMCLAccounts {
String scope = "openid offline_access"; String scope = "openid offline_access";
return Task.supplyAsync(() -> { return Task.supplyAsync(() -> {
MicrosoftService.OAuthSession session = Accounts.MICROSOFT_OAUTH_CALLBACK.startServer(); OAuth.Session session = Accounts.OAUTH_CALLBACK.startServer();
Accounts.MICROSOFT_OAUTH_CALLBACK.openBrowser(NetworkUtils.withQuery( Accounts.OAUTH_CALLBACK.openBrowser(NetworkUtils.withQuery(
"https://login.microsoftonline.com/common/oauth2/v2.0/authorize", "https://login.microsoftonline.com/common/oauth2/v2.0/authorize",
mapOf( mapOf(
pair("client_id", Accounts.MICROSOFT_OAUTH_CALLBACK.getClientId()), pair("client_id", Accounts.OAUTH_CALLBACK.getClientId()),
pair("response_type", "id_token code"), pair("response_type", "id_token code"),
pair("response_mode", "form_post"), pair("response_mode", "form_post"),
pair("scope", scope), pair("scope", scope),
@@ -72,12 +72,12 @@ public final class HMCLAccounts {
// Authorization Code -> Token // Authorization Code -> Token
String responseText = HttpRequest.POST("https://login.microsoftonline.com/common/oauth2/v2.0/token") String responseText = HttpRequest.POST("https://login.microsoftonline.com/common/oauth2/v2.0/token")
.form(mapOf(pair("client_id", Accounts.MICROSOFT_OAUTH_CALLBACK.getClientId()), pair("code", code), .form(mapOf(pair("client_id", Accounts.OAUTH_CALLBACK.getClientId()), pair("code", code),
pair("grant_type", "authorization_code"), pair("client_secret", Accounts.MICROSOFT_OAUTH_CALLBACK.getClientSecret()), pair("grant_type", "authorization_code"), pair("client_secret", Accounts.OAUTH_CALLBACK.getClientSecret()),
pair("redirect_uri", session.getRedirectURI()), pair("scope", scope))) pair("redirect_uri", session.getRedirectURI()), pair("scope", scope)))
.getString(); .getString();
MicrosoftService.LiveAuthorizationResponse response = JsonUtils.fromNonNullJson(responseText, OAuth.AuthorizationResponse response = JsonUtils.fromNonNullJson(responseText,
MicrosoftService.LiveAuthorizationResponse.class); OAuth.AuthorizationResponse.class);
HMCLAccountProfile profile = HttpRequest.GET("https://hmcl.huangyuhui.net/api/user") HMCLAccountProfile profile = HttpRequest.GET("https://hmcl.huangyuhui.net/api/user")
.header("Token-Type", response.tokenType) .header("Token-Type", response.tokenType)

View File

@@ -43,7 +43,7 @@ import org.jackhuang.hmcl.auth.offline.OfflineAccountFactory;
import org.jackhuang.hmcl.auth.yggdrasil.GameProfile; import org.jackhuang.hmcl.auth.yggdrasil.GameProfile;
import org.jackhuang.hmcl.auth.yggdrasil.YggdrasilAccountFactory; import org.jackhuang.hmcl.auth.yggdrasil.YggdrasilAccountFactory;
import org.jackhuang.hmcl.auth.yggdrasil.YggdrasilService; import org.jackhuang.hmcl.auth.yggdrasil.YggdrasilService;
import org.jackhuang.hmcl.game.MicrosoftAuthenticationServer; import org.jackhuang.hmcl.game.OAuthServer;
import org.jackhuang.hmcl.game.TexturesLoader; import org.jackhuang.hmcl.game.TexturesLoader;
import org.jackhuang.hmcl.setting.Accounts; import org.jackhuang.hmcl.setting.Accounts;
import org.jackhuang.hmcl.setting.Theme; import org.jackhuang.hmcl.setting.Theme;
@@ -267,8 +267,8 @@ public class CreateAccountPane extends JFXDialogLayout implements DialogAware {
? i18n("account.methods.microsoft.manual") ? i18n("account.methods.microsoft.manual")
: i18n("account.methods.microsoft.hint"))); : i18n("account.methods.microsoft.hint")));
hintPane.setOnMouseClicked(e -> { hintPane.setOnMouseClicked(e -> {
if (logging.get() && MicrosoftAuthenticationServer.lastlyOpenedURL != null) { if (logging.get() && OAuthServer.lastlyOpenedURL != null) {
FXUtils.copyText(MicrosoftAuthenticationServer.lastlyOpenedURL); FXUtils.copyText(OAuthServer.lastlyOpenedURL);
} }
}); });

View File

@@ -1,7 +1,7 @@
package org.jackhuang.hmcl.ui.account; package org.jackhuang.hmcl.ui.account;
import javafx.beans.property.BooleanProperty; import javafx.beans.property.ObjectProperty;
import javafx.beans.property.SimpleBooleanProperty; import javafx.beans.property.SimpleObjectProperty;
import javafx.scene.control.Label; import javafx.scene.control.Label;
import javafx.scene.layout.GridPane; import javafx.scene.layout.GridPane;
import javafx.scene.layout.HBox; import javafx.scene.layout.HBox;
@@ -9,11 +9,12 @@ import javafx.scene.layout.VBox;
import org.jackhuang.hmcl.auth.AuthInfo; import org.jackhuang.hmcl.auth.AuthInfo;
import org.jackhuang.hmcl.auth.OAuthAccount; import org.jackhuang.hmcl.auth.OAuthAccount;
import org.jackhuang.hmcl.auth.yggdrasil.YggdrasilService; import org.jackhuang.hmcl.auth.yggdrasil.YggdrasilService;
import org.jackhuang.hmcl.game.MicrosoftAuthenticationServer; import org.jackhuang.hmcl.game.OAuthServer;
import org.jackhuang.hmcl.setting.Accounts; import org.jackhuang.hmcl.setting.Accounts;
import org.jackhuang.hmcl.task.Schedulers; import org.jackhuang.hmcl.task.Schedulers;
import org.jackhuang.hmcl.task.Task; import org.jackhuang.hmcl.task.Task;
import org.jackhuang.hmcl.ui.FXUtils; import org.jackhuang.hmcl.ui.FXUtils;
import org.jackhuang.hmcl.ui.WeakListenerHolder;
import org.jackhuang.hmcl.ui.construct.*; import org.jackhuang.hmcl.ui.construct.*;
import org.jackhuang.hmcl.util.javafx.BindingMapping; import org.jackhuang.hmcl.util.javafx.BindingMapping;
@@ -27,7 +28,9 @@ public class OAuthAccountLoginDialog extends DialogPane {
private final OAuthAccount account; private final OAuthAccount account;
private final Consumer<AuthInfo> success; private final Consumer<AuthInfo> success;
private final Runnable failed; private final Runnable failed;
private final BooleanProperty logging = new SimpleBooleanProperty(); private final ObjectProperty<OAuthServer.GrantDeviceCodeEvent> deviceCode = new SimpleObjectProperty<>();
private final WeakListenerHolder holder = new WeakListenerHolder();
public OAuthAccountLoginDialog(OAuthAccount account, Consumer<AuthInfo> success, Runnable failed) { public OAuthAccountLoginDialog(OAuthAccount account, Consumer<AuthInfo> success, Runnable failed) {
this.account = account; this.account = account;
@@ -40,13 +43,13 @@ public class OAuthAccountLoginDialog extends DialogPane {
Label usernameLabel = new Label(account.getUsername()); Label usernameLabel = new Label(account.getUsername());
HintPane hintPane = new HintPane(MessageDialogPane.MessageType.INFO); HintPane hintPane = new HintPane(MessageDialogPane.MessageType.INFO);
hintPane.textProperty().bind(BindingMapping.of(logging).map(logging -> hintPane.textProperty().bind(BindingMapping.of(deviceCode).map(deviceCode ->
logging deviceCode != null
? i18n("account.methods.microsoft.manual") ? i18n("account.methods.microsoft.manual", deviceCode.getUserCode())
: i18n("account.methods.microsoft.hint"))); : i18n("account.methods.microsoft.hint")));
hintPane.setOnMouseClicked(e -> { hintPane.setOnMouseClicked(e -> {
if (logging.get() && MicrosoftAuthenticationServer.lastlyOpenedURL != null) { if (deviceCode.get() != null) {
FXUtils.copyText(MicrosoftAuthenticationServer.lastlyOpenedURL); FXUtils.copyText(deviceCode.get().getVerificationUri());
} }
}); });
@@ -62,15 +65,19 @@ public class OAuthAccountLoginDialog extends DialogPane {
vbox.getChildren().setAll(usernameLabel, hintPane, box); vbox.getChildren().setAll(usernameLabel, hintPane, box);
setBody(vbox); setBody(vbox);
holder.add(Accounts.OAUTH_CALLBACK.onGrantDeviceCode.registerWeak(this::onGrantDeviceCode));
}
private void onGrantDeviceCode(OAuthServer.GrantDeviceCodeEvent event) {
deviceCode.set(event);
} }
@Override @Override
protected void onAccept() { protected void onAccept() {
setLoading(); setLoading();
logging.set(true);
Task.supplyAsync(account::logInWhenCredentialsExpired) Task.supplyAsync(account::logInWhenCredentialsExpired)
.whenComplete(Schedulers.javafx(), (authInfo, exception) -> { .whenComplete(Schedulers.javafx(), (authInfo, exception) -> {
logging.set(false);
if (exception == null) { if (exception == null) {
success.accept(authInfo); success.accept(authInfo);
onSuccess(); onSuccess();

View File

@@ -31,7 +31,7 @@ import javafx.geometry.Pos;
import javafx.scene.control.Label; import javafx.scene.control.Label;
import javafx.scene.layout.*; import javafx.scene.layout.*;
import org.jackhuang.hmcl.Metadata; import org.jackhuang.hmcl.Metadata;
import org.jackhuang.hmcl.game.MicrosoftAuthenticationServer; import org.jackhuang.hmcl.game.OAuthServer;
import org.jackhuang.hmcl.setting.Accounts; import org.jackhuang.hmcl.setting.Accounts;
import org.jackhuang.hmcl.setting.HMCLAccounts; import org.jackhuang.hmcl.setting.HMCLAccounts;
import org.jackhuang.hmcl.setting.Theme; import org.jackhuang.hmcl.setting.Theme;
@@ -246,8 +246,8 @@ public class FeedbackPage extends VBox implements PageAware {
? i18n("account.methods.microsoft.manual") ? i18n("account.methods.microsoft.manual")
: i18n("account.methods.microsoft.hint"))); : i18n("account.methods.microsoft.hint")));
hintPane.setOnMouseClicked(e -> { hintPane.setOnMouseClicked(e -> {
if (logging.get() && MicrosoftAuthenticationServer.lastlyOpenedURL != null) { if (logging.get() && OAuthServer.lastlyOpenedURL != null) {
FXUtils.copyText(MicrosoftAuthenticationServer.lastlyOpenedURL); FXUtils.copyText(OAuthServer.lastlyOpenedURL);
} }
}); });
vbox.getChildren().setAll(hintPane); vbox.getChildren().setAll(hintPane);

View File

@@ -96,8 +96,8 @@ account.methods.microsoft.error.missing_xbox_account=Your Microsoft account is n
account.methods.microsoft.error.no_character=Account is missing a Minecraft Java profile. While the Microsoft account is valid, it does not own the game. account.methods.microsoft.error.no_character=Account is missing a Minecraft Java profile. While the Microsoft account is valid, it does not own the game.
account.methods.microsoft.error.unknown=Failed to log in. Microsoft respond with error code %d. account.methods.microsoft.error.unknown=Failed to log in. Microsoft respond with error code %d.
account.methods.microsoft.logging_in=Logging in... account.methods.microsoft.logging_in=Logging in...
account.methods.microsoft.hint=You should click "login" button and continue login process in newly opened browser window. account.methods.microsoft.hint=You should click "login" button, paste the device code shown here later and continue login process in newly opened browser window.
account.methods.microsoft.manual=After clicking "login" button, you should finish authorization in the newly opened browser window. If the browser window failed to show, you can click here to copy the URL, and manually open it in your browser. account.methods.microsoft.manual=Your device code is <b>%s</b>. After clicking "login" button, you should finish authorization in the newly opened browser window. If the browser window failed to show, you can click here to copy the URL, and manually open it in your browser.
account.methods.microsoft.profile=Account Profile... account.methods.microsoft.profile=Account Profile...
account.methods.microsoft.snapshot=HMCL Snapshot version does not support Microsoft login. account.methods.microsoft.snapshot=HMCL Snapshot version does not support Microsoft login.
account.methods.microsoft.waiting_browser=Waiting for authorization in opened browser window... account.methods.microsoft.waiting_browser=Waiting for authorization in opened browser window...

View File

@@ -96,8 +96,8 @@ account.methods.microsoft.error.missing_xbox_account=你的微軟帳號尚未關
account.methods.microsoft.error.no_character=該帳號沒有包含 Minecraft Java 版購買記錄 account.methods.microsoft.error.no_character=該帳號沒有包含 Minecraft Java 版購買記錄
account.methods.microsoft.error.unknown=登入失敗,錯誤碼:%d account.methods.microsoft.error.unknown=登入失敗,錯誤碼:%d
account.methods.microsoft.logging_in=登入中... account.methods.microsoft.logging_in=登入中...
account.methods.microsoft.hint=您需要點擊登按鈕,並在新打開的瀏覽器窗口中完成登 account.methods.microsoft.hint=您需要點擊登按鈕,並在新打開的瀏覽器窗口中完成登錄,並輸入待會在此處顯示的設備代碼
account.methods.microsoft.manual=若登頁面未能打開,您可以點擊此處複製連結,並手動在瀏覽器中打開網頁。 account.methods.microsoft.manual=設備代碼為:<b>%s</b>。若登頁面未能打開,您可以點擊此處複製連結,並手動在瀏覽器中打開網頁。
account.methods.microsoft.profile=帳戶設置頁 account.methods.microsoft.profile=帳戶設置頁
account.methods.microsoft.snapshot=HMCL 快照版不支持微软登录 account.methods.microsoft.snapshot=HMCL 快照版不支持微软登录
account.methods.microsoft.waiting_browser=等待在新打開的瀏覽器窗口中完成登入... account.methods.microsoft.waiting_browser=等待在新打開的瀏覽器窗口中完成登入...

View File

@@ -96,8 +96,8 @@ account.methods.microsoft.error.missing_xbox_account=你的微软帐户尚未关
account.methods.microsoft.error.no_character=该帐户没有包含 Minecraft Java 版购买记录 account.methods.microsoft.error.no_character=该帐户没有包含 Minecraft Java 版购买记录
account.methods.microsoft.error.unknown=登录失败,错误码:%d account.methods.microsoft.error.unknown=登录失败,错误码:%d
account.methods.microsoft.logging_in=登录中... account.methods.microsoft.logging_in=登录中...
account.methods.microsoft.hint=您需要点击登录按钮,并在新打开的浏览器窗口中完成登录。 account.methods.microsoft.hint=您需要点击登录按钮,并在新打开的浏览器窗口中完成登录,并输入待会在此处显示的设备代码
account.methods.microsoft.manual=若登录页面未能打开,您可以点击此处复制链接,并手动在浏览器中打开网页。 account.methods.microsoft.manual=设备代码为:<b>%s</b>。若登录页面未能打开,您可以点击此处复制链接,并手动在浏览器中打开网页。
account.methods.microsoft.profile=帐户设置页 account.methods.microsoft.profile=帐户设置页
account.methods.microsoft.snapshot=HMCL 快照版不支持微软登录 account.methods.microsoft.snapshot=HMCL 快照版不支持微软登录
account.methods.microsoft.waiting_browser=等待在新打开的浏览器窗口中完成登录... account.methods.microsoft.waiting_browser=等待在新打开的浏览器窗口中完成登录...

View File

@@ -0,0 +1,350 @@
/*
* 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.auth;
import com.google.gson.JsonParseException;
import com.google.gson.annotations.SerializedName;
import org.jackhuang.hmcl.auth.yggdrasil.RemoteAuthenticationException;
import org.jackhuang.hmcl.util.io.HttpRequest;
import org.jackhuang.hmcl.util.io.NetworkUtils;
import java.io.IOException;
import java.util.concurrent.ExecutionException;
import java.util.concurrent.TimeUnit;
import static org.jackhuang.hmcl.util.Lang.mapOf;
import static org.jackhuang.hmcl.util.Pair.pair;
public class OAuth {
public static final OAuth MICROSOFT = new OAuth(
"https://login.live.com/oauth20_authorize.srf",
"https://login.live.com/oauth20_token.srf",
"https://login.microsoftonline.com/consumers/oauth2/v2.0/devicecode",
"https://login.microsoftonline.com/consumers/oauth2/v2.0/token");
private final String authorizationURL;
private final String accessTokenURL;
private final String deviceCodeURL;
private final String tokenURL;
public OAuth(String authorizationURL, String accessTokenURL, String deviceCodeURL, String tokenURL) {
this.authorizationURL = authorizationURL;
this.accessTokenURL = accessTokenURL;
this.deviceCodeURL = deviceCodeURL;
this.tokenURL = tokenURL;
}
public Result authenticate(GrantFlow grantFlow, Options options) throws AuthenticationException {
try {
switch (grantFlow) {
case AUTHORIZATION_CODE:
return authenticateAuthorizationCode(options);
case DEVICE:
return authenticateDevice(options);
default:
throw new UnsupportedOperationException("grant flow " + grantFlow);
}
} catch (IOException e) {
throw new ServerDisconnectException(e);
} catch (InterruptedException e) {
throw new NoSelectedCharacterException();
} catch (ExecutionException e) {
if (e.getCause() instanceof InterruptedException) {
throw new NoSelectedCharacterException();
} else {
throw new ServerDisconnectException(e);
}
} catch (JsonParseException e) {
throw new ServerResponseMalformedException(e);
}
}
private Result authenticateAuthorizationCode(Options options) throws IOException, InterruptedException, JsonParseException, ExecutionException, AuthenticationException {
Session session = options.callback.startServer();
options.callback.openBrowser(NetworkUtils.withQuery(authorizationURL,
mapOf(pair("client_id", options.callback.getClientId()), pair("response_type", "code"),
pair("redirect_uri", session.getRedirectURI()), pair("scope", options.scope),
pair("prompt", "select_account"))));
String code = session.waitFor();
// Authorization Code -> Token
AuthorizationResponse response = HttpRequest.POST(accessTokenURL)
.form(pair("client_id", options.callback.getClientId()), pair("code", code),
pair("grant_type", "authorization_code"), pair("client_secret", options.callback.getClientSecret()),
pair("redirect_uri", session.getRedirectURI()), pair("scope", options.scope))
.ignoreHttpCode()
.getJson(AuthorizationResponse.class);
handleErrorResponse(response);
return new Result(response.accessToken, response.refreshToken);
}
private Result authenticateDevice(Options options) throws IOException, InterruptedException, JsonParseException, AuthenticationException {
DeviceTokenResponse deviceTokenResponse = HttpRequest.POST(deviceCodeURL)
.form(pair("client_id", options.callback.getClientId()), pair("scope", options.scope))
.ignoreHttpCode()
.getJson(DeviceTokenResponse.class);
options.callback.grantDeviceCode(deviceTokenResponse.deviceCode, deviceTokenResponse.verificationURI);
// Microsoft OAuth Flow
options.callback.openBrowser(deviceTokenResponse.verificationURI);
long startTime = System.nanoTime();
int interval = deviceTokenResponse.interval;
while (true) {
Thread.sleep(Math.max(interval, 1));
// We stop waiting if user does not respond our authentication request in 15 minutes.
long estimatedTime = System.nanoTime() - startTime;
if (TimeUnit.MINUTES.convert(estimatedTime, TimeUnit.SECONDS) >= Math.min(deviceTokenResponse.expiresIn, 900)) {
throw new NoSelectedCharacterException();
}
TokenResponse tokenResponse = HttpRequest.POST(tokenURL)
.form(
pair("grant_type", "urn:ietf:params:oauth:grant-type:device_code"),
pair("code", deviceTokenResponse.deviceCode),
pair("client_id", options.callback.getClientId()))
.getJson(TokenResponse.class);
if ("authorization_pending".equals(tokenResponse.error)) {
continue;
}
if ("expired_token".equals(tokenResponse.error)) {
throw new NoSelectedCharacterException();
}
if ("slow_down".equals(tokenResponse.error)) {
interval += 5;
continue;
}
return new Result(tokenResponse.accessToken, tokenResponse.refreshToken);
}
}
public Result refresh(String refreshToken, Options options) throws AuthenticationException {
try {
RefreshResponse response = HttpRequest.POST(accessTokenURL)
.form(pair("client_id", options.callback.getClientId()),
pair("client_secret", options.callback.getClientSecret()),
pair("refresh_token", refreshToken),
pair("grant_type", "refresh_token"))
.accept("application/json")
.ignoreHttpCode()
.getJson(RefreshResponse.class);
handleErrorResponse(response);
return new Result(response.accessToken, response.refreshToken);
} catch (IOException e) {
throw new ServerDisconnectException(e);
} catch (JsonParseException e) {
throw new ServerResponseMalformedException(e);
}
}
private static void handleErrorResponse(ErrorResponse response) throws AuthenticationException {
if (response.error == null || response.errorDescription == null) {
return;
}
switch (response.error) {
case "invalid_grant":
if (response.errorDescription.contains("The user must sign in again and if needed grant the client application access to the requested scope")) {
throw new CredentialExpiredException();
}
break;
}
throw new RemoteAuthenticationException(response.error, response.errorDescription, "");
}
public static class Options {
private String userAgent;
private final String scope;
private final Callback callback;
public Options(String scope, Callback callback) {
this.scope = scope;
this.callback = callback;
}
public Options setUserAgent(String userAgent) {
this.userAgent = userAgent;
return this;
}
}
public interface Session {
String getRedirectURI();
/**
* Wait for authentication
*
* @return authentication code
* @throws InterruptedException if interrupted
* @throws ExecutionException if an I/O error occurred.
*/
String waitFor() throws InterruptedException, ExecutionException;
default String getIdToken() {
return null;
}
}
public interface Callback {
/**
* Start OAuth callback server at localhost.
*
* @throws IOException if an I/O error occurred.
*/
Session startServer() throws IOException, AuthenticationException;
void grantDeviceCode(String userCode, String verificationURI);
/**
* Open browser
*
* @param url OAuth url.
*/
void openBrowser(String url) throws IOException;
String getClientId();
String getClientSecret();
}
public enum GrantFlow {
AUTHORIZATION_CODE,
DEVICE,
}
public class Result {
private final String accessToken;
private final String refreshToken;
public Result(String accessToken, String refreshToken) {
this.accessToken = accessToken;
this.refreshToken = refreshToken;
}
public String getAccessToken() {
return accessToken;
}
public String getRefreshToken() {
return refreshToken;
}
}
private static class DeviceTokenResponse {
@SerializedName("user_code")
public String userCode;
@SerializedName("device_code")
public String deviceCode;
// The URI to be visited for user.
@SerializedName("verification_uri")
public String verificationURI;
// Life time in seconds for device_code and user_code
@SerializedName("expires_in")
public int expiresIn;
// Polling interval
@SerializedName("interval")
public int interval;
}
private static class TokenResponse extends ErrorResponse {
@SerializedName("token_type")
public String tokenType;
@SerializedName("expires_in")
public int expiresIn;
@SerializedName("ext_expires_in")
public int extExpiresIn;
@SerializedName("scope")
public String scope;
@SerializedName("access_token")
public String accessToken;
@SerializedName("refresh_token")
public String refreshToken;
}
private static class ErrorResponse {
@SerializedName("error")
public String error;
@SerializedName("error_description")
public String errorDescription;
@SerializedName("correlation_id")
public String correlationId;
}
/**
* 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":"??????"}
*/
public static class AuthorizationResponse extends ErrorResponse {
@SerializedName("token_type")
public String tokenType;
@SerializedName("expires_in")
public int expiresIn;
@SerializedName("scope")
public String scope;
@SerializedName("access_token")
public String accessToken;
@SerializedName("refresh_token")
public String refreshToken;
@SerializedName("user_id")
public String userId;
@SerializedName("foci")
public String foci;
}
private static class RefreshResponse extends ErrorResponse {
@SerializedName("expires_in")
int expiresIn;
@SerializedName("access_token")
String accessToken;
@SerializedName("refresh_token")
String refreshToken;
}
}

View File

@@ -22,6 +22,7 @@ import com.google.gson.GsonBuilder;
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.OAuth;
import org.jackhuang.hmcl.auth.yggdrasil.CompleteGameProfile; import org.jackhuang.hmcl.auth.yggdrasil.CompleteGameProfile;
import org.jackhuang.hmcl.auth.yggdrasil.RemoteAuthenticationException; import org.jackhuang.hmcl.auth.yggdrasil.RemoteAuthenticationException;
import org.jackhuang.hmcl.auth.yggdrasil.Texture; import org.jackhuang.hmcl.auth.yggdrasil.Texture;
@@ -36,11 +37,9 @@ import java.io.IOException;
import java.net.HttpURLConnection; import java.net.HttpURLConnection;
import java.net.URL; import java.net.URL;
import java.util.*; import java.util.*;
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.logging.Level; import java.util.logging.Level;
import java.util.regex.Pattern;
import static java.net.HttpURLConnection.HTTP_NOT_FOUND; import static java.net.HttpURLConnection.HTTP_NOT_FOUND;
import static java.util.Objects.requireNonNull; import static java.util.Objects.requireNonNull;
@@ -50,20 +49,15 @@ 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 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 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, private static final ThreadPoolExecutor POOL = threadPool("MicrosoftProfileProperties", true, 2, 10,
TimeUnit.SECONDS); TimeUnit.SECONDS);
private static final Pattern OAUTH_URL_PATTERN = Pattern
.compile("^https://login\\.live\\.com/oauth20_desktop\\.srf\\?code=(.*?)&lc=(.*?)$");
private final OAuthCallback callback; private final OAuth.Callback callback;
private final ObservableOptionalCache<UUID, CompleteGameProfile, AuthenticationException> profileRepository; private final ObservableOptionalCache<UUID, CompleteGameProfile, AuthenticationException> profileRepository;
public MicrosoftService(OAuthCallback callback) { public MicrosoftService(OAuth.Callback callback) {
this.callback = requireNonNull(callback); this.callback = requireNonNull(callback);
this.profileRepository = new ObservableOptionalCache<>(uuid -> { this.profileRepository = new ObservableOptionalCache<>(uuid -> {
LOG.info("Fetching properties of " + uuid); LOG.info("Fetching properties of " + uuid);
@@ -76,37 +70,11 @@ public class MicrosoftService {
} }
public MicrosoftSession authenticate() throws AuthenticationException { public MicrosoftSession authenticate() throws AuthenticationException {
// 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 OAuth.Result result = OAuth.MICROSOFT.authenticate(OAuth.GrantFlow.DEVICE, new OAuth.Options(SCOPE, callback));
OAuthSession session = callback.startServer(); return authenticateViaLiveAccessToken(result.getAccessToken(), result.getRefreshToken());
callback.openBrowser(NetworkUtils.withQuery(AUTHORIZATION_URL,
mapOf(pair("client_id", callback.getClientId()), 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(ACCESS_TOKEN_URL)
.form(mapOf(pair("client_id", callback.getClientId()), pair("code", code),
pair("grant_type", "authorization_code"), pair("client_secret", callback.getClientSecret()),
pair("redirect_uri", session.getRedirectURI()), pair("scope", SCOPE)))
.getString();
LiveAuthorizationResponse response = JsonUtils.fromNonNullJson(responseText,
LiveAuthorizationResponse.class);
return authenticateViaLiveAccessToken(response.accessToken, response.refreshToken);
} catch (IOException e) { } catch (IOException e) {
throw new ServerDisconnectException(e); throw new ServerDisconnectException(e);
} catch (InterruptedException e) {
throw new NoSelectedCharacterException();
} catch (ExecutionException e) {
if (e.getCause() instanceof InterruptedException) {
throw new NoSelectedCharacterException();
} else {
throw new ServerDisconnectException(e);
}
} catch (JsonParseException e) { } catch (JsonParseException e) {
throw new ServerResponseMalformedException(e); throw new ServerResponseMalformedException(e);
} }
@@ -114,19 +82,8 @@ public class MicrosoftService {
public MicrosoftSession refresh(MicrosoftSession oldSession) throws AuthenticationException { public MicrosoftSession refresh(MicrosoftSession oldSession) throws AuthenticationException {
try { try {
LiveRefreshResponse response = HttpRequest.POST(ACCESS_TOKEN_URL) OAuth.Result result = OAuth.MICROSOFT.refresh(oldSession.getRefreshToken(), new OAuth.Options(SCOPE, callback));
.form(pair("client_id", callback.getClientId()), return authenticateViaLiveAccessToken(result.getAccessToken(), result.getRefreshToken());
pair("client_secret", callback.getClientSecret()),
pair("refresh_token", oldSession.getRefreshToken()),
pair("grant_type", "refresh_token"))
.accept("application/json")
.ignoreHttpErrorCode(400)
.ignoreHttpErrorCode(401)
.getJson(LiveRefreshResponse.class);
handleLiveErrorMessage(response);
return authenticateViaLiveAccessToken(response.accessToken, response.refreshToken);
} catch (IOException e) { } catch (IOException e) {
throw new ServerDisconnectException(e); throw new ServerDisconnectException(e);
} catch (JsonParseException e) { } catch (JsonParseException e) {
@@ -134,22 +91,6 @@ public class MicrosoftService {
} }
} }
private void handleLiveErrorMessage(LiveErrorResponse response) throws AuthenticationException {
if (response.error == null || response.errorDescription == null) {
return;
}
switch (response.error) {
case "invalid_grant":
if (response.errorDescription.contains("The user must sign in again and if needed grant the client application access to the requested scope")) {
throw new CredentialExpiredException();
}
break;
}
throw new RemoteAuthenticationException(response.error, response.errorDescription, "");
}
private String getUhs(XBoxLiveAuthenticationResponse response, String existingUhs) throws AuthenticationException { private String getUhs(XBoxLiveAuthenticationResponse response, String existingUhs) throws AuthenticationException {
if (response.errorCode != 0) { if (response.errorCode != 0) {
throw new XboxAuthorizationException(response.errorCode, response.redirectUrl); throw new XboxAuthorizationException(response.errorCode, response.redirectUrl);
@@ -357,57 +298,6 @@ public class MicrosoftService {
public static class NoXuiException extends AuthenticationException { public static class NoXuiException extends AuthenticationException {
} }
public static class LiveErrorResponse {
@SerializedName("error")
public String error;
@SerializedName("error_description")
public String errorDescription;
@SerializedName("correlation_id")
public String correlationId;
}
/**
* 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":"??????"}
*/
public static class LiveAuthorizationResponse extends LiveErrorResponse {
@SerializedName("token_type")
public String tokenType;
@SerializedName("expires_in")
public int expiresIn;
@SerializedName("scope")
public String scope;
@SerializedName("access_token")
public String accessToken;
@SerializedName("refresh_token")
public String refreshToken;
@SerializedName("user_id")
public String userId;
@SerializedName("foci")
public String foci;
}
private static class LiveRefreshResponse extends LiveErrorResponse {
@SerializedName("expires_in")
int expiresIn;
@SerializedName("access_token")
String accessToken;
@SerializedName("refresh_token")
String refreshToken;
}
private static class XBoxLiveAuthenticationResponseDisplayClaims { private static class XBoxLiveAuthenticationResponseDisplayClaims {
List<Map<Object, Object>> xui; List<Map<Object, Object>> xui;
} }
@@ -530,44 +420,6 @@ public class MicrosoftService {
public String developerMessage; public String developerMessage;
} }
public interface OAuthCallback {
/**
* Start OAuth callback server at localhost.
*
* @throws IOException if an I/O error occurred.
*/
OAuthSession startServer() throws IOException, AuthenticationException;
/**
* Open browser
*
* @param url OAuth url.
*/
void openBrowser(String url) throws IOException;
String getClientId();
String getClientSecret();
}
public interface OAuthSession {
String getRedirectURI();
/**
* Wait for authentication
*
* @return authentication code
* @throws InterruptedException if interrupted
* @throws ExecutionException if an I/O error occurred.
*/
String waitFor() throws InterruptedException, ExecutionException;
default String getIdToken() {
return null;
}
}
private static final Gson GSON = new GsonBuilder() private static final Gson GSON = new GsonBuilder()
.registerTypeAdapter(UUID.class, UUIDTypeAdapter.INSTANCE) .registerTypeAdapter(UUID.class, UUIDTypeAdapter.INSTANCE)
.registerTypeAdapterFactory(ValidationTypeAdapterFactory.INSTANCE) .registerTypeAdapterFactory(ValidationTypeAdapterFactory.INSTANCE)

View File

@@ -49,6 +49,7 @@ public abstract class HttpRequest {
protected final Map<String, String> headers = new HashMap<>(); protected final Map<String, String> headers = new HashMap<>();
protected ExceptionalBiConsumer<URL, Integer, IOException> responseCodeTester; protected ExceptionalBiConsumer<URL, Integer, IOException> responseCodeTester;
protected final Set<Integer> toleratedHttpCodes = new HashSet<>(); protected final Set<Integer> toleratedHttpCodes = new HashSet<>();
protected boolean ignoreHttpCode;
private HttpRequest(String url, String method) { private HttpRequest(String url, String method) {
this.url = url; this.url = url;
@@ -76,6 +77,11 @@ public abstract class HttpRequest {
return this; return this;
} }
public HttpRequest ignoreHttpCode() {
ignoreHttpCode = true;
return this;
}
public abstract String getString() throws IOException; public abstract String getString() throws IOException;
public CompletableFuture<String> getStringAsync() { public CompletableFuture<String> getStringAsync() {
@@ -173,7 +179,7 @@ public abstract class HttpRequest {
responseCodeTester.accept(new URL(url), con.getResponseCode()); responseCodeTester.accept(new URL(url), con.getResponseCode());
} else { } else {
if (con.getResponseCode() / 100 != 2) { if (con.getResponseCode() / 100 != 2) {
if (!toleratedHttpCodes.contains(con.getResponseCode())) { if (!ignoreHttpCode && !toleratedHttpCodes.contains(con.getResponseCode())) {
String data = NetworkUtils.readData(con); String data = NetworkUtils.readData(con);
throw new ResponseCodeException(new URL(url), con.getResponseCode(), data); throw new ResponseCodeException(new URL(url), con.getResponseCode(), data);
} }