feat: authenticate Microsoft accounts via external browser
This commit is contained in:
@@ -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;
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
@@ -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() {
|
||||
|
||||
@@ -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());
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user