feat: Microsoft Account authentication
This commit is contained in:
@@ -34,15 +34,20 @@ import org.jackhuang.hmcl.auth.authlibinjector.AuthlibInjectorArtifactProvider;
|
|||||||
import org.jackhuang.hmcl.auth.authlibinjector.AuthlibInjectorDownloader;
|
import org.jackhuang.hmcl.auth.authlibinjector.AuthlibInjectorDownloader;
|
||||||
import org.jackhuang.hmcl.auth.authlibinjector.AuthlibInjectorServer;
|
import org.jackhuang.hmcl.auth.authlibinjector.AuthlibInjectorServer;
|
||||||
import org.jackhuang.hmcl.auth.authlibinjector.SimpleAuthlibInjectorArtifactProvider;
|
import org.jackhuang.hmcl.auth.authlibinjector.SimpleAuthlibInjectorArtifactProvider;
|
||||||
|
import org.jackhuang.hmcl.auth.microsoft.MicrosoftAccount;
|
||||||
|
import org.jackhuang.hmcl.auth.microsoft.MicrosoftAccountFactory;
|
||||||
|
import org.jackhuang.hmcl.auth.microsoft.MicrosoftService;
|
||||||
import org.jackhuang.hmcl.auth.offline.OfflineAccount;
|
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.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;
|
||||||
import java.util.HashMap;
|
import java.util.HashMap;
|
||||||
|
import java.util.List;
|
||||||
import java.util.Map;
|
import java.util.Map;
|
||||||
import java.util.Optional;
|
import java.util.Optional;
|
||||||
import java.util.logging.Level;
|
import java.util.logging.Level;
|
||||||
@@ -51,6 +56,7 @@ import static java.util.stream.Collectors.toList;
|
|||||||
import static javafx.collections.FXCollections.observableArrayList;
|
import static javafx.collections.FXCollections.observableArrayList;
|
||||||
import static org.jackhuang.hmcl.setting.ConfigHolder.config;
|
import static org.jackhuang.hmcl.setting.ConfigHolder.config;
|
||||||
import static org.jackhuang.hmcl.ui.FXUtils.onInvalidating;
|
import static org.jackhuang.hmcl.ui.FXUtils.onInvalidating;
|
||||||
|
import static org.jackhuang.hmcl.util.Lang.immutableListOf;
|
||||||
import static org.jackhuang.hmcl.util.Lang.mapOf;
|
import static org.jackhuang.hmcl.util.Lang.mapOf;
|
||||||
import static org.jackhuang.hmcl.util.Logging.LOG;
|
import static org.jackhuang.hmcl.util.Logging.LOG;
|
||||||
import static org.jackhuang.hmcl.util.Pair.pair;
|
import static org.jackhuang.hmcl.util.Pair.pair;
|
||||||
@@ -78,6 +84,8 @@ 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 List<AccountFactory<?>> FACTORIES = immutableListOf(FACTORY_OFFLINE, FACTORY_MOJANG, FACTORY_AUTHLIB_INJECTOR, FACTORY_MICROSOFT);
|
||||||
|
|
||||||
// ==== login type / account factory mapping ====
|
// ==== login type / account factory mapping ====
|
||||||
private static final Map<String, AccountFactory<?>> type2factory = new HashMap<>();
|
private static final Map<String, AccountFactory<?>> type2factory = new HashMap<>();
|
||||||
@@ -86,6 +94,7 @@ public final class Accounts {
|
|||||||
type2factory.put("offline", FACTORY_OFFLINE);
|
type2factory.put("offline", FACTORY_OFFLINE);
|
||||||
type2factory.put("yggdrasil", FACTORY_MOJANG);
|
type2factory.put("yggdrasil", FACTORY_MOJANG);
|
||||||
type2factory.put("authlibInjector", FACTORY_AUTHLIB_INJECTOR);
|
type2factory.put("authlibInjector", FACTORY_AUTHLIB_INJECTOR);
|
||||||
|
type2factory.put("microsoft", FACTORY_MICROSOFT);
|
||||||
|
|
||||||
type2factory.forEach((type, factory) -> factory2type.put(factory, type));
|
type2factory.forEach((type, factory) -> factory2type.put(factory, type));
|
||||||
}
|
}
|
||||||
@@ -108,6 +117,8 @@ public final class Accounts {
|
|||||||
return FACTORY_AUTHLIB_INJECTOR;
|
return FACTORY_AUTHLIB_INJECTOR;
|
||||||
else if (account instanceof YggdrasilAccount)
|
else if (account instanceof YggdrasilAccount)
|
||||||
return FACTORY_MOJANG;
|
return FACTORY_MOJANG;
|
||||||
|
else if (account instanceof MicrosoftAccount)
|
||||||
|
return FACTORY_MICROSOFT;
|
||||||
else
|
else
|
||||||
throw new IllegalArgumentException("Failed to determine account type: " + account);
|
throw new IllegalArgumentException("Failed to determine account type: " + account);
|
||||||
}
|
}
|
||||||
@@ -309,7 +320,8 @@ public final class Accounts {
|
|||||||
private static Map<AccountFactory<?>, String> unlocalizedLoginTypeNames = mapOf(
|
private static Map<AccountFactory<?>, String> unlocalizedLoginTypeNames = mapOf(
|
||||||
pair(Accounts.FACTORY_OFFLINE, "account.methods.offline"),
|
pair(Accounts.FACTORY_OFFLINE, "account.methods.offline"),
|
||||||
pair(Accounts.FACTORY_MOJANG, "account.methods.yggdrasil"),
|
pair(Accounts.FACTORY_MOJANG, "account.methods.yggdrasil"),
|
||||||
pair(Accounts.FACTORY_AUTHLIB_INJECTOR, "account.methods.authlib_injector"));
|
pair(Accounts.FACTORY_AUTHLIB_INJECTOR, "account.methods.authlib_injector"),
|
||||||
|
pair(Accounts.FACTORY_MICROSOFT, "account.methods.microsoft"));
|
||||||
|
|
||||||
public static String getLocalizedLoginTypeName(AccountFactory<?> factory) {
|
public static String getLocalizedLoginTypeName(AccountFactory<?> factory) {
|
||||||
return i18n(Optional.ofNullable(unlocalizedLoginTypeNames.get(factory))
|
return i18n(Optional.ofNullable(unlocalizedLoginTypeNames.get(factory))
|
||||||
|
|||||||
@@ -31,6 +31,7 @@ import org.jackhuang.hmcl.setting.EnumCommonDirectory;
|
|||||||
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;
|
||||||
@@ -107,6 +108,7 @@ 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());
|
||||||
|
|||||||
@@ -17,7 +17,11 @@
|
|||||||
*/
|
*/
|
||||||
package org.jackhuang.hmcl.ui;
|
package org.jackhuang.hmcl.ui;
|
||||||
|
|
||||||
|
import javafx.beans.value.ChangeListener;
|
||||||
|
import javafx.beans.value.ObservableValue;
|
||||||
|
import javafx.concurrent.Worker;
|
||||||
import javafx.scene.Scene;
|
import javafx.scene.Scene;
|
||||||
|
import javafx.scene.web.WebEngine;
|
||||||
import javafx.scene.web.WebView;
|
import javafx.scene.web.WebView;
|
||||||
import javafx.stage.Stage;
|
import javafx.stage.Stage;
|
||||||
|
|
||||||
@@ -25,12 +29,19 @@ import static org.jackhuang.hmcl.setting.ConfigHolder.config;
|
|||||||
import static org.jackhuang.hmcl.ui.FXUtils.newImage;
|
import static org.jackhuang.hmcl.ui.FXUtils.newImage;
|
||||||
|
|
||||||
public class WebStage extends Stage {
|
public class WebStage extends Stage {
|
||||||
private final WebView webView = new WebView();
|
protected final WebView webView = new WebView();
|
||||||
|
protected final WebEngine webEngine = webView.getEngine();
|
||||||
|
|
||||||
public WebStage() {
|
public WebStage() {
|
||||||
setScene(new Scene(webView, 800, 480));
|
this(800, 480);
|
||||||
|
}
|
||||||
|
|
||||||
|
public WebStage(int width, int height) {
|
||||||
|
setScene(new Scene(webView, width, height));
|
||||||
getScene().getStylesheets().addAll(config().getTheme().getStylesheets());
|
getScene().getStylesheets().addAll(config().getTheme().getStylesheets());
|
||||||
getIcons().add(newImage("/assets/img/icon.png"));
|
getIcons().add(newImage("/assets/img/icon.png"));
|
||||||
|
webView.setContextMenuEnabled(false);
|
||||||
|
titleProperty().bind(webEngine.titleProperty());
|
||||||
}
|
}
|
||||||
|
|
||||||
public WebView getWebView() {
|
public WebView getWebView() {
|
||||||
|
|||||||
@@ -73,7 +73,9 @@ public class AccountListItem extends RadioButton {
|
|||||||
if (account instanceof OfflineAccount) {
|
if (account instanceof OfflineAccount) {
|
||||||
title.bind(characterName);
|
title.bind(characterName);
|
||||||
} else {
|
} else {
|
||||||
title.bind(Bindings.concat(account.getUsername(), " - ", characterName));
|
title.bind(
|
||||||
|
account.getUsername().isEmpty() ? characterName :
|
||||||
|
Bindings.concat(account.getUsername(), " - ", characterName));
|
||||||
}
|
}
|
||||||
|
|
||||||
image.bind(TexturesLoader.fxAvatarBinding(account, 32));
|
image.bind(TexturesLoader.fxAvatarBinding(account, 32));
|
||||||
|
|||||||
@@ -68,6 +68,7 @@ public class AddAccountPane extends StackPane {
|
|||||||
@FXML private JFXPasswordField txtPassword;
|
@FXML private JFXPasswordField txtPassword;
|
||||||
@FXML private Label lblCreationWarning;
|
@FXML private Label lblCreationWarning;
|
||||||
@FXML private Label lblPassword;
|
@FXML private Label lblPassword;
|
||||||
|
@FXML private Label lblUsername;
|
||||||
@FXML private JFXComboBox<AccountFactory<?>> cboType;
|
@FXML private JFXComboBox<AccountFactory<?>> cboType;
|
||||||
@FXML private JFXComboBox<AuthlibInjectorServer> cboServers;
|
@FXML private JFXComboBox<AuthlibInjectorServer> cboServers;
|
||||||
@FXML private Label lblInjectorServer;
|
@FXML private Label lblInjectorServer;
|
||||||
@@ -88,7 +89,7 @@ public class AddAccountPane extends StackPane {
|
|||||||
cboServers.getItems().addListener(onInvalidating(this::selectDefaultServer));
|
cboServers.getItems().addListener(onInvalidating(this::selectDefaultServer));
|
||||||
selectDefaultServer();
|
selectDefaultServer();
|
||||||
|
|
||||||
cboType.getItems().setAll(Accounts.FACTORY_OFFLINE, Accounts.FACTORY_MOJANG, Accounts.FACTORY_AUTHLIB_INJECTOR);
|
cboType.getItems().setAll(Accounts.FACTORIES);
|
||||||
cboType.setConverter(stringConverter(Accounts::getLocalizedLoginTypeName));
|
cboType.setConverter(stringConverter(Accounts::getLocalizedLoginTypeName));
|
||||||
// try selecting the preferred login type
|
// try selecting the preferred login type
|
||||||
cboType.getSelectionModel().select(
|
cboType.getSelectionModel().select(
|
||||||
@@ -108,7 +109,9 @@ public class AddAccountPane extends StackPane {
|
|||||||
// remember the last used login type
|
// remember the last used login type
|
||||||
loginType.addListener((observable, oldValue, newValue) -> config().setPreferredLoginType(Accounts.getLoginType(newValue)));
|
loginType.addListener((observable, oldValue, newValue) -> config().setPreferredLoginType(Accounts.getLoginType(newValue)));
|
||||||
|
|
||||||
txtPassword.visibleProperty().bind(loginType.isNotEqualTo(Accounts.FACTORY_OFFLINE));
|
txtUsername.visibleProperty().bind(Bindings.createBooleanBinding(() -> loginType.get().getLoginType().requiresUsername, loginType));
|
||||||
|
lblUsername.visibleProperty().bind(txtUsername.visibleProperty());
|
||||||
|
txtPassword.visibleProperty().bind(Bindings.createBooleanBinding(() -> loginType.get().getLoginType().requiresPassword, loginType));
|
||||||
lblPassword.visibleProperty().bind(txtPassword.visibleProperty());
|
lblPassword.visibleProperty().bind(txtPassword.visibleProperty());
|
||||||
|
|
||||||
cboServers.visibleProperty().bind(loginType.isEqualTo(Accounts.FACTORY_AUTHLIB_INJECTOR));
|
cboServers.visibleProperty().bind(loginType.isEqualTo(Accounts.FACTORY_AUTHLIB_INJECTOR));
|
||||||
@@ -118,7 +121,7 @@ public class AddAccountPane extends StackPane {
|
|||||||
|
|
||||||
btnAccept.disableProperty().bind(Bindings.createBooleanBinding(
|
btnAccept.disableProperty().bind(Bindings.createBooleanBinding(
|
||||||
() -> !( // consider the opposite situation: input is valid
|
() -> !( // consider the opposite situation: input is valid
|
||||||
txtUsername.validate() &&
|
(!txtUsername.isVisible() || txtUsername.validate()) &&
|
||||||
// invisible means the field is not needed, neither should it be validated
|
// invisible means the field is not needed, neither should it be validated
|
||||||
(!txtPassword.isVisible() || txtPassword.validate()) &&
|
(!txtPassword.isVisible() || txtPassword.validate()) &&
|
||||||
(!cboServers.isVisible() || cboServers.getSelectionModel().getSelectedItem() != null)
|
(!cboServers.isVisible() || cboServers.getSelectionModel().getSelectedItem() != null)
|
||||||
|
|||||||
@@ -0,0 +1,49 @@
|
|||||||
|
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;
|
||||||
|
|
||||||
|
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);
|
||||||
|
|
||||||
|
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(() -> {
|
||||||
|
webEngine.load(initialURL);
|
||||||
|
show();
|
||||||
|
});
|
||||||
|
this.future = new CompletableFuture<>();
|
||||||
|
this.urlTester = urlTester;
|
||||||
|
return future;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -45,7 +45,7 @@
|
|||||||
</JFXButton>
|
</JFXButton>
|
||||||
</HBox>
|
</HBox>
|
||||||
|
|
||||||
<Label text="%account.username" GridPane.rowIndex="2" GridPane.columnIndex="0"/>
|
<Label fx:id="lblUsername" text="%account.username" GridPane.rowIndex="2" GridPane.columnIndex="0"/>
|
||||||
|
|
||||||
<JFXTextField fx:id="txtUsername" GridPane.columnIndex="1" GridPane.rowIndex="2" GridPane.columnSpan="2"
|
<JFXTextField fx:id="txtUsername" GridPane.columnIndex="1" GridPane.rowIndex="2" GridPane.columnSpan="2"
|
||||||
FXUtils.validateWhileTextChanged="true" onAction="#onCreationAccept">
|
FXUtils.validateWhileTextChanged="true" onAction="#onCreationAccept">
|
||||||
|
|||||||
@@ -57,6 +57,7 @@ account.injector.server_name=Server Name
|
|||||||
account.manage=Account List
|
account.manage=Account List
|
||||||
account.methods=Login Type
|
account.methods=Login Type
|
||||||
account.methods.authlib_injector=authlib-injector
|
account.methods.authlib_injector=authlib-injector
|
||||||
|
account.methods.microsoft=Microsoft Account
|
||||||
account.methods.offline=Offline
|
account.methods.offline=Offline
|
||||||
account.methods.yggdrasil=Mojang
|
account.methods.yggdrasil=Mojang
|
||||||
account.missing=No Account
|
account.missing=No Account
|
||||||
|
|||||||
@@ -57,6 +57,7 @@ account.injector.server_name=Nombre de servidor
|
|||||||
account.manage=Lista de cuentas
|
account.manage=Lista de cuentas
|
||||||
account.methods=Tipo de inición
|
account.methods=Tipo de inición
|
||||||
account.methods.authlib_injector=authlib-injector
|
account.methods.authlib_injector=authlib-injector
|
||||||
|
account.methods.microsoft=Microsoft
|
||||||
account.methods.offline=Offline
|
account.methods.offline=Offline
|
||||||
account.methods.yggdrasil=Mojang
|
account.methods.yggdrasil=Mojang
|
||||||
account.missing=No hay cuenta
|
account.missing=No hay cuenta
|
||||||
|
|||||||
@@ -57,6 +57,7 @@ account.injector.server_name=Имя сервера
|
|||||||
account.manage=Список уч. записей
|
account.manage=Список уч. записей
|
||||||
account.methods=Тип входа
|
account.methods=Тип входа
|
||||||
account.methods.authlib_injector=authlib-injector
|
account.methods.authlib_injector=authlib-injector
|
||||||
|
account.methods.microsoft=Microsoft
|
||||||
account.methods.offline=Вход без пароля
|
account.methods.offline=Вход без пароля
|
||||||
account.methods.yggdrasil=Уч. запись Mojang
|
account.methods.yggdrasil=Уч. запись Mojang
|
||||||
account.missing=Без уч. записи
|
account.missing=Без уч. записи
|
||||||
|
|||||||
@@ -57,6 +57,7 @@ account.injector.server_name=伺服器名稱
|
|||||||
account.manage=帳戶列表
|
account.manage=帳戶列表
|
||||||
account.methods=登入方式
|
account.methods=登入方式
|
||||||
account.methods.authlib_injector=authlib-injector 登入
|
account.methods.authlib_injector=authlib-injector 登入
|
||||||
|
account.methods.microsoft=微軟帳戶
|
||||||
account.methods.offline=離線模式
|
account.methods.offline=離線模式
|
||||||
account.methods.yggdrasil=正版登入
|
account.methods.yggdrasil=正版登入
|
||||||
account.missing=沒有遊戲帳戶
|
account.missing=沒有遊戲帳戶
|
||||||
|
|||||||
@@ -57,6 +57,7 @@ account.injector.server_name=服务器名称
|
|||||||
account.manage=账户列表
|
account.manage=账户列表
|
||||||
account.methods=登录方式
|
account.methods=登录方式
|
||||||
account.methods.authlib_injector=外置登录 (authlib-injector)
|
account.methods.authlib_injector=外置登录 (authlib-injector)
|
||||||
|
account.methods.microsoft=微软账号
|
||||||
account.methods.offline=离线模式
|
account.methods.offline=离线模式
|
||||||
account.methods.yggdrasil=正版登录
|
account.methods.yggdrasil=正版登录
|
||||||
account.missing=没有游戏账户
|
account.missing=没有游戏账户
|
||||||
|
|||||||
@@ -25,7 +25,52 @@ import java.util.Map;
|
|||||||
*/
|
*/
|
||||||
public abstract class AccountFactory<T extends Account> {
|
public abstract class AccountFactory<T extends Account> {
|
||||||
|
|
||||||
|
public enum AccountLoginType {
|
||||||
|
/**
|
||||||
|
* Either username or password should not be provided.
|
||||||
|
* AccountFactory will take its own way to check credentials.
|
||||||
|
*/
|
||||||
|
NONE(false, false),
|
||||||
|
|
||||||
|
/**
|
||||||
|
* AccountFactory only needs username.
|
||||||
|
*/
|
||||||
|
USERNAME(true, false),
|
||||||
|
|
||||||
|
/**
|
||||||
|
* AccountFactory needs both username and password for credential verification.
|
||||||
|
*/
|
||||||
|
USERNAME_PASSWORD(true, true);
|
||||||
|
|
||||||
|
public final boolean requiresUsername, requiresPassword;
|
||||||
|
|
||||||
|
AccountLoginType(boolean requiresUsername, boolean requiresPassword) {
|
||||||
|
this.requiresUsername = requiresUsername;
|
||||||
|
this.requiresPassword = requiresPassword;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Informs how this account factory verifies user's credential.
|
||||||
|
* @see AccountLoginType
|
||||||
|
*/
|
||||||
|
public abstract AccountLoginType getLoginType();
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Create a new(to be verified via network) account, and log in.
|
||||||
|
* @param selector for character selection if multiple characters belong to single account. Pick out which character to act as.
|
||||||
|
* @param username username of the account if needed.
|
||||||
|
* @param password password of the account if needed.
|
||||||
|
* @param additionalData extra data for specific account factory.
|
||||||
|
* @return logged-in account.
|
||||||
|
* @throws AuthenticationException if an error occurs when logging in.
|
||||||
|
*/
|
||||||
public abstract T create(CharacterSelector selector, String username, String password, Object additionalData) throws AuthenticationException;
|
public abstract T create(CharacterSelector selector, String username, String password, Object additionalData) throws AuthenticationException;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Create a existing(stored in local files) account.
|
||||||
|
* @param storage serialized account data.
|
||||||
|
* @return account stored in local storage. Credentials may expired, and you should refresh account state later.
|
||||||
|
*/
|
||||||
public abstract T fromStorage(Map<Object, Object> storage);
|
public abstract T fromStorage(Map<Object, Object> storage);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -44,6 +44,11 @@ public class AuthlibInjectorAccountFactory extends AccountFactory<AuthlibInjecto
|
|||||||
this.serverLookup = serverLookup;
|
this.serverLookup = serverLookup;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public AccountLoginType getLoginType() {
|
||||||
|
return AccountLoginType.USERNAME_PASSWORD;
|
||||||
|
}
|
||||||
|
|
||||||
@Override
|
@Override
|
||||||
public AuthlibInjectorAccount create(CharacterSelector selector, String username, String password, Object additionalData) throws AuthenticationException {
|
public AuthlibInjectorAccount create(CharacterSelector selector, String username, String password, Object additionalData) throws AuthenticationException {
|
||||||
Objects.requireNonNull(selector);
|
Objects.requireNonNull(selector);
|
||||||
|
|||||||
@@ -0,0 +1,117 @@
|
|||||||
|
package org.jackhuang.hmcl.auth.microsoft;
|
||||||
|
|
||||||
|
import org.jackhuang.hmcl.auth.*;
|
||||||
|
|
||||||
|
import java.util.Map;
|
||||||
|
import java.util.Optional;
|
||||||
|
import java.util.UUID;
|
||||||
|
|
||||||
|
import static java.util.Objects.requireNonNull;
|
||||||
|
|
||||||
|
public class MicrosoftAccount extends Account {
|
||||||
|
|
||||||
|
protected final MicrosoftService service;
|
||||||
|
protected UUID characterUUID;
|
||||||
|
|
||||||
|
private boolean authenticated = false;
|
||||||
|
private MicrosoftSession session;
|
||||||
|
|
||||||
|
protected MicrosoftAccount(MicrosoftService service, MicrosoftSession session) {
|
||||||
|
this.service = requireNonNull(service);
|
||||||
|
this.session = requireNonNull(session);
|
||||||
|
this.characterUUID = requireNonNull(session.getProfile().getId());
|
||||||
|
}
|
||||||
|
|
||||||
|
protected MicrosoftAccount(MicrosoftService service, CharacterSelector characterSelector) throws AuthenticationException {
|
||||||
|
this.service = requireNonNull(service);
|
||||||
|
|
||||||
|
MicrosoftSession acquiredSession = service.authenticate();
|
||||||
|
if (acquiredSession.getProfile() == null) {
|
||||||
|
session = service.refresh(acquiredSession);
|
||||||
|
} else {
|
||||||
|
session = acquiredSession;
|
||||||
|
}
|
||||||
|
|
||||||
|
characterUUID = session.getProfile().getId();
|
||||||
|
authenticated = true;
|
||||||
|
}
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public String getUsername() {
|
||||||
|
// TODO: email of Microsoft account is blocked by oauth.
|
||||||
|
return "";
|
||||||
|
}
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public String getCharacter() {
|
||||||
|
return session.getProfile().getName();
|
||||||
|
}
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public UUID getUUID() {
|
||||||
|
return session.getProfile().getId();
|
||||||
|
}
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public AuthInfo logIn() throws AuthenticationException {
|
||||||
|
if (!authenticated) {
|
||||||
|
if (service.validate(session.getTokenType(), session.getAccessToken())) {
|
||||||
|
authenticated = true;
|
||||||
|
} else {
|
||||||
|
MicrosoftSession acquiredSession = service.authenticate();
|
||||||
|
if (acquiredSession.getProfile() == null) {
|
||||||
|
session = service.refresh(acquiredSession);
|
||||||
|
} else {
|
||||||
|
session = acquiredSession;
|
||||||
|
}
|
||||||
|
|
||||||
|
characterUUID = session.getProfile().getId();
|
||||||
|
authenticated = true;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return session.toAuthInfo();
|
||||||
|
}
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public AuthInfo logInWithPassword(String password) throws AuthenticationException {
|
||||||
|
throw new UnsupportedOperationException();
|
||||||
|
}
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public Optional<AuthInfo> playOffline() {
|
||||||
|
return Optional.of(session.toAuthInfo());
|
||||||
|
}
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public Map<Object, Object> toStorage() {
|
||||||
|
return session.toStorage();
|
||||||
|
}
|
||||||
|
|
||||||
|
public MicrosoftService getService() {
|
||||||
|
return service;
|
||||||
|
}
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public void clearCache() {
|
||||||
|
authenticated = false;
|
||||||
|
}
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public String toString() {
|
||||||
|
return "MicrosoftAccount[uuid=" + characterUUID + ", name=" + getCharacter() + "]";
|
||||||
|
}
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public int hashCode() {
|
||||||
|
return characterUUID.hashCode();
|
||||||
|
}
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public boolean equals(Object o) {
|
||||||
|
if (this == o) return true;
|
||||||
|
if (o == null || getClass() != o.getClass()) return false;
|
||||||
|
MicrosoftAccount that = (MicrosoftAccount) o;
|
||||||
|
return characterUUID.equals(that.characterUUID);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,36 @@
|
|||||||
|
package org.jackhuang.hmcl.auth.microsoft;
|
||||||
|
|
||||||
|
import org.jackhuang.hmcl.auth.AccountFactory;
|
||||||
|
import org.jackhuang.hmcl.auth.AuthenticationException;
|
||||||
|
import org.jackhuang.hmcl.auth.CharacterSelector;
|
||||||
|
|
||||||
|
import java.util.Map;
|
||||||
|
import java.util.Objects;
|
||||||
|
|
||||||
|
public class MicrosoftAccountFactory extends AccountFactory<MicrosoftAccount> {
|
||||||
|
|
||||||
|
private final MicrosoftService service;
|
||||||
|
|
||||||
|
public MicrosoftAccountFactory(MicrosoftService service) {
|
||||||
|
this.service = service;
|
||||||
|
}
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public AccountLoginType getLoginType() {
|
||||||
|
return AccountLoginType.NONE;
|
||||||
|
}
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public MicrosoftAccount create(CharacterSelector selector, String username, String password, Object additionalData) throws AuthenticationException {
|
||||||
|
Objects.requireNonNull(selector);
|
||||||
|
|
||||||
|
return new MicrosoftAccount(service, selector);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public MicrosoftAccount fromStorage(Map<Object, Object> storage) {
|
||||||
|
Objects.requireNonNull(storage);
|
||||||
|
MicrosoftSession session = MicrosoftSession.fromStorage(storage);
|
||||||
|
return new MicrosoftAccount(service, session);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,280 @@
|
|||||||
|
package org.jackhuang.hmcl.auth.microsoft;
|
||||||
|
|
||||||
|
import com.google.gson.JsonParseException;
|
||||||
|
import com.google.gson.annotations.SerializedName;
|
||||||
|
import org.jackhuang.hmcl.auth.AuthenticationException;
|
||||||
|
import org.jackhuang.hmcl.auth.NoCharacterException;
|
||||||
|
import org.jackhuang.hmcl.auth.ServerDisconnectException;
|
||||||
|
import org.jackhuang.hmcl.auth.ServerResponseMalformedException;
|
||||||
|
import org.jackhuang.hmcl.auth.yggdrasil.RemoteAuthenticationException;
|
||||||
|
import org.jackhuang.hmcl.util.gson.JsonUtils;
|
||||||
|
import org.jackhuang.hmcl.util.gson.TolerableValidationException;
|
||||||
|
import org.jackhuang.hmcl.util.gson.Validation;
|
||||||
|
import org.jackhuang.hmcl.util.io.HttpRequest;
|
||||||
|
import org.jackhuang.hmcl.util.io.NetworkUtils;
|
||||||
|
import org.jackhuang.hmcl.util.io.ResponseCodeException;
|
||||||
|
|
||||||
|
import java.io.IOException;
|
||||||
|
import java.util.*;
|
||||||
|
import java.util.concurrent.CompletableFuture;
|
||||||
|
import java.util.concurrent.ExecutionException;
|
||||||
|
import java.util.function.Predicate;
|
||||||
|
import java.util.regex.Matcher;
|
||||||
|
import java.util.regex.Pattern;
|
||||||
|
|
||||||
|
import static java.util.Objects.requireNonNull;
|
||||||
|
import static org.jackhuang.hmcl.util.Lang.mapOf;
|
||||||
|
import static org.jackhuang.hmcl.util.Pair.pair;
|
||||||
|
|
||||||
|
public class MicrosoftService {
|
||||||
|
private static final Pattern OAUTH_URL_PATTERN = Pattern.compile("^https://login\\.live\\.com/oauth20_desktop\\.srf\\?code=(.*?)&lc=(.*?)$");
|
||||||
|
|
||||||
|
private final WebViewCallback callback;
|
||||||
|
|
||||||
|
public MicrosoftService(WebViewCallback callback) {
|
||||||
|
this.callback = callback;
|
||||||
|
}
|
||||||
|
|
||||||
|
public MicrosoftSession authenticate() throws AuthenticationException {
|
||||||
|
requireNonNull(callback);
|
||||||
|
|
||||||
|
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();
|
||||||
|
|
||||||
|
// 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);
|
||||||
|
|
||||||
|
// Authenticate with XBox Live
|
||||||
|
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);
|
||||||
|
String uhs = (String) xboxResponse.displayClaims.xui.get(0).get("uhs");
|
||||||
|
|
||||||
|
// Authenticate with XSTS
|
||||||
|
XBoxLiveAuthenticationResponse xstsResponse = 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")))
|
||||||
|
.getJson(XBoxLiveAuthenticationResponse.class);
|
||||||
|
|
||||||
|
// 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);
|
||||||
|
|
||||||
|
// Checking Game Ownership
|
||||||
|
MinecraftStoreResponse storeResponse = HttpRequest.GET("https://api.minecraftservices.com/entitlements/mcstore")
|
||||||
|
.authorization(String.format("%s %s", minecraftResponse.tokenType, minecraftResponse.accessToken))
|
||||||
|
.getJson(MinecraftStoreResponse.class);
|
||||||
|
handleErrorResponse(storeResponse);
|
||||||
|
if (storeResponse.items.isEmpty()) {
|
||||||
|
throw new NoCharacterException();
|
||||||
|
}
|
||||||
|
|
||||||
|
return new MicrosoftSession(minecraftResponse.tokenType, minecraftResponse.accessToken, new MicrosoftSession.User(minecraftResponse.username), null);
|
||||||
|
} catch (IOException | ExecutionException | InterruptedException e) {
|
||||||
|
throw new ServerDisconnectException(e);
|
||||||
|
} catch (JsonParseException e) {
|
||||||
|
throw new ServerResponseMalformedException(e);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
public MicrosoftSession refresh(MicrosoftSession oldSession) throws AuthenticationException {
|
||||||
|
try {
|
||||||
|
// Get the 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));
|
||||||
|
} catch (IOException e) {
|
||||||
|
throw new ServerDisconnectException(e);
|
||||||
|
} catch (JsonParseException e) {
|
||||||
|
throw new ServerResponseMalformedException(e);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
public boolean validate(String tokenType, String accessToken) throws AuthenticationException {
|
||||||
|
requireNonNull(tokenType);
|
||||||
|
requireNonNull(accessToken);
|
||||||
|
|
||||||
|
try {
|
||||||
|
HttpRequest.GET(NetworkUtils.toURL("https://api.minecraftservices.com/minecraft/profile"))
|
||||||
|
.authorization(String.format("%s %s", tokenType, accessToken))
|
||||||
|
.filter((url, responseCode) -> {
|
||||||
|
if (responseCode / 100 == 4) {
|
||||||
|
throw new ResponseCodeException(url, responseCode);
|
||||||
|
}
|
||||||
|
})
|
||||||
|
.getString();
|
||||||
|
return true;
|
||||||
|
} catch (ResponseCodeException e) {
|
||||||
|
return false;
|
||||||
|
} catch (IOException e) {
|
||||||
|
throw new ServerDisconnectException(e);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private static void handleErrorResponse(MinecraftErrorResponse response) throws AuthenticationException {
|
||||||
|
if (response.error != null) {
|
||||||
|
throw new RemoteAuthenticationException(response.error, response.errorMessage, response.developerMessage);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private static class LiveAuthorizationResponse {
|
||||||
|
@SerializedName("token_type")
|
||||||
|
String tokenType;
|
||||||
|
|
||||||
|
@SerializedName("expires_in")
|
||||||
|
int expiresIn;
|
||||||
|
|
||||||
|
@SerializedName("scope")
|
||||||
|
String scope;
|
||||||
|
|
||||||
|
@SerializedName("access_token")
|
||||||
|
String accessToken;
|
||||||
|
|
||||||
|
@SerializedName("refresh_token")
|
||||||
|
String refreshToken;
|
||||||
|
|
||||||
|
@SerializedName("user_id")
|
||||||
|
String userId;
|
||||||
|
|
||||||
|
@SerializedName("foci")
|
||||||
|
String foci;
|
||||||
|
}
|
||||||
|
|
||||||
|
private static class XBoxLiveAuthenticationResponseDisplayClaims {
|
||||||
|
List<Map<Object, Object>> xui;
|
||||||
|
}
|
||||||
|
|
||||||
|
private static class XBoxLiveAuthenticationResponse {
|
||||||
|
@SerializedName("IssueInstant")
|
||||||
|
String issueInstant;
|
||||||
|
|
||||||
|
@SerializedName("NotAfter")
|
||||||
|
String notAfter;
|
||||||
|
|
||||||
|
@SerializedName("Token")
|
||||||
|
String token;
|
||||||
|
|
||||||
|
@SerializedName("DisplayClaims")
|
||||||
|
XBoxLiveAuthenticationResponseDisplayClaims displayClaims;
|
||||||
|
}
|
||||||
|
|
||||||
|
private static class MinecraftLoginWithXBoxResponse {
|
||||||
|
@SerializedName("username")
|
||||||
|
String username;
|
||||||
|
|
||||||
|
@SerializedName("roles")
|
||||||
|
List<String> roles;
|
||||||
|
|
||||||
|
@SerializedName("access_token")
|
||||||
|
String accessToken;
|
||||||
|
|
||||||
|
@SerializedName("token_type")
|
||||||
|
String tokenType;
|
||||||
|
|
||||||
|
@SerializedName("expires_in")
|
||||||
|
int expiresIn;
|
||||||
|
}
|
||||||
|
|
||||||
|
private static class MinecraftStoreResponseItem {
|
||||||
|
@SerializedName("name")
|
||||||
|
String name;
|
||||||
|
@SerializedName("signature")
|
||||||
|
String signature;
|
||||||
|
}
|
||||||
|
|
||||||
|
private static class MinecraftStoreResponse extends MinecraftErrorResponse {
|
||||||
|
@SerializedName("items")
|
||||||
|
List<MinecraftStoreResponseItem> items;
|
||||||
|
|
||||||
|
@SerializedName("signature")
|
||||||
|
String signature;
|
||||||
|
|
||||||
|
@SerializedName("keyId")
|
||||||
|
String keyId;
|
||||||
|
}
|
||||||
|
|
||||||
|
private static class MinecraftProfileResponseSkin implements Validation {
|
||||||
|
public String id;
|
||||||
|
public String state;
|
||||||
|
public String url;
|
||||||
|
public String variant;
|
||||||
|
public String alias;
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public void validate() throws JsonParseException, TolerableValidationException {
|
||||||
|
Validation.requireNonNull(id, "id cannot be null");
|
||||||
|
Validation.requireNonNull(state, "state cannot be null");
|
||||||
|
Validation.requireNonNull(url, "url cannot be null");
|
||||||
|
Validation.requireNonNull(variant, "variant cannot be null");
|
||||||
|
Validation.requireNonNull(alias, "alias cannot be null");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private static class MinecraftProfileResponseCape {
|
||||||
|
|
||||||
|
}
|
||||||
|
|
||||||
|
private static class MinecraftProfileResponse extends MinecraftErrorResponse implements Validation {
|
||||||
|
@SerializedName("id")
|
||||||
|
UUID id;
|
||||||
|
@SerializedName("name")
|
||||||
|
String name;
|
||||||
|
@SerializedName("skins")
|
||||||
|
List<MinecraftProfileResponseSkin> skins;
|
||||||
|
@SerializedName("capes")
|
||||||
|
List<MinecraftProfileResponseCape> capes;
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public void validate() throws JsonParseException, TolerableValidationException {
|
||||||
|
Validation.requireNonNull(id, "id cannot be null");
|
||||||
|
Validation.requireNonNull(name, "name cannot be null");
|
||||||
|
Validation.requireNonNull(skins, "skins cannot be null");
|
||||||
|
Validation.requireNonNull(capes, "capes cannot be null");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private static class MinecraftErrorResponse {
|
||||||
|
public String path;
|
||||||
|
public String errorType;
|
||||||
|
public String error;
|
||||||
|
public String errorMessage;
|
||||||
|
public String developerMessage;
|
||||||
|
}
|
||||||
|
|
||||||
|
public interface WebViewCallback {
|
||||||
|
CompletableFuture<String> show(MicrosoftService service, Predicate<String> urlTester, String initialURL);
|
||||||
|
}
|
||||||
|
|
||||||
|
}
|
||||||
@@ -0,0 +1,100 @@
|
|||||||
|
package org.jackhuang.hmcl.auth.microsoft;
|
||||||
|
|
||||||
|
import org.jackhuang.hmcl.auth.AuthInfo;
|
||||||
|
import org.jackhuang.hmcl.util.gson.UUIDTypeAdapter;
|
||||||
|
|
||||||
|
import java.util.Map;
|
||||||
|
import java.util.UUID;
|
||||||
|
|
||||||
|
import static java.util.Objects.requireNonNull;
|
||||||
|
import static org.jackhuang.hmcl.util.Lang.mapOf;
|
||||||
|
import static org.jackhuang.hmcl.util.Lang.tryCast;
|
||||||
|
import static org.jackhuang.hmcl.util.Pair.pair;
|
||||||
|
|
||||||
|
public class MicrosoftSession {
|
||||||
|
private final String tokenType;
|
||||||
|
private final String accessToken;
|
||||||
|
private final User user;
|
||||||
|
private final GameProfile profile;
|
||||||
|
|
||||||
|
public MicrosoftSession(String tokenType, String accessToken, User user, GameProfile profile) {
|
||||||
|
this.tokenType = tokenType;
|
||||||
|
this.accessToken = accessToken;
|
||||||
|
this.user = user;
|
||||||
|
this.profile = profile;
|
||||||
|
}
|
||||||
|
|
||||||
|
public String getTokenType() {
|
||||||
|
return tokenType;
|
||||||
|
}
|
||||||
|
|
||||||
|
public String getAccessToken() {
|
||||||
|
return accessToken;
|
||||||
|
}
|
||||||
|
|
||||||
|
public User getUser() {
|
||||||
|
return user;
|
||||||
|
}
|
||||||
|
|
||||||
|
public GameProfile getProfile() {
|
||||||
|
return profile;
|
||||||
|
}
|
||||||
|
|
||||||
|
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));
|
||||||
|
}
|
||||||
|
|
||||||
|
public Map<Object, Object> toStorage() {
|
||||||
|
requireNonNull(profile);
|
||||||
|
requireNonNull(user);
|
||||||
|
|
||||||
|
return mapOf(
|
||||||
|
pair("tokenType", tokenType),
|
||||||
|
pair("accessToken", accessToken),
|
||||||
|
pair("uuid", UUIDTypeAdapter.fromUUID(profile.getId())),
|
||||||
|
pair("displayName", profile.getName()),
|
||||||
|
pair("userid", user.id)
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
public AuthInfo toAuthInfo() {
|
||||||
|
requireNonNull(profile);
|
||||||
|
|
||||||
|
return new AuthInfo(profile.getName(), profile.getId(), accessToken, "{}");
|
||||||
|
}
|
||||||
|
|
||||||
|
public static class User {
|
||||||
|
private final String id;
|
||||||
|
|
||||||
|
public User(String id) {
|
||||||
|
this.id = id;
|
||||||
|
}
|
||||||
|
|
||||||
|
public String getId() {
|
||||||
|
return id;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
public static class GameProfile {
|
||||||
|
private final UUID id;
|
||||||
|
private final String name;
|
||||||
|
|
||||||
|
public GameProfile(UUID id, String name) {
|
||||||
|
this.id = id;
|
||||||
|
this.name = name;
|
||||||
|
}
|
||||||
|
|
||||||
|
public UUID getId() {
|
||||||
|
return id;
|
||||||
|
}
|
||||||
|
|
||||||
|
public String getName() {
|
||||||
|
return name;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -37,6 +37,11 @@ public class OfflineAccountFactory extends AccountFactory<OfflineAccount> {
|
|||||||
private OfflineAccountFactory() {
|
private OfflineAccountFactory() {
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public AccountLoginType getLoginType() {
|
||||||
|
return AccountLoginType.USERNAME;
|
||||||
|
}
|
||||||
|
|
||||||
public OfflineAccount create(String username, UUID uuid) {
|
public OfflineAccount create(String username, UUID uuid) {
|
||||||
return new OfflineAccount(username, uuid);
|
return new OfflineAccount(username, uuid);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -17,9 +17,8 @@
|
|||||||
*/
|
*/
|
||||||
package org.jackhuang.hmcl.auth.yggdrasil;
|
package org.jackhuang.hmcl.auth.yggdrasil;
|
||||||
|
|
||||||
import static java.util.Objects.requireNonNull;
|
|
||||||
|
|
||||||
import java.util.Map;
|
import java.util.Map;
|
||||||
|
import java.util.Objects;
|
||||||
import java.util.UUID;
|
import java.util.UUID;
|
||||||
|
|
||||||
import org.jackhuang.hmcl.util.Immutable;
|
import org.jackhuang.hmcl.util.Immutable;
|
||||||
@@ -38,7 +37,7 @@ public class CompleteGameProfile extends GameProfile {
|
|||||||
|
|
||||||
public CompleteGameProfile(UUID id, String name, Map<String, String> properties) {
|
public CompleteGameProfile(UUID id, String name, Map<String, String> properties) {
|
||||||
super(id, name);
|
super(id, name);
|
||||||
this.properties = requireNonNull(properties);
|
this.properties = Objects.requireNonNull(properties);
|
||||||
}
|
}
|
||||||
|
|
||||||
public CompleteGameProfile(GameProfile profile, Map<String, String> properties) {
|
public CompleteGameProfile(GameProfile profile, Map<String, String> properties) {
|
||||||
|
|||||||
@@ -17,8 +17,7 @@
|
|||||||
*/
|
*/
|
||||||
package org.jackhuang.hmcl.auth.yggdrasil;
|
package org.jackhuang.hmcl.auth.yggdrasil;
|
||||||
|
|
||||||
import static java.util.Objects.requireNonNull;
|
import java.util.Objects;
|
||||||
|
|
||||||
import java.util.UUID;
|
import java.util.UUID;
|
||||||
|
|
||||||
import org.jackhuang.hmcl.util.Immutable;
|
import org.jackhuang.hmcl.util.Immutable;
|
||||||
@@ -40,8 +39,8 @@ public class GameProfile implements Validation {
|
|||||||
private final String name;
|
private final String name;
|
||||||
|
|
||||||
public GameProfile(UUID id, String name) {
|
public GameProfile(UUID id, String name) {
|
||||||
this.id = requireNonNull(id);
|
this.id = Objects.requireNonNull(id);
|
||||||
this.name = requireNonNull(name);
|
this.name = Objects.requireNonNull(name);
|
||||||
}
|
}
|
||||||
|
|
||||||
public UUID getId() {
|
public UUID getId() {
|
||||||
@@ -54,9 +53,7 @@ public class GameProfile implements Validation {
|
|||||||
|
|
||||||
@Override
|
@Override
|
||||||
public void validate() throws JsonParseException {
|
public void validate() throws JsonParseException {
|
||||||
if (id == null)
|
Validation.requireNonNull(id, "Game profile id cannot be null");
|
||||||
throw new JsonParseException("Game profile id cannot be null");
|
Validation.requireNonNull(name, "Game profile name cannot be null");
|
||||||
if (name == null)
|
|
||||||
throw new JsonParseException("Game profile name cannot be null");
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -209,6 +209,7 @@ public class YggdrasilAccount extends Account {
|
|||||||
|
|
||||||
@Override
|
@Override
|
||||||
public boolean equals(Object obj) {
|
public boolean equals(Object obj) {
|
||||||
|
if (this == obj) return true;
|
||||||
if (obj == null || obj.getClass() != YggdrasilAccount.class)
|
if (obj == null || obj.getClass() != YggdrasilAccount.class)
|
||||||
return false;
|
return false;
|
||||||
YggdrasilAccount another = (YggdrasilAccount) obj;
|
YggdrasilAccount another = (YggdrasilAccount) obj;
|
||||||
|
|||||||
@@ -36,12 +36,17 @@ public class YggdrasilAccountFactory extends AccountFactory<YggdrasilAccount> {
|
|||||||
|
|
||||||
public static final YggdrasilAccountFactory MOJANG = new YggdrasilAccountFactory(YggdrasilService.MOJANG);
|
public static final YggdrasilAccountFactory MOJANG = new YggdrasilAccountFactory(YggdrasilService.MOJANG);
|
||||||
|
|
||||||
private YggdrasilService service;
|
private final YggdrasilService service;
|
||||||
|
|
||||||
public YggdrasilAccountFactory(YggdrasilService service) {
|
public YggdrasilAccountFactory(YggdrasilService service) {
|
||||||
this.service = service;
|
this.service = service;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public AccountLoginType getLoginType() {
|
||||||
|
return AccountLoginType.USERNAME_PASSWORD;
|
||||||
|
}
|
||||||
|
|
||||||
@Override
|
@Override
|
||||||
public YggdrasilAccount create(CharacterSelector selector, String username, String password, Object additionalData) throws AuthenticationException {
|
public YggdrasilAccount create(CharacterSelector selector, String username, String password, Object additionalData) throws AuthenticationException {
|
||||||
Objects.requireNonNull(selector);
|
Objects.requireNonNull(selector);
|
||||||
|
|||||||
@@ -38,4 +38,9 @@ public interface Validation {
|
|||||||
* @throws TolerableValidationException if we want to replace this object with null (i.e. the object does not fulfill the constraints).
|
* @throws TolerableValidationException if we want to replace this object with null (i.e. the object does not fulfill the constraints).
|
||||||
*/
|
*/
|
||||||
void validate() throws JsonParseException, TolerableValidationException;
|
void validate() throws JsonParseException, TolerableValidationException;
|
||||||
|
|
||||||
|
static void requireNonNull(Object object, String message) throws JsonParseException {
|
||||||
|
if (object == null)
|
||||||
|
throw new JsonParseException(message);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,133 @@
|
|||||||
|
package org.jackhuang.hmcl.util.io;
|
||||||
|
|
||||||
|
import com.google.gson.JsonParseException;
|
||||||
|
import org.jackhuang.hmcl.util.function.ExceptionalBiConsumer;
|
||||||
|
import org.jackhuang.hmcl.util.gson.JsonUtils;
|
||||||
|
|
||||||
|
import java.io.IOException;
|
||||||
|
import java.io.OutputStream;
|
||||||
|
import java.net.HttpURLConnection;
|
||||||
|
import java.net.MalformedURLException;
|
||||||
|
import java.net.URL;
|
||||||
|
import java.util.HashMap;
|
||||||
|
import java.util.Map;
|
||||||
|
|
||||||
|
import static java.nio.charset.StandardCharsets.UTF_8;
|
||||||
|
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;
|
||||||
|
|
||||||
|
public abstract class HttpRequest {
|
||||||
|
protected final URL url;
|
||||||
|
protected final String method;
|
||||||
|
protected final Map<String, String> headers = new HashMap<>();
|
||||||
|
protected ExceptionalBiConsumer<URL, Integer, IOException> responseCodeTester;
|
||||||
|
|
||||||
|
private HttpRequest(URL url, String method) {
|
||||||
|
this.url = url;
|
||||||
|
this.method = method;
|
||||||
|
}
|
||||||
|
|
||||||
|
public HttpRequest accept(String contentType) {
|
||||||
|
return header("Accept", contentType);
|
||||||
|
}
|
||||||
|
|
||||||
|
public HttpRequest authorization(String token) {
|
||||||
|
return header("Authorization", token);
|
||||||
|
}
|
||||||
|
|
||||||
|
public HttpRequest contentType(String contentType) {
|
||||||
|
return header("Content-Type", contentType);
|
||||||
|
}
|
||||||
|
|
||||||
|
public HttpRequest header(String key, String value) {
|
||||||
|
headers.put(key, value);
|
||||||
|
return this;
|
||||||
|
}
|
||||||
|
|
||||||
|
public abstract String getString() throws IOException;
|
||||||
|
|
||||||
|
public <T> T getJson(Class<T> typeOfT) throws IOException, JsonParseException {
|
||||||
|
return JsonUtils.fromNonNullJson(getString(), typeOfT);
|
||||||
|
}
|
||||||
|
|
||||||
|
public HttpRequest filter(ExceptionalBiConsumer<URL, Integer, IOException> responseCodeTester) {
|
||||||
|
this.responseCodeTester = responseCodeTester;
|
||||||
|
return this;
|
||||||
|
}
|
||||||
|
|
||||||
|
protected HttpURLConnection createConnection() throws IOException {
|
||||||
|
HttpURLConnection con = createHttpConnection(url);
|
||||||
|
con.setRequestMethod(method);
|
||||||
|
for (Map.Entry<String, String> entry : headers.entrySet()) {
|
||||||
|
con.setRequestProperty(entry.getKey(), entry.getValue());
|
||||||
|
}
|
||||||
|
return con;
|
||||||
|
}
|
||||||
|
|
||||||
|
public static class HttpGetRequest extends HttpRequest {
|
||||||
|
public HttpGetRequest(URL url) {
|
||||||
|
super(url, "GET");
|
||||||
|
}
|
||||||
|
|
||||||
|
public String getString() throws IOException {
|
||||||
|
HttpURLConnection con = createConnection();
|
||||||
|
con = resolveConnection(con);
|
||||||
|
return IOUtils.readFullyAsString(con.getInputStream());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
public static class HttpPostRequest extends HttpRequest {
|
||||||
|
private byte[] bytes;
|
||||||
|
|
||||||
|
public HttpPostRequest(URL url) {
|
||||||
|
super(url, "POST");
|
||||||
|
}
|
||||||
|
|
||||||
|
public <T> HttpPostRequest json(Object payload) throws JsonParseException {
|
||||||
|
return string(payload instanceof String ? (String) payload : GSON.toJson(payload),
|
||||||
|
"application/json");
|
||||||
|
}
|
||||||
|
|
||||||
|
public HttpPostRequest form(Map<String, String> params) {
|
||||||
|
return string(NetworkUtils.withQuery("", params), "application/x-www-form-urlencoded");
|
||||||
|
}
|
||||||
|
|
||||||
|
public HttpPostRequest string(String payload, String contentType) {
|
||||||
|
bytes = payload.getBytes(UTF_8);
|
||||||
|
header("Content-Length", "" + bytes.length);
|
||||||
|
contentType(contentType + "; charset=utf-8");
|
||||||
|
return this;
|
||||||
|
}
|
||||||
|
|
||||||
|
public String getString() throws IOException {
|
||||||
|
HttpURLConnection con = createConnection();
|
||||||
|
con.setDoOutput(true);
|
||||||
|
|
||||||
|
if (responseCodeTester != null) {
|
||||||
|
responseCodeTester.accept(url, con.getResponseCode());
|
||||||
|
}
|
||||||
|
|
||||||
|
try (OutputStream os = con.getOutputStream()) {
|
||||||
|
os.write(bytes);
|
||||||
|
}
|
||||||
|
return NetworkUtils.readData(con);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
public static HttpGetRequest GET(String url) throws MalformedURLException {
|
||||||
|
return GET(new URL(url));
|
||||||
|
}
|
||||||
|
|
||||||
|
public static HttpGetRequest GET(URL url) {
|
||||||
|
return new HttpGetRequest(url);
|
||||||
|
}
|
||||||
|
|
||||||
|
public static HttpPostRequest POST(String url) throws MalformedURLException {
|
||||||
|
return POST(new URL(url));
|
||||||
|
}
|
||||||
|
|
||||||
|
public static HttpPostRequest POST(URL url) {
|
||||||
|
return new HttpPostRequest(url);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -43,7 +43,9 @@ public final class NetworkUtils {
|
|||||||
if (param.getValue() == null)
|
if (param.getValue() == null)
|
||||||
continue;
|
continue;
|
||||||
if (first) {
|
if (first) {
|
||||||
sb.append('?');
|
if (!baseUrl.isEmpty()) {
|
||||||
|
sb.append('?');
|
||||||
|
}
|
||||||
first = false;
|
first = false;
|
||||||
} else {
|
} else {
|
||||||
sb.append('&');
|
sb.append('&');
|
||||||
|
|||||||
Reference in New Issue
Block a user