feat: Microsoft Account authentication
This commit is contained in:
@@ -25,7 +25,52 @@ import java.util.Map;
|
||||
*/
|
||||
public abstract class AccountFactory<T extends Account> {
|
||||
|
||||
public enum AccountLoginType {
|
||||
/**
|
||||
* Either username or password should not be provided.
|
||||
* AccountFactory will take its own way to check credentials.
|
||||
*/
|
||||
NONE(false, false),
|
||||
|
||||
/**
|
||||
* AccountFactory only needs username.
|
||||
*/
|
||||
USERNAME(true, false),
|
||||
|
||||
/**
|
||||
* AccountFactory needs both username and password for credential verification.
|
||||
*/
|
||||
USERNAME_PASSWORD(true, true);
|
||||
|
||||
public final boolean requiresUsername, requiresPassword;
|
||||
|
||||
AccountLoginType(boolean requiresUsername, boolean requiresPassword) {
|
||||
this.requiresUsername = requiresUsername;
|
||||
this.requiresPassword = requiresPassword;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Informs how this account factory verifies user's credential.
|
||||
* @see AccountLoginType
|
||||
*/
|
||||
public abstract AccountLoginType getLoginType();
|
||||
|
||||
/**
|
||||
* Create a new(to be verified via network) account, and log in.
|
||||
* @param selector for character selection if multiple characters belong to single account. Pick out which character to act as.
|
||||
* @param username username of the account if needed.
|
||||
* @param password password of the account if needed.
|
||||
* @param additionalData extra data for specific account factory.
|
||||
* @return logged-in account.
|
||||
* @throws AuthenticationException if an error occurs when logging in.
|
||||
*/
|
||||
public abstract T create(CharacterSelector selector, String username, String password, Object additionalData) throws AuthenticationException;
|
||||
|
||||
/**
|
||||
* Create a existing(stored in local files) account.
|
||||
* @param storage serialized account data.
|
||||
* @return account stored in local storage. Credentials may expired, and you should refresh account state later.
|
||||
*/
|
||||
public abstract T fromStorage(Map<Object, Object> storage);
|
||||
}
|
||||
|
||||
@@ -44,6 +44,11 @@ public class AuthlibInjectorAccountFactory extends AccountFactory<AuthlibInjecto
|
||||
this.serverLookup = serverLookup;
|
||||
}
|
||||
|
||||
@Override
|
||||
public AccountLoginType getLoginType() {
|
||||
return AccountLoginType.USERNAME_PASSWORD;
|
||||
}
|
||||
|
||||
@Override
|
||||
public AuthlibInjectorAccount create(CharacterSelector selector, String username, String password, Object additionalData) throws AuthenticationException {
|
||||
Objects.requireNonNull(selector);
|
||||
|
||||
@@ -0,0 +1,117 @@
|
||||
package org.jackhuang.hmcl.auth.microsoft;
|
||||
|
||||
import org.jackhuang.hmcl.auth.*;
|
||||
|
||||
import java.util.Map;
|
||||
import java.util.Optional;
|
||||
import java.util.UUID;
|
||||
|
||||
import static java.util.Objects.requireNonNull;
|
||||
|
||||
public class MicrosoftAccount extends Account {
|
||||
|
||||
protected final MicrosoftService service;
|
||||
protected UUID characterUUID;
|
||||
|
||||
private boolean authenticated = false;
|
||||
private MicrosoftSession session;
|
||||
|
||||
protected MicrosoftAccount(MicrosoftService service, MicrosoftSession session) {
|
||||
this.service = requireNonNull(service);
|
||||
this.session = requireNonNull(session);
|
||||
this.characterUUID = requireNonNull(session.getProfile().getId());
|
||||
}
|
||||
|
||||
protected MicrosoftAccount(MicrosoftService service, CharacterSelector characterSelector) throws AuthenticationException {
|
||||
this.service = requireNonNull(service);
|
||||
|
||||
MicrosoftSession acquiredSession = service.authenticate();
|
||||
if (acquiredSession.getProfile() == null) {
|
||||
session = service.refresh(acquiredSession);
|
||||
} else {
|
||||
session = acquiredSession;
|
||||
}
|
||||
|
||||
characterUUID = session.getProfile().getId();
|
||||
authenticated = true;
|
||||
}
|
||||
|
||||
@Override
|
||||
public String getUsername() {
|
||||
// TODO: email of Microsoft account is blocked by oauth.
|
||||
return "";
|
||||
}
|
||||
|
||||
@Override
|
||||
public String getCharacter() {
|
||||
return session.getProfile().getName();
|
||||
}
|
||||
|
||||
@Override
|
||||
public UUID getUUID() {
|
||||
return session.getProfile().getId();
|
||||
}
|
||||
|
||||
@Override
|
||||
public AuthInfo logIn() throws AuthenticationException {
|
||||
if (!authenticated) {
|
||||
if (service.validate(session.getTokenType(), session.getAccessToken())) {
|
||||
authenticated = true;
|
||||
} else {
|
||||
MicrosoftSession acquiredSession = service.authenticate();
|
||||
if (acquiredSession.getProfile() == null) {
|
||||
session = service.refresh(acquiredSession);
|
||||
} else {
|
||||
session = acquiredSession;
|
||||
}
|
||||
|
||||
characterUUID = session.getProfile().getId();
|
||||
authenticated = true;
|
||||
}
|
||||
}
|
||||
|
||||
return session.toAuthInfo();
|
||||
}
|
||||
|
||||
@Override
|
||||
public AuthInfo logInWithPassword(String password) throws AuthenticationException {
|
||||
throw new UnsupportedOperationException();
|
||||
}
|
||||
|
||||
@Override
|
||||
public Optional<AuthInfo> playOffline() {
|
||||
return Optional.of(session.toAuthInfo());
|
||||
}
|
||||
|
||||
@Override
|
||||
public Map<Object, Object> toStorage() {
|
||||
return session.toStorage();
|
||||
}
|
||||
|
||||
public MicrosoftService getService() {
|
||||
return service;
|
||||
}
|
||||
|
||||
@Override
|
||||
public void clearCache() {
|
||||
authenticated = false;
|
||||
}
|
||||
|
||||
@Override
|
||||
public String toString() {
|
||||
return "MicrosoftAccount[uuid=" + characterUUID + ", name=" + getCharacter() + "]";
|
||||
}
|
||||
|
||||
@Override
|
||||
public int hashCode() {
|
||||
return characterUUID.hashCode();
|
||||
}
|
||||
|
||||
@Override
|
||||
public boolean equals(Object o) {
|
||||
if (this == o) return true;
|
||||
if (o == null || getClass() != o.getClass()) return false;
|
||||
MicrosoftAccount that = (MicrosoftAccount) o;
|
||||
return characterUUID.equals(that.characterUUID);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,36 @@
|
||||
package org.jackhuang.hmcl.auth.microsoft;
|
||||
|
||||
import org.jackhuang.hmcl.auth.AccountFactory;
|
||||
import org.jackhuang.hmcl.auth.AuthenticationException;
|
||||
import org.jackhuang.hmcl.auth.CharacterSelector;
|
||||
|
||||
import java.util.Map;
|
||||
import java.util.Objects;
|
||||
|
||||
public class MicrosoftAccountFactory extends AccountFactory<MicrosoftAccount> {
|
||||
|
||||
private final MicrosoftService service;
|
||||
|
||||
public MicrosoftAccountFactory(MicrosoftService service) {
|
||||
this.service = service;
|
||||
}
|
||||
|
||||
@Override
|
||||
public AccountLoginType getLoginType() {
|
||||
return AccountLoginType.NONE;
|
||||
}
|
||||
|
||||
@Override
|
||||
public MicrosoftAccount create(CharacterSelector selector, String username, String password, Object additionalData) throws AuthenticationException {
|
||||
Objects.requireNonNull(selector);
|
||||
|
||||
return new MicrosoftAccount(service, selector);
|
||||
}
|
||||
|
||||
@Override
|
||||
public MicrosoftAccount fromStorage(Map<Object, Object> storage) {
|
||||
Objects.requireNonNull(storage);
|
||||
MicrosoftSession session = MicrosoftSession.fromStorage(storage);
|
||||
return new MicrosoftAccount(service, session);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,280 @@
|
||||
package org.jackhuang.hmcl.auth.microsoft;
|
||||
|
||||
import com.google.gson.JsonParseException;
|
||||
import com.google.gson.annotations.SerializedName;
|
||||
import org.jackhuang.hmcl.auth.AuthenticationException;
|
||||
import org.jackhuang.hmcl.auth.NoCharacterException;
|
||||
import org.jackhuang.hmcl.auth.ServerDisconnectException;
|
||||
import org.jackhuang.hmcl.auth.ServerResponseMalformedException;
|
||||
import org.jackhuang.hmcl.auth.yggdrasil.RemoteAuthenticationException;
|
||||
import org.jackhuang.hmcl.util.gson.JsonUtils;
|
||||
import org.jackhuang.hmcl.util.gson.TolerableValidationException;
|
||||
import org.jackhuang.hmcl.util.gson.Validation;
|
||||
import org.jackhuang.hmcl.util.io.HttpRequest;
|
||||
import org.jackhuang.hmcl.util.io.NetworkUtils;
|
||||
import org.jackhuang.hmcl.util.io.ResponseCodeException;
|
||||
|
||||
import java.io.IOException;
|
||||
import java.util.*;
|
||||
import java.util.concurrent.CompletableFuture;
|
||||
import java.util.concurrent.ExecutionException;
|
||||
import java.util.function.Predicate;
|
||||
import java.util.regex.Matcher;
|
||||
import java.util.regex.Pattern;
|
||||
|
||||
import static java.util.Objects.requireNonNull;
|
||||
import static org.jackhuang.hmcl.util.Lang.mapOf;
|
||||
import static org.jackhuang.hmcl.util.Pair.pair;
|
||||
|
||||
public class MicrosoftService {
|
||||
private static final Pattern OAUTH_URL_PATTERN = Pattern.compile("^https://login\\.live\\.com/oauth20_desktop\\.srf\\?code=(.*?)&lc=(.*?)$");
|
||||
|
||||
private final WebViewCallback callback;
|
||||
|
||||
public MicrosoftService(WebViewCallback callback) {
|
||||
this.callback = callback;
|
||||
}
|
||||
|
||||
public MicrosoftSession authenticate() throws AuthenticationException {
|
||||
requireNonNull(callback);
|
||||
|
||||
try {
|
||||
// Microsoft OAuth Flow
|
||||
String code = callback.show(this, urlToBeTested -> OAUTH_URL_PATTERN.matcher(urlToBeTested).find(), "https://login.live.com/oauth20_authorize.srf" +
|
||||
"?client_id=00000000402b5328" +
|
||||
"&response_type=code" +
|
||||
"&scope=service%3A%3Auser.auth.xboxlive.com%3A%3AMBI_SSL" +
|
||||
"&redirect_uri=https%3A%2F%2Flogin.live.com%2Foauth20_desktop.srf")
|
||||
.thenApply(url -> {
|
||||
Matcher matcher = OAUTH_URL_PATTERN.matcher(url);
|
||||
matcher.find();
|
||||
return matcher.group(1);
|
||||
})
|
||||
.get();
|
||||
|
||||
// Authorization Code -> Token
|
||||
String responseText = HttpRequest.POST("https://login.live.com/oauth20_token.srf").form(mapOf(
|
||||
pair("client_id", "00000000402b5328"),
|
||||
pair("code", code),
|
||||
pair("grant_type", "authorization_code"),
|
||||
pair("redirect_uri", "https://login.live.com/oauth20_desktop.srf"),
|
||||
pair("scope", "service::user.auth.xboxlive.com::MBI_SSL"))).getString();
|
||||
LiveAuthorizationResponse response = JsonUtils.fromNonNullJson(responseText, LiveAuthorizationResponse.class);
|
||||
|
||||
// Authenticate with XBox Live
|
||||
XBoxLiveAuthenticationResponse xboxResponse = HttpRequest.POST("https://user.auth.xboxlive.com/user/authenticate")
|
||||
.json(mapOf(
|
||||
pair("Properties", mapOf(
|
||||
pair("AuthMethod", "RPS"),
|
||||
pair("SiteName", "user.auth.xboxlive.com"),
|
||||
pair("RpsTicket", response.accessToken)
|
||||
)),
|
||||
pair("RelyingParty", "http://auth.xboxlive.com"),
|
||||
pair("TokenType", "JWT")))
|
||||
.getJson(XBoxLiveAuthenticationResponse.class);
|
||||
String uhs = (String) xboxResponse.displayClaims.xui.get(0).get("uhs");
|
||||
|
||||
// Authenticate with XSTS
|
||||
XBoxLiveAuthenticationResponse xstsResponse = HttpRequest.POST("https://xsts.auth.xboxlive.com/xsts/authorize")
|
||||
.json(mapOf(
|
||||
pair("Properties", mapOf(
|
||||
pair("SandboxId", "RETAIL"),
|
||||
pair("UserTokens", Collections.singletonList(xboxResponse.token))
|
||||
)),
|
||||
pair("RelyingParty", "rp://api.minecraftservices.com/"),
|
||||
pair("TokenType", "JWT")))
|
||||
.getJson(XBoxLiveAuthenticationResponse.class);
|
||||
|
||||
// Authenticate with Minecraft
|
||||
MinecraftLoginWithXBoxResponse minecraftResponse = HttpRequest.POST("https://api.minecraftservices.com/authentication/login_with_xbox")
|
||||
.json(mapOf(pair("identityToken", "XBL3.0 x=" + uhs + ";" + xstsResponse.token)))
|
||||
.getJson(MinecraftLoginWithXBoxResponse.class);
|
||||
|
||||
// Checking Game Ownership
|
||||
MinecraftStoreResponse storeResponse = HttpRequest.GET("https://api.minecraftservices.com/entitlements/mcstore")
|
||||
.authorization(String.format("%s %s", minecraftResponse.tokenType, minecraftResponse.accessToken))
|
||||
.getJson(MinecraftStoreResponse.class);
|
||||
handleErrorResponse(storeResponse);
|
||||
if (storeResponse.items.isEmpty()) {
|
||||
throw new NoCharacterException();
|
||||
}
|
||||
|
||||
return new MicrosoftSession(minecraftResponse.tokenType, minecraftResponse.accessToken, new MicrosoftSession.User(minecraftResponse.username), null);
|
||||
} catch (IOException | ExecutionException | InterruptedException e) {
|
||||
throw new ServerDisconnectException(e);
|
||||
} catch (JsonParseException e) {
|
||||
throw new ServerResponseMalformedException(e);
|
||||
}
|
||||
}
|
||||
|
||||
public MicrosoftSession refresh(MicrosoftSession oldSession) throws AuthenticationException {
|
||||
try {
|
||||
// Get the profile
|
||||
MinecraftProfileResponse profileResponse = HttpRequest.GET(NetworkUtils.toURL("https://api.minecraftservices.com/minecraft/profile"))
|
||||
.authorization(String.format("%s %s", oldSession.getTokenType(), oldSession.getAccessToken()))
|
||||
.getJson(MinecraftProfileResponse.class);
|
||||
handleErrorResponse(profileResponse);
|
||||
return new MicrosoftSession(oldSession.getTokenType(), oldSession.getAccessToken(), oldSession.getUser(), new MicrosoftSession.GameProfile(profileResponse.id, profileResponse.name));
|
||||
} catch (IOException e) {
|
||||
throw new ServerDisconnectException(e);
|
||||
} catch (JsonParseException e) {
|
||||
throw new ServerResponseMalformedException(e);
|
||||
}
|
||||
}
|
||||
|
||||
public boolean validate(String tokenType, String accessToken) throws AuthenticationException {
|
||||
requireNonNull(tokenType);
|
||||
requireNonNull(accessToken);
|
||||
|
||||
try {
|
||||
HttpRequest.GET(NetworkUtils.toURL("https://api.minecraftservices.com/minecraft/profile"))
|
||||
.authorization(String.format("%s %s", tokenType, accessToken))
|
||||
.filter((url, responseCode) -> {
|
||||
if (responseCode / 100 == 4) {
|
||||
throw new ResponseCodeException(url, responseCode);
|
||||
}
|
||||
})
|
||||
.getString();
|
||||
return true;
|
||||
} catch (ResponseCodeException e) {
|
||||
return false;
|
||||
} catch (IOException e) {
|
||||
throw new ServerDisconnectException(e);
|
||||
}
|
||||
}
|
||||
|
||||
private static void handleErrorResponse(MinecraftErrorResponse response) throws AuthenticationException {
|
||||
if (response.error != null) {
|
||||
throw new RemoteAuthenticationException(response.error, response.errorMessage, response.developerMessage);
|
||||
}
|
||||
}
|
||||
|
||||
private static class LiveAuthorizationResponse {
|
||||
@SerializedName("token_type")
|
||||
String tokenType;
|
||||
|
||||
@SerializedName("expires_in")
|
||||
int expiresIn;
|
||||
|
||||
@SerializedName("scope")
|
||||
String scope;
|
||||
|
||||
@SerializedName("access_token")
|
||||
String accessToken;
|
||||
|
||||
@SerializedName("refresh_token")
|
||||
String refreshToken;
|
||||
|
||||
@SerializedName("user_id")
|
||||
String userId;
|
||||
|
||||
@SerializedName("foci")
|
||||
String foci;
|
||||
}
|
||||
|
||||
private static class XBoxLiveAuthenticationResponseDisplayClaims {
|
||||
List<Map<Object, Object>> xui;
|
||||
}
|
||||
|
||||
private static class XBoxLiveAuthenticationResponse {
|
||||
@SerializedName("IssueInstant")
|
||||
String issueInstant;
|
||||
|
||||
@SerializedName("NotAfter")
|
||||
String notAfter;
|
||||
|
||||
@SerializedName("Token")
|
||||
String token;
|
||||
|
||||
@SerializedName("DisplayClaims")
|
||||
XBoxLiveAuthenticationResponseDisplayClaims displayClaims;
|
||||
}
|
||||
|
||||
private static class MinecraftLoginWithXBoxResponse {
|
||||
@SerializedName("username")
|
||||
String username;
|
||||
|
||||
@SerializedName("roles")
|
||||
List<String> roles;
|
||||
|
||||
@SerializedName("access_token")
|
||||
String accessToken;
|
||||
|
||||
@SerializedName("token_type")
|
||||
String tokenType;
|
||||
|
||||
@SerializedName("expires_in")
|
||||
int expiresIn;
|
||||
}
|
||||
|
||||
private static class MinecraftStoreResponseItem {
|
||||
@SerializedName("name")
|
||||
String name;
|
||||
@SerializedName("signature")
|
||||
String signature;
|
||||
}
|
||||
|
||||
private static class MinecraftStoreResponse extends MinecraftErrorResponse {
|
||||
@SerializedName("items")
|
||||
List<MinecraftStoreResponseItem> items;
|
||||
|
||||
@SerializedName("signature")
|
||||
String signature;
|
||||
|
||||
@SerializedName("keyId")
|
||||
String keyId;
|
||||
}
|
||||
|
||||
private static class MinecraftProfileResponseSkin implements Validation {
|
||||
public String id;
|
||||
public String state;
|
||||
public String url;
|
||||
public String variant;
|
||||
public String alias;
|
||||
|
||||
@Override
|
||||
public void validate() throws JsonParseException, TolerableValidationException {
|
||||
Validation.requireNonNull(id, "id cannot be null");
|
||||
Validation.requireNonNull(state, "state cannot be null");
|
||||
Validation.requireNonNull(url, "url cannot be null");
|
||||
Validation.requireNonNull(variant, "variant cannot be null");
|
||||
Validation.requireNonNull(alias, "alias cannot be null");
|
||||
}
|
||||
}
|
||||
|
||||
private static class MinecraftProfileResponseCape {
|
||||
|
||||
}
|
||||
|
||||
private static class MinecraftProfileResponse extends MinecraftErrorResponse implements Validation {
|
||||
@SerializedName("id")
|
||||
UUID id;
|
||||
@SerializedName("name")
|
||||
String name;
|
||||
@SerializedName("skins")
|
||||
List<MinecraftProfileResponseSkin> skins;
|
||||
@SerializedName("capes")
|
||||
List<MinecraftProfileResponseCape> capes;
|
||||
|
||||
@Override
|
||||
public void validate() throws JsonParseException, TolerableValidationException {
|
||||
Validation.requireNonNull(id, "id cannot be null");
|
||||
Validation.requireNonNull(name, "name cannot be null");
|
||||
Validation.requireNonNull(skins, "skins cannot be null");
|
||||
Validation.requireNonNull(capes, "capes cannot be null");
|
||||
}
|
||||
}
|
||||
|
||||
private static class MinecraftErrorResponse {
|
||||
public String path;
|
||||
public String errorType;
|
||||
public String error;
|
||||
public String errorMessage;
|
||||
public String developerMessage;
|
||||
}
|
||||
|
||||
public interface WebViewCallback {
|
||||
CompletableFuture<String> show(MicrosoftService service, Predicate<String> urlTester, String initialURL);
|
||||
}
|
||||
|
||||
}
|
||||
@@ -0,0 +1,100 @@
|
||||
package org.jackhuang.hmcl.auth.microsoft;
|
||||
|
||||
import org.jackhuang.hmcl.auth.AuthInfo;
|
||||
import org.jackhuang.hmcl.util.gson.UUIDTypeAdapter;
|
||||
|
||||
import java.util.Map;
|
||||
import java.util.UUID;
|
||||
|
||||
import static java.util.Objects.requireNonNull;
|
||||
import static org.jackhuang.hmcl.util.Lang.mapOf;
|
||||
import static org.jackhuang.hmcl.util.Lang.tryCast;
|
||||
import static org.jackhuang.hmcl.util.Pair.pair;
|
||||
|
||||
public class MicrosoftSession {
|
||||
private final String tokenType;
|
||||
private final String accessToken;
|
||||
private final User user;
|
||||
private final GameProfile profile;
|
||||
|
||||
public MicrosoftSession(String tokenType, String accessToken, User user, GameProfile profile) {
|
||||
this.tokenType = tokenType;
|
||||
this.accessToken = accessToken;
|
||||
this.user = user;
|
||||
this.profile = profile;
|
||||
}
|
||||
|
||||
public String getTokenType() {
|
||||
return tokenType;
|
||||
}
|
||||
|
||||
public String getAccessToken() {
|
||||
return accessToken;
|
||||
}
|
||||
|
||||
public User getUser() {
|
||||
return user;
|
||||
}
|
||||
|
||||
public GameProfile getProfile() {
|
||||
return profile;
|
||||
}
|
||||
|
||||
public static MicrosoftSession fromStorage(Map<?, ?> storage) {
|
||||
UUID uuid = tryCast(storage.get("uuid"), String.class).map(UUIDTypeAdapter::fromString).orElseThrow(() -> new IllegalArgumentException("uuid is missing"));
|
||||
String name = tryCast(storage.get("displayName"), String.class).orElseThrow(() -> new IllegalArgumentException("displayName is missing"));
|
||||
String tokenType = tryCast(storage.get("tokenType"), String.class).orElseThrow(() -> new IllegalArgumentException("tokenType is missing"));
|
||||
String accessToken = tryCast(storage.get("accessToken"), String.class).orElseThrow(() -> new IllegalArgumentException("accessToken is missing"));
|
||||
String userId = tryCast(storage.get("userid"), String.class).orElseThrow(() -> new IllegalArgumentException("userid is missing"));
|
||||
return new MicrosoftSession(tokenType, accessToken, new User(userId), new GameProfile(uuid, name));
|
||||
}
|
||||
|
||||
public Map<Object, Object> toStorage() {
|
||||
requireNonNull(profile);
|
||||
requireNonNull(user);
|
||||
|
||||
return mapOf(
|
||||
pair("tokenType", tokenType),
|
||||
pair("accessToken", accessToken),
|
||||
pair("uuid", UUIDTypeAdapter.fromUUID(profile.getId())),
|
||||
pair("displayName", profile.getName()),
|
||||
pair("userid", user.id)
|
||||
);
|
||||
}
|
||||
|
||||
public AuthInfo toAuthInfo() {
|
||||
requireNonNull(profile);
|
||||
|
||||
return new AuthInfo(profile.getName(), profile.getId(), accessToken, "{}");
|
||||
}
|
||||
|
||||
public static class User {
|
||||
private final String id;
|
||||
|
||||
public User(String id) {
|
||||
this.id = id;
|
||||
}
|
||||
|
||||
public String getId() {
|
||||
return id;
|
||||
}
|
||||
}
|
||||
|
||||
public static class GameProfile {
|
||||
private final UUID id;
|
||||
private final String name;
|
||||
|
||||
public GameProfile(UUID id, String name) {
|
||||
this.id = id;
|
||||
this.name = name;
|
||||
}
|
||||
|
||||
public UUID getId() {
|
||||
return id;
|
||||
}
|
||||
|
||||
public String getName() {
|
||||
return name;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -37,6 +37,11 @@ public class OfflineAccountFactory extends AccountFactory<OfflineAccount> {
|
||||
private OfflineAccountFactory() {
|
||||
}
|
||||
|
||||
@Override
|
||||
public AccountLoginType getLoginType() {
|
||||
return AccountLoginType.USERNAME;
|
||||
}
|
||||
|
||||
public OfflineAccount create(String username, UUID uuid) {
|
||||
return new OfflineAccount(username, uuid);
|
||||
}
|
||||
|
||||
@@ -17,9 +17,8 @@
|
||||
*/
|
||||
package org.jackhuang.hmcl.auth.yggdrasil;
|
||||
|
||||
import static java.util.Objects.requireNonNull;
|
||||
|
||||
import java.util.Map;
|
||||
import java.util.Objects;
|
||||
import java.util.UUID;
|
||||
|
||||
import org.jackhuang.hmcl.util.Immutable;
|
||||
@@ -38,7 +37,7 @@ public class CompleteGameProfile extends GameProfile {
|
||||
|
||||
public CompleteGameProfile(UUID id, String name, Map<String, String> properties) {
|
||||
super(id, name);
|
||||
this.properties = requireNonNull(properties);
|
||||
this.properties = Objects.requireNonNull(properties);
|
||||
}
|
||||
|
||||
public CompleteGameProfile(GameProfile profile, Map<String, String> properties) {
|
||||
|
||||
@@ -17,8 +17,7 @@
|
||||
*/
|
||||
package org.jackhuang.hmcl.auth.yggdrasil;
|
||||
|
||||
import static java.util.Objects.requireNonNull;
|
||||
|
||||
import java.util.Objects;
|
||||
import java.util.UUID;
|
||||
|
||||
import org.jackhuang.hmcl.util.Immutable;
|
||||
@@ -40,8 +39,8 @@ public class GameProfile implements Validation {
|
||||
private final String name;
|
||||
|
||||
public GameProfile(UUID id, String name) {
|
||||
this.id = requireNonNull(id);
|
||||
this.name = requireNonNull(name);
|
||||
this.id = Objects.requireNonNull(id);
|
||||
this.name = Objects.requireNonNull(name);
|
||||
}
|
||||
|
||||
public UUID getId() {
|
||||
@@ -54,9 +53,7 @@ public class GameProfile implements Validation {
|
||||
|
||||
@Override
|
||||
public void validate() throws JsonParseException {
|
||||
if (id == null)
|
||||
throw new JsonParseException("Game profile id cannot be null");
|
||||
if (name == null)
|
||||
throw new JsonParseException("Game profile name cannot be null");
|
||||
Validation.requireNonNull(id, "Game profile id cannot be null");
|
||||
Validation.requireNonNull(name, "Game profile name cannot be null");
|
||||
}
|
||||
}
|
||||
|
||||
@@ -209,6 +209,7 @@ public class YggdrasilAccount extends Account {
|
||||
|
||||
@Override
|
||||
public boolean equals(Object obj) {
|
||||
if (this == obj) return true;
|
||||
if (obj == null || obj.getClass() != YggdrasilAccount.class)
|
||||
return false;
|
||||
YggdrasilAccount another = (YggdrasilAccount) obj;
|
||||
|
||||
@@ -36,12 +36,17 @@ public class YggdrasilAccountFactory extends AccountFactory<YggdrasilAccount> {
|
||||
|
||||
public static final YggdrasilAccountFactory MOJANG = new YggdrasilAccountFactory(YggdrasilService.MOJANG);
|
||||
|
||||
private YggdrasilService service;
|
||||
private final YggdrasilService service;
|
||||
|
||||
public YggdrasilAccountFactory(YggdrasilService service) {
|
||||
this.service = service;
|
||||
}
|
||||
|
||||
@Override
|
||||
public AccountLoginType getLoginType() {
|
||||
return AccountLoginType.USERNAME_PASSWORD;
|
||||
}
|
||||
|
||||
@Override
|
||||
public YggdrasilAccount create(CharacterSelector selector, String username, String password, Object additionalData) throws AuthenticationException {
|
||||
Objects.requireNonNull(selector);
|
||||
|
||||
@@ -38,4 +38,9 @@ public interface Validation {
|
||||
* @throws TolerableValidationException if we want to replace this object with null (i.e. the object does not fulfill the constraints).
|
||||
*/
|
||||
void validate() throws JsonParseException, TolerableValidationException;
|
||||
|
||||
static void requireNonNull(Object object, String message) throws JsonParseException {
|
||||
if (object == null)
|
||||
throw new JsonParseException(message);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,133 @@
|
||||
package org.jackhuang.hmcl.util.io;
|
||||
|
||||
import com.google.gson.JsonParseException;
|
||||
import org.jackhuang.hmcl.util.function.ExceptionalBiConsumer;
|
||||
import org.jackhuang.hmcl.util.gson.JsonUtils;
|
||||
|
||||
import java.io.IOException;
|
||||
import java.io.OutputStream;
|
||||
import java.net.HttpURLConnection;
|
||||
import java.net.MalformedURLException;
|
||||
import java.net.URL;
|
||||
import java.util.HashMap;
|
||||
import java.util.Map;
|
||||
|
||||
import static java.nio.charset.StandardCharsets.UTF_8;
|
||||
import static org.jackhuang.hmcl.util.gson.JsonUtils.GSON;
|
||||
import static org.jackhuang.hmcl.util.io.NetworkUtils.createHttpConnection;
|
||||
import static org.jackhuang.hmcl.util.io.NetworkUtils.resolveConnection;
|
||||
|
||||
public abstract class HttpRequest {
|
||||
protected final URL url;
|
||||
protected final String method;
|
||||
protected final Map<String, String> headers = new HashMap<>();
|
||||
protected ExceptionalBiConsumer<URL, Integer, IOException> responseCodeTester;
|
||||
|
||||
private HttpRequest(URL url, String method) {
|
||||
this.url = url;
|
||||
this.method = method;
|
||||
}
|
||||
|
||||
public HttpRequest accept(String contentType) {
|
||||
return header("Accept", contentType);
|
||||
}
|
||||
|
||||
public HttpRequest authorization(String token) {
|
||||
return header("Authorization", token);
|
||||
}
|
||||
|
||||
public HttpRequest contentType(String contentType) {
|
||||
return header("Content-Type", contentType);
|
||||
}
|
||||
|
||||
public HttpRequest header(String key, String value) {
|
||||
headers.put(key, value);
|
||||
return this;
|
||||
}
|
||||
|
||||
public abstract String getString() throws IOException;
|
||||
|
||||
public <T> T getJson(Class<T> typeOfT) throws IOException, JsonParseException {
|
||||
return JsonUtils.fromNonNullJson(getString(), typeOfT);
|
||||
}
|
||||
|
||||
public HttpRequest filter(ExceptionalBiConsumer<URL, Integer, IOException> responseCodeTester) {
|
||||
this.responseCodeTester = responseCodeTester;
|
||||
return this;
|
||||
}
|
||||
|
||||
protected HttpURLConnection createConnection() throws IOException {
|
||||
HttpURLConnection con = createHttpConnection(url);
|
||||
con.setRequestMethod(method);
|
||||
for (Map.Entry<String, String> entry : headers.entrySet()) {
|
||||
con.setRequestProperty(entry.getKey(), entry.getValue());
|
||||
}
|
||||
return con;
|
||||
}
|
||||
|
||||
public static class HttpGetRequest extends HttpRequest {
|
||||
public HttpGetRequest(URL url) {
|
||||
super(url, "GET");
|
||||
}
|
||||
|
||||
public String getString() throws IOException {
|
||||
HttpURLConnection con = createConnection();
|
||||
con = resolveConnection(con);
|
||||
return IOUtils.readFullyAsString(con.getInputStream());
|
||||
}
|
||||
}
|
||||
|
||||
public static class HttpPostRequest extends HttpRequest {
|
||||
private byte[] bytes;
|
||||
|
||||
public HttpPostRequest(URL url) {
|
||||
super(url, "POST");
|
||||
}
|
||||
|
||||
public <T> HttpPostRequest json(Object payload) throws JsonParseException {
|
||||
return string(payload instanceof String ? (String) payload : GSON.toJson(payload),
|
||||
"application/json");
|
||||
}
|
||||
|
||||
public HttpPostRequest form(Map<String, String> params) {
|
||||
return string(NetworkUtils.withQuery("", params), "application/x-www-form-urlencoded");
|
||||
}
|
||||
|
||||
public HttpPostRequest string(String payload, String contentType) {
|
||||
bytes = payload.getBytes(UTF_8);
|
||||
header("Content-Length", "" + bytes.length);
|
||||
contentType(contentType + "; charset=utf-8");
|
||||
return this;
|
||||
}
|
||||
|
||||
public String getString() throws IOException {
|
||||
HttpURLConnection con = createConnection();
|
||||
con.setDoOutput(true);
|
||||
|
||||
if (responseCodeTester != null) {
|
||||
responseCodeTester.accept(url, con.getResponseCode());
|
||||
}
|
||||
|
||||
try (OutputStream os = con.getOutputStream()) {
|
||||
os.write(bytes);
|
||||
}
|
||||
return NetworkUtils.readData(con);
|
||||
}
|
||||
}
|
||||
|
||||
public static HttpGetRequest GET(String url) throws MalformedURLException {
|
||||
return GET(new URL(url));
|
||||
}
|
||||
|
||||
public static HttpGetRequest GET(URL url) {
|
||||
return new HttpGetRequest(url);
|
||||
}
|
||||
|
||||
public static HttpPostRequest POST(String url) throws MalformedURLException {
|
||||
return POST(new URL(url));
|
||||
}
|
||||
|
||||
public static HttpPostRequest POST(URL url) {
|
||||
return new HttpPostRequest(url);
|
||||
}
|
||||
}
|
||||
@@ -43,7 +43,9 @@ public final class NetworkUtils {
|
||||
if (param.getValue() == null)
|
||||
continue;
|
||||
if (first) {
|
||||
sb.append('?');
|
||||
if (!baseUrl.isEmpty()) {
|
||||
sb.append('?');
|
||||
}
|
||||
first = false;
|
||||
} else {
|
||||
sb.append('&');
|
||||
|
||||
Reference in New Issue
Block a user