Compare commits

...
7 changed files with 148 additions and 54 deletions

No files matched your search

-2
View File
@@ -28,7 +28,6 @@ val versionType = System.getenv("VERSION_TYPE") ?: if (isOfficial) "nightly" els
val versionRoot = System.getenv("VERSION_ROOT") ?: projectConfig.getProperty("versionRoot") ?: "3" val versionRoot = System.getenv("VERSION_ROOT") ?: projectConfig.getProperty("versionRoot") ?: "3"
val microsoftAuthId = System.getenv("MICROSOFT_AUTH_ID") ?: "" val microsoftAuthId = System.getenv("MICROSOFT_AUTH_ID") ?: ""
val microsoftAuthSecret = System.getenv("MICROSOFT_AUTH_SECRET") ?: ""
val curseForgeApiKey = System.getenv("CURSEFORGE_API_KEY") ?: "" val curseForgeApiKey = System.getenv("CURSEFORGE_API_KEY") ?: ""
val launcherExe = System.getenv("HMCL_LAUNCHER_EXE") ?: "" val launcherExe = System.getenv("HMCL_LAUNCHER_EXE") ?: ""
@@ -153,7 +152,6 @@ val hmclProperties = buildList {
} }
add("hmcl.version.type" to versionType) add("hmcl.version.type" to versionType)
add("hmcl.microsoft.auth.id" to microsoftAuthId) add("hmcl.microsoft.auth.id" to microsoftAuthId)
add("hmcl.microsoft.auth.secret" to microsoftAuthSecret)
add("hmcl.curseforge.apikey" to curseForgeApiKey) add("hmcl.curseforge.apikey" to curseForgeApiKey)
add("hmcl.authlib-injector.version" to libs.authlib.injector.get().version!!) add("hmcl.authlib-injector.version" to libs.authlib.injector.get().version!!)
} }
@@ -29,9 +29,8 @@ import org.jackhuang.hmcl.util.io.JarUtils;
import org.jackhuang.hmcl.util.io.NetworkUtils; import org.jackhuang.hmcl.util.io.NetworkUtils;
import java.io.IOException; import java.io.IOException;
import java.util.HashMap; import java.security.SecureRandom;
import java.util.Locale; import java.util.*;
import java.util.Map;
import java.util.concurrent.CompletableFuture; import java.util.concurrent.CompletableFuture;
import java.util.concurrent.ExecutionException; import java.util.concurrent.ExecutionException;
@@ -43,6 +42,8 @@ import static org.jackhuang.hmcl.util.logging.Logger.LOG;
public final class OAuthServer extends NanoHTTPD implements OAuth.Session { public final class OAuthServer extends NanoHTTPD implements OAuth.Session {
private final int port; private final int port;
private final CompletableFuture<String> future = new CompletableFuture<>(); private final CompletableFuture<String> future = new CompletableFuture<>();
private final String codeVerifier;
private final String state;
public static String lastlyOpenedURL; public static String lastlyOpenedURL;
@@ -52,6 +53,34 @@ public final class OAuthServer extends NanoHTTPD implements OAuth.Session {
super(port); super(port);
this.port = port; this.port = port;
var encoder = Base64.getUrlEncoder().withoutPadding();
var random = new SecureRandom();
{
// https://datatracker.ietf.org/doc/html/rfc6749#section-4.1.1
// https://datatracker.ietf.org/doc/html/rfc6749#section-10.12
byte[] bytes = new byte[32];
random.nextBytes(bytes);
this.state = encoder.encodeToString(bytes);
}
{
// https://datatracker.ietf.org/doc/html/rfc7636#section-4.1
byte[] bytes = new byte[64];
random.nextBytes(bytes);
this.codeVerifier = encoder.encodeToString(bytes);
}
}
@Override
public String getCodeVerifier() {
return codeVerifier;
}
@Override
public String getState() {
return state;
} }
@Override @Override
@@ -93,12 +122,22 @@ public final class OAuthServer extends NanoHTTPD implements OAuth.Session {
String parameters = session.getQueryParameterString(); String parameters = session.getQueryParameterString();
Map<String, String> query = mapOf(NetworkUtils.parseQuery(parameters)); Map<String, String> query = mapOf(NetworkUtils.parseQuery(parameters));
if (query.containsKey("code")) {
idToken = query.get("id_token"); String code = query.get("code");
future.complete(query.get("code")); if (code != null) {
if (this.state.equals(query.get("state"))) {
idToken = query.get("id_token");
future.complete(code);
} else if (query.containsKey("state")) {
LOG.warning("Failed to authenticate: invalid state in parameters");
future.completeExceptionally(new AuthenticationException("Failed to authenticate: invalid state"));
} else {
LOG.warning("Failed to authenticate: missing state in parameters");
future.completeExceptionally(new AuthenticationException("Failed to authenticate: missing state"));
}
} else { } else {
LOG.warning("Error: " + parameters); LOG.warning("Failed to authenticate: missing authorization code in parameters");
future.completeExceptionally(new AuthenticationException("failed to authenticate")); future.completeExceptionally(new AuthenticationException("Failed to authenticate: missing authorization code"));
} }
String html; String html;
@@ -168,17 +207,6 @@ public final class OAuthServer extends NanoHTTPD implements OAuth.Session {
return System.getProperty("hmcl.microsoft.auth.id", return System.getProperty("hmcl.microsoft.auth.id",
JarUtils.getAttribute("hmcl.microsoft.auth.id", "")); JarUtils.getAttribute("hmcl.microsoft.auth.id", ""));
} }
@Override
public String getClientSecret() {
return System.getProperty("hmcl.microsoft.auth.secret",
JarUtils.getAttribute("hmcl.microsoft.auth.secret", ""));
}
@Override
public boolean isPublicClient() {
return true; // We have turned on the device auth flow.
}
} }
public static class GrantDeviceCodeEvent extends Event { public static class GrantDeviceCodeEvent extends Event {
@@ -141,7 +141,7 @@ public class MicrosoftAccountLoginPane extends JFXDialogLayout implements Dialog
hintPane.setText(i18n("account.methods.microsoft.hint")); hintPane.setText(i18n("account.methods.microsoft.hint"));
rootContainer.getChildren().add(hintPane); rootContainer.getChildren().add(hintPane);
} else if (currentStep instanceof Step.StartAuthorizationCodeLogin) { } else if (currentStep instanceof Step.StartAuthorizationCodeLogin) {
loginButtonSpinner.setLoading(true); loginButtonSpinner.setLoading(false);
cancelAllTasks(); cancelAllTasks();
rootContainer.getChildren().add(new JFXSpinner()); rootContainer.getChildren().add(new JFXSpinner());
@@ -175,16 +175,20 @@ public class MicrosoftAccountLoginPane extends JFXDialogLayout implements Dialog
} else if (currentStep instanceof Step.WaitForScanQrCode wait) { } else if (currentStep instanceof Step.WaitForScanQrCode wait) {
loginButtonSpinner.setLoading(true); loginButtonSpinner.setLoading(true);
String scanUri = "https://www.microsoft.com/link".equals(wait.verificationUri())
? "https://www.microsoft.com/link?otc=" + wait.userCode()
: wait.verificationUri();
var deviceHint = new HintPane(MessageDialogPane.MessageType.INFO); var deviceHint = new HintPane(MessageDialogPane.MessageType.INFO);
deviceHint.setSegment(i18n("account.methods.microsoft.methods.device.hint", deviceHint.setSegment(i18n("account.methods.microsoft.methods.device.hint",
StringUtils.escapeXmlAttribute(wait.verificationUri()), StringUtils.escapeXmlAttribute(scanUri),
wait.verificationUri(), wait.verificationUri(),
wait.userCode() wait.userCode()
)); ));
var qrCode = new SVGPath(); var qrCode = new SVGPath();
qrCode.fillProperty().bind(Themes.colorSchemeProperty().getPrimary()); qrCode.fillProperty().bind(Themes.colorSchemeProperty().getPrimary());
qrCode.setContent(QrCodeUtils.toSVGPath(QrCode.encodeText(wait.verificationUri() + "?otc=" + wait.userCode(), QrCode.Ecc.MEDIUM))); qrCode.setContent(QrCodeUtils.toSVGPath(QrCode.encodeText(scanUri, QrCode.Ecc.MEDIUM)));
qrCode.setScaleX(3); qrCode.setScaleX(3);
qrCode.setScaleY(3); qrCode.setScaleY(3);
@@ -205,7 +209,7 @@ public class MicrosoftAccountLoginPane extends JFXDialogLayout implements Dialog
cancelAllTasks(); cancelAllTasks();
HintPane errHintPane = new HintPane(MessageDialogPane.MessageType.ERROR); HintPane errHintPane = new HintPane(MessageDialogPane.MessageType.ERROR);
errHintPane.setText(failed.message()); errHintPane.setSegment(failed.message());
rootContainer.getChildren().add(errHintPane); rootContainer.getChildren().add(errHintPane);
} }
@@ -139,7 +139,7 @@ public final class ModpackSelectionPage extends VBox implements WizardPage {
} else { } else {
handler.reject(e.getMessage()); handler.reject(e.getMessage());
} }
}).executor(true), i18n("message.downloading"), TaskCancellationAction.NORMAL); }), i18n("message.downloading"), TaskCancellationAction.NORMAL);
} else { } else {
// otherwise we still consider the file as modpack zip file // otherwise we still consider the file as modpack zip file
// since casually the url may not ends with ".zip" // since casually the url may not ends with ".zip"
@@ -156,7 +156,7 @@ public final class ModpackSelectionPage extends VBox implements WizardPage {
} else { } else {
handler.reject(e.getMessage()); handler.reject(e.getMessage());
} }
}).executor(true), }),
i18n("message.downloading"), i18n("message.downloading"),
TaskCancellationAction.NORMAL TaskCancellationAction.NORMAL
); );
@@ -103,7 +103,7 @@ public final class Versions {
i18n("install.failed.downloading.detail", file.getFile().getUrl()) + "\n" + StringUtils.getStackTrace(e), i18n("install.failed.downloading.detail", file.getFile().getUrl()) + "\n" + StringUtils.getStackTrace(e),
i18n("download.failed.no_code"), MessageDialogPane.MessageType.ERROR); i18n("download.failed.no_code"), MessageDialogPane.MessageType.ERROR);
} }
}).executor(true), }),
i18n("message.downloading"), i18n("message.downloading"),
TaskCancellationAction.NORMAL TaskCancellationAction.NORMAL
); );
@@ -24,12 +24,16 @@ import org.jackhuang.hmcl.util.io.HttpRequest;
import org.jackhuang.hmcl.util.io.NetworkUtils; import org.jackhuang.hmcl.util.io.NetworkUtils;
import java.io.IOException; import java.io.IOException;
import java.nio.charset.StandardCharsets;
import java.security.MessageDigest;
import java.util.Base64;
import java.util.Map; import java.util.Map;
import java.util.concurrent.ExecutionException; import java.util.concurrent.ExecutionException;
import java.util.concurrent.TimeUnit; import java.util.concurrent.TimeUnit;
import static org.jackhuang.hmcl.util.Lang.mapOf; import static org.jackhuang.hmcl.util.Lang.mapOf;
import static org.jackhuang.hmcl.util.Pair.pair; import static org.jackhuang.hmcl.util.Pair.pair;
import static org.jackhuang.hmcl.util.logging.Logger.LOG;
public class OAuth { public class OAuth {
public static final OAuth MICROSOFT = new OAuth( public static final OAuth MICROSOFT = new OAuth(
@@ -77,17 +81,31 @@ public class OAuth {
private Result authenticateAuthorizationCode(Options options) throws IOException, InterruptedException, JsonParseException, ExecutionException, AuthenticationException { private Result authenticateAuthorizationCode(Options options) throws IOException, InterruptedException, JsonParseException, ExecutionException, AuthenticationException {
Session session = options.callback.startServer(); Session session = options.callback.startServer();
String codeVerifier = session.getCodeVerifier();
String state = session.getState();
String codeChallenge = generateCodeChallenge(codeVerifier);
options.callback.openBrowser(GrantFlow.AUTHORIZATION_CODE, NetworkUtils.withQuery(authorizationURL, options.callback.openBrowser(GrantFlow.AUTHORIZATION_CODE, NetworkUtils.withQuery(authorizationURL,
mapOf(pair("client_id", options.callback.getClientId()), pair("response_type", "code"), mapOf(pair("client_id", options.callback.getClientId()),
pair("redirect_uri", session.getRedirectURI()), pair("scope", options.scope), pair("response_type", "code"),
pair("prompt", "select_account")))); pair("redirect_uri", session.getRedirectURI()),
pair("scope", options.scope),
pair("prompt", "select_account"),
pair("code_challenge", codeChallenge),
pair("state", state),
pair("code_challenge_method", "S256")
)));
String code = session.waitFor(); String code = session.waitFor();
// Authorization Code -> Token // Authorization Code -> Token
AuthorizationResponse response = HttpRequest.POST(accessTokenURL) AuthorizationResponse response = HttpRequest.POST(accessTokenURL)
.form(pair("client_id", options.callback.getClientId()), pair("code", code), .form(pair("client_id", options.callback.getClientId()),
pair("grant_type", "authorization_code"), pair("client_secret", options.callback.getClientSecret()), pair("code", code),
pair("redirect_uri", session.getRedirectURI()), pair("scope", options.scope)) pair("grant_type", "authorization_code"),
pair("code_verifier", codeVerifier),
pair("redirect_uri", session.getRedirectURI()),
pair("scope", options.scope))
.ignoreHttpCode() .ignoreHttpCode()
.retry(5) .retry(5)
.getJson(AuthorizationResponse.class); .getJson(AuthorizationResponse.class);
@@ -153,10 +171,6 @@ public class OAuth {
pair("grant_type", "refresh_token") pair("grant_type", "refresh_token")
); );
if (!options.callback.isPublicClient()) {
query.put("client_secret", options.callback.getClientSecret());
}
RefreshResponse response = HttpRequest.POST(tokenURL) RefreshResponse response = HttpRequest.POST(tokenURL)
.form(query) .form(query)
.accept("application/json") .accept("application/json")
@@ -174,6 +188,20 @@ public class OAuth {
} }
} }
private static String generateCodeChallenge(String codeVerifier) {
// https://datatracker.ietf.org/doc/html/rfc7636#section-4.2
try {
byte[] bytes = codeVerifier.getBytes(StandardCharsets.US_ASCII);
MessageDigest messageDigest = MessageDigest.getInstance("SHA-256");
messageDigest.update(bytes, 0, bytes.length);
byte[] digest = messageDigest.digest();
return Base64.getUrlEncoder().withoutPadding().encodeToString(digest);
} catch (Exception e) {
LOG.warning("Failed to generate code challenge", e);
throw new RuntimeException(e);
}
}
private static void handleErrorResponse(ErrorResponse response) throws AuthenticationException { private static void handleErrorResponse(ErrorResponse response) throws AuthenticationException {
if (response.error == null || response.errorDescription == null) { if (response.error == null || response.errorDescription == null) {
return; return;
@@ -207,6 +235,9 @@ public class OAuth {
} }
public interface Session { public interface Session {
String getState();
String getCodeVerifier();
String getRedirectURI(); String getRedirectURI();
@@ -243,10 +274,6 @@ public class OAuth {
void openBrowser(GrantFlow grantFlow, String url) throws IOException; void openBrowser(GrantFlow grantFlow, String url) throws IOException;
String getClientId(); String getClientId();
String getClientSecret();
boolean isPublicClient();
} }
public enum GrantFlow { public enum GrantFlow {
@@ -20,6 +20,8 @@ package org.jackhuang.hmcl.download;
import org.jackhuang.hmcl.task.Task; import org.jackhuang.hmcl.task.Task;
import java.util.Arrays; import java.util.Arrays;
import java.util.Collection;
import java.util.List;
import static org.jackhuang.hmcl.util.logging.Logger.LOG; import static org.jackhuang.hmcl.util.logging.Logger.LOG;
@@ -47,25 +49,60 @@ public class MultipleSourceVersionList extends VersionList<RemoteVersion> {
private Task<?> refreshAsync(String gameVersion, int sourceIndex) { private Task<?> refreshAsync(String gameVersion, int sourceIndex) {
VersionList<?> versionList = backends[sourceIndex]; VersionList<?> versionList = backends[sourceIndex];
return versionList.refreshAsync(gameVersion) Task<?> refreshTask = versionList.refreshAsync(gameVersion);
.thenComposeAsync(() -> {
return new Task<>() {
private Task<?> nextTask = null;
{
setSignificance(TaskSignificance.MODERATE);
setName("MultipleSourceVersionList.refreshAsync(task=%s, index=%d, all=%d)".formatted(
refreshTask.getName(), sourceIndex, backends.length)
);
}
@Override
public Collection<Task<?>> getDependents() {
return List.of(refreshTask);
}
@Override
public Collection<? extends Task<?>> getDependencies() {
return nextTask != null ? List.of(nextTask) : List.of();
}
@Override
public boolean isRelyingOnDependents() {
return false;
}
@Override
public void execute() throws Exception {
if (isDependentsSucceeded()) {
lock.writeLock().lock(); lock.writeLock().lock();
try { try {
versions.putAll(gameVersion, versionList.getVersions(gameVersion)); versions.putAll(gameVersion, versionList.getVersions(gameVersion));
} catch (Exception e) {
if (sourceIndex == backends.length - 1) {
LOG.warning("Failed to fetch versions list from all sources", e);
throw e;
} else {
LOG.warning("Failed to fetch versions list and try to fetch from other source", e);
return refreshAsync(gameVersion, sourceIndex + 1);
}
} finally { } finally {
lock.writeLock().unlock(); lock.writeLock().unlock();
} }
return null; setResult(refreshTask.getResult());
}); } else {
Exception exception = refreshTask.getException();
assert exception != null;
if (sourceIndex == backends.length - 1) {
LOG.warning("Failed to fetch versions list from all sources", exception);
setSignificance(TaskSignificance.MINOR);
throw exception;
} else {
LOG.warning("Failed to fetch versions list and try to fetch from other source", exception);
nextTask = refreshAsync(gameVersion, sourceIndex + 1);
nextTask.storeTo(this::setResult);
}
}
}
};
} }
@Override @Override