feat: authenticate Microsoft accounts via external browser

This commit is contained in:
huanghongxun
2021-08-22 20:35:30 +08:00
parent b39508922f
commit 5890f0c782
12 changed files with 419 additions and 189 deletions

View File

@@ -1,6 +1,6 @@
/*
* Hello Minecraft! Launcher
* Copyright (C) 2020 huangyuhui <huanghongxun2008@126.com> and contributors
* Copyright (C) 2021 huangyuhui <huanghongxun2008@126.com> and contributors
*
* This program is free software: you can redistribute it and/or modify
* it under the terms of the GNU General Public License as published by
@@ -20,7 +20,9 @@ package org.jackhuang.hmcl.auth.microsoft;
import com.google.gson.JsonParseException;
import com.google.gson.annotations.SerializedName;
import org.jackhuang.hmcl.auth.*;
import org.jackhuang.hmcl.auth.yggdrasil.*;
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.gson.JsonUtils;
import org.jackhuang.hmcl.util.gson.TolerableValidationException;
import org.jackhuang.hmcl.util.gson.Validation;
@@ -30,16 +32,15 @@ import org.jackhuang.hmcl.util.io.ResponseCodeException;
import org.jackhuang.hmcl.util.javafx.ObservableOptionalCache;
import java.io.IOException;
import java.net.HttpURLConnection;
import java.util.*;
import java.util.concurrent.CompletableFuture;
import java.util.concurrent.ExecutionException;
import java.util.concurrent.ThreadPoolExecutor;
import java.util.concurrent.TimeUnit;
import java.util.function.Predicate;
import java.util.logging.Level;
import java.util.regex.Matcher;
import java.util.regex.Pattern;
import static java.net.HttpURLConnection.HTTP_NOT_FOUND;
import static java.util.Objects.requireNonNull;
import static org.jackhuang.hmcl.util.Lang.mapOf;
import static org.jackhuang.hmcl.util.Lang.threadPool;
@@ -47,22 +48,26 @@ import static org.jackhuang.hmcl.util.Logging.LOG;
import static org.jackhuang.hmcl.util.Pair.pair;
public class MicrosoftService {
private static final ThreadPoolExecutor POOL = threadPool("MicrosoftProfileProperties", true, 2, 10, TimeUnit.SECONDS);
private static final Pattern OAUTH_URL_PATTERN = Pattern.compile("^https://login\\.live\\.com/oauth20_desktop\\.srf\\?code=(.*?)&lc=(.*?)$");
private static final String CLIENT_ID = "6a3728d6-27a3-4180-99bb-479895b8f88e";
private static final String AUTHORIZATION_URL = "https://login.live.com/oauth20_authorize.srf";
private static final String ACCESS_TOKEN_URL = "https://login.live.com/oauth20_token.srf";
private static final String SCOPE = "XboxLive.signin offline_access";
private static final int[] PORTS = { 29111, 29112, 29113, 29114, 29115 };
private static final ThreadPoolExecutor POOL = threadPool("MicrosoftProfileProperties", true, 2, 10,
TimeUnit.SECONDS);
private static final Pattern OAUTH_URL_PATTERN = Pattern
.compile("^https://login\\.live\\.com/oauth20_desktop\\.srf\\?code=(.*?)&lc=(.*?)$");
private final WebViewCallback callback;
private final OAuthCallback callback;
private final ObservableOptionalCache<String, MinecraftProfileResponse, AuthenticationException> profileRepository;
public MicrosoftService(WebViewCallback callback) {
this.callback = callback;
this.profileRepository = new ObservableOptionalCache<>(
authorization -> {
LOG.info("Fetching properties");
return getCompleteProfile(authorization);
},
(uuid, e) -> LOG.log(Level.WARNING, "Failed to fetch properties of " + uuid, e),
POOL);
public MicrosoftService(OAuthCallback callback) {
this.callback = requireNonNull(callback);
this.profileRepository = new ObservableOptionalCache<>(authorization -> {
LOG.info("Fetching properties");
return getCompleteProfile(authorization);
}, (uuid, e) -> LOG.log(Level.WARNING, "Failed to fetch properties of " + uuid, e), POOL);
}
public ObservableOptionalCache<String, MinecraftProfileResponse, AuthenticationException> getProfileRepository() {
@@ -70,62 +75,78 @@ public class MicrosoftService {
}
public MicrosoftSession authenticate() throws AuthenticationException {
requireNonNull(callback);
// Example URL:
// https://login.live.com/oauth20_authorize.srf?response_type=code&client_id=6a3728d6-27a3-4180-99bb-479895b8f88e&redirect_uri=http://localhost:29111/auth-response&scope=XboxLive.signin+offline_access&state=612fd24a2447427383e8b222b597db66&prompt=select_account
try {
// Microsoft OAuth Flow
String code = callback.show(this, urlToBeTested -> OAUTH_URL_PATTERN.matcher(urlToBeTested).find(), "https://login.live.com/oauth20_authorize.srf" +
"?client_id=00000000402b5328" +
"&response_type=code" +
"&scope=service%3A%3Auser.auth.xboxlive.com%3A%3AMBI_SSL" +
"&redirect_uri=https%3A%2F%2Flogin.live.com%2Foauth20_desktop.srf")
.thenApply(url -> {
Matcher matcher = OAUTH_URL_PATTERN.matcher(url);
matcher.find();
return matcher.group(1);
})
.get();
OAuthSession session = callback.startServer();
callback.openBrowser(NetworkUtils.withQuery(AUTHORIZATION_URL,
mapOf(pair("client_id", CLIENT_ID), pair("response_type", "code"),
pair("redirect_uri", session.getRedirectURI()), pair("scope", SCOPE),
pair("prompt", "select_account"))));
String code = session.waitFor();
// Authorization Code -> Token
String responseText = HttpRequest.POST("https://login.live.com/oauth20_token.srf").form(mapOf(
pair("client_id", "00000000402b5328"),
pair("code", code),
pair("grant_type", "authorization_code"),
pair("redirect_uri", "https://login.live.com/oauth20_desktop.srf"),
pair("scope", "service::user.auth.xboxlive.com::MBI_SSL"))).getString();
LiveAuthorizationResponse response = JsonUtils.fromNonNullJson(responseText, LiveAuthorizationResponse.class);
String responseText = HttpRequest.POST("https://login.live.com/oauth20_token.srf")
.form(mapOf(pair("client_id", CLIENT_ID), pair("code", code),
pair("grant_type", "authorization_code"), pair("client_secret", session.getClientSecret()),
pair("redirect_uri", session.getRedirectURI()), pair("scope", SCOPE)))
.getString();
LiveAuthorizationResponse response = JsonUtils.fromNonNullJson(responseText,
LiveAuthorizationResponse.class);
// Authenticate with XBox Live
XBoxLiveAuthenticationResponse xboxResponse = HttpRequest.POST("https://user.auth.xboxlive.com/user/authenticate")
XBoxLiveAuthenticationResponse xboxResponse = HttpRequest
.POST("https://user.auth.xboxlive.com/user/authenticate")
.json(mapOf(
pair("Properties", mapOf(
pair("AuthMethod", "RPS"),
pair("SiteName", "user.auth.xboxlive.com"),
pair("RpsTicket", response.accessToken)
)),
pair("RelyingParty", "http://auth.xboxlive.com"),
pair("TokenType", "JWT")))
.getJson(XBoxLiveAuthenticationResponse.class);
pair("Properties",
mapOf(pair("AuthMethod", "RPS"), pair("SiteName", "user.auth.xboxlive.com"),
pair("RpsTicket", "d=" + response.accessToken))),
pair("RelyingParty", "http://auth.xboxlive.com"), pair("TokenType", "JWT")))
.accept("application/json").getJson(XBoxLiveAuthenticationResponse.class);
String uhs = (String) xboxResponse.displayClaims.xui.get(0).get("uhs");
// Authenticate with XSTS
XBoxLiveAuthenticationResponse xstsResponse = HttpRequest.POST("https://xsts.auth.xboxlive.com/xsts/authorize")
// Authenticate Minecraft with XSTS
XBoxLiveAuthenticationResponse minecraftXstsResponse = HttpRequest
.POST("https://xsts.auth.xboxlive.com/xsts/authorize")
.json(mapOf(
pair("Properties", mapOf(
pair("SandboxId", "RETAIL"),
pair("UserTokens", Collections.singletonList(xboxResponse.token))
)),
pair("RelyingParty", "rp://api.minecraftservices.com/"),
pair("TokenType", "JWT")))
pair("Properties",
mapOf(pair("SandboxId", "RETAIL"),
pair("UserTokens", Collections.singletonList(xboxResponse.token)))),
pair("RelyingParty", "rp://api.minecraftservices.com/"), pair("TokenType", "JWT")))
.getJson(XBoxLiveAuthenticationResponse.class);
String minecraftXstsUhs = (String) minecraftXstsResponse.displayClaims.xui.get(0).get("uhs");
if (!Objects.equals(uhs, minecraftXstsUhs)) {
throw new ServerResponseMalformedException("uhs mismatched");
}
// Authenticate XBox with XSTS
XBoxLiveAuthenticationResponse xboxXstsResponse = HttpRequest
.POST("https://xsts.auth.xboxlive.com/xsts/authorize")
.json(mapOf(
pair("Properties",
mapOf(pair("SandboxId", "RETAIL"),
pair("UserTokens", Collections.singletonList(xboxResponse.token)))),
pair("RelyingParty", "http://xboxlive.com"), pair("TokenType", "JWT")))
.getJson(XBoxLiveAuthenticationResponse.class);
String xboxXstsUhs = (String) xboxXstsResponse.displayClaims.xui.get(0).get("uhs");
if (!Objects.equals(uhs, xboxXstsUhs)) {
throw new ServerResponseMalformedException("uhs mismatched");
}
getXBoxProfile(uhs, xboxXstsResponse.token);
// Authenticate with Minecraft
MinecraftLoginWithXBoxResponse minecraftResponse = HttpRequest.POST("https://api.minecraftservices.com/authentication/login_with_xbox")
.json(mapOf(pair("identityToken", "XBL3.0 x=" + uhs + ";" + xstsResponse.token)))
.getJson(MinecraftLoginWithXBoxResponse.class);
MinecraftLoginWithXBoxResponse minecraftResponse = HttpRequest
.POST("https://api.minecraftservices.com/authentication/login_with_xbox")
.json(mapOf(pair("identityToken", "XBL3.0 x=" + uhs + ";" + minecraftXstsResponse.token)))
.accept("application/json").getJson(MinecraftLoginWithXBoxResponse.class);
long notAfter = minecraftResponse.expiresIn * 1000L + System.currentTimeMillis();
// Checking Game Ownership
MinecraftStoreResponse storeResponse = HttpRequest.GET("https://api.minecraftservices.com/entitlements/mcstore")
MinecraftStoreResponse storeResponse = HttpRequest
.GET("https://api.minecraftservices.com/entitlements/mcstore")
.authorization(String.format("%s %s", minecraftResponse.tokenType, minecraftResponse.accessToken))
.getJson(MinecraftStoreResponse.class);
handleErrorResponse(storeResponse);
@@ -133,7 +154,8 @@ public class MicrosoftService {
throw new NoCharacterException();
}
return new MicrosoftSession(minecraftResponse.tokenType, minecraftResponse.accessToken, new MicrosoftSession.User(minecraftResponse.username), null);
return new MicrosoftSession(minecraftResponse.tokenType, minecraftResponse.accessToken, notAfter,
new MicrosoftSession.User(minecraftResponse.username), null);
} catch (IOException e) {
throw new ServerDisconnectException(e);
} catch (InterruptedException e) {
@@ -152,11 +174,14 @@ public class MicrosoftService {
public MicrosoftSession refresh(MicrosoftSession oldSession) throws AuthenticationException {
try {
// Get the profile
MinecraftProfileResponse profileResponse = HttpRequest.GET(NetworkUtils.toURL("https://api.minecraftservices.com/minecraft/profile"))
MinecraftProfileResponse profileResponse = HttpRequest
.GET(NetworkUtils.toURL("https://api.minecraftservices.com/minecraft/profile"))
.authorization(String.format("%s %s", oldSession.getTokenType(), oldSession.getAccessToken()))
.getJson(MinecraftProfileResponse.class);
handleErrorResponse(profileResponse);
return new MicrosoftSession(oldSession.getTokenType(), oldSession.getAccessToken(), oldSession.getUser(), new MicrosoftSession.GameProfile(profileResponse.id, profileResponse.name));
return new MicrosoftSession(oldSession.getTokenType(), oldSession.getAccessToken(),
oldSession.getNotAfter(), oldSession.getUser(),
new MicrosoftSession.GameProfile(profileResponse.id, profileResponse.name));
} catch (IOException e) {
throw new ServerDisconnectException(e);
} catch (JsonParseException e) {
@@ -166,9 +191,9 @@ public class MicrosoftService {
public Optional<MinecraftProfileResponse> getCompleteProfile(String authorization) throws AuthenticationException {
try {
return Optional.ofNullable(HttpRequest.GET(NetworkUtils.toURL("https://api.minecraftservices.com/minecraft/profile"))
.authorization(authorization)
.getJson(MinecraftProfileResponse.class));
return Optional.ofNullable(
HttpRequest.GET(NetworkUtils.toURL("https://api.minecraftservices.com/minecraft/profile"))
.authorization(authorization).getJson(MinecraftProfileResponse.class));
} catch (IOException e) {
throw new ServerDisconnectException(e);
} catch (JsonParseException e) {
@@ -182,13 +207,11 @@ public class MicrosoftService {
try {
HttpRequest.GET(NetworkUtils.toURL("https://api.minecraftservices.com/minecraft/profile"))
.authorization(String.format("%s %s", tokenType, accessToken))
.filter((url, responseCode) -> {
.authorization(String.format("%s %s", tokenType, accessToken)).filter((url, responseCode) -> {
if (responseCode / 100 == 4) {
throw new ResponseCodeException(url, responseCode);
}
})
.getString();
}).getString();
return true;
} catch (ResponseCodeException e) {
return false;
@@ -211,13 +234,44 @@ public class MicrosoftService {
if (!profile.skins.isEmpty()) {
textures.put(TextureType.SKIN, new Texture(profile.skins.get(0).url, null));
}
// if (!profile.capes.isEmpty()) {
// textures.put(TextureType.CAPE, new Texture(profile.capes.get(0).url, null);
// }
// if (!profile.capes.isEmpty()) {
// textures.put(TextureType.CAPE, new Texture(profile.capes.get(0).url, null);
// }
return Optional.of(textures);
}
private static void getXBoxProfile(String uhs, String xstsToken) throws IOException {
HttpRequest.GET("https://profile.xboxlive.com/users/me/profile/settings",
pair("settings", "GameDisplayName,AppDisplayName,AppDisplayPicRaw,GameDisplayPicRaw,"
+ "PublicGamerpic,ShowUserAsAvatar,Gamerscore,Gamertag,ModernGamertag,ModernGamertagSuffix,"
+ "UniqueModernGamertag,AccountTier,TenureLevel,XboxOneRep,"
+ "PreferredColor,Location,Bio,Watermarks," + "RealName,RealNameOverride,IsQuarantined"))
.contentType("application/json").accept("application/json")
.authorization(String.format("XBL3.0 x=%s;%s", uhs, xstsToken)).header("x-xbl-contract-version", "3")
.getString();
}
private static MinecraftProfileResponse getMinecraftProfile(String tokenType, String accessToken)
throws IOException, AuthenticationException {
HttpURLConnection conn = HttpRequest.GET("https://api.minecraftservices.com/minecraft/profile")
.contentType("application/json").authorization(String.format("%s %s", tokenType, accessToken))
.createConnection();
int responseCode = conn.getResponseCode();
if (responseCode == HTTP_NOT_FOUND) {
throw new NoCharacterException();
}
String result = NetworkUtils.readData(conn);
return JsonUtils.fromNonNullJson(result, MinecraftProfileResponse.class);
}
/**
* Error response: {"error":"invalid_grant","error_description":"The provided
* value for the 'redirect_uri' is not valid. The value must exactly match the
* redirect URI used to obtain the authorization
* code.","correlation_id":"??????"}
*/
private static class LiveAuthorizationResponse {
@SerializedName("token_type")
String tokenType;
@@ -245,6 +299,18 @@ public class MicrosoftService {
List<Map<Object, Object>> xui;
}
/**
*
* Success Response: { "IssueInstant":"2020-12-07T19:52:08.4463796Z",
* "NotAfter":"2020-12-21T19:52:08.4463796Z", "Token":"token", "DisplayClaims":{
* "xui":[ { "uhs":"userhash" } ] } }
*
* Error response: { "Identity":"0", "XErr":2148916238, "Message":"",
* "Redirect":"https://start.ui.xboxlive.com/AddChildToFamily" }
*
* XErr Candidates: 2148916233 = missing XBox account 2148916238 = child account
* not linked to a family
*/
private static class XBoxLiveAuthenticationResponse {
@SerializedName("IssueInstant")
String issueInstant;
@@ -341,8 +407,36 @@ public class MicrosoftService {
public String developerMessage;
}
public interface WebViewCallback {
CompletableFuture<String> show(MicrosoftService service, Predicate<String> urlTester, String initialURL);
public interface OAuthCallback {
/**
* Start OAuth callback server at localhost.
*
* @throws IOException if an I/O error occurred.
*/
OAuthSession startServer() throws IOException;
/**
* Open browser
*
* @param url OAuth url.
*/
void openBrowser(String url) throws IOException;
}
public interface OAuthSession {
String getRedirectURI();
String getClientSecret() throws IOException;
/**
* Wait for authentication
*
* @return authentication code
* @throws InterruptedException if interrupted
* @throws ExecutionException if an I/O error occurred.
*/
String waitFor() throws InterruptedException, ExecutionException;
}
}

View File

@@ -1,6 +1,6 @@
/*
* Hello Minecraft! Launcher
* Copyright (C) 2020 huangyuhui <huanghongxun2008@126.com> and contributors
* Copyright (C) 2021 huangyuhui <huanghongxun2008@126.com> and contributors
*
* This program is free software: you can redistribute it and/or modify
* it under the terms of the GNU General Public License as published by
@@ -30,13 +30,15 @@ import static org.jackhuang.hmcl.util.Pair.pair;
public class MicrosoftSession {
private final String tokenType;
private final long notAfter;
private final String accessToken;
private final User user;
private final GameProfile profile;
public MicrosoftSession(String tokenType, String accessToken, User user, GameProfile profile) {
public MicrosoftSession(String tokenType, String accessToken, long notAfter, User user, GameProfile profile) {
this.tokenType = tokenType;
this.accessToken = accessToken;
this.notAfter = notAfter;
this.user = user;
this.profile = profile;
}
@@ -49,6 +51,10 @@ public class MicrosoftSession {
return accessToken;
}
public long getNotAfter() {
return notAfter;
}
public String getAuthorization() {
return String.format("%s %s", getTokenType(), getAccessToken());
}
@@ -62,25 +68,27 @@ public class MicrosoftSession {
}
public static MicrosoftSession fromStorage(Map<?, ?> storage) {
UUID uuid = tryCast(storage.get("uuid"), String.class).map(UUIDTypeAdapter::fromString).orElseThrow(() -> new IllegalArgumentException("uuid is missing"));
String name = tryCast(storage.get("displayName"), String.class).orElseThrow(() -> new IllegalArgumentException("displayName is missing"));
String tokenType = tryCast(storage.get("tokenType"), String.class).orElseThrow(() -> new IllegalArgumentException("tokenType is missing"));
String accessToken = tryCast(storage.get("accessToken"), String.class).orElseThrow(() -> new IllegalArgumentException("accessToken is missing"));
String userId = tryCast(storage.get("userid"), String.class).orElseThrow(() -> new IllegalArgumentException("userid is missing"));
return new MicrosoftSession(tokenType, accessToken, new User(userId), new GameProfile(uuid, name));
UUID uuid = tryCast(storage.get("uuid"), String.class).map(UUIDTypeAdapter::fromString)
.orElseThrow(() -> new IllegalArgumentException("uuid is missing"));
String name = tryCast(storage.get("displayName"), String.class)
.orElseThrow(() -> new IllegalArgumentException("displayName is missing"));
String tokenType = tryCast(storage.get("tokenType"), String.class)
.orElseThrow(() -> new IllegalArgumentException("tokenType is missing"));
String accessToken = tryCast(storage.get("accessToken"), String.class)
.orElseThrow(() -> new IllegalArgumentException("accessToken is missing"));
Long notAfter = tryCast(storage.get("notAfter"), Long.class).orElse(0L);
String userId = tryCast(storage.get("userid"), String.class)
.orElseThrow(() -> new IllegalArgumentException("userid is missing"));
return new MicrosoftSession(tokenType, accessToken, notAfter, new User(userId), new GameProfile(uuid, name));
}
public Map<Object, Object> toStorage() {
requireNonNull(profile);
requireNonNull(user);
return mapOf(
pair("tokenType", tokenType),
pair("accessToken", accessToken),
pair("uuid", UUIDTypeAdapter.fromUUID(profile.getId())),
pair("displayName", profile.getName()),
pair("userid", user.id)
);
return mapOf(pair("tokenType", tokenType), pair("accessToken", accessToken),
pair("uuid", UUIDTypeAdapter.fromUUID(profile.getId())), pair("displayName", profile.getName()),
pair("userid", user.id));
}
public AuthInfo toAuthInfo() {

View File

@@ -47,6 +47,17 @@ public final class Lang {
*/
@SafeVarargs
public static <K, V> Map<K, V> mapOf(Pair<K, V>... pairs) {
return mapOf(Arrays.asList(pairs));
}
/**
* Construct a mutable map by given key-value pairs.
* @param pairs entries in the new map
* @param <K> the type of keys
* @param <V> the type of values
* @return the map which contains data in {@code pairs}.
*/
public static <K, V> Map<K, V> mapOf(Iterable<Pair<K, V>> pairs) {
Map<K, V> map = new LinkedHashMap<>();
for (Pair<K, V> pair : pairs)
map.put(pair.getKey(), pair.getValue());

View File

@@ -18,6 +18,7 @@
package org.jackhuang.hmcl.util.io;
import com.google.gson.JsonParseException;
import org.jackhuang.hmcl.util.Pair;
import org.jackhuang.hmcl.util.function.ExceptionalBiConsumer;
import org.jackhuang.hmcl.util.gson.JsonUtils;
@@ -30,6 +31,7 @@ import java.util.HashMap;
import java.util.Map;
import static java.nio.charset.StandardCharsets.UTF_8;
import static org.jackhuang.hmcl.util.Lang.mapOf;
import static org.jackhuang.hmcl.util.gson.JsonUtils.GSON;
import static org.jackhuang.hmcl.util.io.NetworkUtils.createHttpConnection;
import static org.jackhuang.hmcl.util.io.NetworkUtils.resolveConnection;
@@ -73,7 +75,7 @@ public abstract class HttpRequest {
return this;
}
protected HttpURLConnection createConnection() throws IOException {
public HttpURLConnection createConnection() throws IOException {
HttpURLConnection con = createHttpConnection(url);
con.setRequestMethod(method);
for (Map.Entry<String, String> entry : headers.entrySet()) {
@@ -102,8 +104,7 @@ public abstract class HttpRequest {
}
public <T> HttpPostRequest json(Object payload) throws JsonParseException {
return string(payload instanceof String ? (String) payload : GSON.toJson(payload),
"application/json");
return string(payload instanceof String ? (String) payload : GSON.toJson(payload), "application/json");
}
public HttpPostRequest form(Map<String, String> params) {
@@ -136,6 +137,11 @@ public abstract class HttpRequest {
return GET(new URL(url));
}
@SafeVarargs
public static HttpGetRequest GET(String url, Pair<String, String>... query) throws MalformedURLException {
return GET(new URL(NetworkUtils.withQuery(url, mapOf(query))));
}
public static HttpGetRequest GET(URL url) {
return new HttpGetRequest(url);
}

View File

@@ -1,6 +1,6 @@
/*
* Hello Minecraft! Launcher
* Copyright (C) 2020 huangyuhui <huanghongxun2008@126.com> and contributors
* Copyright (C) 2021 huangyuhui <huanghongxun2008@126.com> and contributors
*
* This program is free software: you can redistribute it and/or modify
* it under the terms of the GNU General Public License as published by
@@ -17,14 +17,15 @@
*/
package org.jackhuang.hmcl.util.io;
import org.jackhuang.hmcl.util.Pair;
import java.io.*;
import java.net.*;
import java.util.List;
import java.util.Locale;
import java.util.Map;
import java.util.*;
import java.util.Map.Entry;
import static java.nio.charset.StandardCharsets.UTF_8;
import static org.jackhuang.hmcl.util.Pair.pair;
import static org.jackhuang.hmcl.util.StringUtils.*;
/**
@@ -32,6 +33,8 @@ import static org.jackhuang.hmcl.util.StringUtils.*;
* @author huangyuhui
*/
public final class NetworkUtils {
public static final String PARAMETER_SEPARATOR = "&";
public static final String NAME_VALUE_SEPARATOR = "=";
private NetworkUtils() {
}
@@ -48,15 +51,38 @@ public final class NetworkUtils {
}
first = false;
} else {
sb.append('&');
sb.append(PARAMETER_SEPARATOR);
}
sb.append(encodeURL(param.getKey()));
sb.append('=');
sb.append(NAME_VALUE_SEPARATOR);
sb.append(encodeURL(param.getValue()));
}
return sb.toString();
}
public static List<Pair<String, String>> parseQuery(URI uri) {
return parseQuery(uri.getRawQuery());
}
public static List<Pair<String, String>> parseQuery(String queryParameterString) {
List<Pair<String, String>> result = new ArrayList<>();
try (Scanner scanner = new Scanner(queryParameterString)) {
scanner.useDelimiter("&");
while (scanner.hasNext()) {
String[] nameValue = scanner.next().split(NAME_VALUE_SEPARATOR);
if (nameValue.length <= 0 || nameValue.length > 2) {
throw new IllegalArgumentException("bad query string");
}
String name = decodeURL(nameValue[0]);
String value = nameValue.length == 2 ? decodeURL(nameValue[1]) : null;
result.add(pair(name, value));
}
}
return result;
}
public static URLConnection createConnection(URL url) throws IOException {
URLConnection connection = url.openConnection();
connection.setUseCaches(false);
@@ -71,7 +97,8 @@ public final class NetworkUtils {
}
/**
* @see <a href="https://github.com/curl/curl/blob/3f7b1bb89f92c13e69ee51b710ac54f775aab320/lib/transfer.c#L1427-L1461">Curl</a>
* @see <a href=
* "https://github.com/curl/curl/blob/3f7b1bb89f92c13e69ee51b710ac54f775aab320/lib/transfer.c#L1427-L1461">Curl</a>
* @param location the url to be URL encoded
* @return encoded URL
*/
@@ -81,8 +108,10 @@ public final class NetworkUtils {
for (char ch : location.toCharArray()) {
switch (ch) {
case ' ':
if (left) sb.append("%20");
else sb.append('+');
if (left)
sb.append("%20");
else
sb.append('+');
break;
case '?':
left = false;
@@ -100,7 +129,9 @@ public final class NetworkUtils {
}
/**
* This method is a work-around that aims to solve problem when "Location" in stupid server's response is not encoded.
* This method is a work-around that aims to solve problem when "Location" in
* stupid server's response is not encoded.
*
* @see <a href="https://github.com/curl/curl/issues/473">Issue with libcurl</a>
* @param conn the stupid http connection.
* @return manually redirected http connection.
@@ -125,8 +156,10 @@ public final class NetworkUtils {
throw new IOException("Too much redirects");
}
HttpURLConnection redirected = (HttpURLConnection) new URL(conn.getURL(), encodeLocation(newURL)).openConnection();
properties.forEach((key, value) -> value.forEach(element -> redirected.addRequestProperty(key, element)));
HttpURLConnection redirected = (HttpURLConnection) new URL(conn.getURL(), encodeLocation(newURL))
.openConnection();
properties
.forEach((key, value) -> value.forEach(element -> redirected.addRequestProperty(key, element)));
redirected.setRequestMethod(method);
conn = redirected;
++redirect;
@@ -178,7 +211,8 @@ public final class NetworkUtils {
}
} catch (IOException e) {
try (InputStream stderr = con.getErrorStream()) {
if (stderr == null) throw e;
if (stderr == null)
throw e;
return IOUtils.readFullyAsString(stderr, UTF_8);
}
}