diff --git a/HMCL/build.gradle.kts b/HMCL/build.gradle.kts index 86bda3f8f1..266c14e424 100644 --- a/HMCL/build.gradle.kts +++ b/HMCL/build.gradle.kts @@ -31,6 +31,7 @@ val versionType = System.getenv("VERSION_TYPE") ?: if (isOfficial) "nightly" els val microsoftAuthId = System.getenv("MICROSOFT_AUTH_ID") ?: "" val microsoftAuthSecret = System.getenv("MICROSOFT_AUTH_SECRET") ?: "" val curseForgeApiKey = System.getenv("CURSEFORGE_API_KEY") ?: "" +val littleSkinClientId = System.getenv("LITTLT_SKIN_CLIENT_ID") ?: "866" // TODO version = "$versionRoot.$buildNumber" @@ -128,6 +129,7 @@ tasks.getByName("sha "Microsoft-Auth-Id" to microsoftAuthId, "Microsoft-Auth-Secret" to microsoftAuthSecret, "CurseForge-Api-Key" to curseForgeApiKey, + "LittleSkin-Client-Id" to littleSkinClientId, "Build-Channel" to versionType, "Class-Path" to "pack200.jar", "Add-Opens" to listOf( diff --git a/HMCL/src/main/java/org/jackhuang/hmcl/game/OAuthServer.java b/HMCL/src/main/java/org/jackhuang/hmcl/game/OAuthServer.java index de7849c3aa..cc1f20a176 100644 --- a/HMCL/src/main/java/org/jackhuang/hmcl/game/OAuthServer.java +++ b/HMCL/src/main/java/org/jackhuang/hmcl/game/OAuthServer.java @@ -25,7 +25,6 @@ import org.jackhuang.hmcl.ui.FXUtils; import org.jackhuang.hmcl.util.StringUtils; import org.jackhuang.hmcl.util.io.IOUtils; -import org.jackhuang.hmcl.util.io.JarUtils; import org.jackhuang.hmcl.util.io.NetworkUtils; import java.io.IOException; @@ -123,6 +122,14 @@ public static class Factory implements OAuth.Callback { public final EventManager onGrantDeviceCode = new EventManager<>(); public final EventManager onOpenBrowser = new EventManager<>(); + private final String clientId; + private final String clientSecret; + + public Factory(String clientId, String clientSecret) { + this.clientId = clientId; + this.clientSecret = clientSecret; + } + @Override public OAuth.Session startServer() throws IOException, AuthenticationException { if (StringUtils.isBlank(getClientId())) { @@ -130,21 +137,24 @@ public OAuth.Session startServer() throws IOException, AuthenticationException { } IOException exception = null; - for (int port : new int[]{29111, 29112, 29113, 29114, 29115}) { + for (int port = 29111; port < 29116; port++) { try { OAuthServer server = new OAuthServer(port); server.start(NanoHTTPD.SOCKET_READ_TIMEOUT, true); return server; } catch (IOException e) { - exception = e; + if (exception == null) { + exception = new IOException(); + } + exception.addSuppressed(e); } } throw exception; } @Override - public void grantDeviceCode(String userCode, String verificationURI) { - onGrantDeviceCode.fireEvent(new GrantDeviceCodeEvent(this, userCode, verificationURI)); + public void grantDeviceCode(String userCode, String verificationURI, String verificationUriComplete) { + onGrantDeviceCode.fireEvent(new GrantDeviceCodeEvent(this, userCode, verificationURI, verificationUriComplete)); } @Override @@ -157,14 +167,12 @@ public void openBrowser(String url) throws IOException { @Override public String getClientId() { - return System.getProperty("hmcl.microsoft.auth.id", - JarUtils.getManifestAttribute("Microsoft-Auth-Id", "")); + return clientId; } @Override public String getClientSecret() { - return System.getProperty("hmcl.microsoft.auth.secret", - JarUtils.getManifestAttribute("Microsoft-Auth-Secret", "")); + return clientSecret; } @Override @@ -176,11 +184,13 @@ public boolean isPublicClient() { public static class GrantDeviceCodeEvent extends Event { private final String userCode; private final String verificationUri; + private final String verificationUriComplete; - public GrantDeviceCodeEvent(Object source, String userCode, String verificationUri) { + public GrantDeviceCodeEvent(Object source, String userCode, String verificationUri, String verificationUriComplete) { super(source); this.userCode = userCode; this.verificationUri = verificationUri; + this.verificationUriComplete = verificationUriComplete; } public String getUserCode() { @@ -190,6 +200,10 @@ public String getUserCode() { public String getVerificationUri() { return verificationUri; } + + public String getVerificationUriComplete() { + return verificationUriComplete; + } } public static class OpenBrowserEvent extends Event { diff --git a/HMCL/src/main/java/org/jackhuang/hmcl/game/TexturesLoader.java b/HMCL/src/main/java/org/jackhuang/hmcl/game/TexturesLoader.java index d314869eef..1273402feb 100644 --- a/HMCL/src/main/java/org/jackhuang/hmcl/game/TexturesLoader.java +++ b/HMCL/src/main/java/org/jackhuang/hmcl/game/TexturesLoader.java @@ -31,6 +31,7 @@ import org.jackhuang.hmcl.Metadata; import org.jackhuang.hmcl.auth.Account; import org.jackhuang.hmcl.auth.ServerResponseMalformedException; +import org.jackhuang.hmcl.auth.littleskin.LittleSkinAccount; import org.jackhuang.hmcl.auth.microsoft.MicrosoftAccount; import org.jackhuang.hmcl.auth.offline.OfflineAccount; import org.jackhuang.hmcl.auth.offline.Skin; @@ -355,7 +356,7 @@ public static void bindAvatar(Canvas canvas, YggdrasilService service, UUID uuid } public static void bindAvatar(Canvas canvas, Account account) { - if (account instanceof YggdrasilAccount || account instanceof MicrosoftAccount || account instanceof OfflineAccount) + if (account instanceof YggdrasilAccount || account instanceof MicrosoftAccount || account instanceof LittleSkinAccount || account instanceof OfflineAccount) fxAvatarBinding(canvas, skinBinding(account)); else { unbindAvatar(canvas); diff --git a/HMCL/src/main/java/org/jackhuang/hmcl/setting/Accounts.java b/HMCL/src/main/java/org/jackhuang/hmcl/setting/Accounts.java index 009cf6b40e..a48b8c4937 100644 --- a/HMCL/src/main/java/org/jackhuang/hmcl/setting/Accounts.java +++ b/HMCL/src/main/java/org/jackhuang/hmcl/setting/Accounts.java @@ -26,6 +26,9 @@ import org.jackhuang.hmcl.Metadata; import org.jackhuang.hmcl.auth.*; import org.jackhuang.hmcl.auth.authlibinjector.*; +import org.jackhuang.hmcl.auth.littleskin.LittleSkinAccount; +import org.jackhuang.hmcl.auth.littleskin.LittleSkinAccountFactory; +import org.jackhuang.hmcl.auth.littleskin.LittleSkinService; import org.jackhuang.hmcl.auth.microsoft.MicrosoftAccount; import org.jackhuang.hmcl.auth.microsoft.MicrosoftAccountFactory; import org.jackhuang.hmcl.auth.microsoft.MicrosoftService; @@ -37,6 +40,7 @@ import org.jackhuang.hmcl.util.InvocationDispatcher; import org.jackhuang.hmcl.util.Lang; import org.jackhuang.hmcl.util.io.FileUtils; +import org.jackhuang.hmcl.util.io.JarUtils; import org.jackhuang.hmcl.util.skin.InvalidSkinException; import javax.net.ssl.SSLException; @@ -80,12 +84,23 @@ private static void triggerAuthlibInjectorUpdateCheck() { } } - public static final OAuthServer.Factory OAUTH_CALLBACK = new OAuthServer.Factory(); + public static final OAuthServer.Factory MICROSOFT_OAUTH_CALLBACK = new OAuthServer.Factory( + System.getProperty("hmcl.microsoft.auth.id", + JarUtils.getManifestAttribute("Microsoft-Auth-Id", "")), + System.getProperty("hmcl.microsoft.auth.secret", + JarUtils.getManifestAttribute("Microsoft-Auth-Secret", "")) + ); + + public static final OAuthServer.Factory LITTLE_SKIN_CALLBACK = new OAuthServer.Factory( + JarUtils.getManifestAttribute("LittleSkin-Client-Id", ""), + "" + ); public static final OfflineAccountFactory FACTORY_OFFLINE = new OfflineAccountFactory(AUTHLIB_INJECTOR_DOWNLOADER); public static final AuthlibInjectorAccountFactory FACTORY_AUTHLIB_INJECTOR = new AuthlibInjectorAccountFactory(AUTHLIB_INJECTOR_DOWNLOADER, Accounts::getOrCreateAuthlibInjectorServer); - public static final MicrosoftAccountFactory FACTORY_MICROSOFT = new MicrosoftAccountFactory(new MicrosoftService(OAUTH_CALLBACK)); - public static final List> FACTORIES = immutableListOf(FACTORY_OFFLINE, FACTORY_MICROSOFT, FACTORY_AUTHLIB_INJECTOR); + public static final MicrosoftAccountFactory FACTORY_MICROSOFT = new MicrosoftAccountFactory(new MicrosoftService(MICROSOFT_OAUTH_CALLBACK)); + public static final LittleSkinAccountFactory FACTORY_LITTLE_SKIN = new LittleSkinAccountFactory(new LittleSkinService(LITTLE_SKIN_CALLBACK)); + public static final List> FACTORIES = immutableListOf(FACTORY_OFFLINE, FACTORY_MICROSOFT, FACTORY_LITTLE_SKIN, FACTORY_AUTHLIB_INJECTOR); // ==== login type / account factory mapping ==== private static final Map> type2factory = new HashMap<>(); @@ -95,6 +110,7 @@ private static void triggerAuthlibInjectorUpdateCheck() { type2factory.put("offline", FACTORY_OFFLINE); type2factory.put("authlibInjector", FACTORY_AUTHLIB_INJECTOR); type2factory.put("microsoft", FACTORY_MICROSOFT); + type2factory.put("littleskin", FACTORY_LITTLE_SKIN); type2factory.forEach((type, factory) -> factory2type.put(factory, type)); } @@ -127,6 +143,8 @@ else if (account instanceof AuthlibInjectorAccount) return FACTORY_AUTHLIB_INJECTOR; else if (account instanceof MicrosoftAccount) return FACTORY_MICROSOFT; + else if (account instanceof LittleSkinAccount) + return FACTORY_LITTLE_SKIN; else throw new IllegalArgumentException("Failed to determine account type: " + account); } @@ -221,16 +239,6 @@ static void init() { if (initialized) throw new IllegalStateException("Already initialized"); - if (!config().isAddedLittleSkin()) { - AuthlibInjectorServer littleSkin = new AuthlibInjectorServer("https://littleskin.cn/api/yggdrasil/"); - - if (config().getAuthlibInjectorServers().stream().noneMatch(it -> littleSkin.getUrl().equals(it.getUrl()))) { - config().getAuthlibInjectorServers().add(0, littleSkin); - } - - config().setAddedLittleSkin(true); - } - loadGlobalAccountStorages(); // load accounts @@ -409,7 +417,9 @@ private static void removeDanglingAuthlibInjectorAccounts() { private static final Map, String> unlocalizedLoginTypeNames = mapOf( pair(Accounts.FACTORY_OFFLINE, "account.methods.offline"), pair(Accounts.FACTORY_AUTHLIB_INJECTOR, "account.methods.authlib_injector"), - pair(Accounts.FACTORY_MICROSOFT, "account.methods.microsoft")); + pair(Accounts.FACTORY_MICROSOFT, "account.methods.microsoft"), + pair(Accounts.FACTORY_LITTLE_SKIN, "account.methods.littleskin") + ); public static String getLocalizedLoginTypeName(AccountFactory factory) { return i18n(Optional.ofNullable(unlocalizedLoginTypeNames.get(factory)) @@ -473,7 +483,7 @@ public static String localizeErrorMessage(Exception exception) { } else if (exception instanceof MicrosoftService.NoXuiException) { return i18n("account.methods.microsoft.error.add_family_probably"); } else if (exception instanceof OAuthServer.MicrosoftAuthenticationNotSupportedException) { - return i18n("account.methods.microsoft.snapshot"); + return i18n("account.methods.snapshot"); } else if (exception instanceof OAuthAccount.WrongAccountException) { return i18n("account.failed.wrong_account"); } else if (exception.getClass() == AuthenticationException.class) { diff --git a/HMCL/src/main/java/org/jackhuang/hmcl/ui/account/AccountListPage.java b/HMCL/src/main/java/org/jackhuang/hmcl/ui/account/AccountListPage.java index ae970c8643..3d03749bd0 100644 --- a/HMCL/src/main/java/org/jackhuang/hmcl/ui/account/AccountListPage.java +++ b/HMCL/src/main/java/org/jackhuang/hmcl/ui/account/AccountListPage.java @@ -31,6 +31,7 @@ import javafx.scene.layout.VBox; import org.jackhuang.hmcl.auth.Account; import org.jackhuang.hmcl.auth.authlibinjector.AuthlibInjectorServer; +import org.jackhuang.hmcl.auth.littleskin.LittleSkinService; import org.jackhuang.hmcl.setting.Accounts; import org.jackhuang.hmcl.setting.Theme; import org.jackhuang.hmcl.ui.Controllers; @@ -115,12 +116,23 @@ public AccountListPageSkin(AccountListPage skinnable) { microsoftItem.setOnAction(e -> Controllers.dialog(new CreateAccountPane(Accounts.FACTORY_MICROSOFT))); boxMethods.getChildren().add(microsoftItem); + AdvancedListItem littleSkinItem = new AdvancedListItem(); + littleSkinItem.getStyleClass().add("navigation-drawer-item"); + littleSkinItem.setActionButtonVisible(false); + littleSkinItem.setTitle("LittleSkin"); + littleSkinItem.setLeftGraphic(wrap(SVG.SERVER)); + littleSkinItem.setOnAction(e -> Controllers.dialog(new CreateAccountPane(Accounts.FACTORY_LITTLE_SKIN))); + boxMethods.getChildren().add(littleSkinItem); + VBox boxAuthServers = new VBox(); authServerItems = MappedObservableList.create(skinnable.authServersProperty(), server -> { AdvancedListItem item = new AdvancedListItem(); item.getStyleClass().add("navigation-drawer-item"); item.setLeftGraphic(wrap(SVG.SERVER)); item.setOnAction(e -> Controllers.dialog(new CreateAccountPane(server))); + if (LittleSkinService.API_ROOT.equals(server.getUrl())) { + item.setVisible(false); + } JFXButton btnRemove = new JFXButton(); btnRemove.setOnAction(e -> { diff --git a/HMCL/src/main/java/org/jackhuang/hmcl/ui/account/CreateAccountPane.java b/HMCL/src/main/java/org/jackhuang/hmcl/ui/account/CreateAccountPane.java index a5d8bee8b9..84d96cf1f3 100644 --- a/HMCL/src/main/java/org/jackhuang/hmcl/ui/account/CreateAccountPane.java +++ b/HMCL/src/main/java/org/jackhuang/hmcl/ui/account/CreateAccountPane.java @@ -83,7 +83,6 @@ public class CreateAccountPane extends JFXDialogLayout implements DialogAware { private static final Pattern USERNAME_CHECKER_PATTERN = Pattern.compile("^[A-Za-z0-9_]+$"); - private boolean showMethodSwitcher; private AccountFactory factory; private final Label lblErrorMessage; @@ -105,17 +104,16 @@ public CreateAccountPane() { } public CreateAccountPane(AccountFactory factory) { + boolean showMethodSwitcher = factory == null; if (factory == null) { - showMethodSwitcher = true; String preferred = config().getPreferredLoginType(); try { factory = Accounts.getAccountFactory(preferred); } catch (IllegalArgumentException e) { factory = Accounts.FACTORY_OFFLINE; } - } else { - showMethodSwitcher = false; } + this.factory = factory; { @@ -285,62 +283,71 @@ private void initDetailsPane() { detailsContainer.getChildren().remove(detailsPane); lblErrorMessage.setText(""); } - if (factory == Accounts.FACTORY_MICROSOFT) { - VBox vbox = new VBox(8); - if (!Accounts.OAUTH_CALLBACK.getClientId().isEmpty()) { - HintPane hintPane = new HintPane(MessageDialogPane.MessageType.INFO); - FXUtils.onChangeAndOperate(deviceCode, deviceCode -> { - if (deviceCode != null) { - FXUtils.copyText(deviceCode.getUserCode()); - hintPane.setSegment(i18n("account.methods.microsoft.manual", deviceCode.getUserCode(), deviceCode.getVerificationUri())); - } else { - hintPane.setSegment(i18n("account.methods.microsoft.hint")); - } - }); - FXUtils.onClicked(hintPane, () -> { - if (deviceCode.get() != null) { - FXUtils.copyText(deviceCode.get().getUserCode()); - } - }); - holder.add(Accounts.OAUTH_CALLBACK.onGrantDeviceCode.registerWeak(value -> { - runInFX(() -> deviceCode.set(value)); - })); - FlowPane box = new FlowPane(); - box.setHgap(8); - JFXHyperlink birthLink = new JFXHyperlink(i18n("account.methods.microsoft.birth")); - birthLink.setExternalLink("https://support.microsoft.com/account-billing/837badbc-999e-54d2-2617-d19206b9540a"); - JFXHyperlink profileLink = new JFXHyperlink(i18n("account.methods.microsoft.profile")); - profileLink.setExternalLink("https://account.live.com/editprof.aspx"); - JFXHyperlink purchaseLink = new JFXHyperlink(i18n("account.methods.microsoft.purchase")); - purchaseLink.setExternalLink(YggdrasilService.PURCHASE_URL); - JFXHyperlink deauthorizeLink = new JFXHyperlink(i18n("account.methods.microsoft.deauthorize")); - deauthorizeLink.setExternalLink("https://account.live.com/consent/Edit?client_id=000000004C794E0A"); - JFXHyperlink forgotpasswordLink = new JFXHyperlink(i18n("account.methods.forgot_password")); - forgotpasswordLink.setExternalLink("https://account.live.com/ResetPassword.aspx"); - JFXHyperlink createProfileLink = new JFXHyperlink(i18n("account.methods.microsoft.makegameidsettings")); - createProfileLink.setExternalLink("https://www.minecraft.net/msaprofile/mygames/editprofile"); - box.getChildren().setAll(profileLink, birthLink, purchaseLink, deauthorizeLink, forgotpasswordLink, createProfileLink); - GridPane.setColumnSpan(box, 2); + if (!factory.isAvailable()) { + HintPane hintPane = new HintPane(MessageDialogPane.MessageType.WARNING); + hintPane.setSegment(i18n("account.methods.snapshot")); + + JFXHyperlink officialWebsite = new JFXHyperlink(i18n("account.methods.snapshot.website")); + officialWebsite.setExternalLink("https://hmcl.huangyuhui.net"); - if (!IntegrityChecker.isOfficial()) { - HintPane unofficialHint = new HintPane(MessageDialogPane.MessageType.WARNING); - unofficialHint.setText(i18n("unofficial.hint")); - vbox.getChildren().add(unofficialHint); + btnAccept.setDisable(true); + detailsPane = hintPane; + } else if (factory == Accounts.FACTORY_MICROSOFT) { + VBox vbox = new VBox(8); + HintPane hintPane = new HintPane(MessageDialogPane.MessageType.INFO); + FXUtils.onChangeAndOperate(deviceCode, deviceCode -> { + if (deviceCode != null) { + FXUtils.copyText(deviceCode.getUserCode()); + hintPane.setSegment(i18n("account.methods.microsoft.manual", deviceCode.getUserCode(), deviceCode.getVerificationUri())); + } else { + hintPane.setSegment(i18n("account.methods.microsoft.hint")); + } + }); + FXUtils.onClicked(hintPane, () -> { + if (deviceCode.get() != null) { + FXUtils.copyText(deviceCode.get().getUserCode()); } + }); + + holder.add(Accounts.MICROSOFT_OAUTH_CALLBACK.onGrantDeviceCode.registerWeak(value -> { + runInFX(() -> deviceCode.set(value)); + })); + FlowPane box = new FlowPane(); + box.setHgap(8); + JFXHyperlink birthLink = new JFXHyperlink(i18n("account.methods.microsoft.birth")); + birthLink.setExternalLink("https://support.microsoft.com/account-billing/837badbc-999e-54d2-2617-d19206b9540a"); + JFXHyperlink profileLink = new JFXHyperlink(i18n("account.methods.microsoft.profile")); + profileLink.setExternalLink("https://account.live.com/editprof.aspx"); + JFXHyperlink purchaseLink = new JFXHyperlink(i18n("account.methods.microsoft.purchase")); + purchaseLink.setExternalLink(YggdrasilService.PURCHASE_URL); + JFXHyperlink deauthorizeLink = new JFXHyperlink(i18n("account.methods.microsoft.deauthorize")); + deauthorizeLink.setExternalLink("https://account.live.com/consent/Edit?client_id=000000004C794E0A"); + JFXHyperlink forgotpasswordLink = new JFXHyperlink(i18n("account.methods.forgot_password")); + forgotpasswordLink.setExternalLink("https://account.live.com/ResetPassword.aspx"); + JFXHyperlink createProfileLink = new JFXHyperlink(i18n("account.methods.microsoft.makegameidsettings")); + createProfileLink.setExternalLink("https://www.minecraft.net/msaprofile/mygames/editprofile"); + box.getChildren().setAll(profileLink, birthLink, purchaseLink, deauthorizeLink, forgotpasswordLink, createProfileLink); + GridPane.setColumnSpan(box, 2); + + if (!IntegrityChecker.isOfficial()) { + HintPane unofficialHint = new HintPane(MessageDialogPane.MessageType.WARNING); + unofficialHint.setText(i18n("unofficial.hint")); + vbox.getChildren().add(unofficialHint); + } - vbox.getChildren().addAll(hintPane, box); + vbox.getChildren().addAll(hintPane, box); - btnAccept.setDisable(false); - } else { - HintPane hintPane = new HintPane(MessageDialogPane.MessageType.WARNING); - hintPane.setSegment(i18n("account.methods.microsoft.snapshot")); + btnAccept.setDisable(false); - JFXHyperlink officialWebsite = new JFXHyperlink(i18n("account.methods.microsoft.snapshot.website")); - officialWebsite.setExternalLink("https://hmcl.huangyuhui.net"); + detailsPane = vbox; + } else if (factory == Accounts.FACTORY_LITTLE_SKIN) { + VBox vbox = new VBox(8); - vbox.getChildren().setAll(hintPane, officialWebsite); - btnAccept.setDisable(true); + if (!IntegrityChecker.isOfficial()) { + HintPane unofficialHint = new HintPane(MessageDialogPane.MessageType.WARNING); + unofficialHint.setText(i18n("unofficial.hint")); + vbox.getChildren().add(unofficialHint); } detailsPane = vbox; @@ -722,5 +729,4 @@ private void evalTextInputField() { } } - private static final String MICROSOFT_ACCOUNT_EDIT_PROFILE_URL = "https://support.microsoft.com/account-billing/837badbc-999e-54d2-2617-d19206b9540a"; } diff --git a/HMCL/src/main/java/org/jackhuang/hmcl/ui/account/OAuthAccountLoginDialog.java b/HMCL/src/main/java/org/jackhuang/hmcl/ui/account/OAuthAccountLoginDialog.java index 36780c3d47..5866c22c08 100644 --- a/HMCL/src/main/java/org/jackhuang/hmcl/ui/account/OAuthAccountLoginDialog.java +++ b/HMCL/src/main/java/org/jackhuang/hmcl/ui/account/OAuthAccountLoginDialog.java @@ -1,3 +1,20 @@ +/* + * Hello Minecraft! Launcher + * Copyright (C) 2024 huangyuhui and contributors + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program. If not, see . + */ package org.jackhuang.hmcl.ui.account; import javafx.beans.property.ObjectProperty; @@ -14,7 +31,6 @@ import org.jackhuang.hmcl.task.Schedulers; import org.jackhuang.hmcl.task.Task; import org.jackhuang.hmcl.ui.FXUtils; -import org.jackhuang.hmcl.ui.WeakListenerHolder; import org.jackhuang.hmcl.ui.construct.DialogPane; import org.jackhuang.hmcl.ui.construct.HintPane; import org.jackhuang.hmcl.ui.construct.JFXHyperlink; @@ -31,7 +47,8 @@ public class OAuthAccountLoginDialog extends DialogPane { private final Runnable failed; private final ObjectProperty deviceCode = new SimpleObjectProperty<>(); - private final WeakListenerHolder holder = new WeakListenerHolder(); + @SuppressWarnings({"FieldCanBeLocal", "unused",}) + private final Object holder; public OAuthAccountLoginDialog(OAuthAccount account, Consumer success, Runnable failed) { this.account = account; @@ -77,13 +94,11 @@ public OAuthAccountLoginDialog(OAuthAccount account, Consumer success, vbox.getChildren().setAll(usernameLabel, hintPane, box); setBody(vbox); - holder.add(Accounts.OAUTH_CALLBACK.onGrantDeviceCode.registerWeak(this::onGrantDeviceCode)); + holder = Accounts.MICROSOFT_OAUTH_CALLBACK.onGrantDeviceCode.registerWeak(this::onGrantDeviceCode); } private void onGrantDeviceCode(OAuthServer.GrantDeviceCodeEvent event) { - FXUtils.runInFX(() -> { - deviceCode.set(event); - }); + FXUtils.runInFX(() -> deviceCode.set(event)); } @Override diff --git a/HMCL/src/main/java/org/jackhuang/hmcl/ui/decorator/DecoratorSkin.java b/HMCL/src/main/java/org/jackhuang/hmcl/ui/decorator/DecoratorSkin.java index bbcf7585c1..5bc5e1b50d 100644 --- a/HMCL/src/main/java/org/jackhuang/hmcl/ui/decorator/DecoratorSkin.java +++ b/HMCL/src/main/java/org/jackhuang/hmcl/ui/decorator/DecoratorSkin.java @@ -27,10 +27,11 @@ import javafx.scene.Node; import javafx.scene.control.Label; import javafx.scene.control.SkinBase; -import javafx.scene.effect.BlurType; -import javafx.scene.effect.DropShadow; import javafx.scene.input.MouseEvent; -import javafx.scene.layout.*; +import javafx.scene.layout.AnchorPane; +import javafx.scene.layout.BorderPane; +import javafx.scene.layout.HBox; +import javafx.scene.layout.StackPane; import javafx.scene.paint.Color; import javafx.scene.shape.Rectangle; import javafx.stage.Stage; @@ -67,7 +68,6 @@ public DecoratorSkin(Decorator control) { StackPane shadowContainer = new StackPane(); shadowContainer.getStyleClass().add("body"); - shadowContainer.setEffect(new DropShadow(BlurType.ONE_PASS_BOX, Color.rgb(0, 0, 0, 0.4), 10, 0.3, 0.0, 0.0)); parent = new StackPane(); Rectangle clip = new Rectangle(); diff --git a/HMCL/src/main/resources/assets/css/root.css b/HMCL/src/main/resources/assets/css/root.css index e836437e54..6ee1010484 100644 --- a/HMCL/src/main/resources/assets/css/root.css +++ b/HMCL/src/main/resources/assets/css/root.css @@ -1307,6 +1307,11 @@ -fx-padding: 8; } +.body { + -fx-border-radius: 5; + -fx-effect: dropshadow(gaussian, rgba(0, 0, 0, 0.4), 10, 0.3, 0.0, 0.0); +} + .debug-border { -fx-border-color: red; -fx-border-width: 1; diff --git a/HMCL/src/main/resources/assets/lang/I18N.properties b/HMCL/src/main/resources/assets/lang/I18N.properties index 79f5b8394b..ccf1ba4c83 100644 --- a/HMCL/src/main/resources/assets/lang/I18N.properties +++ b/HMCL/src/main/resources/assets/lang/I18N.properties @@ -54,6 +54,7 @@ account.cape=Cape account.character=Player account.choose=Choose a Player account.create=Add Account +account.create.littleskin=Add a LittleSkin Account account.create.microsoft=Add a Microsoft Account account.create.offline=Add an Offline Account account.create.authlibInjector=Add an authlib-injector Account @@ -94,6 +95,7 @@ account.manage=Account List account.copy_uuid=Copy UUID of the Account account.methods=Login Type account.methods.authlib_injector=authlib-injector +account.methods.littleskin=LittleSkin account.methods.microsoft=Microsoft account.methods.microsoft.birth=How to Change Your Account Birthday account.methods.microsoft.close_page=Microsoft account authorization is now completed.\n\ @@ -119,8 +121,8 @@ account.methods.microsoft.manual=Your device code is %1$s. Please click h account.methods.microsoft.makegameidsettings=Create Profile / Edit Profile Name account.methods.microsoft.profile=Account Profile account.methods.microsoft.purchase=Buy Minecraft -account.methods.microsoft.snapshot=You are using an unofficial build of HMCL. Please download the official build for login. -account.methods.microsoft.snapshot.website=Official Website +account.methods.snapshot=You are using an unofficial build of HMCL. Please download the official build for login. +account.methods.snapshot.website=Official Website account.methods.offline=Offline account.methods.offline.name.special_characters=Recommended to use English letters, numbers, and underscores account.methods.offline.name.invalid=Generally, game usernames only allow English letters, numbers, and underscores and cannot exceed 16 characters in length.\n\ diff --git a/HMCL/src/main/resources/assets/lang/I18N_es.properties b/HMCL/src/main/resources/assets/lang/I18N_es.properties index 20122a125d..5f9a34d6d5 100644 --- a/HMCL/src/main/resources/assets/lang/I18N_es.properties +++ b/HMCL/src/main/resources/assets/lang/I18N_es.properties @@ -111,7 +111,7 @@ Si el token utilizado para acceder a la cuenta de Microsoft se ha filtrado, pued Si encuentra algún problema, puede hacer clic en el botón de ayuda en la esquina superior derecha para obtener ayuda. account.methods.microsoft.profile=Perfil de la cuenta account.methods.microsoft.purchase=Comprar Minecraft -account.methods.microsoft.snapshot=Estás usando una construcción no oficial de hmcls, por favor descargue la construcción oficial para iniciar sesión en microsoft. +account.methods.snapshot=Estás usando una construcción no oficial de hmcls, por favor descargue la construcción oficial para iniciar sesión en microsoft. account.methods.offline=Sin conexión account.methods.offline.uuid=UUID account.methods.offline.uuid.hint=UUID es el identificador único del personaje del juego en Minecraft. La forma en que se genera puede variar entre diferentes launcheres. Cambiarlo por el generado por otros launchers te permite mantener tus objetos en el inventario de tu cuenta offline.\n\ diff --git a/HMCL/src/main/resources/assets/lang/I18N_ja.properties b/HMCL/src/main/resources/assets/lang/I18N_ja.properties index 170d0c691f..28f33770ae 100644 --- a/HMCL/src/main/resources/assets/lang/I18N_ja.properties +++ b/HMCL/src/main/resources/assets/lang/I18N_ja.properties @@ -103,7 +103,7 @@ account.methods.microsoft.hint=「ログイン」ボタンをクリックして account.methods.microsoft.manual=「ログイン」ボタンをクリックした後、新しく開いたブラウザウィンドウで認証を完了する必要があります。ブラウザウィンドウが表示されない場合は、ここをクリックしてURLをコピーし、ブラウザで手動で開くことができます。\nMicrosoftアカウントへのログインに使用されたトークンが誤って漏洩した場合は、下の[アカウントのバインドを解除]をクリックして、ログイン認証をキャンセルできます。\n問題が発生した場合は、右上隅にあるヘルプ ボタンをクリックするとヘルプが表示されます。 account.methods.microsoft.profile=アカウントプロファイル.. account.methods.microsoft.purchase=Minecraftを購入する -account.methods.microsoft.snapshot=非公式構築 HMCL を使用しているので、公式構築をダウンロードしてマイクロソフトにログインしてください。 +account.methods.snapshot=非公式構築 HMCL を使用しているので、公式構築をダウンロードしてマイクロソフトにログインしてください。 account.methods.offline=オフライン account.methods.offline.uuid=UUID account.methods.offline.uuid.hint=UUIDは、Minecraftのゲームキャラクターの一意の識別子です。UUIDの生成方法は、ゲームランチャーによって異なります。UUIDを他のランチャーによって生成されたものに変更すると、オフラインアカウントのバックパック内のゲームブロック/アイテムが残ることが約束されます。このオプションは専門家向けです。何をしているのかわからない限り、このオプションを変更することはお勧めしません。\nこのオプションはサーバーに参加する場合には必要ありません。 diff --git a/HMCL/src/main/resources/assets/lang/I18N_ru.properties b/HMCL/src/main/resources/assets/lang/I18N_ru.properties index 15f88033d9..4dfe1979cc 100644 --- a/HMCL/src/main/resources/assets/lang/I18N_ru.properties +++ b/HMCL/src/main/resources/assets/lang/I18N_ru.properties @@ -109,7 +109,7 @@ account.methods.microsoft.manual=После нажатия кнопки «Вой account.methods.microsoft.profile=Профиль аккаунта... account.methods.microsoft.purchase=Купить Minecraft account.methods.forgot_password=ЗАБЫЛ ПАРОЛЬ -account.methods.microsoft.snapshot=Вы используете неофициальное построение HMCL, загрузите официальное построение для входа в Microsoft. +account.methods.snapshot=Вы используете неофициальное построение HMCL, загрузите официальное построение для входа в Microsoft. account.methods.offline=Офлайн account.methods.offline.uuid=UUID account.methods.offline.uuid.hint=UUID - это уникальный идентификатор игрового персонажа в Minecraft. Способ генерации UUID различается в разных лаунчерах игр. Изменение UUID на тот, который генерируется другим лаунчером гарантирует, что игровые блоки/предметы в рюкзаке вашего офлайн аккаунта останутся. Этот параметр предназначен для экспертов. Если вы не знаете что делаете, мы не советуем вам изменять этот параметр.\nЭта опция не требуется для присоединения к серверам. diff --git a/HMCL/src/main/resources/assets/lang/I18N_zh.properties b/HMCL/src/main/resources/assets/lang/I18N_zh.properties index 4058fadbfb..dba60a3e35 100644 --- a/HMCL/src/main/resources/assets/lang/I18N_zh.properties +++ b/HMCL/src/main/resources/assets/lang/I18N_zh.properties @@ -53,6 +53,7 @@ account.cape=披風 account.character=角色 account.choose=請選取角色 account.create=建立帳戶 +account.create.littleskin=加入 LittleSkin 帳戶 account.create.microsoft=加入 Microsoft 帳戶 account.create.offline=加入離線模式帳戶 account.create.authlibInjector=加入 authlib-injector 帳戶 @@ -97,6 +98,7 @@ account.manage=帳戶清單 account.copy_uuid=複製該帳戶的 UUID account.methods=登入方式 account.methods.authlib_injector=authlib-injector 登入 +account.methods.littleskin=LittleSkin 帳戶 account.methods.microsoft=Microsoft 帳戶 account.methods.microsoft.birth=如何變更帳戶出生日期 account.methods.microsoft.deauthorize=移除應用存取權 @@ -125,8 +127,8 @@ account.methods.microsoft.manual=你的代碼為 %1$s,請點擊此處 account.methods.microsoft.profile=編輯帳戶配置檔 account.methods.microsoft.purchase=購買 Minecraft account.methods.forgot_password=忘記密碼 -account.methods.microsoft.snapshot=你正在使用第三方提供的 HMCL,請下載官方版本進行登入。 -account.methods.microsoft.snapshot.website=官方網站 +account.methods.snapshot=你正在使用第三方提供的 HMCL,請下載官方版本進行登入。 +account.methods.snapshot.website=官方網站 account.methods.offline=離線模式 account.methods.offline.name.special_characters=建議使用英文字母、數字以及底線命名 account.methods.offline.name.invalid=遊戲使用者名稱通常僅允許使用英文字母、數字及底線,且長度不能超過 16 個字元。\n\ diff --git a/HMCL/src/main/resources/assets/lang/I18N_zh_CN.properties b/HMCL/src/main/resources/assets/lang/I18N_zh_CN.properties index a521d602fd..4ed776d2a1 100644 --- a/HMCL/src/main/resources/assets/lang/I18N_zh_CN.properties +++ b/HMCL/src/main/resources/assets/lang/I18N_zh_CN.properties @@ -53,6 +53,7 @@ account.cape=披风 account.character=角色 account.choose=选择一个角色 account.create=添加账户 +account.create.littleskin=添加 LittleSkin 账户 account.create.microsoft=添加微软账户 account.create.offline=添加离线模式账户 account.create.authlibInjector=添加外置登录账户 (authlib-injector) @@ -98,6 +99,7 @@ account.manage=账户列表 account.copy_uuid=复制该账户的 UUID account.methods=登录方式 account.methods.authlib_injector=外置登录 (authlib-injector) +account.methods.littleskin=LittleSkin 账户 account.methods.microsoft=微软账户 account.methods.microsoft.birth=如何更改账户出生日期 account.methods.microsoft.close_page=已完成微软账户授权,接下来启动器还需要完成其余登录步骤。你现在可以关闭本页面了。 @@ -133,8 +135,8 @@ account.methods.microsoft.manual=你需要按照以下步骤添加:\n\ account.methods.microsoft.profile=编辑账户个人信息 account.methods.microsoft.purchase=购买 Minecraft account.methods.forgot_password=忘记密码 -account.methods.microsoft.snapshot=你正在使用第三方提供的 HMCL,请下载官方版本来登录微软账户。 -account.methods.microsoft.snapshot.website=官方网站 +account.methods.snapshot=你正在使用第三方提供的 HMCL,请下载官方版本来登录账户。 +account.methods.snapshot.website=官方网站 account.methods.offline=离线模式 account.methods.offline.name.special_characters=建议使用英文字符、数字以及下划线命名 account.methods.offline.name.invalid=游戏用户名通常仅允许使用英文字母、数字及下划线,且长度不能超过 16 个字符。\n\ diff --git a/HMCLCore/src/main/java/org/jackhuang/hmcl/auth/Account.java b/HMCLCore/src/main/java/org/jackhuang/hmcl/auth/Account.java index 3d40c983fe..1d80520f0a 100644 --- a/HMCLCore/src/main/java/org/jackhuang/hmcl/auth/Account.java +++ b/HMCLCore/src/main/java/org/jackhuang/hmcl/auth/Account.java @@ -49,6 +49,7 @@ public abstract class Account implements Observable { /** * @return the character name + * */ public abstract String getCharacter(); diff --git a/HMCLCore/src/main/java/org/jackhuang/hmcl/auth/AccountFactory.java b/HMCLCore/src/main/java/org/jackhuang/hmcl/auth/AccountFactory.java index 7670ae27fe..ae46529c71 100644 --- a/HMCLCore/src/main/java/org/jackhuang/hmcl/auth/AccountFactory.java +++ b/HMCLCore/src/main/java/org/jackhuang/hmcl/auth/AccountFactory.java @@ -81,4 +81,8 @@ public interface ProgressCallback { * @return account stored in local storage. Credentials may expired, and you should refresh account state later. */ public abstract T fromStorage(Map storage); + + public boolean isAvailable() { + return true; + } } diff --git a/HMCLCore/src/main/java/org/jackhuang/hmcl/auth/OAuth.java b/HMCLCore/src/main/java/org/jackhuang/hmcl/auth/OAuth.java index c71b022168..06864511b7 100644 --- a/HMCLCore/src/main/java/org/jackhuang/hmcl/auth/OAuth.java +++ b/HMCLCore/src/main/java/org/jackhuang/hmcl/auth/OAuth.java @@ -20,6 +20,7 @@ import com.google.gson.JsonParseException; import com.google.gson.annotations.SerializedName; import org.jackhuang.hmcl.auth.yggdrasil.RemoteAuthenticationException; +import org.jackhuang.hmcl.util.StringUtils; import org.jackhuang.hmcl.util.io.HttpRequest; import org.jackhuang.hmcl.util.io.NetworkUtils; @@ -32,12 +33,6 @@ import static org.jackhuang.hmcl.util.Pair.pair; public class OAuth { - public static final OAuth MICROSOFT = new OAuth( - "https://login.live.com/oauth20_authorize.srf", - "https://login.live.com/oauth20_token.srf", - "https://login.microsoftonline.com/consumers/oauth2/v2.0/devicecode", - "https://login.microsoftonline.com/consumers/oauth2/v2.0/token"); - private final String authorizationURL; private final String accessTokenURL; private final String deviceCodeURL; @@ -50,13 +45,13 @@ public OAuth(String authorizationURL, String accessTokenURL, String deviceCodeUR this.tokenURL = tokenURL; } - public Result authenticate(GrantFlow grantFlow, Options options) throws AuthenticationException { + public Result authenticate(GrantFlow grantFlow, OAuthService service) throws AuthenticationException { try { switch (grantFlow) { case AUTHORIZATION_CODE: - return authenticateAuthorizationCode(options); + return authenticateAuthorizationCode(service); case DEVICE: - return authenticateDevice(options); + return authenticateDevice(service); default: throw new UnsupportedOperationException("grant flow " + grantFlow); } @@ -75,19 +70,25 @@ public Result authenticate(GrantFlow grantFlow, Options options) throws Authenti } } - private Result authenticateAuthorizationCode(Options options) throws IOException, InterruptedException, JsonParseException, ExecutionException, AuthenticationException { - Session session = options.callback.startServer(); - options.callback.openBrowser(NetworkUtils.withQuery(authorizationURL, - mapOf(pair("client_id", options.callback.getClientId()), pair("response_type", "code"), - pair("redirect_uri", session.getRedirectURI()), pair("scope", options.scope), - pair("prompt", "select_account")))); + private Result authenticateAuthorizationCode(OAuthService service) throws IOException, InterruptedException, JsonParseException, ExecutionException, AuthenticationException { + Session session = service.callback.startServer(); + service.callback.openBrowser(NetworkUtils.withQuery(authorizationURL, mapOf( + pair("client_id", service.callback.getClientId()), + pair("response_type", "code"), + pair("redirect_uri", session.getRedirectURI()), + pair("scope", service.scope), + pair("prompt", "select_account") + ))); String code = session.waitFor(); // Authorization Code -> Token - AuthorizationResponse response = HttpRequest.POST(accessTokenURL) - .form(pair("client_id", options.callback.getClientId()), pair("code", code), - pair("grant_type", "authorization_code"), pair("client_secret", options.callback.getClientSecret()), - pair("redirect_uri", session.getRedirectURI()), pair("scope", options.scope)) + AuthorizationResponse response = HttpRequest.POST(accessTokenURL).form( + pair("client_id", service.callback.getClientId()), + pair("code", code), + pair("grant_type", "authorization_code"), + pair("client_secret", service.callback.getClientSecret()), + pair("redirect_uri", session.getRedirectURI()), + pair("scope", service.scope)) .ignoreHttpCode() .retry(5) .getJson(AuthorizationResponse.class); @@ -95,18 +96,22 @@ private Result authenticateAuthorizationCode(Options options) throws IOException return new Result(response.accessToken, response.refreshToken); } - private Result authenticateDevice(Options options) throws IOException, InterruptedException, JsonParseException, AuthenticationException { + private Result authenticateDevice(OAuthService service) throws IOException, InterruptedException, JsonParseException, AuthenticationException { DeviceTokenResponse deviceTokenResponse = HttpRequest.POST(deviceCodeURL) - .form(pair("client_id", options.callback.getClientId()), pair("scope", options.scope)) + .form(pair("client_id", service.callback.getClientId()), pair("scope", service.scope)) .ignoreHttpCode() .retry(5) .getJson(DeviceTokenResponse.class); handleErrorResponse(deviceTokenResponse); - options.callback.grantDeviceCode(deviceTokenResponse.userCode, deviceTokenResponse.verificationURI); + service.callback.grantDeviceCode(deviceTokenResponse.userCode, + deviceTokenResponse.verificationUri, + deviceTokenResponse.verificationUriComplete); - // Microsoft OAuth Flow - options.callback.openBrowser(deviceTokenResponse.verificationURI); + if (StringUtils.isBlank(deviceTokenResponse.verificationUriComplete)) + service.callback.openBrowser(deviceTokenResponse.verificationUri); + else + service.callback.openBrowser(deviceTokenResponse.verificationUriComplete); long startTime = System.nanoTime(); long interval = TimeUnit.MILLISECONDS.convert(deviceTokenResponse.interval, TimeUnit.SECONDS); @@ -123,8 +128,8 @@ private Result authenticateDevice(Options options) throws IOException, Interrupt TokenResponse tokenResponse = HttpRequest.POST(tokenURL) .form( pair("grant_type", "urn:ietf:params:oauth:grant-type:device_code"), - pair("code", deviceTokenResponse.deviceCode), - pair("client_id", options.callback.getClientId())) + pair("device_code", deviceTokenResponse.deviceCode), + pair("client_id", service.callback.getClientId())) .ignoreHttpCode() .retry(5) .getJson(TokenResponse.class); @@ -142,19 +147,19 @@ private Result authenticateDevice(Options options) throws IOException, Interrupt continue; } - return new Result(tokenResponse.accessToken, tokenResponse.refreshToken); + return new Result(tokenResponse.accessToken, tokenResponse.refreshToken, tokenResponse.idToken); } } - public Result refresh(String refreshToken, Options options) throws AuthenticationException { + public Result refresh(String refreshToken, OAuthService service) throws AuthenticationException { try { - Map query = mapOf(pair("client_id", options.callback.getClientId()), + Map query = mapOf(pair("client_id", service.callback.getClientId()), pair("refresh_token", refreshToken), pair("grant_type", "refresh_token") ); - if (!options.callback.isPublicClient()) { - query.put("client_secret", options.callback.getClientSecret()); + if (!service.callback.isPublicClient()) { + query.put("client_secret", service.callback.getClientSecret()); } RefreshResponse response = HttpRequest.POST(tokenURL) @@ -166,7 +171,7 @@ public Result refresh(String refreshToken, Options options) throws Authenticatio handleErrorResponse(response); - return new Result(response.accessToken, response.refreshToken); + return new Result(response.accessToken, response.refreshToken, response.idToken); } catch (IOException e) { throw new ServerDisconnectException(e); } catch (JsonParseException e) { @@ -190,22 +195,6 @@ private static void handleErrorResponse(ErrorResponse response) throws Authentic throw new RemoteAuthenticationException(response.error, response.errorDescription, ""); } - public static class Options { - private String userAgent; - private final String scope; - private final Callback callback; - - public Options(String scope, Callback callback) { - this.scope = scope; - this.callback = callback; - } - - public Options setUserAgent(String userAgent) { - this.userAgent = userAgent; - return this; - } - } - public interface Session { String getRedirectURI(); @@ -232,7 +221,7 @@ public interface Callback { */ Session startServer() throws IOException, AuthenticationException; - void grantDeviceCode(String userCode, String verificationURI); + void grantDeviceCode(String userCode, String verificationURI, String verificationUriComplete); /** * Open browser @@ -256,10 +245,16 @@ public enum GrantFlow { public static final class Result { private final String accessToken; private final String refreshToken; + private final String idToken; public Result(String accessToken, String refreshToken) { + this(accessToken, refreshToken, null); + } + + public Result(String accessToken, String refreshToken, String idToken) { this.accessToken = accessToken; this.refreshToken = refreshToken; + this.idToken = idToken; } public String getAccessToken() { @@ -269,6 +264,10 @@ public String getAccessToken() { public String getRefreshToken() { return refreshToken; } + + public String getIdToken() { + return idToken; + } } private static class DeviceTokenResponse extends ErrorResponse { @@ -280,7 +279,10 @@ private static class DeviceTokenResponse extends ErrorResponse { // The URI to be visited for user. @SerializedName("verification_uri") - public String verificationURI; + public String verificationUri; + + @SerializedName("verification_uri_complete") + public String verificationUriComplete; // Lifetime in seconds for device_code and user_code @SerializedName("expires_in") @@ -310,6 +312,11 @@ private static class TokenResponse extends ErrorResponse { @SerializedName("refresh_token") public String refreshToken; + /** + * LittleSkin ID Token + */ + @SerializedName("id_token") + public String idToken; } private static class ErrorResponse { @@ -361,5 +368,8 @@ private static class RefreshResponse extends ErrorResponse { @SerializedName("refresh_token") String refreshToken; + + @SerializedName("id_token") + String idToken; } } diff --git a/HMCLCore/src/main/java/org/jackhuang/hmcl/auth/OAuthAccount.java b/HMCLCore/src/main/java/org/jackhuang/hmcl/auth/OAuthAccount.java index f52ee4931f..77abe22f83 100644 --- a/HMCLCore/src/main/java/org/jackhuang/hmcl/auth/OAuthAccount.java +++ b/HMCLCore/src/main/java/org/jackhuang/hmcl/auth/OAuthAccount.java @@ -35,6 +35,12 @@ public abstract class OAuthAccount extends Account { */ public abstract AuthInfo logInWhenCredentialsExpired() throws AuthenticationException; + @Override + public String getUsername() { + // TODO: email of Microsoft account is blocked by oauth. + return ""; + } + public static class WrongAccountException extends AuthenticationException { private final UUID expected; private final UUID actual; diff --git a/HMCLCore/src/main/java/org/jackhuang/hmcl/auth/OAuthService.java b/HMCLCore/src/main/java/org/jackhuang/hmcl/auth/OAuthService.java new file mode 100644 index 0000000000..3ada243052 --- /dev/null +++ b/HMCLCore/src/main/java/org/jackhuang/hmcl/auth/OAuthService.java @@ -0,0 +1,37 @@ +/* + * Hello Minecraft! Launcher + * Copyright (C) 2024 huangyuhui and contributors + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program. If not, see . + */ +package org.jackhuang.hmcl.auth; + +/** + * @author Glavo + */ +public abstract class OAuthService { + protected final OAuth oAuth; + protected final String scope; + protected final OAuth.Callback callback; + + public OAuthService(OAuth oAuth, String scope, OAuth.Callback callback) { + this.oAuth = oAuth; + this.scope = scope; + this.callback = callback; + } + + public boolean isAvailable() { + return !callback.getClientId().isEmpty(); + } +} diff --git a/HMCLCore/src/main/java/org/jackhuang/hmcl/auth/authlibinjector/AuthlibInjectorAccount.java b/HMCLCore/src/main/java/org/jackhuang/hmcl/auth/authlibinjector/AuthlibInjectorAccount.java index 6be37269ab..a2bfe53fe1 100644 --- a/HMCLCore/src/main/java/org/jackhuang/hmcl/auth/authlibinjector/AuthlibInjectorAccount.java +++ b/HMCLCore/src/main/java/org/jackhuang/hmcl/auth/authlibinjector/AuthlibInjectorAccount.java @@ -40,14 +40,14 @@ public class AuthlibInjectorAccount extends YggdrasilAccount { private final AuthlibInjectorServer server; private AuthlibInjectorArtifactProvider downloader; - public AuthlibInjectorAccount(AuthlibInjectorServer server, AuthlibInjectorArtifactProvider downloader, String username, String password, CharacterSelector selector) throws AuthenticationException { - super(server.getYggdrasilService(), username, password, selector); + public AuthlibInjectorAccount(AuthlibInjectorServer server, AuthlibInjectorArtifactProvider downloader, String username, YggdrasilSession session) { + super(server.getYggdrasilService(), username, session); this.server = server; this.downloader = downloader; } - public AuthlibInjectorAccount(AuthlibInjectorServer server, AuthlibInjectorArtifactProvider downloader, String username, YggdrasilSession session) { - super(server.getYggdrasilService(), username, session); + public AuthlibInjectorAccount(AuthlibInjectorServer server, AuthlibInjectorArtifactProvider downloader, String username, String password, CharacterSelector selector) throws AuthenticationException { + super(server.getYggdrasilService(), username, password, selector); this.server = server; this.downloader = downloader; } diff --git a/HMCLCore/src/main/java/org/jackhuang/hmcl/auth/littleskin/LittleSkinAccount.java b/HMCLCore/src/main/java/org/jackhuang/hmcl/auth/littleskin/LittleSkinAccount.java new file mode 100644 index 0000000000..9767c2de95 --- /dev/null +++ b/HMCLCore/src/main/java/org/jackhuang/hmcl/auth/littleskin/LittleSkinAccount.java @@ -0,0 +1,141 @@ +/* + * Hello Minecraft! Launcher + * Copyright (C) 2024 huangyuhui and contributors + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program. If not, see . + */ +package org.jackhuang.hmcl.auth.littleskin; + +import javafx.beans.binding.ObjectBinding; +import org.jackhuang.hmcl.auth.AuthInfo; +import org.jackhuang.hmcl.auth.AuthenticationException; +import org.jackhuang.hmcl.auth.OAuthAccount; +import org.jackhuang.hmcl.auth.ServerResponseMalformedException; +import org.jackhuang.hmcl.auth.yggdrasil.Texture; +import org.jackhuang.hmcl.auth.yggdrasil.TextureType; +import org.jackhuang.hmcl.auth.yggdrasil.YggdrasilService; +import org.jackhuang.hmcl.util.javafx.BindingMapping; + +import java.nio.file.Path; +import java.util.Map; +import java.util.Objects; +import java.util.Optional; +import java.util.UUID; + +import static org.jackhuang.hmcl.util.logging.Logger.LOG; + +/** + * @author Glavo + */ +public final class LittleSkinAccount extends OAuthAccount { + private final LittleSkinService service; + private LittleSkinSession session; + private final UUID characterUUID; + + private boolean authenticated = false; + + LittleSkinAccount(LittleSkinService service) throws AuthenticationException { + this(service, service.authenticate()); + } + + LittleSkinAccount(LittleSkinService service, LittleSkinSession session) { + this.service = service; + this.session = session; + this.characterUUID = session.getIdToken().getSelectedProfile().getId(); + } + + @Override + public AuthInfo logIn() throws AuthenticationException { + if (!authenticated) { + if (service.validate(session)) { + authenticated = true; + } else { + LittleSkinSession acquiredSession = service.refresh(session); + if (!Objects.equals(acquiredSession.getIdToken().getSelectedProfile().getId(), characterUUID)) { + throw new ServerResponseMalformedException("Selected profile changed"); + } + + session = acquiredSession; + + authenticated = true; + invalidate(); + } + } + + return session.toAuthInfo(); + } + + @Override + public AuthInfo logInWhenCredentialsExpired() throws AuthenticationException { + LittleSkinSession acquiredSession = service.authenticate(); + + if (acquiredSession.getIdToken() == null) { + session = service.refresh(acquiredSession); + } else { + session = acquiredSession; + } + + authenticated = true; + invalidate(); + return session.toAuthInfo(); + } + + @Override + public String getCharacter() { + return session.getIdToken().getSelectedProfile().getName(); + } + + @Override + public UUID getUUID() { + return characterUUID; + } + + @Override + public AuthInfo playOffline() { + return session.toAuthInfo(); + } + + @Override + public ObjectBinding>> getTextures() { + return BindingMapping.of(service.getProfileRepository().binding(getUUID())) + .map(profile -> profile.flatMap(it -> { + try { + return YggdrasilService.getTextures(it); + } catch (ServerResponseMalformedException e) { + LOG.warning("Failed to parse texture payload", e); + return Optional.empty(); + } + })); + } + + @Override + public boolean canUploadSkin() { + return true; + } + + @Override + public void uploadSkin(boolean isSlim, Path file) throws AuthenticationException, UnsupportedOperationException { + service.uploadSkin(characterUUID, session.getAccessToken(), isSlim, file); + } + + @Override + public Map toStorage() { + return session.toStorage(); + } + + @Override + public String getIdentifier() { + return ""; + } +} diff --git a/HMCLCore/src/main/java/org/jackhuang/hmcl/auth/littleskin/LittleSkinAccountFactory.java b/HMCLCore/src/main/java/org/jackhuang/hmcl/auth/littleskin/LittleSkinAccountFactory.java new file mode 100644 index 0000000000..5ddfb8c5cc --- /dev/null +++ b/HMCLCore/src/main/java/org/jackhuang/hmcl/auth/littleskin/LittleSkinAccountFactory.java @@ -0,0 +1,57 @@ +/* + * Hello Minecraft! Launcher + * Copyright (C) 2020 huangyuhui and contributors + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program. If not, see . + */ +package org.jackhuang.hmcl.auth.littleskin; + +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 LittleSkinAccountFactory extends AccountFactory { + + private final LittleSkinService service; + + public LittleSkinAccountFactory(LittleSkinService service) { + this.service = service; + } + + @Override + public AccountLoginType getLoginType() { + return AccountLoginType.NONE; + } + + @Override + public LittleSkinAccount create(CharacterSelector selector, String username, String password, ProgressCallback progressCallback, Object additionalData) throws AuthenticationException { + Objects.requireNonNull(selector); + return new LittleSkinAccount(service); + } + + @Override + public LittleSkinAccount fromStorage(Map storage) { + Objects.requireNonNull(storage); + LittleSkinSession session = LittleSkinSession.fromStorage(storage); + return new LittleSkinAccount(service, session); + } + + @Override + public boolean isAvailable() { + return service.isAvailable(); + } +} diff --git a/HMCLCore/src/main/java/org/jackhuang/hmcl/auth/littleskin/LittleSkinIdToken.java b/HMCLCore/src/main/java/org/jackhuang/hmcl/auth/littleskin/LittleSkinIdToken.java new file mode 100644 index 0000000000..992d542116 --- /dev/null +++ b/HMCLCore/src/main/java/org/jackhuang/hmcl/auth/littleskin/LittleSkinIdToken.java @@ -0,0 +1,86 @@ +/* + * Hello Minecraft! Launcher + * Copyright (C) 2024 huangyuhui and contributors + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program. If not, see . + */ +package org.jackhuang.hmcl.auth.littleskin; + +import com.google.gson.JsonParseException; +import com.google.gson.annotations.SerializedName; +import org.jackhuang.hmcl.auth.yggdrasil.CompleteGameProfile; +import org.jackhuang.hmcl.util.gson.Validation; + +/** + * @author Glavo + * @since About Id Token + */ +public final class LittleSkinIdToken implements Validation { + @SerializedName("aud") + private final String audience; + + @SerializedName("exp") + private final long expirationTime; + + @SerializedName("iat") + private final long issuedAt; + + @SerializedName("iss") + private final String issuer; + + @SerializedName("sub") + private final String subject; + + @SerializedName("selectedProfile") + private final CompleteGameProfile selectedProfile; + + public LittleSkinIdToken(String audience, long expirationTime, long issuedAt, String issuer, String subject, CompleteGameProfile selectedProfile) { + this.audience = audience; + this.expirationTime = expirationTime; + this.issuedAt = issuedAt; + this.issuer = issuer; + this.subject = subject; + this.selectedProfile = selectedProfile; + } + + public String getAudience() { + return audience; + } + + public long getExpirationTime() { + return expirationTime; + } + + public long getIssuedAt() { + return issuedAt; + } + + public String getIssuer() { + return issuer; + } + + public String getSubject() { + return subject; + } + + public CompleteGameProfile getSelectedProfile() { + return selectedProfile; + } + + @Override + public void validate() throws JsonParseException { + Validation.requireNonNull(selectedProfile, "selectedProfile"); + selectedProfile.validate(); + } +} diff --git a/HMCLCore/src/main/java/org/jackhuang/hmcl/auth/littleskin/LittleSkinService.java b/HMCLCore/src/main/java/org/jackhuang/hmcl/auth/littleskin/LittleSkinService.java new file mode 100644 index 0000000000..5bca00e57a --- /dev/null +++ b/HMCLCore/src/main/java/org/jackhuang/hmcl/auth/littleskin/LittleSkinService.java @@ -0,0 +1,91 @@ +/* + * Hello Minecraft! Launcher + * Copyright (C) 2024 huangyuhui and contributors + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program. If not, see . + */ +package org.jackhuang.hmcl.auth.littleskin; + +import com.google.gson.JsonParseException; +import org.jackhuang.hmcl.auth.*; +import org.jackhuang.hmcl.auth.authlibinjector.AuthlibInjectorProvider; +import org.jackhuang.hmcl.auth.yggdrasil.CompleteGameProfile; +import org.jackhuang.hmcl.auth.yggdrasil.YggdrasilService; +import org.jackhuang.hmcl.util.JWTToken; +import org.jackhuang.hmcl.util.javafx.ObservableOptionalCache; + +import java.nio.file.Path; +import java.util.UUID; + +import static java.util.Objects.requireNonNull; + +/** + * @author Glavo + */ +public final class LittleSkinService extends OAuthService { + public static final String API_ROOT = "https://littleskin.cn/api/yggdrasil/"; + private static final OAuth OAUTH = new OAuth( + "https://littleskin.cn/api/yggdrasil/authserver/oauth", + "https://littleskin.cn/oauth/token", + "https://open.littleskin.cn/oauth/device_code", + "https://open.littleskin.cn/oauth/token" + ); + private static final String SCOPE = "openid offline_access User.Read Player.ReadWrite Yggdrasil.PlayerProfiles.Select Yggdrasil.Server.Join"; + + private final YggdrasilService yggdrasilService = new YggdrasilService(new AuthlibInjectorProvider(API_ROOT)); + + public LittleSkinService(OAuth.Callback callback) { + super(OAUTH, SCOPE, callback); + } + + public ObservableOptionalCache getProfileRepository() { + return yggdrasilService.getProfileRepository(); + } + + private static LittleSkinSession fromResult(OAuth.Result result) throws JsonParseException { + LittleSkinIdToken idToken = JWTToken.parse(LittleSkinIdToken.class, result.getIdToken()).getPayload(); + idToken.validate(); + return new LittleSkinSession(result.getAccessToken(), result.getRefreshToken(), idToken); + } + + public LittleSkinSession authenticate() throws AuthenticationException { + try { + return fromResult(OAUTH.authenticate(OAuth.GrantFlow.DEVICE, this)); + } catch (JsonParseException | IllegalArgumentException e) { + throw new ServerResponseMalformedException(e); + } + } + + public LittleSkinSession refresh(LittleSkinSession oldSession) throws AuthenticationException { + try { + return fromResult(OAUTH.refresh(oldSession.getRefreshToken(), this)); + } catch (JsonParseException | IllegalArgumentException e) { + throw new ServerResponseMalformedException(e); + } + } + + public boolean validate(LittleSkinSession session) throws AuthenticationException { + requireNonNull(session); + + if (System.currentTimeMillis() > session.getIdToken().getExpirationTime()) { + return false; + } + + return yggdrasilService.validate(session.getAccessToken(), null); + } + + public void uploadSkin(UUID uuid, String accessToken, boolean isSlim, Path file) throws AuthenticationException, UnsupportedOperationException { + yggdrasilService.uploadSkin(uuid, accessToken, isSlim, file); + } +} diff --git a/HMCLCore/src/main/java/org/jackhuang/hmcl/auth/littleskin/LittleSkinSession.java b/HMCLCore/src/main/java/org/jackhuang/hmcl/auth/littleskin/LittleSkinSession.java new file mode 100644 index 0000000000..554a2b3043 --- /dev/null +++ b/HMCLCore/src/main/java/org/jackhuang/hmcl/auth/littleskin/LittleSkinSession.java @@ -0,0 +1,85 @@ +/* + * Hello Minecraft! Launcher + * Copyright (C) 2024 huangyuhui and contributors + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program. If not, see . + */ +package org.jackhuang.hmcl.auth.littleskin; + +import org.jackhuang.hmcl.auth.AuthInfo; +import org.jackhuang.hmcl.auth.yggdrasil.CompleteGameProfile; +import org.jackhuang.hmcl.util.logging.Logger; + +import java.util.Map; +import java.util.Objects; + +import static org.jackhuang.hmcl.util.Lang.*; +import static org.jackhuang.hmcl.util.Pair.pair; +import static org.jackhuang.hmcl.util.gson.JsonUtils.GSON; + +/** + * @author Glavo + */ +public final class LittleSkinSession { + private final String accessToken; + private final String refreshToken; + private final LittleSkinIdToken idToken; + + public LittleSkinSession(String accessToken, String refreshToken, LittleSkinIdToken idToken) { + this.accessToken = accessToken; + this.refreshToken = refreshToken; + this.idToken = idToken; + + if (accessToken != null) Logger.registerAccessToken(accessToken); + } + + public String getAccessToken() { + return accessToken; + } + + public String getRefreshToken() { + return refreshToken; + } + + public LittleSkinIdToken getIdToken() { + return idToken; + } + + public static LittleSkinSession fromStorage(Map storage) { + String accessToken = getOrThrow(storage, "accessToken", String.class); + String refreshToken = getOrThrow(storage, "refreshToken", String.class); + LittleSkinIdToken idToken = tryCast(storage.get("idToken"), Map.class) + .map(it -> GSON.fromJson(GSON.toJsonTree(it), LittleSkinIdToken.class)) + .orElseThrow(() -> new IllegalArgumentException("refreshToken is missing")); + idToken.validate(); + return new LittleSkinSession(accessToken, refreshToken, idToken); + } + + public Map toStorage() { + Objects.requireNonNull(idToken); + return mapOf( + pair("accessToken", accessToken), + pair("refreshToken", refreshToken), + pair("idToken", GSON.toJsonTree(idToken)) + ); + } + + public AuthInfo toAuthInfo() { + Objects.requireNonNull(idToken); + CompleteGameProfile selectedProfile = idToken.getSelectedProfile(); + selectedProfile.validate(); + + return new AuthInfo(selectedProfile.getName(), selectedProfile.getId(), accessToken, AuthInfo.USER_TYPE_MSA, "{}"); + } +} diff --git a/HMCLCore/src/main/java/org/jackhuang/hmcl/auth/microsoft/MicrosoftAccount.java b/HMCLCore/src/main/java/org/jackhuang/hmcl/auth/microsoft/MicrosoftAccount.java index ef9401630d..058d62978e 100644 --- a/HMCLCore/src/main/java/org/jackhuang/hmcl/auth/microsoft/MicrosoftAccount.java +++ b/HMCLCore/src/main/java/org/jackhuang/hmcl/auth/microsoft/MicrosoftAccount.java @@ -35,19 +35,19 @@ public final class MicrosoftAccount extends OAuthAccount { - protected final MicrosoftService service; - protected UUID characterUUID; + private final MicrosoftService service; + private final UUID characterUUID; private boolean authenticated = false; private MicrosoftSession session; - protected MicrosoftAccount(MicrosoftService service, MicrosoftSession session) { + 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 { + MicrosoftAccount(MicrosoftService service, CharacterSelector characterSelector) throws AuthenticationException { this.service = requireNonNull(service); MicrosoftSession acquiredSession = service.authenticate(); @@ -61,12 +61,6 @@ protected MicrosoftAccount(MicrosoftService service, CharacterSelector character authenticated = true; } - @Override - public String getUsername() { - // TODO: email of Microsoft account is blocked by oauth. - return ""; - } - @Override public String getCharacter() { return session.getProfile().getName(); diff --git a/HMCLCore/src/main/java/org/jackhuang/hmcl/auth/microsoft/MicrosoftAccountFactory.java b/HMCLCore/src/main/java/org/jackhuang/hmcl/auth/microsoft/MicrosoftAccountFactory.java index e795ec2367..60a406b80f 100644 --- a/HMCLCore/src/main/java/org/jackhuang/hmcl/auth/microsoft/MicrosoftAccountFactory.java +++ b/HMCLCore/src/main/java/org/jackhuang/hmcl/auth/microsoft/MicrosoftAccountFactory.java @@ -50,4 +50,9 @@ public MicrosoftAccount fromStorage(Map storage) { MicrosoftSession session = MicrosoftSession.fromStorage(storage); return new MicrosoftAccount(service, session); } + + @Override + public boolean isAvailable() { + return service.isAvailable(); + } } diff --git a/HMCLCore/src/main/java/org/jackhuang/hmcl/auth/microsoft/MicrosoftService.java b/HMCLCore/src/main/java/org/jackhuang/hmcl/auth/microsoft/MicrosoftService.java index 335e074d00..1dffd99366 100644 --- a/HMCLCore/src/main/java/org/jackhuang/hmcl/auth/microsoft/MicrosoftService.java +++ b/HMCLCore/src/main/java/org/jackhuang/hmcl/auth/microsoft/MicrosoftService.java @@ -21,11 +21,11 @@ import com.google.gson.GsonBuilder; import com.google.gson.JsonParseException; import com.google.gson.annotations.SerializedName; -import org.jackhuang.hmcl.auth.AuthenticationException; -import org.jackhuang.hmcl.auth.OAuth; -import org.jackhuang.hmcl.auth.ServerDisconnectException; -import org.jackhuang.hmcl.auth.ServerResponseMalformedException; -import org.jackhuang.hmcl.auth.yggdrasil.*; +import org.jackhuang.hmcl.auth.*; +import org.jackhuang.hmcl.auth.yggdrasil.CompleteGameProfile; +import org.jackhuang.hmcl.auth.yggdrasil.RemoteAuthenticationException; +import org.jackhuang.hmcl.auth.yggdrasil.Texture; +import org.jackhuang.hmcl.auth.yggdrasil.TextureType; import org.jackhuang.hmcl.util.StringUtils; import org.jackhuang.hmcl.util.gson.*; import org.jackhuang.hmcl.util.io.*; @@ -48,17 +48,21 @@ import static org.jackhuang.hmcl.util.Pair.pair; import static org.jackhuang.hmcl.util.logging.Logger.LOG; -public class MicrosoftService { +public final class MicrosoftService extends OAuthService { + private static final OAuth OAUTH = new OAuth( + "https://login.live.com/oauth20_authorize.srf", + "https://login.live.com/oauth20_token.srf", + "https://login.microsoftonline.com/consumers/oauth2/v2.0/devicecode", + "https://login.microsoftonline.com/consumers/oauth2/v2.0/token"); + private static final String SCOPE = "XboxLive.signin offline_access"; private static final ThreadPoolExecutor POOL = threadPool("MicrosoftProfileProperties", true, 2, 10, TimeUnit.SECONDS); - private final OAuth.Callback callback; - private final ObservableOptionalCache profileRepository; public MicrosoftService(OAuth.Callback callback) { - this.callback = requireNonNull(callback); + super(OAUTH, SCOPE, callback); this.profileRepository = new ObservableOptionalCache<>(uuid -> { LOG.info("Fetching properties of " + uuid); return getCompleteGameProfile(uuid); @@ -71,7 +75,7 @@ public ObservableOptionalCache>> getTextures() { return Optional.empty(); } })); - } @Override diff --git a/HMCLCore/src/main/java/org/jackhuang/hmcl/auth/yggdrasil/YggdrasilService.java b/HMCLCore/src/main/java/org/jackhuang/hmcl/auth/yggdrasil/YggdrasilService.java index ef3b695626..a46c045fc1 100644 --- a/HMCLCore/src/main/java/org/jackhuang/hmcl/auth/yggdrasil/YggdrasilService.java +++ b/HMCLCore/src/main/java/org/jackhuang/hmcl/auth/yggdrasil/YggdrasilService.java @@ -30,6 +30,7 @@ import org.jackhuang.hmcl.util.io.HttpMultipartRequest; import org.jackhuang.hmcl.util.io.NetworkUtils; import org.jackhuang.hmcl.util.javafx.ObservableOptionalCache; +import org.jetbrains.annotations.Nullable; import java.io.IOException; import java.io.InputStream; @@ -120,11 +121,7 @@ public YggdrasilSession refresh(String accessToken, String clientToken, GameProf return response; } - public boolean validate(String accessToken) throws AuthenticationException { - return validate(accessToken, null); - } - - public boolean validate(String accessToken, String clientToken) throws AuthenticationException { + public boolean validate(String accessToken, @Nullable String clientToken) throws AuthenticationException { Objects.requireNonNull(accessToken); try { diff --git a/HMCLCore/src/main/java/org/jackhuang/hmcl/util/JWTToken.java b/HMCLCore/src/main/java/org/jackhuang/hmcl/util/JWTToken.java new file mode 100644 index 0000000000..ef921ffaac --- /dev/null +++ b/HMCLCore/src/main/java/org/jackhuang/hmcl/util/JWTToken.java @@ -0,0 +1,121 @@ +/* + * Hello Minecraft! Launcher + * Copyright (C) 2024 huangyuhui and contributors + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program. If not, see . + */ +package org.jackhuang.hmcl.util; + +import com.google.gson.annotations.SerializedName; +import com.google.gson.reflect.TypeToken; + +import java.util.Base64; + +import static java.nio.charset.StandardCharsets.UTF_8; +import static org.jackhuang.hmcl.util.gson.JsonUtils.GSON; + +/** + * @author Glavo + */ +public final class JWTToken { + + public static JWTToken parse(Class type, final String token) { + return parse(TypeToken.get(type), token); + } + + public static JWTToken parse(TypeToken type, final String token) { + String[] parts = token.split("\\."); + if (parts.length != 3) + throw new IllegalArgumentException("Invalid JWT token: " + token); + + try { + Base64.Decoder decoder = Base64.getUrlDecoder(); + Header header = GSON.fromJson(new String(decoder.decode(parts[0]), UTF_8), Header.class); + T payload = GSON.fromJson(new String(decoder.decode(parts[1]), UTF_8), type); + String signature = parts[2]; + return new JWTToken<>(header, payload, signature); + } catch (Throwable e) { + throw new IllegalArgumentException("Invalid JWT token: " + token, e); + } + } + + private final Header header; + private final T payload; + private final String signature; + + public JWTToken(Header header, T payload, String signature) { + this.header = header; + this.payload = payload; + this.signature = signature; + } + + public Header getHeader() { + return header; + } + + public T getPayload() { + return payload; + } + + public String getSignature() { + return signature; + } + + @Override + public String toString() { + return GSON.toJson(this); + } + + public static final class Header { + @SerializedName("alg") + private final String algorithm; + + @SerializedName("typ") + private final String type; + + @SerializedName("cty") + private final String contentType; + + @SerializedName("kid") + private final String keyId; + + public Header(String algorithm, String type, String contentType, String keyId) { + this.algorithm = algorithm; + this.type = type; + this.contentType = contentType; + this.keyId = keyId; + } + + public String getAlgorithm() { + return algorithm; + } + + public String getType() { + return type; + } + + public String getContentType() { + return contentType; + } + + public String getKeyId() { + return keyId; + } + + @Override + public String toString() { + return "JWTToken.Header " + GSON.toJson(this); + } + } +} diff --git a/HMCLCore/src/main/java/org/jackhuang/hmcl/util/Lang.java b/HMCLCore/src/main/java/org/jackhuang/hmcl/util/Lang.java index b0be0988c0..dc06cabf64 100644 --- a/HMCLCore/src/main/java/org/jackhuang/hmcl/util/Lang.java +++ b/HMCLCore/src/main/java/org/jackhuang/hmcl/util/Lang.java @@ -26,7 +26,6 @@ import java.util.stream.Stream; /** - * * @author huangyuhui */ public final class Lang { @@ -48,9 +47,10 @@ public static U requireNonNullElseGet(T value, Function the type of keys - * @param the type of values + * @param the type of keys + * @param the type of values * @return the map which contains data in {@code pairs}. */ @SafeVarargs @@ -60,9 +60,10 @@ public static Map mapOf(Pair... pairs) { /** * Construct a mutable map by given key-value pairs. + * * @param pairs entries in the new map - * @param the type of keys - * @param the type of values + * @param the type of keys + * @param the type of values * @return the map which contains data in {@code pairs}. */ public static Map mapOf(Iterable> pairs) { @@ -122,9 +123,10 @@ public static T ignoringException(ExceptionalSupplier supplier, T defa /** * Cast {@code obj} to V dynamically. - * @param obj the object reference to be cast. + * + * @param obj the object reference to be cast. * @param clazz the class reference of {@code V}. - * @param the type that {@code obj} is being cast to. + * @param the type that {@code obj} is being cast to. * @return {@code obj} in the type of {@code V}. */ public static Optional tryCast(Object obj, Class clazz) { @@ -135,6 +137,16 @@ public static Optional tryCast(Object obj, Class clazz) { } } + public static V getOrThrow(Map map, String key, Class clazz) { + Object value = map.get(key); + if (value == null) + throw new IllegalArgumentException(key + " is missing"); + else if (!clazz.isInstance(value)) + throw new IllegalArgumentException(key + " has the wrong type: expected: " + clazz.getName() + ", actual: " + value.getClass().getName()); + else + return clazz.cast(value); + } + public static T getOrDefault(List a, int index, T defaultValue) { return index < 0 || index >= a.size() ? defaultValue : a.get(index); } @@ -154,8 +166,8 @@ public static List removingDuplicates(List list) { /** * Join two collections into one list. * - * @param a one collection, to be joined. - * @param b another collection to be joined. + * @param a one collection, to be joined. + * @param b another collection to be joined. * @param the super type of elements in {@code a} and {@code b} * @return the joint collection */ @@ -185,6 +197,7 @@ public static void executeDelayed(Runnable runnable, TimeUnit timeUnit, long tim /** * Start a thread invoking {@code runnable} immediately. + * * @param runnable code to run. * @return the reference of the started thread */ @@ -194,8 +207,9 @@ public static Thread thread(Runnable runnable) { /** * Start a thread invoking {@code runnable} immediately. + * * @param runnable code to run - * @param name the name of thread + * @param name the name of thread * @return the reference of the started thread */ public static Thread thread(Runnable runnable, String name) { @@ -204,8 +218,9 @@ public static Thread thread(Runnable runnable, String name) { /** * Start a thread invoking {@code runnable} immediately. + * * @param runnable code to run - * @param name the name of thread + * @param name the name of thread * @param isDaemon true if thread will be terminated when only daemon threads are running. * @return the reference of the started thread */ @@ -258,7 +273,8 @@ public static Double toDoubleOrNull(Object string) { /** * Find the first non-null reference in given list. - * @param t nullable references list. + * + * @param t nullable references list. * @param the type of nullable references * @return the first non-null reference. */ diff --git a/HMCLCore/src/main/java/org/jackhuang/hmcl/util/io/HttpRequest.java b/HMCLCore/src/main/java/org/jackhuang/hmcl/util/io/HttpRequest.java index 4fb0fead5b..65a1056e29 100644 --- a/HMCLCore/src/main/java/org/jackhuang/hmcl/util/io/HttpRequest.java +++ b/HMCLCore/src/main/java/org/jackhuang/hmcl/util/io/HttpRequest.java @@ -69,10 +69,6 @@ public HttpRequest authorization(String tokenType, String tokenString) { return authorization(tokenType + " " + tokenString); } - public HttpRequest authorization(Authorization authorization) { - return authorization(authorization.getTokenType(), authorization.getAccessToken()); - } - public HttpRequest header(String key, String value) { headers.put(key, value); return this; @@ -239,10 +235,4 @@ private static String getStringWithRetry(ExceptionalSupplier