feat: Microsoft Account authentication

This commit is contained in:
yuhuihuang
2020-12-10 21:47:53 +08:00
parent 4715a95a54
commit c191186023
26 changed files with 835 additions and 21 deletions

View File

@@ -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))

View File

@@ -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());

View File

@@ -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() {

View File

@@ -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));

View File

@@ -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)

View File

@@ -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;
}
}

View File

@@ -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">

View File

@@ -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

View File

@@ -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

View File

@@ -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=Без уч. записи

View File

@@ -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=沒有遊戲帳戶

View File

@@ -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=没有游戏账户

View File

@@ -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);
} }

View File

@@ -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);

View File

@@ -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);
}
}

View File

@@ -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);
}
}

View File

@@ -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);
}
}

View File

@@ -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;
}
}
}

View File

@@ -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);
} }

View File

@@ -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) {

View File

@@ -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");
} }
} }

View File

@@ -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;

View File

@@ -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);

View File

@@ -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);
}
} }

View File

@@ -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);
}
}

View File

@@ -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('&');