add fakefx utils & import auth part
This commit is contained in:
parent
c1c0925e6f
commit
6c33c0054a
|
@ -0,0 +1,83 @@
|
|||
package com.tungsten.fclcore.auth;
|
||||
|
||||
import com.tungsten.fclcore.auth.yggdrasil.Texture;
|
||||
import com.tungsten.fclcore.auth.yggdrasil.TextureType;
|
||||
import com.tungsten.fclcore.util.ToStringBuilder;
|
||||
import com.tungsten.fclcore.util.fakefx.fx.Bindings;
|
||||
import com.tungsten.fclcore.util.fakefx.fx.InvalidationListener;
|
||||
import com.tungsten.fclcore.util.fakefx.fx.ObjectBinding;
|
||||
import com.tungsten.fclcore.util.fakefx.fx.Observable;
|
||||
import com.tungsten.fclcore.util.fakefx.ObservableHelper;
|
||||
|
||||
import java.util.Map;
|
||||
import java.util.Optional;
|
||||
import java.util.UUID;
|
||||
|
||||
public abstract class Account implements Observable {
|
||||
|
||||
/**
|
||||
* @return the name of the account who owns the character
|
||||
*/
|
||||
public abstract String getUsername();
|
||||
|
||||
/**
|
||||
* @return the character name
|
||||
*/
|
||||
public abstract String getCharacter();
|
||||
|
||||
/**
|
||||
* @return the character UUID
|
||||
*/
|
||||
public abstract UUID getUUID();
|
||||
|
||||
/**
|
||||
* Login with stored credentials.
|
||||
*
|
||||
* @throws CredentialExpiredException when the stored credentials has expired, in which case a password login will be performed
|
||||
*/
|
||||
public abstract AuthInfo logIn() throws AuthenticationException;
|
||||
|
||||
/**
|
||||
* Play offline.
|
||||
* @return the specific offline player's info.
|
||||
*/
|
||||
public abstract AuthInfo playOffline() throws AuthenticationException;
|
||||
|
||||
public abstract Map<Object, Object> toStorage();
|
||||
|
||||
public void clearCache() {
|
||||
}
|
||||
|
||||
private ObservableHelper helper = new ObservableHelper(this);
|
||||
|
||||
@Override
|
||||
public void addListener(InvalidationListener listener) {
|
||||
helper.addListener(listener);
|
||||
}
|
||||
|
||||
@Override
|
||||
public void removeListener(InvalidationListener listener) {
|
||||
helper.removeListener(listener);
|
||||
}
|
||||
|
||||
/**
|
||||
* Called when the account has changed.
|
||||
* This method can be called from any thread.
|
||||
*/
|
||||
protected void invalidate() {
|
||||
helper.invalidate();
|
||||
}
|
||||
|
||||
public ObjectBinding<Optional<Map<TextureType, Texture>>> getTextures() {
|
||||
return Bindings.createObjectBinding(Optional::empty);
|
||||
}
|
||||
|
||||
@Override
|
||||
public String toString() {
|
||||
return new ToStringBuilder(this)
|
||||
.append("username", getUsername())
|
||||
.append("character", getCharacter())
|
||||
.append("uuid", getUUID())
|
||||
.toString();
|
||||
}
|
||||
}
|
|
@ -0,0 +1,63 @@
|
|||
package com.tungsten.fclcore.auth;
|
||||
|
||||
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;
|
||||
}
|
||||
}
|
||||
|
||||
public interface ProgressCallback {
|
||||
void onProgressChanged(String stageName);
|
||||
}
|
||||
|
||||
/**
|
||||
* 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 progressCallback notify login progress.
|
||||
* @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, ProgressCallback progressCallback, 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);
|
||||
}
|
|
@ -0,0 +1,56 @@
|
|||
package com.tungsten.fclcore.auth;
|
||||
|
||||
import com.tungsten.fclcore.game.Arguments;
|
||||
import com.tungsten.fclcore.game.LaunchOptions;
|
||||
|
||||
import java.io.IOException;
|
||||
import java.util.UUID;
|
||||
|
||||
public class AuthInfo implements AutoCloseable {
|
||||
|
||||
private final String username;
|
||||
private final UUID uuid;
|
||||
private final String accessToken;
|
||||
private final String userProperties;
|
||||
|
||||
public AuthInfo(String username, UUID uuid, String accessToken, String userProperties) {
|
||||
this.username = username;
|
||||
this.uuid = uuid;
|
||||
this.accessToken = accessToken;
|
||||
this.userProperties = userProperties;
|
||||
}
|
||||
|
||||
public String getUsername() {
|
||||
return username;
|
||||
}
|
||||
|
||||
public UUID getUUID() {
|
||||
return uuid;
|
||||
}
|
||||
|
||||
public String getAccessToken() {
|
||||
return accessToken;
|
||||
}
|
||||
|
||||
/**
|
||||
* Properties of this user.
|
||||
* Don't know the difference between user properties and user property map.
|
||||
*
|
||||
* @return the user property map in JSON.
|
||||
*/
|
||||
public String getUserProperties() {
|
||||
return userProperties;
|
||||
}
|
||||
|
||||
/**
|
||||
* Called when launching game.
|
||||
* @return null if no argument is specified
|
||||
*/
|
||||
public Arguments getLaunchArguments(LaunchOptions options) throws IOException {
|
||||
return null;
|
||||
}
|
||||
|
||||
@Override
|
||||
public void close() throws Exception {
|
||||
}
|
||||
}
|
|
@ -0,0 +1,18 @@
|
|||
package com.tungsten.fclcore.auth;
|
||||
|
||||
public class AuthenticationException extends Exception {
|
||||
public AuthenticationException() {
|
||||
}
|
||||
|
||||
public AuthenticationException(String message) {
|
||||
super(message);
|
||||
}
|
||||
|
||||
public AuthenticationException(String message, Throwable cause) {
|
||||
super(message, cause);
|
||||
}
|
||||
|
||||
public AuthenticationException(Throwable cause) {
|
||||
super(cause);
|
||||
}
|
||||
}
|
|
@ -0,0 +1,9 @@
|
|||
package com.tungsten.fclcore.auth;
|
||||
|
||||
/**
|
||||
* Thrown when a previously existing character cannot be found.
|
||||
*/
|
||||
public final class CharacterDeletedException extends AuthenticationException {
|
||||
public CharacterDeletedException() {
|
||||
}
|
||||
}
|
|
@ -0,0 +1,22 @@
|
|||
package com.tungsten.fclcore.auth;
|
||||
|
||||
import com.tungsten.fclcore.auth.yggdrasil.GameProfile;
|
||||
import com.tungsten.fclcore.auth.yggdrasil.YggdrasilService;
|
||||
|
||||
import java.util.List;
|
||||
|
||||
/**
|
||||
* This interface is for your application to open a GUI for user to choose the character
|
||||
* when a having-multi-character yggdrasil account is being logging in.
|
||||
*/
|
||||
public interface CharacterSelector {
|
||||
|
||||
/**
|
||||
* Select one of {@code names} GameProfiles to login.
|
||||
* @param names available game profiles.
|
||||
* @throws NoSelectedCharacterException if cannot select any character may because user close the selection window or cancel the selection.
|
||||
* @return your choice of game profile.
|
||||
*/
|
||||
GameProfile select(YggdrasilService yggdrasilService, List<GameProfile> names) throws NoSelectedCharacterException;
|
||||
|
||||
}
|
|
@ -0,0 +1,12 @@
|
|||
package com.tungsten.fclcore.auth;
|
||||
|
||||
public abstract class ClassicAccount extends Account {
|
||||
|
||||
/**
|
||||
* Login with specified password.
|
||||
*
|
||||
* When credentials expired, the auth server will ask you to login with password to refresh
|
||||
* credentials.
|
||||
*/
|
||||
public abstract AuthInfo logInWithPassword(String password) throws AuthenticationException;
|
||||
}
|
|
@ -0,0 +1,23 @@
|
|||
package com.tungsten.fclcore.auth;
|
||||
|
||||
/**
|
||||
* Thrown when the stored credentials has expired.
|
||||
* This exception indicates that a password login should be performed.
|
||||
* @see Account#logIn()
|
||||
*/
|
||||
public class CredentialExpiredException extends AuthenticationException {
|
||||
|
||||
public CredentialExpiredException() {}
|
||||
|
||||
public CredentialExpiredException(String message, Throwable cause) {
|
||||
super(message, cause);
|
||||
}
|
||||
|
||||
public CredentialExpiredException(String message) {
|
||||
super(message);
|
||||
}
|
||||
|
||||
public CredentialExpiredException(Throwable cause) {
|
||||
super(cause);
|
||||
}
|
||||
}
|
|
@ -0,0 +1,10 @@
|
|||
package com.tungsten.fclcore.auth;
|
||||
|
||||
/**
|
||||
* This exception gets threw when authenticating a yggdrasil account and there is no valid character.
|
||||
* (A account may hold more than one characters.)
|
||||
*/
|
||||
public final class NoCharacterException extends AuthenticationException {
|
||||
public NoCharacterException() {
|
||||
}
|
||||
}
|
|
@ -0,0 +1,12 @@
|
|||
package com.tungsten.fclcore.auth;
|
||||
|
||||
/**
|
||||
* This exception gets threw when a monitor of {@link CharacterSelector} cannot select a
|
||||
* valid character.
|
||||
*
|
||||
* @see CharacterSelector
|
||||
*/
|
||||
public final class NoSelectedCharacterException extends AuthenticationException {
|
||||
public NoSelectedCharacterException() {
|
||||
}
|
||||
}
|
|
@ -0,0 +1,4 @@
|
|||
package com.tungsten.fclcore.auth;
|
||||
|
||||
public class NotLoggedInException extends AuthenticationException {
|
||||
}
|
|
@ -0,0 +1,348 @@
|
|||
package com.tungsten.fclcore.auth;
|
||||
|
||||
import static com.tungsten.fclcore.util.Lang.mapOf;
|
||||
import static com.tungsten.fclcore.util.Pair.pair;
|
||||
|
||||
import com.google.gson.JsonParseException;
|
||||
import com.google.gson.annotations.SerializedName;
|
||||
import com.tungsten.fclcore.auth.yggdrasil.RemoteAuthenticationException;
|
||||
import com.tungsten.fclcore.util.io.HttpRequest;
|
||||
import com.tungsten.fclcore.util.io.NetworkUtils;
|
||||
|
||||
import java.io.IOException;
|
||||
import java.util.Map;
|
||||
import java.util.concurrent.ExecutionException;
|
||||
import java.util.concurrent.TimeUnit;
|
||||
|
||||
public class OAuth {
|
||||
public static final OAuth MICROSOFT = new OAuth(
|
||||
"https://login.live.com/oauth20_authorize.srf",
|
||||
"https://login.live.com/oauth20_token.srf",
|
||||
"https://login.microsoftonline.com/consumers/oauth2/v2.0/devicecode",
|
||||
"https://login.microsoftonline.com/consumers/oauth2/v2.0/token");
|
||||
|
||||
private final String authorizationURL;
|
||||
private final String accessTokenURL;
|
||||
private final String deviceCodeURL;
|
||||
private final String tokenURL;
|
||||
|
||||
public OAuth(String authorizationURL, String accessTokenURL, String deviceCodeURL, String tokenURL) {
|
||||
this.authorizationURL = authorizationURL;
|
||||
this.accessTokenURL = accessTokenURL;
|
||||
this.deviceCodeURL = deviceCodeURL;
|
||||
this.tokenURL = tokenURL;
|
||||
}
|
||||
|
||||
public Result authenticate(GrantFlow grantFlow, Options options) throws AuthenticationException {
|
||||
try {
|
||||
switch (grantFlow) {
|
||||
case AUTHORIZATION_CODE:
|
||||
return authenticateAuthorizationCode(options);
|
||||
case DEVICE:
|
||||
return authenticateDevice(options);
|
||||
default:
|
||||
throw new UnsupportedOperationException("grant flow " + grantFlow);
|
||||
}
|
||||
} catch (IOException e) {
|
||||
throw new ServerDisconnectException(e);
|
||||
} catch (InterruptedException e) {
|
||||
throw new NoSelectedCharacterException();
|
||||
} catch (ExecutionException e) {
|
||||
if (e.getCause() instanceof InterruptedException) {
|
||||
throw new NoSelectedCharacterException();
|
||||
} else {
|
||||
throw new ServerDisconnectException(e);
|
||||
}
|
||||
} catch (JsonParseException e) {
|
||||
throw new ServerResponseMalformedException(e);
|
||||
}
|
||||
}
|
||||
|
||||
private Result authenticateAuthorizationCode(Options options) throws IOException, InterruptedException, JsonParseException, ExecutionException, AuthenticationException {
|
||||
Session session = options.callback.startServer();
|
||||
options.callback.openBrowser(NetworkUtils.withQuery(authorizationURL,
|
||||
mapOf(pair("client_id", options.callback.getClientId()), pair("response_type", "code"),
|
||||
pair("redirect_uri", session.getRedirectURI()), pair("scope", options.scope),
|
||||
pair("prompt", "select_account"))));
|
||||
String code = session.waitFor();
|
||||
|
||||
// Authorization Code -> Token
|
||||
AuthorizationResponse response = HttpRequest.POST(accessTokenURL)
|
||||
.form(pair("client_id", options.callback.getClientId()), pair("code", code),
|
||||
pair("grant_type", "authorization_code"), pair("client_secret", options.callback.getClientSecret()),
|
||||
pair("redirect_uri", session.getRedirectURI()), pair("scope", options.scope))
|
||||
.ignoreHttpCode()
|
||||
.retry(5)
|
||||
.getJson(AuthorizationResponse.class);
|
||||
handleErrorResponse(response);
|
||||
return new Result(response.accessToken, response.refreshToken);
|
||||
}
|
||||
|
||||
private Result authenticateDevice(Options options) throws IOException, InterruptedException, JsonParseException, AuthenticationException {
|
||||
DeviceTokenResponse deviceTokenResponse = HttpRequest.POST(deviceCodeURL)
|
||||
.form(pair("client_id", options.callback.getClientId()), pair("scope", options.scope))
|
||||
.ignoreHttpCode()
|
||||
.retry(5)
|
||||
.getJson(DeviceTokenResponse.class);
|
||||
handleErrorResponse(deviceTokenResponse);
|
||||
|
||||
options.callback.grantDeviceCode(deviceTokenResponse.userCode, deviceTokenResponse.verificationURI);
|
||||
|
||||
// Microsoft OAuth Flow
|
||||
options.callback.openBrowser(deviceTokenResponse.verificationURI);
|
||||
|
||||
long startTime = System.nanoTime();
|
||||
int interval = deviceTokenResponse.interval;
|
||||
|
||||
while (true) {
|
||||
Thread.sleep(Math.max(interval, 1));
|
||||
|
||||
// We stop waiting if user does not respond our authentication request in 15 minutes.
|
||||
long estimatedTime = System.nanoTime() - startTime;
|
||||
if (TimeUnit.SECONDS.convert(estimatedTime, TimeUnit.NANOSECONDS) >= Math.min(deviceTokenResponse.expiresIn, 900)) {
|
||||
throw new NoSelectedCharacterException();
|
||||
}
|
||||
|
||||
TokenResponse tokenResponse = HttpRequest.POST(tokenURL)
|
||||
.form(
|
||||
pair("grant_type", "urn:ietf:params:oauth:grant-type:device_code"),
|
||||
pair("code", deviceTokenResponse.deviceCode),
|
||||
pair("client_id", options.callback.getClientId()))
|
||||
.ignoreHttpCode()
|
||||
.retry(5)
|
||||
.getJson(TokenResponse.class);
|
||||
|
||||
if ("authorization_pending".equals(tokenResponse.error)) {
|
||||
continue;
|
||||
}
|
||||
|
||||
if ("expired_token".equals(tokenResponse.error)) {
|
||||
throw new NoSelectedCharacterException();
|
||||
}
|
||||
|
||||
if ("slow_down".equals(tokenResponse.error)) {
|
||||
interval += 5;
|
||||
continue;
|
||||
}
|
||||
|
||||
return new Result(tokenResponse.accessToken, tokenResponse.refreshToken);
|
||||
}
|
||||
}
|
||||
|
||||
public Result refresh(String refreshToken, Options options) throws AuthenticationException {
|
||||
try {
|
||||
Map<String, String> query = mapOf(pair("client_id", options.callback.getClientId()),
|
||||
pair("refresh_token", refreshToken),
|
||||
pair("grant_type", "refresh_token")
|
||||
);
|
||||
|
||||
if (!options.callback.isPublicClient()) {
|
||||
query.put("client_secret", options.callback.getClientSecret());
|
||||
}
|
||||
|
||||
RefreshResponse response = HttpRequest.POST(tokenURL)
|
||||
.form(query)
|
||||
.accept("application/json")
|
||||
.ignoreHttpCode()
|
||||
.retry(5)
|
||||
.getJson(RefreshResponse.class);
|
||||
|
||||
handleErrorResponse(response);
|
||||
|
||||
return new Result(response.accessToken, response.refreshToken);
|
||||
} catch (IOException e) {
|
||||
throw new ServerDisconnectException(e);
|
||||
} catch (JsonParseException e) {
|
||||
throw new ServerResponseMalformedException(e);
|
||||
}
|
||||
}
|
||||
|
||||
private static void handleErrorResponse(ErrorResponse response) throws AuthenticationException {
|
||||
if (response.error == null || response.errorDescription == null) {
|
||||
return;
|
||||
}
|
||||
|
||||
switch (response.error) {
|
||||
case "invalid_grant":
|
||||
if (response.errorDescription.contains("AADSTS70000")) {
|
||||
throw new CredentialExpiredException();
|
||||
}
|
||||
break;
|
||||
}
|
||||
|
||||
throw new RemoteAuthenticationException(response.error, response.errorDescription, "");
|
||||
}
|
||||
|
||||
public static class Options {
|
||||
private String userAgent;
|
||||
private final String scope;
|
||||
private final Callback callback;
|
||||
|
||||
public Options(String scope, Callback callback) {
|
||||
this.scope = scope;
|
||||
this.callback = callback;
|
||||
}
|
||||
|
||||
public Options setUserAgent(String userAgent) {
|
||||
this.userAgent = userAgent;
|
||||
return this;
|
||||
}
|
||||
}
|
||||
|
||||
public interface Session {
|
||||
|
||||
String getRedirectURI();
|
||||
|
||||
/**
|
||||
* Wait for authentication
|
||||
*
|
||||
* @return authentication code
|
||||
* @throws InterruptedException if interrupted
|
||||
* @throws ExecutionException if an I/O error occurred.
|
||||
*/
|
||||
String waitFor() throws InterruptedException, ExecutionException;
|
||||
|
||||
default String getIdToken() {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
public interface Callback {
|
||||
/**
|
||||
* Start OAuth callback server at localhost.
|
||||
*
|
||||
* @throws IOException if an I/O error occurred.
|
||||
*/
|
||||
Session startServer() throws IOException, AuthenticationException;
|
||||
|
||||
void grantDeviceCode(String userCode, String verificationURI);
|
||||
|
||||
/**
|
||||
* Open browser
|
||||
*
|
||||
* @param url OAuth url.
|
||||
*/
|
||||
void openBrowser(String url) throws IOException;
|
||||
|
||||
String getClientId();
|
||||
|
||||
String getClientSecret();
|
||||
|
||||
boolean isPublicClient();
|
||||
}
|
||||
|
||||
public enum GrantFlow {
|
||||
AUTHORIZATION_CODE,
|
||||
DEVICE,
|
||||
}
|
||||
|
||||
public class Result {
|
||||
private final String accessToken;
|
||||
private final String refreshToken;
|
||||
|
||||
public Result(String accessToken, String refreshToken) {
|
||||
this.accessToken = accessToken;
|
||||
this.refreshToken = refreshToken;
|
||||
}
|
||||
|
||||
public String getAccessToken() {
|
||||
return accessToken;
|
||||
}
|
||||
|
||||
public String getRefreshToken() {
|
||||
return refreshToken;
|
||||
}
|
||||
}
|
||||
|
||||
private static class DeviceTokenResponse extends ErrorResponse {
|
||||
@SerializedName("user_code")
|
||||
public String userCode;
|
||||
|
||||
@SerializedName("device_code")
|
||||
public String deviceCode;
|
||||
|
||||
// The URI to be visited for user.
|
||||
@SerializedName("verification_uri")
|
||||
public String verificationURI;
|
||||
|
||||
// Life time in seconds for device_code and user_code
|
||||
@SerializedName("expires_in")
|
||||
public int expiresIn;
|
||||
|
||||
// Polling interval
|
||||
@SerializedName("interval")
|
||||
public int interval;
|
||||
}
|
||||
|
||||
private static class TokenResponse extends ErrorResponse {
|
||||
@SerializedName("token_type")
|
||||
public String tokenType;
|
||||
|
||||
@SerializedName("expires_in")
|
||||
public int expiresIn;
|
||||
|
||||
@SerializedName("ext_expires_in")
|
||||
public int extExpiresIn;
|
||||
|
||||
@SerializedName("scope")
|
||||
public String scope;
|
||||
|
||||
@SerializedName("access_token")
|
||||
public String accessToken;
|
||||
|
||||
@SerializedName("refresh_token")
|
||||
public String refreshToken;
|
||||
|
||||
}
|
||||
|
||||
private static class ErrorResponse {
|
||||
@SerializedName("error")
|
||||
public String error;
|
||||
|
||||
@SerializedName("error_description")
|
||||
public String errorDescription;
|
||||
|
||||
@SerializedName("correlation_id")
|
||||
public String correlationId;
|
||||
}
|
||||
|
||||
/**
|
||||
* 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":"??????"}
|
||||
*/
|
||||
public static class AuthorizationResponse extends ErrorResponse {
|
||||
@SerializedName("token_type")
|
||||
public String tokenType;
|
||||
|
||||
@SerializedName("expires_in")
|
||||
public int expiresIn;
|
||||
|
||||
@SerializedName("scope")
|
||||
public String scope;
|
||||
|
||||
@SerializedName("access_token")
|
||||
public String accessToken;
|
||||
|
||||
@SerializedName("refresh_token")
|
||||
public String refreshToken;
|
||||
|
||||
@SerializedName("user_id")
|
||||
public String userId;
|
||||
|
||||
@SerializedName("foci")
|
||||
public String foci;
|
||||
}
|
||||
|
||||
private static class RefreshResponse extends ErrorResponse {
|
||||
@SerializedName("expires_in")
|
||||
int expiresIn;
|
||||
|
||||
@SerializedName("access_token")
|
||||
String accessToken;
|
||||
|
||||
@SerializedName("refresh_token")
|
||||
String refreshToken;
|
||||
}
|
||||
}
|
|
@ -0,0 +1,39 @@
|
|||
package com.tungsten.fclcore.auth;
|
||||
|
||||
import java.util.UUID;
|
||||
|
||||
public abstract class OAuthAccount extends Account {
|
||||
|
||||
/**
|
||||
* Fully login.
|
||||
*
|
||||
* OAuth server may ask us to do fully login because too frequent action to log in, IP changed,
|
||||
* or some other vulnerabilities detected.
|
||||
*
|
||||
* Difference between logIn & logInWhenCredentialsExpired.
|
||||
* logIn only update access token by refresh token, and will not ask user to login by opening the authorization
|
||||
* page in web browser.
|
||||
* logInWhenCredentialsExpired will open the authorization page in web browser, asking user to select an account
|
||||
* (and enter password or PIN if necessary).
|
||||
*/
|
||||
public abstract AuthInfo logInWhenCredentialsExpired() throws AuthenticationException;
|
||||
|
||||
public static class WrongAccountException extends AuthenticationException {
|
||||
private final UUID expected;
|
||||
private final UUID actual;
|
||||
|
||||
public WrongAccountException(UUID expected, UUID actual) {
|
||||
super("Expected account " + expected + ", but found " + actual);
|
||||
this.expected = expected;
|
||||
this.actual = actual;
|
||||
}
|
||||
|
||||
public UUID getExpected() {
|
||||
return expected;
|
||||
}
|
||||
|
||||
public UUID getActual() {
|
||||
return actual;
|
||||
}
|
||||
}
|
||||
}
|
|
@ -0,0 +1,18 @@
|
|||
package com.tungsten.fclcore.auth;
|
||||
|
||||
public class ServerDisconnectException extends AuthenticationException {
|
||||
public ServerDisconnectException() {
|
||||
}
|
||||
|
||||
public ServerDisconnectException(String message) {
|
||||
super(message);
|
||||
}
|
||||
|
||||
public ServerDisconnectException(String message, Throwable cause) {
|
||||
super(message, cause);
|
||||
}
|
||||
|
||||
public ServerDisconnectException(Throwable cause) {
|
||||
super(cause);
|
||||
}
|
||||
}
|
|
@ -0,0 +1,18 @@
|
|||
package com.tungsten.fclcore.auth;
|
||||
|
||||
public class ServerResponseMalformedException extends AuthenticationException {
|
||||
public ServerResponseMalformedException() {
|
||||
}
|
||||
|
||||
public ServerResponseMalformedException(String message) {
|
||||
super(message);
|
||||
}
|
||||
|
||||
public ServerResponseMalformedException(String message, Throwable cause) {
|
||||
super(message, cause);
|
||||
}
|
||||
|
||||
public ServerResponseMalformedException(Throwable cause) {
|
||||
super(cause);
|
||||
}
|
||||
}
|
|
@ -0,0 +1,183 @@
|
|||
package com.tungsten.fclcore.auth.authlibinjector;
|
||||
|
||||
import java.io.IOException;
|
||||
import java.util.*;
|
||||
import java.util.concurrent.CompletableFuture;
|
||||
import java.util.concurrent.CompletionException;
|
||||
import java.util.concurrent.ExecutionException;
|
||||
|
||||
import static java.nio.charset.StandardCharsets.UTF_8;
|
||||
import static java.util.Collections.emptySet;
|
||||
import static java.util.Collections.unmodifiableSet;
|
||||
|
||||
import com.tungsten.fclcore.auth.AuthInfo;
|
||||
import com.tungsten.fclcore.auth.AuthenticationException;
|
||||
import com.tungsten.fclcore.auth.CharacterSelector;
|
||||
import com.tungsten.fclcore.auth.NotLoggedInException;
|
||||
import com.tungsten.fclcore.auth.ServerDisconnectException;
|
||||
import com.tungsten.fclcore.auth.yggdrasil.CompleteGameProfile;
|
||||
import com.tungsten.fclcore.auth.yggdrasil.TextureType;
|
||||
import com.tungsten.fclcore.auth.yggdrasil.YggdrasilAccount;
|
||||
import com.tungsten.fclcore.auth.yggdrasil.YggdrasilSession;
|
||||
import com.tungsten.fclcore.game.Arguments;
|
||||
import com.tungsten.fclcore.game.LaunchOptions;
|
||||
import com.tungsten.fclcore.util.ToStringBuilder;
|
||||
import com.tungsten.fclcore.util.function.ExceptionalSupplier;
|
||||
|
||||
public class AuthlibInjectorAccount extends YggdrasilAccount {
|
||||
private final AuthlibInjectorServer server;
|
||||
private AuthlibInjectorArtifactProvider downloader;
|
||||
|
||||
public AuthlibInjectorAccount(AuthlibInjectorServer server, AuthlibInjectorArtifactProvider downloader, String username, String password, CharacterSelector selector) throws AuthenticationException {
|
||||
super(server.getYggdrasilService(), username, password, selector);
|
||||
this.server = server;
|
||||
this.downloader = downloader;
|
||||
}
|
||||
|
||||
public AuthlibInjectorAccount(AuthlibInjectorServer server, AuthlibInjectorArtifactProvider downloader, String username, YggdrasilSession session) {
|
||||
super(server.getYggdrasilService(), username, session);
|
||||
this.server = server;
|
||||
this.downloader = downloader;
|
||||
}
|
||||
|
||||
@Override
|
||||
public synchronized AuthInfo logIn() throws AuthenticationException {
|
||||
return inject(super::logIn);
|
||||
}
|
||||
|
||||
@Override
|
||||
public synchronized AuthInfo logInWithPassword(String password) throws AuthenticationException {
|
||||
return inject(() -> super.logInWithPassword(password));
|
||||
}
|
||||
|
||||
@Override
|
||||
public AuthInfo playOffline() throws AuthenticationException {
|
||||
AuthInfo auth = super.playOffline();
|
||||
Optional<AuthlibInjectorArtifactInfo> artifact = downloader.getArtifactInfoImmediately();
|
||||
Optional<String> prefetchedMeta = server.getMetadataResponse();
|
||||
|
||||
if (artifact.isPresent() && prefetchedMeta.isPresent()) {
|
||||
return new AuthlibInjectorAuthInfo(auth, artifact.get(), server, prefetchedMeta.get());
|
||||
} else {
|
||||
throw new NotLoggedInException();
|
||||
}
|
||||
}
|
||||
|
||||
private AuthInfo inject(ExceptionalSupplier<AuthInfo, AuthenticationException> loginAction) throws AuthenticationException {
|
||||
CompletableFuture<String> prefetchedMetaTask = CompletableFuture.supplyAsync(() -> {
|
||||
try {
|
||||
return server.fetchMetadataResponse();
|
||||
} catch (IOException e) {
|
||||
throw new CompletionException(new ServerDisconnectException(e));
|
||||
}
|
||||
});
|
||||
|
||||
CompletableFuture<AuthlibInjectorArtifactInfo> artifactTask = CompletableFuture.supplyAsync(() -> {
|
||||
try {
|
||||
return downloader.getArtifactInfo();
|
||||
} catch (IOException e) {
|
||||
throw new CompletionException(new AuthlibInjectorDownloadException(e));
|
||||
}
|
||||
});
|
||||
|
||||
AuthInfo auth = loginAction.get();
|
||||
String prefetchedMeta;
|
||||
AuthlibInjectorArtifactInfo artifact;
|
||||
|
||||
try {
|
||||
prefetchedMeta = prefetchedMetaTask.get();
|
||||
artifact = artifactTask.get();
|
||||
} catch (InterruptedException e) {
|
||||
Thread.currentThread().interrupt();
|
||||
throw new AuthenticationException(e);
|
||||
} catch (ExecutionException e) {
|
||||
if (e.getCause() instanceof AuthenticationException) {
|
||||
throw (AuthenticationException) e.getCause();
|
||||
} else {
|
||||
throw new AuthenticationException(e.getCause());
|
||||
}
|
||||
}
|
||||
|
||||
return new AuthlibInjectorAuthInfo(auth, artifact, server, prefetchedMeta);
|
||||
}
|
||||
|
||||
private static class AuthlibInjectorAuthInfo extends AuthInfo {
|
||||
|
||||
private final AuthlibInjectorArtifactInfo artifact;
|
||||
private final AuthlibInjectorServer server;
|
||||
private final String prefetchedMeta;
|
||||
|
||||
public AuthlibInjectorAuthInfo(AuthInfo authInfo, AuthlibInjectorArtifactInfo artifact, AuthlibInjectorServer server, String prefetchedMeta) {
|
||||
super(authInfo.getUsername(), authInfo.getUUID(), authInfo.getAccessToken(), authInfo.getUserProperties());
|
||||
|
||||
this.artifact = artifact;
|
||||
this.server = server;
|
||||
this.prefetchedMeta = prefetchedMeta;
|
||||
}
|
||||
|
||||
@Override
|
||||
public Arguments getLaunchArguments(LaunchOptions options) {
|
||||
return new Arguments().addJVMArguments(
|
||||
"-javaagent:" + artifact.getLocation().toString() + "=" + server.getUrl(),
|
||||
"-Dauthlibinjector.side=client",
|
||||
"-Dauthlibinjector.yggdrasil.prefetched=" + Base64.getEncoder().encodeToString(prefetchedMeta.getBytes(UTF_8)));
|
||||
}
|
||||
}
|
||||
|
||||
@Override
|
||||
public Map<Object, Object> toStorage() {
|
||||
Map<Object, Object> map = super.toStorage();
|
||||
map.put("serverBaseURL", server.getUrl());
|
||||
return map;
|
||||
}
|
||||
|
||||
@Override
|
||||
public void clearCache() {
|
||||
super.clearCache();
|
||||
server.invalidateMetadataCache();
|
||||
}
|
||||
|
||||
public AuthlibInjectorServer getServer() {
|
||||
return server;
|
||||
}
|
||||
|
||||
@Override
|
||||
public int hashCode() {
|
||||
return Objects.hash(super.hashCode(), server.hashCode());
|
||||
}
|
||||
|
||||
@Override
|
||||
public boolean equals(Object obj) {
|
||||
if (obj == null || obj.getClass() != AuthlibInjectorAccount.class)
|
||||
return false;
|
||||
AuthlibInjectorAccount another = (AuthlibInjectorAccount) obj;
|
||||
return characterUUID.equals(another.characterUUID) && server.equals(another.server);
|
||||
}
|
||||
|
||||
@Override
|
||||
public String toString() {
|
||||
return new ToStringBuilder(this)
|
||||
.append("uuid", characterUUID)
|
||||
.append("username", getUsername())
|
||||
.append("server", getServer().getUrl())
|
||||
.toString();
|
||||
}
|
||||
|
||||
public static Set<TextureType> getUploadableTextures(CompleteGameProfile profile) {
|
||||
String prop = profile.getProperties().get("uploadableTextures");
|
||||
if (prop == null)
|
||||
return emptySet();
|
||||
Set<TextureType> result = EnumSet.noneOf(TextureType.class);
|
||||
for (String val : prop.split(",")) {
|
||||
val = val.toUpperCase();
|
||||
TextureType parsed;
|
||||
try {
|
||||
parsed = TextureType.valueOf(val);
|
||||
} catch (IllegalArgumentException e) {
|
||||
continue;
|
||||
}
|
||||
result.add(parsed);
|
||||
}
|
||||
return unmodifiableSet(result);
|
||||
}
|
||||
}
|
|
@ -0,0 +1,74 @@
|
|||
package com.tungsten.fclcore.auth.authlibinjector;
|
||||
|
||||
import static com.tungsten.fclcore.util.Lang.tryCast;
|
||||
|
||||
import com.tungsten.fclcore.auth.AccountFactory;
|
||||
import com.tungsten.fclcore.auth.AuthenticationException;
|
||||
import com.tungsten.fclcore.auth.CharacterSelector;
|
||||
import com.tungsten.fclcore.auth.yggdrasil.CompleteGameProfile;
|
||||
import com.tungsten.fclcore.auth.yggdrasil.GameProfile;
|
||||
import com.tungsten.fclcore.auth.yggdrasil.YggdrasilSession;
|
||||
import com.tungsten.fclcore.util.fakefx.ObservableOptionalCache;
|
||||
|
||||
import java.util.Map;
|
||||
import java.util.Objects;
|
||||
import java.util.UUID;
|
||||
import java.util.function.Function;
|
||||
|
||||
public class AuthlibInjectorAccountFactory extends AccountFactory<AuthlibInjectorAccount> {
|
||||
private final AuthlibInjectorArtifactProvider downloader;
|
||||
private final Function<String, AuthlibInjectorServer> serverLookup;
|
||||
|
||||
/**
|
||||
* @param serverLookup a function that looks up {@link AuthlibInjectorServer} by url
|
||||
*/
|
||||
public AuthlibInjectorAccountFactory(AuthlibInjectorArtifactProvider downloader, Function<String, AuthlibInjectorServer> serverLookup) {
|
||||
this.downloader = downloader;
|
||||
this.serverLookup = serverLookup;
|
||||
}
|
||||
|
||||
@Override
|
||||
public AccountLoginType getLoginType() {
|
||||
return AccountLoginType.USERNAME_PASSWORD;
|
||||
}
|
||||
|
||||
@Override
|
||||
public AuthlibInjectorAccount create(CharacterSelector selector, String username, String password, ProgressCallback progressCallback, Object additionalData) throws AuthenticationException {
|
||||
Objects.requireNonNull(selector);
|
||||
Objects.requireNonNull(username);
|
||||
Objects.requireNonNull(password);
|
||||
|
||||
AuthlibInjectorServer server = (AuthlibInjectorServer) additionalData;
|
||||
|
||||
return new AuthlibInjectorAccount(server, downloader, username, password, selector);
|
||||
}
|
||||
|
||||
@Override
|
||||
public AuthlibInjectorAccount fromStorage(Map<Object, Object> storage) {
|
||||
Objects.requireNonNull(storage);
|
||||
|
||||
String apiRoot = tryCast(storage.get("serverBaseURL"), String.class)
|
||||
.orElseThrow(() -> new IllegalArgumentException("storage does not have API root."));
|
||||
AuthlibInjectorServer server = serverLookup.apply(apiRoot);
|
||||
return fromStorage(storage, downloader, server);
|
||||
}
|
||||
|
||||
static AuthlibInjectorAccount fromStorage(Map<Object, Object> storage, AuthlibInjectorArtifactProvider downloader, AuthlibInjectorServer server) {
|
||||
YggdrasilSession session = YggdrasilSession.fromStorage(storage);
|
||||
|
||||
String username = tryCast(storage.get("username"), String.class)
|
||||
.orElseThrow(() -> new IllegalArgumentException("storage does not have username"));
|
||||
|
||||
tryCast(storage.get("profileProperties"), Map.class).ifPresent(
|
||||
it -> {
|
||||
@SuppressWarnings("unchecked")
|
||||
Map<String, String> properties = it;
|
||||
GameProfile selected = session.getSelectedProfile();
|
||||
ObservableOptionalCache<UUID, CompleteGameProfile, AuthenticationException> profileRepository = server.getYggdrasilService().getProfileRepository();
|
||||
profileRepository.put(selected.getId(), new CompleteGameProfile(selected, properties));
|
||||
profileRepository.invalidate(selected.getId());
|
||||
});
|
||||
|
||||
return new AuthlibInjectorAccount(server, downloader, username, session);
|
||||
}
|
||||
}
|
|
@ -0,0 +1,62 @@
|
|||
package com.tungsten.fclcore.auth.authlibinjector;
|
||||
|
||||
import java.io.IOException;
|
||||
import java.nio.file.Path;
|
||||
import java.util.Optional;
|
||||
import java.util.jar.Attributes;
|
||||
import java.util.jar.JarFile;
|
||||
|
||||
public class AuthlibInjectorArtifactInfo {
|
||||
|
||||
public static AuthlibInjectorArtifactInfo from(Path location) throws IOException {
|
||||
try (JarFile jarFile = new JarFile(location.toFile())) {
|
||||
Attributes attributes = jarFile.getManifest().getMainAttributes();
|
||||
|
||||
String title = Optional.ofNullable(attributes.getValue("Implementation-Title"))
|
||||
.orElseThrow(() -> new IOException("Missing Implementation-Title"));
|
||||
if (!"authlib-injector".equals(title)) {
|
||||
throw new IOException("Bad Implementation-Title");
|
||||
}
|
||||
|
||||
String version = Optional.ofNullable(attributes.getValue("Implementation-Version"))
|
||||
.orElseThrow(() -> new IOException("Missing Implementation-Version"));
|
||||
|
||||
int buildNumber;
|
||||
try {
|
||||
buildNumber = Optional.ofNullable(attributes.getValue("Build-Number"))
|
||||
.map(Integer::parseInt)
|
||||
.orElseThrow(() -> new IOException("Missing Build-Number"));
|
||||
} catch (NumberFormatException e) {
|
||||
throw new IOException("Bad Build-Number", e);
|
||||
}
|
||||
return new AuthlibInjectorArtifactInfo(buildNumber, version, location.toAbsolutePath());
|
||||
}
|
||||
}
|
||||
|
||||
private int buildNumber;
|
||||
private String version;
|
||||
private Path location;
|
||||
|
||||
public AuthlibInjectorArtifactInfo(int buildNumber, String version, Path location) {
|
||||
this.buildNumber = buildNumber;
|
||||
this.version = version;
|
||||
this.location = location;
|
||||
}
|
||||
|
||||
public int getBuildNumber() {
|
||||
return buildNumber;
|
||||
}
|
||||
|
||||
public String getVersion() {
|
||||
return version;
|
||||
}
|
||||
|
||||
public Path getLocation() {
|
||||
return location;
|
||||
}
|
||||
|
||||
@Override
|
||||
public String toString() {
|
||||
return "authlib-injector [buildNumber=" + buildNumber + ", version=" + version + "]";
|
||||
}
|
||||
}
|
|
@ -0,0 +1,12 @@
|
|||
package com.tungsten.fclcore.auth.authlibinjector;
|
||||
|
||||
import java.io.IOException;
|
||||
import java.util.Optional;
|
||||
|
||||
public interface AuthlibInjectorArtifactProvider {
|
||||
|
||||
AuthlibInjectorArtifactInfo getArtifactInfo() throws IOException;
|
||||
|
||||
Optional<AuthlibInjectorArtifactInfo> getArtifactInfoImmediately();
|
||||
|
||||
}
|
|
@ -0,0 +1,21 @@
|
|||
package com.tungsten.fclcore.auth.authlibinjector;
|
||||
|
||||
import com.tungsten.fclcore.auth.AuthenticationException;
|
||||
|
||||
public class AuthlibInjectorDownloadException extends AuthenticationException {
|
||||
|
||||
public AuthlibInjectorDownloadException() {
|
||||
}
|
||||
|
||||
public AuthlibInjectorDownloadException(String message, Throwable cause) {
|
||||
super(message, cause);
|
||||
}
|
||||
|
||||
public AuthlibInjectorDownloadException(String message) {
|
||||
super(message);
|
||||
}
|
||||
|
||||
public AuthlibInjectorDownloadException(Throwable cause) {
|
||||
super(cause);
|
||||
}
|
||||
}
|
|
@ -0,0 +1,133 @@
|
|||
package com.tungsten.fclcore.auth.authlibinjector;
|
||||
|
||||
import static com.tungsten.fclcore.util.Logging.LOG;
|
||||
|
||||
import com.google.gson.JsonParseException;
|
||||
import com.google.gson.annotations.SerializedName;
|
||||
import com.tungsten.fclcore.download.DownloadProvider;
|
||||
import com.tungsten.fclcore.task.FileDownloadTask;
|
||||
import com.tungsten.fclcore.util.gson.JsonUtils;
|
||||
import com.tungsten.fclcore.util.io.NetworkUtils;
|
||||
|
||||
import java.io.IOException;
|
||||
import java.net.URL;
|
||||
import java.nio.file.Files;
|
||||
import java.nio.file.Path;
|
||||
import java.util.Map;
|
||||
import java.util.Optional;
|
||||
import java.util.concurrent.atomic.AtomicBoolean;
|
||||
import java.util.function.Supplier;
|
||||
import java.util.logging.Level;
|
||||
|
||||
public class AuthlibInjectorDownloader implements AuthlibInjectorArtifactProvider {
|
||||
|
||||
private static final String LATEST_BUILD_URL = "https://authlib-injector.yushi.moe/artifact/latest.json";
|
||||
|
||||
private final Path artifactLocation;
|
||||
private final Supplier<DownloadProvider> downloadProvider;
|
||||
|
||||
public AuthlibInjectorDownloader(Path artifactLocation, Supplier<DownloadProvider> downloadProvider) {
|
||||
this.artifactLocation = artifactLocation;
|
||||
this.downloadProvider = downloadProvider;
|
||||
}
|
||||
|
||||
@Override
|
||||
public AuthlibInjectorArtifactInfo getArtifactInfo() throws IOException {
|
||||
Optional<AuthlibInjectorArtifactInfo> cached = getArtifactInfoImmediately();
|
||||
if (cached.isPresent()) {
|
||||
return cached.get();
|
||||
}
|
||||
|
||||
synchronized (this) {
|
||||
Optional<AuthlibInjectorArtifactInfo> local = getLocalArtifact();
|
||||
if (local.isPresent()) {
|
||||
return local.get();
|
||||
}
|
||||
LOG.info("No local authlib-injector found, downloading");
|
||||
updateChecked.set(true);
|
||||
update();
|
||||
local = getLocalArtifact();
|
||||
return local.orElseThrow(() -> new IOException("The downloaded authlib-inejector cannot be recognized"));
|
||||
}
|
||||
}
|
||||
|
||||
@Override
|
||||
public Optional<AuthlibInjectorArtifactInfo> getArtifactInfoImmediately() {
|
||||
return getLocalArtifact();
|
||||
}
|
||||
|
||||
private final AtomicBoolean updateChecked = new AtomicBoolean(false);
|
||||
|
||||
public void checkUpdate() throws IOException {
|
||||
// this method runs only once
|
||||
if (updateChecked.compareAndSet(false, true)) {
|
||||
synchronized (this) {
|
||||
LOG.info("Checking update of authlib-injector");
|
||||
update();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private void update() throws IOException {
|
||||
AuthlibInjectorVersionInfo latest = getLatestArtifactInfo();
|
||||
|
||||
Optional<AuthlibInjectorArtifactInfo> local = getLocalArtifact();
|
||||
if (local.isPresent() && local.get().getBuildNumber() >= latest.buildNumber) {
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
new FileDownloadTask(new URL(downloadProvider.get().injectURL(latest.downloadUrl)), artifactLocation.toFile(),
|
||||
Optional.ofNullable(latest.checksums.get("sha256"))
|
||||
.map(checksum -> new FileDownloadTask.IntegrityCheck("SHA-256", checksum))
|
||||
.orElse(null))
|
||||
.run();
|
||||
} catch (Exception e) {
|
||||
throw new IOException("Failed to download authlib-injector", e);
|
||||
}
|
||||
|
||||
LOG.info("Updated authlib-injector to " + latest.version);
|
||||
}
|
||||
|
||||
private AuthlibInjectorVersionInfo getLatestArtifactInfo() throws IOException {
|
||||
try {
|
||||
return JsonUtils.fromNonNullJson(
|
||||
NetworkUtils.doGet(
|
||||
new URL(downloadProvider.get().injectURL(LATEST_BUILD_URL))),
|
||||
AuthlibInjectorVersionInfo.class);
|
||||
} catch (JsonParseException e) {
|
||||
throw new IOException("Malformed response", e);
|
||||
}
|
||||
}
|
||||
|
||||
private Optional<AuthlibInjectorArtifactInfo> getLocalArtifact() {
|
||||
return parseArtifact(artifactLocation);
|
||||
}
|
||||
|
||||
protected static Optional<AuthlibInjectorArtifactInfo> parseArtifact(Path path) {
|
||||
if (!Files.isRegularFile(path)) {
|
||||
return Optional.empty();
|
||||
}
|
||||
try {
|
||||
return Optional.of(AuthlibInjectorArtifactInfo.from(path));
|
||||
} catch (IOException e) {
|
||||
LOG.log(Level.WARNING, "Bad authlib-injector artifact", e);
|
||||
return Optional.empty();
|
||||
}
|
||||
}
|
||||
|
||||
private static class AuthlibInjectorVersionInfo {
|
||||
@SerializedName("build_number")
|
||||
public int buildNumber;
|
||||
|
||||
@SerializedName("version")
|
||||
public String version;
|
||||
|
||||
@SerializedName("download_url")
|
||||
public String downloadUrl;
|
||||
|
||||
@SerializedName("checksums")
|
||||
public Map<String, String> checksums;
|
||||
}
|
||||
|
||||
}
|
|
@ -0,0 +1,54 @@
|
|||
package com.tungsten.fclcore.auth.authlibinjector;
|
||||
|
||||
import static com.tungsten.fclcore.util.io.NetworkUtils.toURL;
|
||||
|
||||
import com.tungsten.fclcore.auth.AuthenticationException;
|
||||
import com.tungsten.fclcore.auth.yggdrasil.YggdrasilProvider;
|
||||
import com.tungsten.fclcore.util.gson.UUIDTypeAdapter;
|
||||
|
||||
import java.net.URL;
|
||||
import java.util.UUID;
|
||||
|
||||
public class AuthlibInjectorProvider implements YggdrasilProvider {
|
||||
|
||||
private final String apiRoot;
|
||||
|
||||
public AuthlibInjectorProvider(String apiRoot) {
|
||||
this.apiRoot = apiRoot;
|
||||
}
|
||||
|
||||
@Override
|
||||
public URL getAuthenticationURL() throws AuthenticationException {
|
||||
return toURL(apiRoot + "authserver/authenticate");
|
||||
}
|
||||
|
||||
@Override
|
||||
public URL getRefreshmentURL() throws AuthenticationException {
|
||||
return toURL(apiRoot + "authserver/refresh");
|
||||
}
|
||||
|
||||
@Override
|
||||
public URL getValidationURL() throws AuthenticationException {
|
||||
return toURL(apiRoot + "authserver/validate");
|
||||
}
|
||||
|
||||
@Override
|
||||
public URL getInvalidationURL() throws AuthenticationException {
|
||||
return toURL(apiRoot + "authserver/invalidate");
|
||||
}
|
||||
|
||||
@Override
|
||||
public URL getSkinUploadURL(UUID uuid) throws UnsupportedOperationException {
|
||||
return toURL(apiRoot + "api/user/profile/" + UUIDTypeAdapter.fromUUID(uuid) + "/skin");
|
||||
}
|
||||
|
||||
@Override
|
||||
public URL getProfilePropertiesURL(UUID uuid) throws AuthenticationException {
|
||||
return toURL(apiRoot + "sessionserver/session/minecraft/profile/" + UUIDTypeAdapter.fromUUID(uuid));
|
||||
}
|
||||
|
||||
@Override
|
||||
public String toString() {
|
||||
return apiRoot;
|
||||
}
|
||||
}
|
|
@ -0,0 +1,244 @@
|
|||
package com.tungsten.fclcore.auth.authlibinjector;
|
||||
|
||||
import static com.tungsten.fclcore.util.Lang.tryCast;
|
||||
import static com.tungsten.fclcore.util.Logging.LOG;
|
||||
import static com.tungsten.fclcore.util.io.IOUtils.readFullyAsByteArray;
|
||||
import static com.tungsten.fclcore.util.io.IOUtils.readFullyWithoutClosing;
|
||||
import static java.nio.charset.StandardCharsets.UTF_8;
|
||||
import static java.util.Collections.emptyMap;
|
||||
|
||||
import java.io.IOException;
|
||||
import java.lang.reflect.Type;
|
||||
import java.net.HttpURLConnection;
|
||||
import java.net.URL;
|
||||
import java.util.LinkedHashMap;
|
||||
import java.util.Map;
|
||||
import java.util.Optional;
|
||||
import java.util.logging.Level;
|
||||
|
||||
import org.jetbrains.annotations.Nullable;
|
||||
|
||||
import com.google.gson.Gson;
|
||||
import com.google.gson.GsonBuilder;
|
||||
import com.google.gson.JsonDeserializationContext;
|
||||
import com.google.gson.JsonDeserializer;
|
||||
import com.google.gson.JsonElement;
|
||||
import com.google.gson.JsonObject;
|
||||
import com.google.gson.JsonParseException;
|
||||
import com.google.gson.JsonPrimitive;
|
||||
import com.google.gson.annotations.JsonAdapter;
|
||||
import com.tungsten.fclcore.auth.yggdrasil.YggdrasilService;
|
||||
import com.tungsten.fclcore.util.fakefx.fx.InvalidationListener;
|
||||
import com.tungsten.fclcore.util.fakefx.fx.Observable;
|
||||
import com.tungsten.fclcore.util.fakefx.ObservableHelper;
|
||||
|
||||
@JsonAdapter(AuthlibInjectorServer.Deserializer.class)
|
||||
public class AuthlibInjectorServer implements Observable {
|
||||
|
||||
private static final Gson GSON = new GsonBuilder().create();
|
||||
|
||||
public static AuthlibInjectorServer locateServer(String url) throws IOException {
|
||||
try {
|
||||
url = addHttpsIfMissing(url);
|
||||
HttpURLConnection conn = (HttpURLConnection) new URL(url).openConnection();
|
||||
|
||||
String ali = conn.getHeaderField("x-authlib-injector-api-location");
|
||||
if (ali != null) {
|
||||
URL absoluteAli = new URL(conn.getURL(), ali);
|
||||
if (!urlEqualsIgnoreSlash(url, absoluteAli.toString())) {
|
||||
conn.disconnect();
|
||||
url = absoluteAli.toString();
|
||||
conn = (HttpURLConnection) absoluteAli.openConnection();
|
||||
}
|
||||
}
|
||||
|
||||
if (!url.endsWith("/"))
|
||||
url += "/";
|
||||
|
||||
try {
|
||||
AuthlibInjectorServer server = new AuthlibInjectorServer(url);
|
||||
server.refreshMetadata(readFullyWithoutClosing(conn.getInputStream()));
|
||||
return server;
|
||||
} finally {
|
||||
conn.disconnect();
|
||||
}
|
||||
} catch (IllegalArgumentException e) {
|
||||
throw new IOException(e);
|
||||
}
|
||||
}
|
||||
|
||||
private static String addHttpsIfMissing(String url) {
|
||||
String lowercased = url.toLowerCase();
|
||||
if (!lowercased.startsWith("http://") && !lowercased.startsWith("https://")) {
|
||||
url = "https://" + url;
|
||||
}
|
||||
return url;
|
||||
}
|
||||
|
||||
private static boolean urlEqualsIgnoreSlash(String a, String b) {
|
||||
if (!a.endsWith("/"))
|
||||
a += "/";
|
||||
if (!b.endsWith("/"))
|
||||
b += "/";
|
||||
return a.equals(b);
|
||||
}
|
||||
|
||||
private String url;
|
||||
@Nullable
|
||||
private String metadataResponse;
|
||||
private long metadataTimestamp;
|
||||
|
||||
@Nullable
|
||||
private transient String name;
|
||||
private transient Map<String, String> links = emptyMap();
|
||||
private transient boolean nonEmailLogin;
|
||||
|
||||
private transient boolean metadataRefreshed;
|
||||
private final transient ObservableHelper helper = new ObservableHelper(this);
|
||||
private final transient YggdrasilService yggdrasilService;
|
||||
|
||||
public AuthlibInjectorServer(String url) {
|
||||
this.url = url;
|
||||
this.yggdrasilService = new YggdrasilService(new AuthlibInjectorProvider(url));
|
||||
}
|
||||
|
||||
public String getUrl() {
|
||||
return url;
|
||||
}
|
||||
|
||||
public YggdrasilService getYggdrasilService() {
|
||||
return yggdrasilService;
|
||||
}
|
||||
|
||||
public Optional<String> getMetadataResponse() {
|
||||
return Optional.ofNullable(metadataResponse);
|
||||
}
|
||||
|
||||
public long getMetadataTimestamp() {
|
||||
return metadataTimestamp;
|
||||
}
|
||||
|
||||
public String getName() {
|
||||
return Optional.ofNullable(name)
|
||||
.orElse(url);
|
||||
}
|
||||
|
||||
public Map<String, String> getLinks() {
|
||||
return links;
|
||||
}
|
||||
|
||||
public boolean isNonEmailLogin() {
|
||||
return nonEmailLogin;
|
||||
}
|
||||
|
||||
public String fetchMetadataResponse() throws IOException {
|
||||
if (metadataResponse == null || !metadataRefreshed) {
|
||||
refreshMetadata();
|
||||
}
|
||||
return getMetadataResponse().get();
|
||||
}
|
||||
|
||||
public void refreshMetadata() throws IOException {
|
||||
refreshMetadata(readFullyAsByteArray(new URL(url).openStream()));
|
||||
}
|
||||
|
||||
private void refreshMetadata(byte[] rawResponse) throws IOException {
|
||||
long timestamp = System.currentTimeMillis();
|
||||
String text = new String(rawResponse, UTF_8);
|
||||
try {
|
||||
setMetadataResponse(text, timestamp);
|
||||
} catch (JsonParseException e) {
|
||||
throw new IOException("Malformed response\n" + text, e);
|
||||
}
|
||||
|
||||
metadataRefreshed = true;
|
||||
LOG.info("authlib-injector server metadata refreshed: " + url);
|
||||
helper.invalidate();
|
||||
}
|
||||
|
||||
private void setMetadataResponse(String metadataResponse, long metadataTimestamp) throws JsonParseException {
|
||||
JsonObject response = GSON.fromJson(metadataResponse, JsonObject.class);
|
||||
if (response == null) {
|
||||
throw new JsonParseException("Metadata response is empty");
|
||||
}
|
||||
|
||||
synchronized (this) {
|
||||
this.metadataResponse = metadataResponse;
|
||||
this.metadataTimestamp = metadataTimestamp;
|
||||
|
||||
Optional<JsonObject> metaObject = tryCast(response.get("meta"), JsonObject.class);
|
||||
|
||||
this.name = metaObject.flatMap(meta -> tryCast(meta.get("serverName"), JsonPrimitive.class).map(JsonPrimitive::getAsString))
|
||||
.orElse(null);
|
||||
this.links = metaObject.flatMap(meta -> tryCast(meta.get("links"), JsonObject.class))
|
||||
.map(linksObject -> {
|
||||
Map<String, String> converted = new LinkedHashMap<>();
|
||||
linksObject.entrySet().forEach(
|
||||
entry -> tryCast(entry.getValue(), JsonPrimitive.class).ifPresent(element -> {
|
||||
converted.put(entry.getKey(), element.getAsString());
|
||||
}));
|
||||
return converted;
|
||||
})
|
||||
.orElse(emptyMap());
|
||||
this.nonEmailLogin = metaObject.flatMap(meta -> tryCast(meta.get("feature.non_email_login"), JsonPrimitive.class))
|
||||
.map(it -> it.getAsBoolean())
|
||||
.orElse(false);
|
||||
}
|
||||
}
|
||||
|
||||
public void invalidateMetadataCache() {
|
||||
metadataRefreshed = false;
|
||||
}
|
||||
|
||||
@Override
|
||||
public int hashCode() {
|
||||
return url.hashCode();
|
||||
}
|
||||
|
||||
@Override
|
||||
public boolean equals(Object obj) {
|
||||
if (obj == this)
|
||||
return true;
|
||||
if (!(obj instanceof AuthlibInjectorServer))
|
||||
return false;
|
||||
AuthlibInjectorServer another = (AuthlibInjectorServer) obj;
|
||||
return this.url.equals(another.url);
|
||||
}
|
||||
|
||||
@Override
|
||||
public String toString() {
|
||||
return name == null ? url : url + " (" + name + ")";
|
||||
}
|
||||
|
||||
@Override
|
||||
public void addListener(InvalidationListener listener) {
|
||||
helper.addListener(listener);
|
||||
}
|
||||
|
||||
@Override
|
||||
public void removeListener(InvalidationListener listener) {
|
||||
helper.removeListener(listener);
|
||||
}
|
||||
|
||||
public static class Deserializer implements JsonDeserializer<AuthlibInjectorServer> {
|
||||
@Override
|
||||
public AuthlibInjectorServer deserialize(JsonElement json, Type type, JsonDeserializationContext ctx) throws JsonParseException {
|
||||
JsonObject jsonObj = json.getAsJsonObject();
|
||||
AuthlibInjectorServer instance = new AuthlibInjectorServer(jsonObj.get("url").getAsString());
|
||||
|
||||
if (jsonObj.has("name")) {
|
||||
instance.name = jsonObj.get("name").getAsString();
|
||||
}
|
||||
|
||||
if (jsonObj.has("metadataResponse")) {
|
||||
try {
|
||||
instance.setMetadataResponse(jsonObj.get("metadataResponse").getAsString(), jsonObj.get("metadataTimestamp").getAsLong());
|
||||
} catch (JsonParseException e) {
|
||||
LOG.log(Level.WARNING, "Ignoring malformed metadata response cache: " + jsonObj.get("metadataResponse"), e);
|
||||
}
|
||||
}
|
||||
return instance;
|
||||
}
|
||||
|
||||
}
|
||||
}
|
|
@ -0,0 +1,44 @@
|
|||
package com.tungsten.fclcore.auth.authlibinjector;
|
||||
|
||||
import com.tungsten.fclcore.auth.AccountFactory;
|
||||
import com.tungsten.fclcore.auth.AuthenticationException;
|
||||
import com.tungsten.fclcore.auth.CharacterSelector;
|
||||
|
||||
import java.util.Map;
|
||||
import java.util.Objects;
|
||||
|
||||
public class BoundAuthlibInjectorAccountFactory extends AccountFactory<AuthlibInjectorAccount> {
|
||||
private final AuthlibInjectorArtifactProvider downloader;
|
||||
private final AuthlibInjectorServer server;
|
||||
|
||||
/**
|
||||
* @param server Authlib-Injector Server
|
||||
*/
|
||||
public BoundAuthlibInjectorAccountFactory(AuthlibInjectorArtifactProvider downloader, AuthlibInjectorServer server) {
|
||||
this.downloader = downloader;
|
||||
this.server = server;
|
||||
}
|
||||
|
||||
@Override
|
||||
public AccountLoginType getLoginType() {
|
||||
return AccountLoginType.USERNAME_PASSWORD;
|
||||
}
|
||||
|
||||
public AuthlibInjectorServer getServer() {
|
||||
return server;
|
||||
}
|
||||
|
||||
@Override
|
||||
public AuthlibInjectorAccount create(CharacterSelector selector, String username, String password, ProgressCallback progressCallback, Object additionalData) throws AuthenticationException {
|
||||
Objects.requireNonNull(selector);
|
||||
Objects.requireNonNull(username);
|
||||
Objects.requireNonNull(password);
|
||||
|
||||
return new AuthlibInjectorAccount(server, downloader, username, password, selector);
|
||||
}
|
||||
|
||||
@Override
|
||||
public AuthlibInjectorAccount fromStorage(Map<Object, Object> storage) {
|
||||
return AuthlibInjectorAccountFactory.fromStorage(storage, downloader, server);
|
||||
}
|
||||
}
|
|
@ -0,0 +1,32 @@
|
|||
package com.tungsten.fclcore.auth.authlibinjector;
|
||||
|
||||
import static com.tungsten.fclcore.util.Logging.LOG;
|
||||
|
||||
import java.io.IOException;
|
||||
import java.nio.file.Path;
|
||||
import java.util.Optional;
|
||||
import java.util.logging.Level;
|
||||
|
||||
public class SimpleAuthlibInjectorArtifactProvider implements AuthlibInjectorArtifactProvider {
|
||||
|
||||
private Path location;
|
||||
|
||||
public SimpleAuthlibInjectorArtifactProvider(Path location) {
|
||||
this.location = location;
|
||||
}
|
||||
|
||||
@Override
|
||||
public AuthlibInjectorArtifactInfo getArtifactInfo() throws IOException {
|
||||
return AuthlibInjectorArtifactInfo.from(location);
|
||||
}
|
||||
|
||||
@Override
|
||||
public Optional<AuthlibInjectorArtifactInfo> getArtifactInfoImmediately() {
|
||||
try {
|
||||
return Optional.of(getArtifactInfo());
|
||||
} catch (IOException e) {
|
||||
LOG.log(Level.WARNING, "Bad authlib-injector artifact", e);
|
||||
return Optional.empty();
|
||||
}
|
||||
}
|
||||
}
|
|
@ -0,0 +1,156 @@
|
|||
package com.tungsten.fclcore.auth.microsoft;
|
||||
|
||||
import static com.tungsten.fclcore.util.Logging.LOG;
|
||||
|
||||
import java.util.Map;
|
||||
import java.util.Objects;
|
||||
import java.util.Optional;
|
||||
import java.util.UUID;
|
||||
import java.util.logging.Level;
|
||||
|
||||
import static java.util.Objects.requireNonNull;
|
||||
|
||||
import com.tungsten.fclcore.auth.AuthInfo;
|
||||
import com.tungsten.fclcore.auth.AuthenticationException;
|
||||
import com.tungsten.fclcore.auth.CharacterSelector;
|
||||
import com.tungsten.fclcore.auth.OAuthAccount;
|
||||
import com.tungsten.fclcore.auth.ServerResponseMalformedException;
|
||||
import com.tungsten.fclcore.auth.yggdrasil.Texture;
|
||||
import com.tungsten.fclcore.auth.yggdrasil.TextureType;
|
||||
import com.tungsten.fclcore.auth.yggdrasil.YggdrasilService;
|
||||
import com.tungsten.fclcore.util.fakefx.BindingMapping;
|
||||
import com.tungsten.fclcore.util.fakefx.fx.ObjectBinding;
|
||||
|
||||
public class MicrosoftAccount extends OAuthAccount {
|
||||
|
||||
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.getNotAfter(), session.getTokenType(), session.getAccessToken())) {
|
||||
authenticated = true;
|
||||
} else {
|
||||
MicrosoftSession acquiredSession = service.refresh(session);
|
||||
if (!Objects.equals(acquiredSession.getProfile().getId(), session.getProfile().getId())) {
|
||||
throw new ServerResponseMalformedException("Selected profile changed");
|
||||
}
|
||||
|
||||
session = acquiredSession;
|
||||
|
||||
authenticated = true;
|
||||
invalidate();
|
||||
}
|
||||
}
|
||||
|
||||
return session.toAuthInfo();
|
||||
}
|
||||
|
||||
@Override
|
||||
public AuthInfo logInWhenCredentialsExpired() throws AuthenticationException {
|
||||
MicrosoftSession acquiredSession = service.authenticate();
|
||||
if (!Objects.equals(characterUUID, acquiredSession.getProfile().getId())) {
|
||||
throw new WrongAccountException(characterUUID, acquiredSession.getProfile().getId());
|
||||
}
|
||||
|
||||
if (acquiredSession.getProfile() == null) {
|
||||
session = service.refresh(acquiredSession);
|
||||
} else {
|
||||
session = acquiredSession;
|
||||
}
|
||||
|
||||
authenticated = true;
|
||||
invalidate();
|
||||
return session.toAuthInfo();
|
||||
}
|
||||
|
||||
@Override
|
||||
public AuthInfo playOffline() {
|
||||
return session.toAuthInfo();
|
||||
}
|
||||
|
||||
@Override
|
||||
public Map<Object, Object> toStorage() {
|
||||
return session.toStorage();
|
||||
}
|
||||
|
||||
public MicrosoftService getService() {
|
||||
return service;
|
||||
}
|
||||
|
||||
@Override
|
||||
public ObjectBinding<Optional<Map<TextureType, Texture>>> getTextures() {
|
||||
return BindingMapping.of(service.getProfileRepository().binding(getUUID()))
|
||||
.map(profile -> profile.flatMap(it -> {
|
||||
try {
|
||||
return YggdrasilService.getTextures(it);
|
||||
} catch (ServerResponseMalformedException e) {
|
||||
LOG.log(Level.WARNING, "Failed to parse texture payload", e);
|
||||
return Optional.empty();
|
||||
}
|
||||
}));
|
||||
}
|
||||
|
||||
@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 com.tungsten.fclcore.auth.microsoft;
|
||||
|
||||
import com.tungsten.fclcore.auth.AccountFactory;
|
||||
import com.tungsten.fclcore.auth.AuthenticationException;
|
||||
import com.tungsten.fclcore.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, ProgressCallback progressCallback, 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,407 @@
|
|||
package com.tungsten.fclcore.auth.microsoft;
|
||||
|
||||
import static com.tungsten.fclcore.util.Lang.mapOf;
|
||||
import static com.tungsten.fclcore.util.Lang.threadPool;
|
||||
import static com.tungsten.fclcore.util.Logging.LOG;
|
||||
import static com.tungsten.fclcore.util.Pair.pair;
|
||||
|
||||
import com.google.gson.Gson;
|
||||
import com.google.gson.GsonBuilder;
|
||||
import com.google.gson.JsonParseException;
|
||||
import com.google.gson.annotations.SerializedName;
|
||||
import com.tungsten.fclcore.auth.AuthenticationException;
|
||||
import com.tungsten.fclcore.auth.OAuth;
|
||||
import com.tungsten.fclcore.auth.ServerDisconnectException;
|
||||
import com.tungsten.fclcore.auth.ServerResponseMalformedException;
|
||||
import com.tungsten.fclcore.auth.yggdrasil.CompleteGameProfile;
|
||||
import com.tungsten.fclcore.auth.yggdrasil.RemoteAuthenticationException;
|
||||
import com.tungsten.fclcore.auth.yggdrasil.Texture;
|
||||
import com.tungsten.fclcore.auth.yggdrasil.TextureType;
|
||||
import com.tungsten.fclcore.util.fakefx.ObservableOptionalCache;
|
||||
import com.tungsten.fclcore.util.gson.JsonUtils;
|
||||
import com.tungsten.fclcore.util.gson.TolerableValidationException;
|
||||
import com.tungsten.fclcore.util.gson.UUIDTypeAdapter;
|
||||
import com.tungsten.fclcore.util.gson.Validation;
|
||||
import com.tungsten.fclcore.util.gson.ValidationTypeAdapterFactory;
|
||||
import com.tungsten.fclcore.util.io.HttpRequest;
|
||||
import com.tungsten.fclcore.util.io.NetworkUtils;
|
||||
import com.tungsten.fclcore.util.io.ResponseCodeException;
|
||||
|
||||
import java.io.IOException;
|
||||
import java.net.HttpURLConnection;
|
||||
import java.net.URL;
|
||||
import java.util.*;
|
||||
import java.util.concurrent.ThreadPoolExecutor;
|
||||
import java.util.concurrent.TimeUnit;
|
||||
import java.util.logging.Level;
|
||||
|
||||
import static java.net.HttpURLConnection.HTTP_NOT_FOUND;
|
||||
import static java.util.Objects.requireNonNull;
|
||||
|
||||
public class MicrosoftService {
|
||||
private static final String SCOPE = "XboxLive.signin offline_access";
|
||||
private static final ThreadPoolExecutor POOL = threadPool("MicrosoftProfileProperties", true, 2, 10,
|
||||
TimeUnit.SECONDS);
|
||||
|
||||
private final OAuth.Callback callback;
|
||||
|
||||
private final ObservableOptionalCache<UUID, CompleteGameProfile, AuthenticationException> profileRepository;
|
||||
|
||||
public MicrosoftService(OAuth.Callback callback) {
|
||||
this.callback = requireNonNull(callback);
|
||||
this.profileRepository = new ObservableOptionalCache<>(uuid -> {
|
||||
LOG.info("Fetching properties of " + uuid);
|
||||
return getCompleteGameProfile(uuid);
|
||||
}, (uuid, e) -> LOG.log(Level.WARNING, "Failed to fetch properties of " + uuid, e), POOL);
|
||||
}
|
||||
|
||||
public ObservableOptionalCache<UUID, CompleteGameProfile, AuthenticationException> getProfileRepository() {
|
||||
return profileRepository;
|
||||
}
|
||||
|
||||
public MicrosoftSession authenticate() throws AuthenticationException {
|
||||
try {
|
||||
OAuth.Result result = OAuth.MICROSOFT.authenticate(OAuth.GrantFlow.DEVICE, new OAuth.Options(SCOPE, callback));
|
||||
return authenticateViaLiveAccessToken(result.getAccessToken(), result.getRefreshToken());
|
||||
} catch (IOException e) {
|
||||
throw new ServerDisconnectException(e);
|
||||
} catch (JsonParseException e) {
|
||||
throw new ServerResponseMalformedException(e);
|
||||
}
|
||||
}
|
||||
|
||||
public MicrosoftSession refresh(MicrosoftSession oldSession) throws AuthenticationException {
|
||||
try {
|
||||
OAuth.Result result = OAuth.MICROSOFT.refresh(oldSession.getRefreshToken(), new OAuth.Options(SCOPE, callback));
|
||||
return authenticateViaLiveAccessToken(result.getAccessToken(), result.getRefreshToken());
|
||||
} catch (IOException e) {
|
||||
throw new ServerDisconnectException(e);
|
||||
} catch (JsonParseException e) {
|
||||
throw new ServerResponseMalformedException(e);
|
||||
}
|
||||
}
|
||||
|
||||
private String getUhs(XBoxLiveAuthenticationResponse response, String existingUhs) throws AuthenticationException {
|
||||
if (response.errorCode != 0) {
|
||||
throw new XboxAuthorizationException(response.errorCode, response.redirectUrl);
|
||||
}
|
||||
|
||||
if (response.displayClaims == null || response.displayClaims.xui == null || response.displayClaims.xui.size() == 0 || !response.displayClaims.xui.get(0).containsKey("uhs")) {
|
||||
LOG.log(Level.WARNING, "Unrecognized xbox authorization response " + GSON.toJson(response));
|
||||
throw new NoXuiException();
|
||||
}
|
||||
|
||||
String uhs = (String) response.displayClaims.xui.get(0).get("uhs");
|
||||
if (existingUhs != null) {
|
||||
if (!Objects.equals(uhs, existingUhs)) {
|
||||
throw new ServerResponseMalformedException("uhs mismatched");
|
||||
}
|
||||
}
|
||||
return uhs;
|
||||
}
|
||||
|
||||
private MicrosoftSession authenticateViaLiveAccessToken(String liveAccessToken, String liveRefreshToken) throws IOException, JsonParseException, AuthenticationException {
|
||||
// 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", "d=" + liveAccessToken))),
|
||||
pair("RelyingParty", "http://auth.xboxlive.com"), pair("TokenType", "JWT")))
|
||||
.retry(5)
|
||||
.accept("application/json").getJson(XBoxLiveAuthenticationResponse.class);
|
||||
|
||||
String uhs = getUhs(xboxResponse, null);
|
||||
|
||||
// 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")))
|
||||
.ignoreHttpErrorCode(401)
|
||||
.retry(5)
|
||||
.getJson(XBoxLiveAuthenticationResponse.class);
|
||||
|
||||
getUhs(minecraftXstsResponse, uhs);
|
||||
|
||||
// Authenticate with Minecraft
|
||||
MinecraftLoginWithXBoxResponse minecraftResponse = HttpRequest
|
||||
.POST("https://api.minecraftservices.com/authentication/login_with_xbox")
|
||||
.json(mapOf(pair("identityToken", "XBL3.0 x=" + uhs + ";" + minecraftXstsResponse.token)))
|
||||
.retry(5)
|
||||
.accept("application/json").getJson(MinecraftLoginWithXBoxResponse.class);
|
||||
|
||||
long notAfter = minecraftResponse.expiresIn * 1000L + System.currentTimeMillis();
|
||||
|
||||
// Get Minecraft Account UUID
|
||||
MinecraftProfileResponse profileResponse = getMinecraftProfile(minecraftResponse.tokenType, minecraftResponse.accessToken);
|
||||
handleErrorResponse(profileResponse);
|
||||
|
||||
return new MicrosoftSession(minecraftResponse.tokenType, minecraftResponse.accessToken, notAfter, liveRefreshToken,
|
||||
new MicrosoftSession.User(minecraftResponse.username), new MicrosoftSession.GameProfile(profileResponse.id, profileResponse.name));
|
||||
}
|
||||
|
||||
public Optional<MinecraftProfileResponse> getCompleteProfile(String authorization) throws AuthenticationException {
|
||||
try {
|
||||
return Optional.ofNullable(
|
||||
HttpRequest.GET("https://api.minecraftservices.com/minecraft/profile")
|
||||
.authorization(authorization).getJson(MinecraftProfileResponse.class));
|
||||
} catch (IOException e) {
|
||||
throw new ServerDisconnectException(e);
|
||||
} catch (JsonParseException e) {
|
||||
throw new ServerResponseMalformedException(e);
|
||||
}
|
||||
}
|
||||
|
||||
public boolean validate(long notAfter, String tokenType, String accessToken) throws AuthenticationException {
|
||||
requireNonNull(tokenType);
|
||||
requireNonNull(accessToken);
|
||||
|
||||
if (System.currentTimeMillis() > notAfter) {
|
||||
return false;
|
||||
}
|
||||
|
||||
try {
|
||||
getMinecraftProfile(tokenType, accessToken);
|
||||
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);
|
||||
}
|
||||
}
|
||||
|
||||
public static Optional<Map<TextureType, Texture>> getTextures(MinecraftProfileResponse profile) {
|
||||
Objects.requireNonNull(profile);
|
||||
|
||||
Map<TextureType, Texture> textures = new EnumMap<>(TextureType.class);
|
||||
|
||||
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);
|
||||
// }
|
||||
|
||||
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"))
|
||||
.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")
|
||||
.authorization(tokenType, accessToken)
|
||||
.createConnection();
|
||||
int responseCode = conn.getResponseCode();
|
||||
if (responseCode == HTTP_NOT_FOUND) {
|
||||
throw new NoMinecraftJavaEditionProfileException();
|
||||
} else if (responseCode != 200) {
|
||||
throw new ResponseCodeException(new URL("https://api.minecraftservices.com/minecraft/profile"), responseCode);
|
||||
}
|
||||
|
||||
String result = NetworkUtils.readData(conn);
|
||||
return JsonUtils.fromNonNullJson(result, MinecraftProfileResponse.class);
|
||||
}
|
||||
|
||||
public Optional<CompleteGameProfile> getCompleteGameProfile(UUID uuid) throws AuthenticationException {
|
||||
Objects.requireNonNull(uuid);
|
||||
|
||||
return Optional.ofNullable(GSON.fromJson(request(NetworkUtils.toURL("https://sessionserver.mojang.com/session/minecraft/profile/" + UUIDTypeAdapter.fromUUID(uuid)), null), CompleteGameProfile.class));
|
||||
}
|
||||
|
||||
private static String request(URL url, Object payload) throws AuthenticationException {
|
||||
try {
|
||||
if (payload == null)
|
||||
return NetworkUtils.doGet(url);
|
||||
else
|
||||
return NetworkUtils.doPost(url, payload instanceof String ? (String) payload : GSON.toJson(payload), "application/json");
|
||||
} catch (IOException e) {
|
||||
throw new ServerDisconnectException(e);
|
||||
}
|
||||
}
|
||||
|
||||
private static <T> T fromJson(String text, Class<T> typeOfT) throws ServerResponseMalformedException {
|
||||
try {
|
||||
return GSON.fromJson(text, typeOfT);
|
||||
} catch (JsonParseException e) {
|
||||
throw new ServerResponseMalformedException(text, e);
|
||||
}
|
||||
}
|
||||
|
||||
public static class XboxAuthorizationException extends AuthenticationException {
|
||||
private final long errorCode;
|
||||
private final String redirect;
|
||||
|
||||
public XboxAuthorizationException(long errorCode, String redirect) {
|
||||
this.errorCode = errorCode;
|
||||
this.redirect = redirect;
|
||||
}
|
||||
|
||||
public long getErrorCode() {
|
||||
return errorCode;
|
||||
}
|
||||
|
||||
public String getRedirect() {
|
||||
return redirect;
|
||||
}
|
||||
|
||||
public static final long MISSING_XBOX_ACCOUNT = 2148916233L;
|
||||
public static final long COUNTRY_UNAVAILABLE = 2148916235L;
|
||||
public static final long ADD_FAMILY = 2148916238L;
|
||||
}
|
||||
|
||||
public static class NoMinecraftJavaEditionProfileException extends AuthenticationException {
|
||||
}
|
||||
|
||||
public static class NoXuiException extends AuthenticationException {
|
||||
}
|
||||
|
||||
private static class XBoxLiveAuthenticationResponseDisplayClaims {
|
||||
List<Map<Object, Object>> xui;
|
||||
}
|
||||
|
||||
private static class MicrosoftErrorResponse {
|
||||
@SerializedName("XErr")
|
||||
long errorCode;
|
||||
|
||||
@SerializedName("Message")
|
||||
String message;
|
||||
|
||||
@SerializedName("Redirect")
|
||||
String redirectUrl;
|
||||
}
|
||||
|
||||
/**
|
||||
* Success Response: { "IssueInstant":"2020-12-07T19:52:08.4463796Z",
|
||||
* "NotAfter":"2020-12-21T19:52:08.4463796Z", "Token":"token", "DisplayClaims":{
|
||||
* "xui":[ { "uhs":"userhash" } ] } }
|
||||
* <p>
|
||||
* Error response: { "Identity":"0", "XErr":2148916238, "Message":"",
|
||||
* "Redirect":"https://start.ui.xboxlive.com/AddChildToFamily" }
|
||||
* <p>
|
||||
* XErr Candidates: 2148916233 = missing XBox account 2148916238 = child account
|
||||
* not linked to a family
|
||||
*/
|
||||
private static class XBoxLiveAuthenticationResponse extends MicrosoftErrorResponse {
|
||||
@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;
|
||||
}
|
||||
|
||||
public static class MinecraftProfileResponseSkin implements Validation {
|
||||
public String id;
|
||||
public String state;
|
||||
public String url;
|
||||
public String variant; // CLASSIC, SLIM
|
||||
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");
|
||||
}
|
||||
}
|
||||
|
||||
public static class MinecraftProfileResponseCape {
|
||||
|
||||
}
|
||||
|
||||
public 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;
|
||||
}
|
||||
|
||||
private static final Gson GSON = new GsonBuilder()
|
||||
.registerTypeAdapter(UUID.class, UUIDTypeAdapter.INSTANCE)
|
||||
.registerTypeAdapterFactory(ValidationTypeAdapterFactory.INSTANCE)
|
||||
.create();
|
||||
|
||||
}
|
|
@ -0,0 +1,129 @@
|
|||
package com.tungsten.fclcore.auth.microsoft;
|
||||
|
||||
import static com.tungsten.fclcore.util.Lang.mapOf;
|
||||
import static com.tungsten.fclcore.util.Lang.tryCast;
|
||||
import static com.tungsten.fclcore.util.Pair.pair;
|
||||
|
||||
import java.util.Map;
|
||||
import java.util.UUID;
|
||||
|
||||
import static java.util.Objects.requireNonNull;
|
||||
|
||||
import com.tungsten.fclcore.auth.AuthInfo;
|
||||
import com.tungsten.fclcore.util.Logging;
|
||||
import com.tungsten.fclcore.util.gson.UUIDTypeAdapter;
|
||||
|
||||
public class MicrosoftSession {
|
||||
private final String tokenType;
|
||||
private final long notAfter;
|
||||
private final String accessToken;
|
||||
private final String refreshToken;
|
||||
private final User user;
|
||||
private final GameProfile profile;
|
||||
|
||||
public MicrosoftSession(String tokenType, String accessToken, long notAfter, String refreshToken, User user, GameProfile profile) {
|
||||
this.tokenType = tokenType;
|
||||
this.accessToken = accessToken;
|
||||
this.notAfter = notAfter;
|
||||
this.refreshToken = refreshToken;
|
||||
this.user = user;
|
||||
this.profile = profile;
|
||||
|
||||
if (accessToken != null) Logging.registerAccessToken(accessToken);
|
||||
}
|
||||
|
||||
public String getTokenType() {
|
||||
return tokenType;
|
||||
}
|
||||
|
||||
public String getAccessToken() {
|
||||
return accessToken;
|
||||
}
|
||||
|
||||
public long getNotAfter() {
|
||||
return notAfter;
|
||||
}
|
||||
|
||||
public String getRefreshToken() {
|
||||
return refreshToken;
|
||||
}
|
||||
|
||||
public String getAuthorization() {
|
||||
return String.format("%s %s", getTokenType(), getAccessToken());
|
||||
}
|
||||
|
||||
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 refreshToken = tryCast(storage.get("refreshToken"), String.class)
|
||||
.orElseThrow(() -> new IllegalArgumentException("refreshToken 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, refreshToken, new User(userId), new GameProfile(uuid, name));
|
||||
}
|
||||
|
||||
public Map<Object, Object> toStorage() {
|
||||
requireNonNull(profile);
|
||||
requireNonNull(user);
|
||||
|
||||
return mapOf(
|
||||
pair("uuid", UUIDTypeAdapter.fromUUID(profile.getId())),
|
||||
pair("displayName", profile.getName()),
|
||||
pair("tokenType", tokenType),
|
||||
pair("accessToken", accessToken),
|
||||
pair("refreshToken", refreshToken),
|
||||
pair("notAfter", notAfter),
|
||||
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;
|
||||
}
|
||||
}
|
||||
}
|
|
@ -0,0 +1,201 @@
|
|||
package com.tungsten.fclcore.auth.offline;
|
||||
|
||||
import static com.tungsten.fclcore.util.Lang.mapOf;
|
||||
import static com.tungsten.fclcore.util.Pair.pair;
|
||||
|
||||
import java.io.IOException;
|
||||
import java.util.Map;
|
||||
import java.util.Optional;
|
||||
import java.util.UUID;
|
||||
import java.util.concurrent.CompletableFuture;
|
||||
import java.util.concurrent.CompletionException;
|
||||
import java.util.concurrent.ExecutionException;
|
||||
|
||||
import static java.util.Objects.requireNonNull;
|
||||
|
||||
import com.tungsten.fclcore.auth.Account;
|
||||
import com.tungsten.fclcore.auth.AuthInfo;
|
||||
import com.tungsten.fclcore.auth.AuthenticationException;
|
||||
import com.tungsten.fclcore.auth.authlibinjector.AuthlibInjectorArtifactInfo;
|
||||
import com.tungsten.fclcore.auth.authlibinjector.AuthlibInjectorArtifactProvider;
|
||||
import com.tungsten.fclcore.auth.authlibinjector.AuthlibInjectorDownloadException;
|
||||
import com.tungsten.fclcore.auth.yggdrasil.Texture;
|
||||
import com.tungsten.fclcore.auth.yggdrasil.TextureModel;
|
||||
import com.tungsten.fclcore.auth.yggdrasil.TextureType;
|
||||
import com.tungsten.fclcore.game.Arguments;
|
||||
import com.tungsten.fclcore.game.LaunchOptions;
|
||||
import com.tungsten.fclcore.util.StringUtils;
|
||||
import com.tungsten.fclcore.util.ToStringBuilder;
|
||||
import com.tungsten.fclcore.util.fakefx.fx.ObjectBinding;
|
||||
import com.tungsten.fclcore.util.gson.UUIDTypeAdapter;
|
||||
|
||||
public class OfflineAccount extends Account {
|
||||
|
||||
private final AuthlibInjectorArtifactProvider downloader;
|
||||
private final String username;
|
||||
private final UUID uuid;
|
||||
private Skin skin;
|
||||
|
||||
protected OfflineAccount(AuthlibInjectorArtifactProvider downloader, String username, UUID uuid, Skin skin) {
|
||||
this.downloader = requireNonNull(downloader);
|
||||
this.username = requireNonNull(username);
|
||||
this.uuid = requireNonNull(uuid);
|
||||
this.skin = skin;
|
||||
|
||||
if (StringUtils.isBlank(username)) {
|
||||
throw new IllegalArgumentException("Username cannot be blank");
|
||||
}
|
||||
}
|
||||
|
||||
@Override
|
||||
public UUID getUUID() {
|
||||
return uuid;
|
||||
}
|
||||
|
||||
@Override
|
||||
public String getUsername() {
|
||||
return username;
|
||||
}
|
||||
|
||||
@Override
|
||||
public String getCharacter() {
|
||||
return username;
|
||||
}
|
||||
|
||||
public Skin getSkin() {
|
||||
return skin;
|
||||
}
|
||||
|
||||
public void setSkin(Skin skin) {
|
||||
this.skin = skin;
|
||||
invalidate();
|
||||
}
|
||||
|
||||
private boolean loadAuthlibInjector(Skin skin) {
|
||||
if (skin == null) return false;
|
||||
if (skin.getType() == Skin.Type.DEFAULT) return false;
|
||||
TextureModel defaultModel = TextureModel.detectUUID(getUUID());
|
||||
if (skin.getType() == Skin.Type.ALEX && defaultModel == TextureModel.ALEX ||
|
||||
skin.getType() == Skin.Type.STEVE && defaultModel == TextureModel.STEVE) {
|
||||
return false;
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
@Override
|
||||
public AuthInfo logIn() throws AuthenticationException {
|
||||
AuthInfo authInfo = new AuthInfo(username, uuid, UUIDTypeAdapter.fromUUID(UUID.randomUUID()), "{}");
|
||||
|
||||
if (loadAuthlibInjector(skin)) {
|
||||
CompletableFuture<AuthlibInjectorArtifactInfo> artifactTask = CompletableFuture.supplyAsync(() -> {
|
||||
try {
|
||||
return downloader.getArtifactInfo();
|
||||
} catch (IOException e) {
|
||||
throw new CompletionException(new AuthlibInjectorDownloadException(e));
|
||||
}
|
||||
});
|
||||
|
||||
AuthlibInjectorArtifactInfo artifact;
|
||||
try {
|
||||
artifact = artifactTask.get();
|
||||
} catch (InterruptedException e) {
|
||||
Thread.currentThread().interrupt();
|
||||
throw new AuthenticationException(e);
|
||||
} catch (ExecutionException e) {
|
||||
if (e.getCause() instanceof AuthenticationException) {
|
||||
throw (AuthenticationException) e.getCause();
|
||||
} else {
|
||||
throw new AuthenticationException(e.getCause());
|
||||
}
|
||||
}
|
||||
|
||||
try {
|
||||
return new OfflineAuthInfo(authInfo, artifact);
|
||||
} catch (Exception e) {
|
||||
throw new AuthenticationException(e);
|
||||
}
|
||||
} else {
|
||||
return authInfo;
|
||||
}
|
||||
}
|
||||
|
||||
private class OfflineAuthInfo extends AuthInfo {
|
||||
private final AuthlibInjectorArtifactInfo artifact;
|
||||
private YggdrasilServer server;
|
||||
|
||||
public OfflineAuthInfo(AuthInfo authInfo, AuthlibInjectorArtifactInfo artifact) {
|
||||
super(authInfo.getUsername(), authInfo.getUUID(), authInfo.getAccessToken(), authInfo.getUserProperties());
|
||||
|
||||
this.artifact = artifact;
|
||||
}
|
||||
|
||||
@Override
|
||||
public Arguments getLaunchArguments(LaunchOptions options) throws IOException {
|
||||
if (!options.isDaemon()) return null;
|
||||
|
||||
server = new YggdrasilServer(0);
|
||||
server.start();
|
||||
|
||||
try {
|
||||
server.addCharacter(new YggdrasilServer.Character(uuid, username, skin.load(username).run()));
|
||||
} catch (IOException e) {
|
||||
// ignore
|
||||
} catch (Exception e) {
|
||||
throw new IOException(e);
|
||||
}
|
||||
|
||||
return new Arguments().addJVMArguments(
|
||||
"-javaagent:" + artifact.getLocation().toString() + "=" + "http://localhost:" + server.getListeningPort(),
|
||||
"-Dauthlibinjector.side=client"
|
||||
);
|
||||
}
|
||||
|
||||
@Override
|
||||
public void close() throws Exception {
|
||||
super.close();
|
||||
|
||||
if (server != null)
|
||||
server.stop();
|
||||
}
|
||||
}
|
||||
|
||||
@Override
|
||||
public AuthInfo playOffline() throws AuthenticationException {
|
||||
return logIn();
|
||||
}
|
||||
|
||||
@Override
|
||||
public Map<Object, Object> toStorage() {
|
||||
return mapOf(
|
||||
pair("uuid", UUIDTypeAdapter.fromUUID(uuid)),
|
||||
pair("username", username),
|
||||
pair("skin", skin == null ? null : skin.toStorage())
|
||||
);
|
||||
}
|
||||
|
||||
@Override
|
||||
public ObjectBinding<Optional<Map<TextureType, Texture>>> getTextures() {
|
||||
return super.getTextures();
|
||||
}
|
||||
|
||||
@Override
|
||||
public String toString() {
|
||||
return new ToStringBuilder(this)
|
||||
.append("username", username)
|
||||
.append("uuid", uuid)
|
||||
.toString();
|
||||
}
|
||||
|
||||
@Override
|
||||
public int hashCode() {
|
||||
return username.hashCode();
|
||||
}
|
||||
|
||||
@Override
|
||||
public boolean equals(Object obj) {
|
||||
if (!(obj instanceof OfflineAccount))
|
||||
return false;
|
||||
OfflineAccount another = (OfflineAccount) obj;
|
||||
return username.equals(another.username);
|
||||
}
|
||||
}
|
|
@ -0,0 +1,73 @@
|
|||
package com.tungsten.fclcore.auth.offline;
|
||||
|
||||
import static com.tungsten.fclcore.util.Lang.tryCast;
|
||||
|
||||
import java.util.Map;
|
||||
import java.util.UUID;
|
||||
|
||||
import static java.nio.charset.StandardCharsets.UTF_8;
|
||||
|
||||
import com.tungsten.fclcore.auth.AccountFactory;
|
||||
import com.tungsten.fclcore.auth.CharacterSelector;
|
||||
import com.tungsten.fclcore.auth.authlibinjector.AuthlibInjectorArtifactProvider;
|
||||
import com.tungsten.fclcore.util.gson.UUIDTypeAdapter;
|
||||
|
||||
public final class OfflineAccountFactory extends AccountFactory<OfflineAccount> {
|
||||
private final AuthlibInjectorArtifactProvider downloader;
|
||||
|
||||
public OfflineAccountFactory(AuthlibInjectorArtifactProvider downloader) {
|
||||
this.downloader = downloader;
|
||||
}
|
||||
|
||||
@Override
|
||||
public AccountLoginType getLoginType() {
|
||||
return AccountLoginType.USERNAME;
|
||||
}
|
||||
|
||||
public OfflineAccount create(String username, UUID uuid) {
|
||||
return new OfflineAccount(downloader, username, uuid, null);
|
||||
}
|
||||
|
||||
@Override
|
||||
public OfflineAccount create(CharacterSelector selector, String username, String password, ProgressCallback progressCallback, Object additionalData) {
|
||||
AdditionalData data;
|
||||
UUID uuid;
|
||||
Skin skin;
|
||||
if (additionalData != null) {
|
||||
data = (AdditionalData) additionalData;
|
||||
uuid = data.uuid == null ? getUUIDFromUserName(username) : data.uuid;
|
||||
skin = data.skin;
|
||||
} else {
|
||||
uuid = getUUIDFromUserName(username);
|
||||
skin = null;
|
||||
}
|
||||
return new OfflineAccount(downloader, username, uuid, skin);
|
||||
}
|
||||
|
||||
@Override
|
||||
public OfflineAccount fromStorage(Map<Object, Object> storage) {
|
||||
String username = tryCast(storage.get("username"), String.class)
|
||||
.orElseThrow(() -> new IllegalStateException("Offline account configuration malformed."));
|
||||
UUID uuid = tryCast(storage.get("uuid"), String.class)
|
||||
.map(UUIDTypeAdapter::fromString)
|
||||
.orElse(getUUIDFromUserName(username));
|
||||
Skin skin = Skin.fromStorage(tryCast(storage.get("skin"), Map.class).orElse(null));
|
||||
|
||||
return new OfflineAccount(downloader, username, uuid, skin);
|
||||
}
|
||||
|
||||
public static UUID getUUIDFromUserName(String username) {
|
||||
return UUID.nameUUIDFromBytes(("OfflinePlayer:" + username).getBytes(UTF_8));
|
||||
}
|
||||
|
||||
public static class AdditionalData {
|
||||
private final UUID uuid;
|
||||
private final Skin skin;
|
||||
|
||||
public AdditionalData(UUID uuid, Skin skin) {
|
||||
this.uuid = uuid;
|
||||
this.skin = skin;
|
||||
}
|
||||
}
|
||||
|
||||
}
|
|
@ -0,0 +1,330 @@
|
|||
package com.tungsten.fclcore.auth.offline;
|
||||
|
||||
import static com.tungsten.fclcore.util.Lang.mapOf;
|
||||
import static com.tungsten.fclcore.util.Lang.tryCast;
|
||||
import static com.tungsten.fclcore.util.Pair.pair;
|
||||
|
||||
import com.google.gson.annotations.SerializedName;
|
||||
import com.tungsten.fclcore.auth.yggdrasil.TextureModel;
|
||||
import com.tungsten.fclcore.task.FetchTask;
|
||||
import com.tungsten.fclcore.task.GetTask;
|
||||
import com.tungsten.fclcore.task.Task;
|
||||
import com.tungsten.fclcore.util.StringUtils;
|
||||
import com.tungsten.fclcore.util.gson.JsonUtils;
|
||||
import com.tungsten.fclcore.util.io.FileUtils;
|
||||
|
||||
import org.jetbrains.annotations.Nullable;
|
||||
|
||||
import java.io.ByteArrayInputStream;
|
||||
import java.io.ByteArrayOutputStream;
|
||||
import java.io.IOException;
|
||||
import java.io.InputStream;
|
||||
import java.net.URL;
|
||||
import java.net.URLConnection;
|
||||
import java.nio.file.Files;
|
||||
import java.nio.file.Path;
|
||||
import java.util.Collections;
|
||||
import java.util.Locale;
|
||||
import java.util.Map;
|
||||
import java.util.Optional;
|
||||
|
||||
public class Skin {
|
||||
|
||||
public enum Type {
|
||||
DEFAULT,
|
||||
STEVE,
|
||||
ALEX,
|
||||
LOCAL_FILE,
|
||||
LITTLE_SKIN,
|
||||
CUSTOM_SKIN_LOADER_API,
|
||||
YGGDRASIL_API;
|
||||
|
||||
public static Type fromStorage(String type) {
|
||||
switch (type) {
|
||||
case "default":
|
||||
return DEFAULT;
|
||||
case "steve":
|
||||
return STEVE;
|
||||
case "alex":
|
||||
return ALEX;
|
||||
case "local_file":
|
||||
return LOCAL_FILE;
|
||||
case "little_skin":
|
||||
return LITTLE_SKIN;
|
||||
case "custom_skin_loader_api":
|
||||
return CUSTOM_SKIN_LOADER_API;
|
||||
case "yggdrasil_api":
|
||||
return YGGDRASIL_API;
|
||||
default:
|
||||
return null;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private final Type type;
|
||||
private final String cslApi;
|
||||
private final TextureModel textureModel;
|
||||
private final String localSkinPath;
|
||||
private final String localCapePath;
|
||||
|
||||
public Skin(Type type, String cslApi, TextureModel textureModel, String localSkinPath, String localCapePath) {
|
||||
this.type = type;
|
||||
this.cslApi = cslApi;
|
||||
this.textureModel = textureModel;
|
||||
this.localSkinPath = localSkinPath;
|
||||
this.localCapePath = localCapePath;
|
||||
}
|
||||
|
||||
public Type getType() {
|
||||
return type;
|
||||
}
|
||||
|
||||
public String getCslApi() {
|
||||
return cslApi;
|
||||
}
|
||||
|
||||
public TextureModel getTextureModel() {
|
||||
return textureModel == null ? TextureModel.STEVE : textureModel;
|
||||
}
|
||||
|
||||
public String getLocalSkinPath() {
|
||||
return localSkinPath;
|
||||
}
|
||||
|
||||
public String getLocalCapePath() {
|
||||
return localCapePath;
|
||||
}
|
||||
|
||||
public Task<LoadedSkin> load(String username) {
|
||||
switch (type) {
|
||||
case DEFAULT:
|
||||
return Task.supplyAsync(() -> null);
|
||||
case STEVE:
|
||||
return Task.supplyAsync(() -> new LoadedSkin(TextureModel.STEVE, Texture.loadTexture(Skin.class.getResourceAsStream("/assets/img/steve.png")), null));
|
||||
case ALEX:
|
||||
return Task.supplyAsync(() -> new LoadedSkin(TextureModel.ALEX, Texture.loadTexture(Skin.class.getResourceAsStream("/assets/img/alex.png")), null));
|
||||
case LOCAL_FILE:
|
||||
return Task.supplyAsync(() -> {
|
||||
Texture skin = null, cape = null;
|
||||
Optional<Path> skinPath = FileUtils.tryGetPath(localSkinPath);
|
||||
Optional<Path> capePath = FileUtils.tryGetPath(localCapePath);
|
||||
if (skinPath.isPresent()) skin = Texture.loadTexture(Files.newInputStream(skinPath.get()));
|
||||
if (capePath.isPresent()) cape = Texture.loadTexture(Files.newInputStream(capePath.get()));
|
||||
return new LoadedSkin(getTextureModel(), skin, cape);
|
||||
});
|
||||
case LITTLE_SKIN:
|
||||
case CUSTOM_SKIN_LOADER_API:
|
||||
String realCslApi = type == Type.LITTLE_SKIN ? "https://littleskin.cn" : StringUtils.removeSuffix(cslApi, "/");
|
||||
return Task.composeAsync(() -> new GetTask(new URL(String.format("%s/%s.json", realCslApi, username))))
|
||||
.thenComposeAsync(json -> {
|
||||
SkinJson result = JsonUtils.GSON.fromJson(json, SkinJson.class);
|
||||
|
||||
if (!result.hasSkin()) {
|
||||
return Task.supplyAsync(() -> null);
|
||||
}
|
||||
|
||||
return Task.allOf(
|
||||
Task.supplyAsync(result::getModel),
|
||||
result.getHash() == null ? Task.supplyAsync(() -> null) : new FetchBytesTask(new URL(String.format("%s/textures/%s", realCslApi, result.getHash())), 3),
|
||||
result.getCapeHash() == null ? Task.supplyAsync(() -> null) : new FetchBytesTask(new URL(String.format("%s/textures/%s", realCslApi, result.getCapeHash())), 3)
|
||||
);
|
||||
}).thenApplyAsync(result -> {
|
||||
if (result == null) {
|
||||
return null;
|
||||
}
|
||||
|
||||
Texture skin, cape;
|
||||
if (result.get(1) != null) {
|
||||
skin = Texture.loadTexture((InputStream) result.get(1));
|
||||
} else {
|
||||
skin = null;
|
||||
}
|
||||
|
||||
if (result.get(2) != null) {
|
||||
cape = Texture.loadTexture((InputStream) result.get(2));
|
||||
} else {
|
||||
cape = null;
|
||||
}
|
||||
|
||||
return new LoadedSkin((TextureModel) result.get(0), skin, cape);
|
||||
});
|
||||
default:
|
||||
throw new UnsupportedOperationException();
|
||||
}
|
||||
}
|
||||
|
||||
public Map<?, ?> toStorage() {
|
||||
return mapOf(
|
||||
pair("type", type.name().toLowerCase(Locale.ROOT)),
|
||||
pair("cslApi", cslApi),
|
||||
pair("textureModel", getTextureModel().modelName),
|
||||
pair("localSkinPath", localSkinPath),
|
||||
pair("localCapePath", localCapePath)
|
||||
);
|
||||
}
|
||||
|
||||
public static Skin fromStorage(Map<?, ?> storage) {
|
||||
if (storage == null) return null;
|
||||
|
||||
Type type = tryCast(storage.get("type"), String.class).flatMap(t -> Optional.ofNullable(Type.fromStorage(t)))
|
||||
.orElse(Type.DEFAULT);
|
||||
String cslApi = tryCast(storage.get("cslApi"), String.class).orElse(null);
|
||||
String textureModel = tryCast(storage.get("textureModel"), String.class).orElse("default");
|
||||
String localSkinPath = tryCast(storage.get("localSkinPath"), String.class).orElse(null);
|
||||
String localCapePath = tryCast(storage.get("localCapePath"), String.class).orElse(null);
|
||||
|
||||
TextureModel model;
|
||||
if ("default".equals(textureModel)) {
|
||||
model = TextureModel.STEVE;
|
||||
} else if ("slim".equals(textureModel)) {
|
||||
model = TextureModel.ALEX;
|
||||
} else {
|
||||
model = TextureModel.STEVE;
|
||||
}
|
||||
|
||||
return new Skin(type, cslApi, model, localSkinPath, localCapePath);
|
||||
}
|
||||
|
||||
private static class FetchBytesTask extends FetchTask<InputStream> {
|
||||
|
||||
public FetchBytesTask(URL url, int retry) {
|
||||
super(Collections.singletonList(url), retry);
|
||||
}
|
||||
|
||||
@Override
|
||||
protected void useCachedResult(Path cachedFile) throws IOException {
|
||||
setResult(Files.newInputStream(cachedFile));
|
||||
}
|
||||
|
||||
@Override
|
||||
protected EnumCheckETag shouldCheckETag() {
|
||||
return EnumCheckETag.CHECK_E_TAG;
|
||||
}
|
||||
|
||||
@Override
|
||||
protected Context getContext(URLConnection conn, boolean checkETag) throws IOException {
|
||||
return new Context() {
|
||||
final ByteArrayOutputStream baos = new ByteArrayOutputStream();
|
||||
|
||||
@Override
|
||||
public void write(byte[] buffer, int offset, int len) {
|
||||
baos.write(buffer, offset, len);
|
||||
}
|
||||
|
||||
@Override
|
||||
public void close() throws IOException {
|
||||
if (!isSuccess()) return;
|
||||
|
||||
setResult(new ByteArrayInputStream(baos.toByteArray()));
|
||||
|
||||
if (checkETag) {
|
||||
repository.cacheBytes(baos.toByteArray(), conn);
|
||||
}
|
||||
}
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
public static class LoadedSkin {
|
||||
private final TextureModel model;
|
||||
private final Texture skin;
|
||||
private final Texture cape;
|
||||
|
||||
public LoadedSkin(TextureModel model, Texture skin, Texture cape) {
|
||||
this.model = model;
|
||||
this.skin = skin;
|
||||
this.cape = cape;
|
||||
}
|
||||
|
||||
public TextureModel getModel() {
|
||||
return model;
|
||||
}
|
||||
|
||||
public Texture getSkin() {
|
||||
return skin;
|
||||
}
|
||||
|
||||
public Texture getCape() {
|
||||
return cape;
|
||||
}
|
||||
}
|
||||
|
||||
private static class SkinJson {
|
||||
private final String username;
|
||||
private final String skin;
|
||||
private final String cape;
|
||||
private final String elytra;
|
||||
|
||||
@SerializedName(value = "textures", alternate = { "skins" })
|
||||
private final TextureJson textures;
|
||||
|
||||
public SkinJson(String username, String skin, String cape, String elytra, TextureJson textures) {
|
||||
this.username = username;
|
||||
this.skin = skin;
|
||||
this.cape = cape;
|
||||
this.elytra = elytra;
|
||||
this.textures = textures;
|
||||
}
|
||||
|
||||
public boolean hasSkin() {
|
||||
return StringUtils.isNotBlank(username);
|
||||
}
|
||||
|
||||
@Nullable
|
||||
public TextureModel getModel() {
|
||||
if (textures != null && textures.slim != null) {
|
||||
return TextureModel.ALEX;
|
||||
} else if (textures != null && textures.defaultSkin != null) {
|
||||
return TextureModel.STEVE;
|
||||
} else {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
public String getAlexModelHash() {
|
||||
if (textures != null && textures.slim != null) {
|
||||
return textures.slim;
|
||||
} else {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
public String getSteveModelHash() {
|
||||
if (textures != null && textures.defaultSkin != null) {
|
||||
return textures.defaultSkin;
|
||||
} else return skin;
|
||||
}
|
||||
|
||||
public String getHash() {
|
||||
TextureModel model = getModel();
|
||||
if (model == TextureModel.ALEX)
|
||||
return getAlexModelHash();
|
||||
else if (model == TextureModel.STEVE)
|
||||
return getSteveModelHash();
|
||||
else
|
||||
return null;
|
||||
}
|
||||
|
||||
public String getCapeHash() {
|
||||
if (textures != null && textures.cape != null) {
|
||||
return textures.cape;
|
||||
} else return cape;
|
||||
}
|
||||
|
||||
public static class TextureJson {
|
||||
@SerializedName("default")
|
||||
private final String defaultSkin;
|
||||
|
||||
private final String slim;
|
||||
private final String cape;
|
||||
private final String elytra;
|
||||
|
||||
public TextureJson(String defaultSkin, String slim, String cape, String elytra) {
|
||||
this.defaultSkin = defaultSkin;
|
||||
this.slim = slim;
|
||||
this.cape = cape;
|
||||
this.elytra = elytra;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
|
@ -0,0 +1,127 @@
|
|||
package com.tungsten.fclcore.auth.offline;
|
||||
|
||||
import java.io.ByteArrayInputStream;
|
||||
import java.io.ByteArrayOutputStream;
|
||||
import java.io.IOException;
|
||||
import java.io.InputStream;
|
||||
import java.math.BigInteger;
|
||||
import java.net.URL;
|
||||
import java.security.MessageDigest;
|
||||
import java.security.NoSuchAlgorithmException;
|
||||
import java.util.HashMap;
|
||||
import java.util.Map;
|
||||
|
||||
import static java.util.Objects.requireNonNull;
|
||||
|
||||
import android.graphics.Bitmap;
|
||||
import android.graphics.BitmapFactory;
|
||||
|
||||
public class Texture {
|
||||
private final String hash;
|
||||
private final byte[] data;
|
||||
|
||||
public Texture(String hash, byte[] data) {
|
||||
this.hash = requireNonNull(hash);
|
||||
this.data = requireNonNull(data);
|
||||
}
|
||||
|
||||
public byte[] getData() {
|
||||
return data;
|
||||
}
|
||||
|
||||
public String getHash() {
|
||||
return hash;
|
||||
}
|
||||
|
||||
public InputStream getInputStream() {
|
||||
return new ByteArrayInputStream(data);
|
||||
}
|
||||
|
||||
public int getLength() {
|
||||
return data.length;
|
||||
}
|
||||
|
||||
private static final Map<String, Texture> textures = new HashMap<>();
|
||||
|
||||
public static boolean hasTexture(String hash) {
|
||||
return textures.containsKey(hash);
|
||||
}
|
||||
|
||||
public static Texture getTexture(String hash) {
|
||||
return textures.get(hash);
|
||||
}
|
||||
|
||||
private static String computeTextureHash(Bitmap img) {
|
||||
MessageDigest digest;
|
||||
try {
|
||||
digest = MessageDigest.getInstance("SHA-256");
|
||||
} catch (NoSuchAlgorithmException e) {
|
||||
throw new RuntimeException(e);
|
||||
}
|
||||
int width = img.getWidth();
|
||||
int height = img.getHeight();
|
||||
byte[] buf = new byte[4096];
|
||||
|
||||
putInt(buf, 0, width);
|
||||
putInt(buf, 4, height);
|
||||
int pos = 8;
|
||||
for (int x = 0; x < width; x++) {
|
||||
for (int y = 0; y < height; y++) {
|
||||
putInt(buf, pos, img.getPixel(x, y));
|
||||
if (buf[pos + 0] == 0) {
|
||||
buf[pos + 1] = buf[pos + 2] = buf[pos + 3] = 0;
|
||||
}
|
||||
pos += 4;
|
||||
if (pos == buf.length) {
|
||||
pos = 0;
|
||||
digest.update(buf, 0, buf.length);
|
||||
}
|
||||
}
|
||||
}
|
||||
if (pos > 0) {
|
||||
digest.update(buf, 0, pos);
|
||||
}
|
||||
|
||||
byte[] sha256 = digest.digest();
|
||||
return String.format("%0" + (sha256.length << 1) + "x", new BigInteger(1, sha256));
|
||||
}
|
||||
|
||||
private static void putInt(byte[] array, int offset, int x) {
|
||||
array[offset + 0] = (byte) (x >> 24 & 0xff);
|
||||
array[offset + 1] = (byte) (x >> 16 & 0xff);
|
||||
array[offset + 2] = (byte) (x >> 8 & 0xff);
|
||||
array[offset + 3] = (byte) (x >> 0 & 0xff);
|
||||
}
|
||||
|
||||
public static Texture loadTexture(InputStream in) throws IOException {
|
||||
if (in == null) return null;
|
||||
Bitmap img = BitmapFactory.decodeStream(in);
|
||||
if (img == null) {
|
||||
throw new IOException("No image found");
|
||||
}
|
||||
|
||||
String hash = computeTextureHash(img);
|
||||
|
||||
Texture existent = textures.get(hash);
|
||||
if (existent != null) {
|
||||
return existent;
|
||||
}
|
||||
|
||||
ByteArrayOutputStream buf = new ByteArrayOutputStream();
|
||||
img.compress(Bitmap.CompressFormat.PNG, 100, buf);
|
||||
Texture texture = new Texture(hash, buf.toByteArray());
|
||||
|
||||
existent = textures.putIfAbsent(hash, texture);
|
||||
|
||||
if (existent != null) {
|
||||
return existent;
|
||||
}
|
||||
return texture;
|
||||
}
|
||||
|
||||
public static Texture loadTexture(String url) throws IOException {
|
||||
if (url == null) return null;
|
||||
return loadTexture(new URL(url).openStream());
|
||||
}
|
||||
|
||||
}
|
|
@ -0,0 +1,240 @@
|
|||
package com.tungsten.fclcore.auth.offline;
|
||||
|
||||
import static com.tungsten.fclcore.util.Lang.mapOf;
|
||||
import static com.tungsten.fclcore.util.Pair.pair;
|
||||
|
||||
import com.google.gson.reflect.TypeToken;
|
||||
import com.tungsten.fclcore.auth.yggdrasil.GameProfile;
|
||||
import com.tungsten.fclcore.auth.yggdrasil.TextureModel;
|
||||
import com.tungsten.fclcore.util.KeyUtils;
|
||||
import com.tungsten.fclcore.util.Lang;
|
||||
import com.tungsten.fclcore.util.gson.JsonUtils;
|
||||
import com.tungsten.fclcore.util.gson.UUIDTypeAdapter;
|
||||
import com.tungsten.fclcore.util.io.HttpServer;
|
||||
import com.tungsten.fclcore.util.io.IOUtils;
|
||||
|
||||
import java.io.IOException;
|
||||
import java.security.*;
|
||||
import java.util.*;
|
||||
import java.util.regex.Pattern;
|
||||
import java.util.stream.Collectors;
|
||||
import java.util.stream.Stream;
|
||||
|
||||
import static java.nio.charset.StandardCharsets.UTF_8;
|
||||
|
||||
import fi.iki.elonen.NanoHTTPD;
|
||||
|
||||
public class YggdrasilServer extends HttpServer {
|
||||
|
||||
private final Map<UUID, Character> charactersByUuid = new HashMap<>();
|
||||
private final Map<String, Character> charactersByName = new HashMap<>();
|
||||
|
||||
public YggdrasilServer(int port) {
|
||||
super(port);
|
||||
|
||||
addRoute(NanoHTTPD.Method.GET, Pattern.compile("^/$"), this::root);
|
||||
addRoute(Method.GET, Pattern.compile("/status"), this::status);
|
||||
addRoute(Method.POST, Pattern.compile("/api/profiles/minecraft"), this::profiles);
|
||||
addRoute(Method.GET, Pattern.compile("/sessionserver/session/minecraft/hasJoined"), this::hasJoined);
|
||||
addRoute(Method.POST, Pattern.compile("/sessionserver/session/minecraft/join"), this::joinServer);
|
||||
addRoute(Method.GET, Pattern.compile("/sessionserver/session/minecraft/profile/(?<uuid>[a-f0-9]{32})"), this::profile);
|
||||
addRoute(Method.GET, Pattern.compile("/textures/(?<hash>[a-f0-9]{64})"), this::texture);
|
||||
}
|
||||
|
||||
private Response root(Request request) {
|
||||
return ok(mapOf(
|
||||
pair("signaturePublickey", KeyUtils.toPEMPublicKey(getSignaturePublicKey())),
|
||||
pair("skinDomains", Arrays.asList(
|
||||
"127.0.0.1",
|
||||
"localhost"
|
||||
)),
|
||||
pair("meta", mapOf(
|
||||
pair("serverName", "FCL"),
|
||||
pair("implementationName", "FCL"),
|
||||
pair("implementationVersion", "1.0"),
|
||||
pair("feature.non_email_login", true)
|
||||
))
|
||||
));
|
||||
}
|
||||
|
||||
private Response status(Request request) {
|
||||
return ok(mapOf(
|
||||
pair("user.count", charactersByUuid.size()),
|
||||
pair("token.count", 0),
|
||||
pair("pendingAuthentication.count", 0)
|
||||
));
|
||||
}
|
||||
|
||||
private Response profiles(Request request) throws IOException {
|
||||
String body = IOUtils.readFullyAsString(request.getSession().getInputStream(), UTF_8);
|
||||
List<String> names = JsonUtils.fromNonNullJson(body, new TypeToken<List<String>>() {
|
||||
}.getType());
|
||||
return ok(names.stream().distinct()
|
||||
.map(this::findCharacterByName)
|
||||
.flatMap(Lang::toStream)
|
||||
.map(Character::toSimpleResponse)
|
||||
.collect(Collectors.toList()));
|
||||
}
|
||||
|
||||
private Response hasJoined(Request request) {
|
||||
if (!request.getQuery().containsKey("username")) {
|
||||
return badRequest();
|
||||
}
|
||||
|
||||
Optional<Character> character = findCharacterByName(request.getQuery().get("username"));
|
||||
|
||||
//Workaround for JDK-8138667
|
||||
//noinspection OptionalIsPresent
|
||||
if (character.isPresent()) {
|
||||
return ok(character.get().toCompleteResponse(getRootUrl()));
|
||||
} else {
|
||||
return HttpServer.noContent();
|
||||
}
|
||||
}
|
||||
|
||||
private Response joinServer(Request request) {
|
||||
return noContent();
|
||||
}
|
||||
|
||||
private Response profile(Request request) {
|
||||
String uuid = request.getPathVariables().group("uuid");
|
||||
|
||||
Optional<Character> character = findCharacterByUuid(UUIDTypeAdapter.fromString(uuid));
|
||||
|
||||
//Workaround for JDK-8138667
|
||||
//noinspection OptionalIsPresent
|
||||
if (character.isPresent()) {
|
||||
return ok(character.get().toCompleteResponse(getRootUrl()));
|
||||
} else {
|
||||
return HttpServer.noContent();
|
||||
}
|
||||
}
|
||||
|
||||
private Response texture(Request request) {
|
||||
String hash = request.getPathVariables().group("hash");
|
||||
|
||||
if (Texture.hasTexture(hash)) {
|
||||
Texture texture = Texture.getTexture(hash);
|
||||
Response response = newFixedLengthResponse(Response.Status.OK, "image/png", texture.getInputStream(), texture.getLength());
|
||||
response.addHeader("Etag", String.format("\"%s\"", hash));
|
||||
response.addHeader("Cache-Control", "max-age=2592000, public");
|
||||
return response;
|
||||
} else {
|
||||
return notFound();
|
||||
}
|
||||
}
|
||||
|
||||
private Optional<Character> findCharacterByUuid(UUID uuid) {
|
||||
return Optional.ofNullable(charactersByUuid.get(uuid));
|
||||
}
|
||||
|
||||
private Optional<Character> findCharacterByName(String uuid) {
|
||||
return Optional.ofNullable(charactersByName.get(uuid));
|
||||
}
|
||||
|
||||
public void addCharacter(Character character) {
|
||||
charactersByUuid.put(character.getUUID(), character);
|
||||
charactersByName.put(character.getName(), character);
|
||||
}
|
||||
|
||||
public static class Character {
|
||||
private final UUID uuid;
|
||||
private final String name;
|
||||
private final Skin.LoadedSkin skin;
|
||||
|
||||
public Character(UUID uuid, String name, Skin.LoadedSkin skin) {
|
||||
this.uuid = uuid;
|
||||
this.name = name;
|
||||
this.skin = skin;
|
||||
}
|
||||
|
||||
public UUID getUUID() {
|
||||
return uuid;
|
||||
}
|
||||
|
||||
public String getName() {
|
||||
return name;
|
||||
}
|
||||
|
||||
public GameProfile toSimpleResponse() {
|
||||
return new GameProfile(uuid, name);
|
||||
}
|
||||
|
||||
public Object toCompleteResponse(String rootUrl) {
|
||||
Map<String, Object> realTextures = new HashMap<>();
|
||||
if (skin != null && skin.getSkin() != null) {
|
||||
if (skin.getModel() == TextureModel.ALEX) {
|
||||
realTextures.put("SKIN", mapOf(
|
||||
pair("url", rootUrl + "/textures/" + skin.getSkin().getHash()),
|
||||
pair("metadata", mapOf(
|
||||
pair("model", "slim")
|
||||
))));
|
||||
} else {
|
||||
realTextures.put("SKIN", mapOf(pair("url", rootUrl + "/textures/" + skin.getSkin().getHash())));
|
||||
}
|
||||
}
|
||||
if (skin != null && skin.getCape() != null) {
|
||||
realTextures.put("CAPE", mapOf(pair("url", rootUrl + "/textures/" + skin.getCape().getHash())));
|
||||
}
|
||||
|
||||
Map<String, Object> textureResponse = mapOf(
|
||||
pair("timestamp", System.currentTimeMillis()),
|
||||
pair("profileId", uuid),
|
||||
pair("profileName", name),
|
||||
pair("textures", realTextures)
|
||||
);
|
||||
|
||||
return mapOf(
|
||||
pair("id", uuid),
|
||||
pair("name", name),
|
||||
pair("properties", properties(true,
|
||||
pair("textures", new String(
|
||||
Base64.getEncoder().encode(
|
||||
JsonUtils.GSON.toJson(textureResponse).getBytes(UTF_8)
|
||||
), UTF_8))))
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
// === Signature ===
|
||||
|
||||
private static final KeyPair keyPair = KeyUtils.generateKey();
|
||||
|
||||
public static PublicKey getSignaturePublicKey() {
|
||||
return keyPair.getPublic();
|
||||
}
|
||||
|
||||
private static String sign(String data) {
|
||||
try {
|
||||
Signature signature = Signature.getInstance("SHA1withRSA");
|
||||
signature.initSign(keyPair.getPrivate(), new SecureRandom());
|
||||
signature.update(data.getBytes(UTF_8));
|
||||
return Base64.getEncoder().encodeToString(signature.sign());
|
||||
} catch (GeneralSecurityException e) {
|
||||
throw new RuntimeException(e);
|
||||
}
|
||||
}
|
||||
|
||||
// === properties ===
|
||||
|
||||
@SafeVarargs
|
||||
public static List<?> properties(Map.Entry<String, String>... entries) {
|
||||
return properties(false, entries);
|
||||
}
|
||||
|
||||
@SafeVarargs
|
||||
public static List<?> properties(boolean sign, Map.Entry<String, String>... entries) {
|
||||
return Stream.of(entries)
|
||||
.map(entry -> {
|
||||
LinkedHashMap<String, String> property = new LinkedHashMap<>();
|
||||
property.put("name", entry.getKey());
|
||||
property.put("value", entry.getValue());
|
||||
if (sign) {
|
||||
property.put("signature", sign(entry.getValue()));
|
||||
}
|
||||
return property;
|
||||
})
|
||||
.collect(Collectors.toList());
|
||||
}
|
||||
|
||||
}
|
|
@ -0,0 +1,35 @@
|
|||
package com.tungsten.fclcore.auth.yggdrasil;
|
||||
|
||||
import java.util.Map;
|
||||
import java.util.Objects;
|
||||
import java.util.UUID;
|
||||
|
||||
import com.google.gson.JsonParseException;
|
||||
import com.google.gson.annotations.JsonAdapter;
|
||||
|
||||
public class CompleteGameProfile extends GameProfile {
|
||||
|
||||
@JsonAdapter(PropertyMapSerializer.class)
|
||||
private final Map<String, String> properties;
|
||||
|
||||
public CompleteGameProfile(UUID id, String name, Map<String, String> properties) {
|
||||
super(id, name);
|
||||
this.properties = Objects.requireNonNull(properties);
|
||||
}
|
||||
|
||||
public CompleteGameProfile(GameProfile profile, Map<String, String> properties) {
|
||||
this(profile.getId(), profile.getName(), properties);
|
||||
}
|
||||
|
||||
public Map<String, String> getProperties() {
|
||||
return properties;
|
||||
}
|
||||
|
||||
@Override
|
||||
public void validate() throws JsonParseException {
|
||||
super.validate();
|
||||
|
||||
if (properties == null)
|
||||
throw new JsonParseException("Game profile properties cannot be null");
|
||||
}
|
||||
}
|
|
@ -0,0 +1,36 @@
|
|||
package com.tungsten.fclcore.auth.yggdrasil;
|
||||
|
||||
import java.util.Objects;
|
||||
import java.util.UUID;
|
||||
|
||||
import com.google.gson.JsonParseException;
|
||||
import com.google.gson.annotations.JsonAdapter;
|
||||
import com.tungsten.fclcore.util.gson.UUIDTypeAdapter;
|
||||
import com.tungsten.fclcore.util.gson.Validation;
|
||||
|
||||
public class GameProfile implements Validation {
|
||||
|
||||
@JsonAdapter(UUIDTypeAdapter.class)
|
||||
private final UUID id;
|
||||
|
||||
private final String name;
|
||||
|
||||
public GameProfile(UUID id, String name) {
|
||||
this.id = Objects.requireNonNull(id);
|
||||
this.name = Objects.requireNonNull(name);
|
||||
}
|
||||
|
||||
public UUID getId() {
|
||||
return id;
|
||||
}
|
||||
|
||||
public String getName() {
|
||||
return name;
|
||||
}
|
||||
|
||||
@Override
|
||||
public void validate() throws JsonParseException {
|
||||
Validation.requireNonNull(id, "Game profile id cannot be null");
|
||||
Validation.requireNonNull(name, "Game profile name cannot be null");
|
||||
}
|
||||
}
|
|
@ -0,0 +1,45 @@
|
|||
package com.tungsten.fclcore.auth.yggdrasil;
|
||||
|
||||
import com.tungsten.fclcore.util.gson.UUIDTypeAdapter;
|
||||
import com.tungsten.fclcore.util.io.NetworkUtils;
|
||||
|
||||
import java.net.URL;
|
||||
import java.util.*;
|
||||
|
||||
public class MojangYggdrasilProvider implements YggdrasilProvider {
|
||||
|
||||
@Override
|
||||
public URL getAuthenticationURL() {
|
||||
return NetworkUtils.toURL("https://authserver.mojang.com/authenticate");
|
||||
}
|
||||
|
||||
@Override
|
||||
public URL getRefreshmentURL() {
|
||||
return NetworkUtils.toURL("https://authserver.mojang.com/refresh");
|
||||
}
|
||||
|
||||
@Override
|
||||
public URL getValidationURL() {
|
||||
return NetworkUtils.toURL("https://authserver.mojang.com/validate");
|
||||
}
|
||||
|
||||
@Override
|
||||
public URL getInvalidationURL() {
|
||||
return NetworkUtils.toURL("https://authserver.mojang.com/invalidate");
|
||||
}
|
||||
|
||||
@Override
|
||||
public URL getSkinUploadURL(UUID uuid) throws UnsupportedOperationException {
|
||||
return NetworkUtils.toURL("https://api.mojang.com/user/profile/" + UUIDTypeAdapter.fromUUID(uuid) + "/skin");
|
||||
}
|
||||
|
||||
@Override
|
||||
public URL getProfilePropertiesURL(UUID uuid) {
|
||||
return NetworkUtils.toURL("https://sessionserver.mojang.com/session/minecraft/profile/" + UUIDTypeAdapter.fromUUID(uuid));
|
||||
}
|
||||
|
||||
@Override
|
||||
public String toString() {
|
||||
return "mojang";
|
||||
}
|
||||
}
|
|
@ -0,0 +1,36 @@
|
|||
package com.tungsten.fclcore.auth.yggdrasil;
|
||||
|
||||
import com.google.gson.*;
|
||||
|
||||
import java.lang.reflect.Type;
|
||||
import java.util.LinkedHashMap;
|
||||
import java.util.Map;
|
||||
|
||||
import static java.util.Collections.unmodifiableMap;
|
||||
|
||||
public class PropertyMapSerializer implements JsonSerializer<Map<String, String>>, JsonDeserializer<Map<String, String>> {
|
||||
|
||||
@Override
|
||||
public Map<String, String> deserialize(JsonElement json, Type typeOfT, JsonDeserializationContext context) throws JsonParseException {
|
||||
Map<String, String> result = new LinkedHashMap<>();
|
||||
for (JsonElement element : json.getAsJsonArray())
|
||||
if (element instanceof JsonObject) {
|
||||
JsonObject object = (JsonObject) element;
|
||||
result.put(object.get("name").getAsString(), object.get("value").getAsString());
|
||||
}
|
||||
|
||||
return unmodifiableMap(result);
|
||||
}
|
||||
|
||||
@Override
|
||||
public JsonElement serialize(Map<String, String> src, Type typeOfSrc, JsonSerializationContext context) {
|
||||
JsonArray result = new JsonArray();
|
||||
src.forEach((k, v) -> {
|
||||
JsonObject object = new JsonObject();
|
||||
object.addProperty("name", k);
|
||||
object.addProperty("value", v);
|
||||
result.add(object);
|
||||
});
|
||||
return result;
|
||||
}
|
||||
}
|
|
@ -0,0 +1,40 @@
|
|||
package com.tungsten.fclcore.auth.yggdrasil;
|
||||
|
||||
import com.tungsten.fclcore.auth.AuthenticationException;
|
||||
|
||||
public class RemoteAuthenticationException extends AuthenticationException {
|
||||
|
||||
private final String name;
|
||||
private final String message;
|
||||
private final String cause;
|
||||
|
||||
public RemoteAuthenticationException(String name, String message, String cause) {
|
||||
super(buildMessage(name, message, cause));
|
||||
this.name = name;
|
||||
this.message = message;
|
||||
this.cause = cause;
|
||||
}
|
||||
|
||||
public String getRemoteName() {
|
||||
return name;
|
||||
}
|
||||
|
||||
public String getRemoteMessage() {
|
||||
return message;
|
||||
}
|
||||
|
||||
public String getRemoteCause() {
|
||||
return cause;
|
||||
}
|
||||
|
||||
private static String buildMessage(String name, String message, String cause) {
|
||||
StringBuilder builder = new StringBuilder(name);
|
||||
if (message != null)
|
||||
builder.append(": ").append(message);
|
||||
|
||||
if (cause != null)
|
||||
builder.append(": ").append(cause);
|
||||
|
||||
return builder.toString();
|
||||
}
|
||||
}
|
|
@ -0,0 +1,30 @@
|
|||
package com.tungsten.fclcore.auth.yggdrasil;
|
||||
|
||||
import org.jetbrains.annotations.Nullable;
|
||||
|
||||
import java.util.Map;
|
||||
|
||||
public final class Texture {
|
||||
|
||||
private final String url;
|
||||
private final Map<String, String> metadata;
|
||||
|
||||
public Texture() {
|
||||
this(null, null);
|
||||
}
|
||||
|
||||
public Texture(String url, Map<String, String> metadata) {
|
||||
this.url = url;
|
||||
this.metadata = metadata;
|
||||
}
|
||||
|
||||
@Nullable
|
||||
public String getUrl() {
|
||||
return url;
|
||||
}
|
||||
|
||||
@Nullable
|
||||
public Map<String, String> getMetadata() {
|
||||
return metadata;
|
||||
}
|
||||
}
|
|
@ -0,0 +1,26 @@
|
|||
package com.tungsten.fclcore.auth.yggdrasil;
|
||||
|
||||
import java.util.Map;
|
||||
import java.util.UUID;
|
||||
|
||||
public enum TextureModel {
|
||||
STEVE("default"), ALEX("slim");
|
||||
|
||||
public final String modelName;
|
||||
|
||||
TextureModel(String modelName) {
|
||||
this.modelName = modelName;
|
||||
}
|
||||
|
||||
public static TextureModel detectModelName(Map<String, String> metadata) {
|
||||
if (metadata != null && "slim".equals(metadata.get("model"))) {
|
||||
return ALEX;
|
||||
} else {
|
||||
return STEVE;
|
||||
}
|
||||
}
|
||||
|
||||
public static TextureModel detectUUID(UUID uuid) {
|
||||
return (uuid.hashCode() & 1) == 1 ? ALEX : STEVE;
|
||||
}
|
||||
}
|
|
@ -0,0 +1,5 @@
|
|||
package com.tungsten.fclcore.auth.yggdrasil;
|
||||
|
||||
public enum TextureType {
|
||||
SKIN, CAPE
|
||||
}
|
|
@ -0,0 +1,44 @@
|
|||
package com.tungsten.fclcore.auth.yggdrasil;
|
||||
|
||||
import com.google.gson.JsonParseException;
|
||||
|
||||
import java.util.Map;
|
||||
|
||||
import com.google.gson.annotations.JsonAdapter;
|
||||
import com.tungsten.fclcore.util.StringUtils;
|
||||
import com.tungsten.fclcore.util.gson.Validation;
|
||||
|
||||
import org.jetbrains.annotations.Nullable;
|
||||
|
||||
public final class User implements Validation {
|
||||
|
||||
private final String id;
|
||||
|
||||
@Nullable
|
||||
@JsonAdapter(PropertyMapSerializer.class)
|
||||
private final Map<String, String> properties;
|
||||
|
||||
public User(String id) {
|
||||
this(id, null);
|
||||
}
|
||||
|
||||
public User(String id, @Nullable Map<String, String> properties) {
|
||||
this.id = id;
|
||||
this.properties = properties;
|
||||
}
|
||||
|
||||
public String getId() {
|
||||
return id;
|
||||
}
|
||||
|
||||
@Nullable
|
||||
public Map<String, String> getProperties() {
|
||||
return properties;
|
||||
}
|
||||
|
||||
@Override
|
||||
public void validate() throws JsonParseException {
|
||||
if (StringUtils.isBlank(id))
|
||||
throw new JsonParseException("User id cannot be empty.");
|
||||
}
|
||||
}
|
|
@ -0,0 +1,219 @@
|
|||
package com.tungsten.fclcore.auth.yggdrasil;
|
||||
|
||||
import static com.tungsten.fclcore.util.Logging.LOG;
|
||||
|
||||
import java.nio.file.Path;
|
||||
import java.util.*;
|
||||
import java.util.logging.Level;
|
||||
|
||||
import static java.util.Objects.requireNonNull;
|
||||
|
||||
import com.tungsten.fclcore.auth.AuthInfo;
|
||||
import com.tungsten.fclcore.auth.AuthenticationException;
|
||||
import com.tungsten.fclcore.auth.CharacterDeletedException;
|
||||
import com.tungsten.fclcore.auth.CharacterSelector;
|
||||
import com.tungsten.fclcore.auth.ClassicAccount;
|
||||
import com.tungsten.fclcore.auth.CredentialExpiredException;
|
||||
import com.tungsten.fclcore.auth.NoCharacterException;
|
||||
import com.tungsten.fclcore.auth.ServerResponseMalformedException;
|
||||
import com.tungsten.fclcore.util.fakefx.BindingMapping;
|
||||
import com.tungsten.fclcore.util.fakefx.fx.ObjectBinding;
|
||||
import com.tungsten.fclcore.util.gson.UUIDTypeAdapter;
|
||||
|
||||
public class YggdrasilAccount extends ClassicAccount {
|
||||
|
||||
protected final YggdrasilService service;
|
||||
protected final UUID characterUUID;
|
||||
protected final String username;
|
||||
|
||||
private boolean authenticated = false;
|
||||
private YggdrasilSession session;
|
||||
|
||||
protected YggdrasilAccount(YggdrasilService service, String username, YggdrasilSession session) {
|
||||
this.service = requireNonNull(service);
|
||||
this.username = requireNonNull(username);
|
||||
this.characterUUID = requireNonNull(session.getSelectedProfile().getId());
|
||||
this.session = requireNonNull(session);
|
||||
|
||||
addProfilePropertiesListener();
|
||||
}
|
||||
|
||||
protected YggdrasilAccount(YggdrasilService service, String username, String password, CharacterSelector selector) throws AuthenticationException {
|
||||
this.service = requireNonNull(service);
|
||||
this.username = requireNonNull(username);
|
||||
|
||||
YggdrasilSession acquiredSession = service.authenticate(username, password, randomClientToken());
|
||||
if (acquiredSession.getSelectedProfile() == null) {
|
||||
if (acquiredSession.getAvailableProfiles() == null || acquiredSession.getAvailableProfiles().isEmpty()) {
|
||||
throw new NoCharacterException();
|
||||
}
|
||||
|
||||
GameProfile characterToSelect = selector.select(service, acquiredSession.getAvailableProfiles());
|
||||
|
||||
session = service.refresh(
|
||||
acquiredSession.getAccessToken(),
|
||||
acquiredSession.getClientToken(),
|
||||
characterToSelect);
|
||||
// response validity has been checked in refresh()
|
||||
} else {
|
||||
session = acquiredSession;
|
||||
}
|
||||
|
||||
characterUUID = session.getSelectedProfile().getId();
|
||||
authenticated = true;
|
||||
|
||||
addProfilePropertiesListener();
|
||||
}
|
||||
|
||||
private ObjectBinding<Optional<CompleteGameProfile>> profilePropertiesBinding;
|
||||
private void addProfilePropertiesListener() {
|
||||
// binding() is thread-safe
|
||||
// hold the binding so that it won't be garbage-collected
|
||||
profilePropertiesBinding = service.getProfileRepository().binding(characterUUID, true);
|
||||
// and it's safe to add a listener to an ObjectBinding which does not have any listener attached before (maybe tricky)
|
||||
profilePropertiesBinding.addListener((a, b, c) -> this.invalidate());
|
||||
}
|
||||
|
||||
@Override
|
||||
public String getUsername() {
|
||||
return username;
|
||||
}
|
||||
|
||||
@Override
|
||||
public String getCharacter() {
|
||||
return session.getSelectedProfile().getName();
|
||||
}
|
||||
|
||||
@Override
|
||||
public UUID getUUID() {
|
||||
return session.getSelectedProfile().getId();
|
||||
}
|
||||
|
||||
@Override
|
||||
public synchronized AuthInfo logIn() throws AuthenticationException {
|
||||
if (!authenticated) {
|
||||
if (service.validate(session.getAccessToken(), session.getClientToken())) {
|
||||
authenticated = true;
|
||||
} else {
|
||||
YggdrasilSession acquiredSession;
|
||||
try {
|
||||
acquiredSession = service.refresh(session.getAccessToken(), session.getClientToken(), null);
|
||||
} catch (RemoteAuthenticationException e) {
|
||||
if ("ForbiddenOperationException".equals(e.getRemoteName())) {
|
||||
throw new CredentialExpiredException(e);
|
||||
} else {
|
||||
throw e;
|
||||
}
|
||||
}
|
||||
if (acquiredSession.getSelectedProfile() == null ||
|
||||
!acquiredSession.getSelectedProfile().getId().equals(characterUUID)) {
|
||||
throw new ServerResponseMalformedException("Selected profile changed");
|
||||
}
|
||||
|
||||
session = acquiredSession;
|
||||
|
||||
authenticated = true;
|
||||
invalidate();
|
||||
}
|
||||
}
|
||||
|
||||
return session.toAuthInfo();
|
||||
}
|
||||
|
||||
@Override
|
||||
public synchronized AuthInfo logInWithPassword(String password) throws AuthenticationException {
|
||||
YggdrasilSession acquiredSession = service.authenticate(username, password, randomClientToken());
|
||||
|
||||
if (acquiredSession.getSelectedProfile() == null) {
|
||||
if (acquiredSession.getAvailableProfiles() == null || acquiredSession.getAvailableProfiles().isEmpty()) {
|
||||
throw new CharacterDeletedException();
|
||||
}
|
||||
|
||||
GameProfile characterToSelect = acquiredSession.getAvailableProfiles().stream()
|
||||
.filter(charatcer -> charatcer.getId().equals(characterUUID))
|
||||
.findFirst()
|
||||
.orElseThrow(CharacterDeletedException::new);
|
||||
|
||||
session = service.refresh(
|
||||
acquiredSession.getAccessToken(),
|
||||
acquiredSession.getClientToken(),
|
||||
characterToSelect);
|
||||
|
||||
} else {
|
||||
if (!acquiredSession.getSelectedProfile().getId().equals(characterUUID)) {
|
||||
throw new CharacterDeletedException();
|
||||
}
|
||||
session = acquiredSession;
|
||||
}
|
||||
|
||||
authenticated = true;
|
||||
invalidate();
|
||||
return session.toAuthInfo();
|
||||
}
|
||||
|
||||
@Override
|
||||
public AuthInfo playOffline() throws AuthenticationException {
|
||||
return session.toAuthInfo();
|
||||
}
|
||||
|
||||
@Override
|
||||
public Map<Object, Object> toStorage() {
|
||||
Map<Object, Object> storage = new HashMap<>();
|
||||
storage.put("username", username);
|
||||
storage.putAll(session.toStorage());
|
||||
service.getProfileRepository().getImmediately(characterUUID).ifPresent(profile ->
|
||||
storage.put("profileProperties", profile.getProperties()));
|
||||
return storage;
|
||||
}
|
||||
|
||||
public YggdrasilService getYggdrasilService() {
|
||||
return service;
|
||||
}
|
||||
|
||||
@Override
|
||||
public void clearCache() {
|
||||
authenticated = false;
|
||||
service.getProfileRepository().invalidate(characterUUID);
|
||||
}
|
||||
|
||||
@Override
|
||||
public ObjectBinding<Optional<Map<TextureType, Texture>>> getTextures() {
|
||||
return BindingMapping.of(service.getProfileRepository().binding(getUUID()))
|
||||
.map(profile -> profile.flatMap(it -> {
|
||||
try {
|
||||
return YggdrasilService.getTextures(it);
|
||||
} catch (ServerResponseMalformedException e) {
|
||||
LOG.log(Level.WARNING, "Failed to parse texture payload", e);
|
||||
return Optional.empty();
|
||||
}
|
||||
}));
|
||||
|
||||
}
|
||||
|
||||
public void uploadSkin(String model, Path file) throws AuthenticationException, UnsupportedOperationException {
|
||||
service.uploadSkin(characterUUID, session.getAccessToken(), model, file);
|
||||
}
|
||||
|
||||
private static String randomClientToken() {
|
||||
return UUIDTypeAdapter.fromUUID(UUID.randomUUID());
|
||||
}
|
||||
|
||||
@Override
|
||||
public String toString() {
|
||||
return "YggdrasilAccount[uuid=" + characterUUID + ", username=" + username + "]";
|
||||
}
|
||||
|
||||
@Override
|
||||
public int hashCode() {
|
||||
return characterUUID.hashCode();
|
||||
}
|
||||
|
||||
@Override
|
||||
public boolean equals(Object obj) {
|
||||
if (this == obj) return true;
|
||||
if (obj == null || obj.getClass() != YggdrasilAccount.class)
|
||||
return false;
|
||||
YggdrasilAccount another = (YggdrasilAccount) obj;
|
||||
return characterUUID.equals(another.characterUUID);
|
||||
}
|
||||
}
|
|
@ -0,0 +1,59 @@
|
|||
package com.tungsten.fclcore.auth.yggdrasil;
|
||||
|
||||
import static com.tungsten.fclcore.util.Lang.tryCast;
|
||||
|
||||
import com.tungsten.fclcore.auth.AccountFactory;
|
||||
import com.tungsten.fclcore.auth.AuthenticationException;
|
||||
import com.tungsten.fclcore.auth.CharacterSelector;
|
||||
import com.tungsten.fclcore.util.fakefx.ObservableOptionalCache;
|
||||
|
||||
import java.util.Map;
|
||||
import java.util.Objects;
|
||||
import java.util.UUID;
|
||||
|
||||
public class YggdrasilAccountFactory extends AccountFactory<YggdrasilAccount> {
|
||||
|
||||
public static final YggdrasilAccountFactory MOJANG = new YggdrasilAccountFactory(YggdrasilService.MOJANG);
|
||||
|
||||
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, ProgressCallback progressCallback, Object additionalData) throws AuthenticationException {
|
||||
Objects.requireNonNull(selector);
|
||||
Objects.requireNonNull(username);
|
||||
Objects.requireNonNull(password);
|
||||
|
||||
return new YggdrasilAccount(service, username, password, selector);
|
||||
}
|
||||
|
||||
@Override
|
||||
public YggdrasilAccount fromStorage(Map<Object, Object> storage) {
|
||||
Objects.requireNonNull(storage);
|
||||
|
||||
YggdrasilSession session = YggdrasilSession.fromStorage(storage);
|
||||
|
||||
String username = tryCast(storage.get("username"), String.class)
|
||||
.orElseThrow(() -> new IllegalArgumentException("storage does not have username"));
|
||||
|
||||
tryCast(storage.get("profileProperties"), Map.class).ifPresent(
|
||||
it -> {
|
||||
@SuppressWarnings("unchecked")
|
||||
Map<String, String> properties = it;
|
||||
GameProfile selected = session.getSelectedProfile();
|
||||
ObservableOptionalCache<UUID, CompleteGameProfile, AuthenticationException> profileRepository = service.getProfileRepository();
|
||||
profileRepository.put(selected.getId(), new CompleteGameProfile(selected, properties));
|
||||
profileRepository.invalidate(selected.getId());
|
||||
});
|
||||
|
||||
return new YggdrasilAccount(service, username, session);
|
||||
}
|
||||
}
|
|
@ -0,0 +1,41 @@
|
|||
package com.tungsten.fclcore.auth.yggdrasil;
|
||||
|
||||
import com.tungsten.fclcore.auth.AuthenticationException;
|
||||
|
||||
import java.net.URL;
|
||||
import java.util.UUID;
|
||||
|
||||
/**
|
||||
* @see <a href="http://wiki.vg">http://wiki.vg</a>
|
||||
*/
|
||||
public interface YggdrasilProvider {
|
||||
|
||||
URL getAuthenticationURL() throws AuthenticationException;
|
||||
|
||||
URL getRefreshmentURL() throws AuthenticationException;
|
||||
|
||||
URL getValidationURL() throws AuthenticationException;
|
||||
|
||||
URL getInvalidationURL() throws AuthenticationException;
|
||||
|
||||
/**
|
||||
* URL to upload skin.
|
||||
*
|
||||
* Headers:
|
||||
* Authentication: Bearer <access token>
|
||||
*
|
||||
* Payload:
|
||||
* The payload for this API consists of multipart form data. There are two parts (order does not matter b/c of boundary):
|
||||
* model: Empty string for the default model and "slim" for the slim model
|
||||
* file: Raw image file data
|
||||
*
|
||||
* @see <a href="https://wiki.vg/Mojang_API#Upload_Skin">https://wiki.vg/Mojang_API#Upload_Skin</a>
|
||||
* @return url to upload skin
|
||||
* @throws AuthenticationException if url cannot be generated. e.g. some parameter or query is malformed.
|
||||
* @throws UnsupportedOperationException if the Yggdrasil provider does not support third-party skin uploading.
|
||||
*/
|
||||
URL getSkinUploadURL(UUID uuid) throws AuthenticationException, UnsupportedOperationException;
|
||||
|
||||
URL getProfilePropertiesURL(UUID uuid) throws AuthenticationException;
|
||||
|
||||
}
|
|
@ -0,0 +1,262 @@
|
|||
package com.tungsten.fclcore.auth.yggdrasil;
|
||||
|
||||
import static com.tungsten.fclcore.util.Lang.mapOf;
|
||||
import static com.tungsten.fclcore.util.Lang.threadPool;
|
||||
import static com.tungsten.fclcore.util.Logging.LOG;
|
||||
import static com.tungsten.fclcore.util.Pair.pair;
|
||||
|
||||
import com.google.gson.Gson;
|
||||
import com.google.gson.GsonBuilder;
|
||||
import com.google.gson.JsonParseException;
|
||||
import com.tungsten.fclcore.auth.AuthenticationException;
|
||||
import com.tungsten.fclcore.auth.ServerDisconnectException;
|
||||
import com.tungsten.fclcore.auth.ServerResponseMalformedException;
|
||||
import com.tungsten.fclcore.util.StringUtils;
|
||||
import com.tungsten.fclcore.util.fakefx.ObservableOptionalCache;
|
||||
import com.tungsten.fclcore.util.gson.UUIDTypeAdapter;
|
||||
import com.tungsten.fclcore.util.gson.ValidationTypeAdapterFactory;
|
||||
import com.tungsten.fclcore.util.io.FileUtils;
|
||||
import com.tungsten.fclcore.util.io.HttpMultipartRequest;
|
||||
import com.tungsten.fclcore.util.io.NetworkUtils;
|
||||
|
||||
import java.io.IOException;
|
||||
import java.io.InputStream;
|
||||
import java.net.HttpURLConnection;
|
||||
import java.net.URL;
|
||||
import java.nio.file.Files;
|
||||
import java.nio.file.Path;
|
||||
import java.util.*;
|
||||
import java.util.concurrent.ThreadPoolExecutor;
|
||||
import java.util.concurrent.TimeUnit;
|
||||
import java.util.logging.Level;
|
||||
|
||||
import static java.nio.charset.StandardCharsets.UTF_8;
|
||||
import static java.util.Collections.unmodifiableList;
|
||||
|
||||
public class YggdrasilService {
|
||||
|
||||
private static final ThreadPoolExecutor POOL = threadPool("YggdrasilProfileProperties", true, 2, 10, TimeUnit.SECONDS);
|
||||
|
||||
public static final YggdrasilService MOJANG = new YggdrasilService(new MojangYggdrasilProvider());
|
||||
|
||||
private final YggdrasilProvider provider;
|
||||
private final ObservableOptionalCache<UUID, CompleteGameProfile, AuthenticationException> profileRepository;
|
||||
|
||||
public YggdrasilService(YggdrasilProvider provider) {
|
||||
this.provider = provider;
|
||||
this.profileRepository = new ObservableOptionalCache<>(
|
||||
uuid -> {
|
||||
LOG.info("Fetching properties of " + uuid + " from " + provider);
|
||||
return getCompleteGameProfile(uuid);
|
||||
},
|
||||
(uuid, e) -> LOG.log(Level.WARNING, "Failed to fetch properties of " + uuid + " from " + provider, e),
|
||||
POOL);
|
||||
}
|
||||
|
||||
public ObservableOptionalCache<UUID, CompleteGameProfile, AuthenticationException> getProfileRepository() {
|
||||
return profileRepository;
|
||||
}
|
||||
|
||||
public YggdrasilSession authenticate(String username, String password, String clientToken) throws AuthenticationException {
|
||||
Objects.requireNonNull(username);
|
||||
Objects.requireNonNull(password);
|
||||
Objects.requireNonNull(clientToken);
|
||||
|
||||
Map<String, Object> request = new HashMap<>();
|
||||
request.put("agent", mapOf(
|
||||
pair("name", "Minecraft"),
|
||||
pair("version", 1)
|
||||
));
|
||||
request.put("username", username);
|
||||
request.put("password", password);
|
||||
request.put("clientToken", clientToken);
|
||||
request.put("requestUser", true);
|
||||
|
||||
return handleAuthenticationResponse(request(provider.getAuthenticationURL(), request), clientToken);
|
||||
}
|
||||
|
||||
private static Map<String, Object> createRequestWithCredentials(String accessToken, String clientToken) {
|
||||
Map<String, Object> request = new HashMap<>();
|
||||
request.put("accessToken", accessToken);
|
||||
request.put("clientToken", clientToken);
|
||||
return request;
|
||||
}
|
||||
|
||||
public YggdrasilSession refresh(String accessToken, String clientToken, GameProfile characterToSelect) throws AuthenticationException {
|
||||
Objects.requireNonNull(accessToken);
|
||||
Objects.requireNonNull(clientToken);
|
||||
|
||||
Map<String, Object> request = createRequestWithCredentials(accessToken, clientToken);
|
||||
request.put("requestUser", true);
|
||||
|
||||
if (characterToSelect != null) {
|
||||
request.put("selectedProfile", mapOf(
|
||||
pair("id", characterToSelect.getId()),
|
||||
pair("name", characterToSelect.getName())));
|
||||
}
|
||||
|
||||
YggdrasilSession response = handleAuthenticationResponse(request(provider.getRefreshmentURL(), request), clientToken);
|
||||
|
||||
if (characterToSelect != null) {
|
||||
if (response.getSelectedProfile() == null ||
|
||||
!response.getSelectedProfile().getId().equals(characterToSelect.getId())) {
|
||||
throw new ServerResponseMalformedException("Failed to select character");
|
||||
}
|
||||
}
|
||||
|
||||
return response;
|
||||
}
|
||||
|
||||
public boolean validate(String accessToken) throws AuthenticationException {
|
||||
return validate(accessToken, null);
|
||||
}
|
||||
|
||||
public boolean validate(String accessToken, String clientToken) throws AuthenticationException {
|
||||
Objects.requireNonNull(accessToken);
|
||||
|
||||
try {
|
||||
requireEmpty(request(provider.getValidationURL(), createRequestWithCredentials(accessToken, clientToken)));
|
||||
return true;
|
||||
} catch (RemoteAuthenticationException e) {
|
||||
if ("ForbiddenOperationException".equals(e.getRemoteName())) {
|
||||
return false;
|
||||
}
|
||||
throw e;
|
||||
}
|
||||
}
|
||||
|
||||
public void invalidate(String accessToken) throws AuthenticationException {
|
||||
invalidate(accessToken, null);
|
||||
}
|
||||
|
||||
public void invalidate(String accessToken, String clientToken) throws AuthenticationException {
|
||||
Objects.requireNonNull(accessToken);
|
||||
|
||||
requireEmpty(request(provider.getInvalidationURL(), createRequestWithCredentials(accessToken, clientToken)));
|
||||
}
|
||||
|
||||
public void uploadSkin(UUID uuid, String accessToken, String model, Path file) throws AuthenticationException, UnsupportedOperationException {
|
||||
try {
|
||||
HttpURLConnection con = NetworkUtils.createHttpConnection(provider.getSkinUploadURL(uuid));
|
||||
con.setRequestMethod("PUT");
|
||||
con.setRequestProperty("Authorization", "Bearer " + accessToken);
|
||||
con.setDoOutput(true);
|
||||
try (HttpMultipartRequest request = new HttpMultipartRequest(con)) {
|
||||
request.param("model", model);
|
||||
try (InputStream fis = Files.newInputStream(file)) {
|
||||
request.file("file", FileUtils.getName(file), "image/" + FileUtils.getExtension(file), fis);
|
||||
}
|
||||
}
|
||||
requireEmpty(NetworkUtils.readData(con));
|
||||
} catch (IOException e) {
|
||||
throw new AuthenticationException(e);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Get complete game profile.
|
||||
*
|
||||
* Game profile provided from authentication is not complete (no skin data in properties).
|
||||
*
|
||||
* @param uuid the uuid that the character corresponding to.
|
||||
* @return the complete game profile(filled with more properties)
|
||||
*/
|
||||
public Optional<CompleteGameProfile> getCompleteGameProfile(UUID uuid) throws AuthenticationException {
|
||||
Objects.requireNonNull(uuid);
|
||||
|
||||
return Optional.ofNullable(fromJson(request(provider.getProfilePropertiesURL(uuid), null), CompleteGameProfile.class));
|
||||
}
|
||||
|
||||
public static Optional<Map<TextureType, Texture>> getTextures(CompleteGameProfile profile) throws ServerResponseMalformedException {
|
||||
Objects.requireNonNull(profile);
|
||||
|
||||
String encodedTextures = profile.getProperties().get("textures");
|
||||
|
||||
if (encodedTextures != null) {
|
||||
byte[] decodedBinary;
|
||||
try {
|
||||
decodedBinary = Base64.getDecoder().decode(encodedTextures);
|
||||
} catch (IllegalArgumentException e) {
|
||||
throw new ServerResponseMalformedException(e);
|
||||
}
|
||||
TextureResponse texturePayload = fromJson(new String(decodedBinary, UTF_8), TextureResponse.class);
|
||||
return Optional.ofNullable(texturePayload.textures);
|
||||
} else {
|
||||
return Optional.empty();
|
||||
}
|
||||
}
|
||||
|
||||
private static YggdrasilSession handleAuthenticationResponse(String responseText, String clientToken) throws AuthenticationException {
|
||||
AuthenticationResponse response = fromJson(responseText, AuthenticationResponse.class);
|
||||
handleErrorMessage(response);
|
||||
|
||||
if (!clientToken.equals(response.clientToken))
|
||||
throw new AuthenticationException("Client token changed from " + clientToken + " to " + response.clientToken);
|
||||
|
||||
return new YggdrasilSession(
|
||||
response.clientToken,
|
||||
response.accessToken,
|
||||
response.selectedProfile,
|
||||
response.availableProfiles == null ? null : unmodifiableList(response.availableProfiles),
|
||||
response.user == null ? null : response.user.getProperties());
|
||||
}
|
||||
|
||||
private static void requireEmpty(String response) throws AuthenticationException {
|
||||
if (StringUtils.isBlank(response))
|
||||
return;
|
||||
|
||||
handleErrorMessage(fromJson(response, ErrorResponse.class));
|
||||
}
|
||||
|
||||
private static void handleErrorMessage(ErrorResponse response) throws AuthenticationException {
|
||||
if (!StringUtils.isBlank(response.error)) {
|
||||
throw new RemoteAuthenticationException(response.error, response.errorMessage, response.cause);
|
||||
}
|
||||
}
|
||||
|
||||
private static String request(URL url, Object payload) throws AuthenticationException {
|
||||
try {
|
||||
if (payload == null)
|
||||
return NetworkUtils.doGet(url);
|
||||
else
|
||||
return NetworkUtils.doPost(url, payload instanceof String ? (String) payload : GSON.toJson(payload), "application/json");
|
||||
} catch (IOException e) {
|
||||
throw new ServerDisconnectException(e);
|
||||
}
|
||||
}
|
||||
|
||||
private static <T> T fromJson(String text, Class<T> typeOfT) throws ServerResponseMalformedException {
|
||||
try {
|
||||
return GSON.fromJson(text, typeOfT);
|
||||
} catch (JsonParseException e) {
|
||||
throw new ServerResponseMalformedException(text, e);
|
||||
}
|
||||
}
|
||||
|
||||
private static class TextureResponse {
|
||||
public Map<TextureType, Texture> textures;
|
||||
}
|
||||
|
||||
private static class AuthenticationResponse extends ErrorResponse {
|
||||
public String accessToken;
|
||||
public String clientToken;
|
||||
public GameProfile selectedProfile;
|
||||
public List<GameProfile> availableProfiles;
|
||||
public User user;
|
||||
}
|
||||
|
||||
private static class ErrorResponse {
|
||||
public String error;
|
||||
public String errorMessage;
|
||||
public String cause;
|
||||
}
|
||||
|
||||
private static final Gson GSON = new GsonBuilder()
|
||||
.registerTypeAdapter(UUID.class, UUIDTypeAdapter.INSTANCE)
|
||||
.registerTypeAdapterFactory(ValidationTypeAdapterFactory.INSTANCE)
|
||||
.create();
|
||||
|
||||
public static final String PROFILE_URL = "https://aka.ms/MinecraftMigration";
|
||||
public static final String MIGRATION_FAQ_URL = "https://help.minecraft.net/hc/en-us/articles/360050865492-JAVA-Account-Migration-FAQ";
|
||||
public static final String PURCHASE_URL = "https://www.minecraft.net/store/minecraft-java-bedrock-edition-pc";
|
||||
}
|
|
@ -0,0 +1,98 @@
|
|||
package com.tungsten.fclcore.auth.yggdrasil;
|
||||
|
||||
import static com.tungsten.fclcore.util.Lang.mapOf;
|
||||
import static com.tungsten.fclcore.util.Lang.tryCast;
|
||||
import static com.tungsten.fclcore.util.Pair.pair;
|
||||
|
||||
import com.google.gson.Gson;
|
||||
import com.tungsten.fclcore.auth.AuthInfo;
|
||||
import com.tungsten.fclcore.util.Logging;
|
||||
import com.tungsten.fclcore.util.gson.UUIDTypeAdapter;
|
||||
|
||||
import org.jetbrains.annotations.Nullable;
|
||||
|
||||
import java.util.*;
|
||||
import java.util.stream.Collectors;
|
||||
|
||||
public class YggdrasilSession {
|
||||
|
||||
private final String clientToken;
|
||||
private final String accessToken;
|
||||
private final GameProfile selectedProfile;
|
||||
private final List<GameProfile> availableProfiles;
|
||||
@Nullable
|
||||
private final Map<String, String> userProperties;
|
||||
|
||||
public YggdrasilSession(String clientToken, String accessToken, GameProfile selectedProfile, List<GameProfile> availableProfiles, Map<String, String> userProperties) {
|
||||
this.clientToken = clientToken;
|
||||
this.accessToken = accessToken;
|
||||
this.selectedProfile = selectedProfile;
|
||||
this.availableProfiles = availableProfiles;
|
||||
this.userProperties = userProperties;
|
||||
|
||||
if (accessToken != null) Logging.registerAccessToken(accessToken);
|
||||
}
|
||||
|
||||
public String getClientToken() {
|
||||
return clientToken;
|
||||
}
|
||||
|
||||
public String getAccessToken() {
|
||||
return accessToken;
|
||||
}
|
||||
|
||||
/**
|
||||
* @return nullable (null if no character is selected)
|
||||
*/
|
||||
public GameProfile getSelectedProfile() {
|
||||
return selectedProfile;
|
||||
}
|
||||
|
||||
/**
|
||||
* @return nullable (null if the YggdrasilSession is loaded from storage)
|
||||
*/
|
||||
public List<GameProfile> getAvailableProfiles() {
|
||||
return availableProfiles;
|
||||
}
|
||||
|
||||
public Map<String, String> getUserProperties() {
|
||||
return userProperties;
|
||||
}
|
||||
|
||||
public static YggdrasilSession fromStorage(Map<?, ?> storage) {
|
||||
Objects.requireNonNull(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 clientToken = tryCast(storage.get("clientToken"), String.class).orElseThrow(() -> new IllegalArgumentException("clientToken is missing"));
|
||||
String accessToken = tryCast(storage.get("accessToken"), String.class).orElseThrow(() -> new IllegalArgumentException("accessToken is missing"));
|
||||
Map<String, String> userProperties = tryCast(storage.get("userProperties"), Map.class).orElse(null);
|
||||
return new YggdrasilSession(clientToken, accessToken, new GameProfile(uuid, name), null, userProperties);
|
||||
}
|
||||
|
||||
public Map<Object, Object> toStorage() {
|
||||
if (selectedProfile == null)
|
||||
throw new IllegalStateException("No character is selected");
|
||||
|
||||
return mapOf(
|
||||
pair("clientToken", clientToken),
|
||||
pair("accessToken", accessToken),
|
||||
pair("uuid", UUIDTypeAdapter.fromUUID(selectedProfile.getId())),
|
||||
pair("displayName", selectedProfile.getName()),
|
||||
pair("userProperties", userProperties));
|
||||
}
|
||||
|
||||
public AuthInfo toAuthInfo() {
|
||||
if (selectedProfile == null)
|
||||
throw new IllegalStateException("No character is selected");
|
||||
|
||||
return new AuthInfo(selectedProfile.getName(), selectedProfile.getId(), accessToken,
|
||||
Optional.ofNullable(userProperties)
|
||||
.map(properties -> properties.entrySet().stream()
|
||||
.collect(Collectors.toMap(Map.Entry::getKey,
|
||||
e -> Collections.singleton(e.getValue()))))
|
||||
.map(GSON_PROPERTIES::toJson).orElse("{}"));
|
||||
}
|
||||
|
||||
private static final Gson GSON_PROPERTIES = new Gson();
|
||||
}
|
|
@ -0,0 +1,26 @@
|
|||
package com.tungsten.fclcore.util;
|
||||
|
||||
import java.security.*;
|
||||
import java.util.Base64;
|
||||
|
||||
public final class KeyUtils {
|
||||
private KeyUtils() {
|
||||
}
|
||||
|
||||
public static KeyPair generateKey() {
|
||||
try {
|
||||
KeyPairGenerator gen = KeyPairGenerator.getInstance("RSA");
|
||||
gen.initialize(4096, new SecureRandom());
|
||||
return gen.genKeyPair();
|
||||
} catch (GeneralSecurityException e) {
|
||||
throw new RuntimeException(e);
|
||||
}
|
||||
}
|
||||
|
||||
public static String toPEMPublicKey(PublicKey key) {
|
||||
byte[] encoded = key.getEncoded();
|
||||
return "-----BEGIN PUBLIC KEY-----\n" +
|
||||
Base64.getMimeEncoder(76, new byte[]{'\n'}).encodeToString(encoded) +
|
||||
"\n-----END PUBLIC KEY-----\n";
|
||||
}
|
||||
}
|
|
@ -0,0 +1,202 @@
|
|||
package com.tungsten.fclcore.util.fakefx;
|
||||
|
||||
import static com.tungsten.fclcore.util.Lang.handleUncaughtException;
|
||||
|
||||
import java.util.Objects;
|
||||
import java.util.concurrent.CompletableFuture;
|
||||
import java.util.function.Function;
|
||||
import java.util.function.Supplier;
|
||||
|
||||
import static java.util.Objects.requireNonNull;
|
||||
|
||||
import com.tungsten.fclcore.util.fakefx.fx.Bindings;
|
||||
import com.tungsten.fclcore.util.fakefx.fx.ObjectBinding;
|
||||
import com.tungsten.fclcore.util.fakefx.fx.Observable;
|
||||
import com.tungsten.fclcore.util.fakefx.fx.ObservableValue;
|
||||
|
||||
public abstract class BindingMapping<T, U> extends ObjectBinding<U> {
|
||||
|
||||
public static <T> BindingMapping<?, T> of(ObservableValue<T> property) {
|
||||
if (property instanceof BindingMapping) {
|
||||
return (BindingMapping<?, T>) property;
|
||||
}
|
||||
return new SimpleBinding<>(property);
|
||||
}
|
||||
|
||||
public static <S extends Observable, T> BindingMapping<?, T> of(S watched, Function<S, T> mapper) {
|
||||
return of(Bindings.createObjectBinding(() -> mapper.apply(watched), watched));
|
||||
}
|
||||
|
||||
protected final ObservableValue<? extends T> predecessor;
|
||||
|
||||
public BindingMapping(ObservableValue<? extends T> predecessor) {
|
||||
this.predecessor = requireNonNull(predecessor);
|
||||
bind(predecessor);
|
||||
}
|
||||
|
||||
public <V> BindingMapping<?, V> map(Function<? super U, ? extends V> mapper) {
|
||||
return new MappedBinding<>(this, mapper);
|
||||
}
|
||||
|
||||
public <V> BindingMapping<?, V> flatMap(Function<? super U, ? extends ObservableValue<? extends V>> mapper) {
|
||||
return flatMap(mapper, null);
|
||||
}
|
||||
|
||||
public <V> BindingMapping<?, V> flatMap(Function<? super U, ? extends ObservableValue<? extends V>> mapper, Supplier<? extends V> nullAlternative) {
|
||||
return new FlatMappedBinding<>(map(mapper), nullAlternative);
|
||||
}
|
||||
|
||||
public <V> BindingMapping<?, V> asyncMap(Function<U, CompletableFuture<V>> mapper, V initial) {
|
||||
return new AsyncMappedBinding<>(this, mapper, initial);
|
||||
}
|
||||
|
||||
private static class SimpleBinding<T> extends BindingMapping<T, T> {
|
||||
|
||||
public SimpleBinding(ObservableValue<T> predecessor) {
|
||||
super(predecessor);
|
||||
}
|
||||
|
||||
@Override
|
||||
protected T computeValue() {
|
||||
return predecessor.getValue();
|
||||
}
|
||||
|
||||
@Override
|
||||
public <V> BindingMapping<?, V> map(Function<? super T, ? extends V> mapper) {
|
||||
return new MappedBinding<>(predecessor, mapper);
|
||||
}
|
||||
|
||||
@Override
|
||||
public <V> BindingMapping<?, V> asyncMap(Function<T, CompletableFuture<V>> mapper, V initial) {
|
||||
return new AsyncMappedBinding<>(predecessor, mapper, initial);
|
||||
}
|
||||
}
|
||||
|
||||
private static class MappedBinding<T, U> extends BindingMapping<T, U> {
|
||||
|
||||
private final Function<? super T, ? extends U> mapper;
|
||||
|
||||
public MappedBinding(ObservableValue<? extends T> predecessor, Function<? super T, ? extends U> mapper) {
|
||||
super(predecessor);
|
||||
this.mapper = mapper;
|
||||
}
|
||||
|
||||
@Override
|
||||
protected U computeValue() {
|
||||
return mapper.apply(predecessor.getValue());
|
||||
}
|
||||
}
|
||||
|
||||
private static class FlatMappedBinding<T extends ObservableValue<? extends U>, U> extends BindingMapping<T, U> {
|
||||
|
||||
private final Supplier<? extends U> nullAlternative;
|
||||
private T lastObservable = null;
|
||||
|
||||
public FlatMappedBinding(ObservableValue<? extends T> predecessor, Supplier<? extends U> nullAlternative) {
|
||||
super(predecessor);
|
||||
this.nullAlternative = nullAlternative;
|
||||
}
|
||||
|
||||
@Override
|
||||
protected U computeValue() {
|
||||
T currentObservable = predecessor.getValue();
|
||||
if (currentObservable != lastObservable) {
|
||||
if (lastObservable != null) {
|
||||
unbind(lastObservable);
|
||||
}
|
||||
if (currentObservable != null) {
|
||||
bind(currentObservable);
|
||||
}
|
||||
lastObservable = currentObservable;
|
||||
}
|
||||
|
||||
if (currentObservable == null) {
|
||||
if (nullAlternative == null) {
|
||||
throw new NullPointerException();
|
||||
} else {
|
||||
return nullAlternative.get();
|
||||
}
|
||||
} else {
|
||||
return currentObservable.getValue();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private static class AsyncMappedBinding<T, U> extends BindingMapping<T, U> {
|
||||
|
||||
private boolean initialized = false;
|
||||
private T prev;
|
||||
private U value;
|
||||
|
||||
private final Function<? super T, ? extends CompletableFuture<? extends U>> mapper;
|
||||
private T computingPrev;
|
||||
private boolean computing = false;
|
||||
|
||||
public AsyncMappedBinding(ObservableValue<? extends T> predecessor, Function<? super T, ? extends CompletableFuture<? extends U>> mapper, U initial) {
|
||||
super(predecessor);
|
||||
this.value = initial;
|
||||
this.mapper = mapper;
|
||||
}
|
||||
|
||||
private void tryUpdateValue(T currentPrev) {
|
||||
synchronized (this) {
|
||||
if ((initialized && Objects.equals(prev, currentPrev))
|
||||
|| isComputing(currentPrev)) {
|
||||
return;
|
||||
}
|
||||
computing = true;
|
||||
computingPrev = currentPrev;
|
||||
}
|
||||
|
||||
CompletableFuture<? extends U> task;
|
||||
try {
|
||||
task = requireNonNull(mapper.apply(currentPrev));
|
||||
} catch (Throwable e) {
|
||||
valueUpdateFailed(currentPrev);
|
||||
throw e;
|
||||
}
|
||||
|
||||
task.handle((result, e) -> {
|
||||
if (e == null) {
|
||||
valueUpdate(currentPrev, result);
|
||||
invalidate();
|
||||
} else {
|
||||
handleUncaughtException(e);
|
||||
valueUpdateFailed(currentPrev);
|
||||
}
|
||||
return null;
|
||||
});
|
||||
}
|
||||
|
||||
private void valueUpdate(T currentPrev, U computed) {
|
||||
synchronized (this) {
|
||||
if (isComputing(currentPrev)) {
|
||||
computing = false;
|
||||
computingPrev = null;
|
||||
prev = currentPrev;
|
||||
value = computed;
|
||||
initialized = true;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private void valueUpdateFailed(T currentPrev) {
|
||||
synchronized (this) {
|
||||
if (isComputing(currentPrev)) {
|
||||
computing = false;
|
||||
computingPrev = null;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private boolean isComputing(T prev) {
|
||||
return computing && Objects.equals(prev, computingPrev);
|
||||
}
|
||||
|
||||
@Override
|
||||
protected U computeValue() {
|
||||
tryUpdateValue(predecessor.getValue());
|
||||
return value;
|
||||
}
|
||||
}
|
||||
}
|
|
@ -0,0 +1,148 @@
|
|||
package com.tungsten.fclcore.util.fakefx;
|
||||
|
||||
import com.tungsten.fclcore.util.fakefx.fx.Bindings;
|
||||
import com.tungsten.fclcore.util.fakefx.fx.ObjectBinding;
|
||||
import com.tungsten.fclcore.util.function.ExceptionalFunction;
|
||||
|
||||
import java.util.HashMap;
|
||||
import java.util.Map;
|
||||
import java.util.Optional;
|
||||
import java.util.concurrent.CancellationException;
|
||||
import java.util.concurrent.CompletableFuture;
|
||||
import java.util.concurrent.CompletionException;
|
||||
import java.util.concurrent.Executor;
|
||||
import java.util.function.BiConsumer;
|
||||
|
||||
public class ObservableCache<K, V, E extends Exception> {
|
||||
|
||||
private final ExceptionalFunction<K, V, E> source;
|
||||
private final BiConsumer<K, Throwable> exceptionHandler;
|
||||
private final V fallbackValue;
|
||||
private final Executor executor;
|
||||
private final ObservableHelper observable = new ObservableHelper();
|
||||
private final Map<K, V> cache = new HashMap<>();
|
||||
private final Map<K, CompletableFuture<V>> pendings = new HashMap<>();
|
||||
private final Map<K, Boolean> invalidated = new HashMap<>();
|
||||
|
||||
public ObservableCache(ExceptionalFunction<K, V, E> source, BiConsumer<K, Throwable> exceptionHandler, V fallbackValue, Executor executor) {
|
||||
this.source = source;
|
||||
this.exceptionHandler = exceptionHandler;
|
||||
this.fallbackValue = fallbackValue;
|
||||
this.executor = executor;
|
||||
}
|
||||
|
||||
public Optional<V> getImmediately(K key) {
|
||||
synchronized (this) {
|
||||
return Optional.ofNullable(cache.get(key));
|
||||
}
|
||||
}
|
||||
|
||||
public void put(K key, V value) {
|
||||
synchronized (this) {
|
||||
cache.put(key, value);
|
||||
invalidated.remove(key);
|
||||
}
|
||||
observable.invalidate();
|
||||
}
|
||||
|
||||
private CompletableFuture<V> query(K key, Executor executor) {
|
||||
CompletableFuture<V> future;
|
||||
synchronized (this) {
|
||||
CompletableFuture<V> prev = pendings.get(key);
|
||||
if (prev != null) {
|
||||
return prev;
|
||||
} else {
|
||||
future = new CompletableFuture<>();
|
||||
pendings.put(key, future);
|
||||
}
|
||||
}
|
||||
|
||||
executor.execute(() -> {
|
||||
V result;
|
||||
try {
|
||||
result = source.apply(key);
|
||||
} catch (Throwable ex) {
|
||||
synchronized (this) {
|
||||
pendings.remove(key);
|
||||
}
|
||||
exceptionHandler.accept(key, ex);
|
||||
future.completeExceptionally(ex);
|
||||
return;
|
||||
}
|
||||
|
||||
synchronized (this) {
|
||||
cache.put(key, result);
|
||||
invalidated.remove(key);
|
||||
pendings.remove(key, future);
|
||||
}
|
||||
future.complete(result);
|
||||
observable.invalidate();
|
||||
});
|
||||
|
||||
return future;
|
||||
}
|
||||
|
||||
public V get(K key) {
|
||||
V cached;
|
||||
synchronized (this) {
|
||||
cached = cache.get(key);
|
||||
if (cached != null && !invalidated.containsKey(key)) {
|
||||
return cached;
|
||||
}
|
||||
}
|
||||
|
||||
try {
|
||||
return query(key, Runnable::run).join();
|
||||
} catch (CompletionException | CancellationException ignored) {
|
||||
}
|
||||
|
||||
if (cached == null) {
|
||||
return fallbackValue;
|
||||
} else {
|
||||
return cached;
|
||||
}
|
||||
}
|
||||
|
||||
public V getDirectly(K key) throws E {
|
||||
V result = source.apply(key);
|
||||
put(key, result);
|
||||
return result;
|
||||
}
|
||||
|
||||
public ObjectBinding<V> binding(K key) {
|
||||
return binding(key, false);
|
||||
}
|
||||
|
||||
/**
|
||||
* @param quiet if true, calling get() on the returned binding won't toggle a query
|
||||
*/
|
||||
public ObjectBinding<V> binding(K key, boolean quiet) {
|
||||
// This method is thread-safe because ObservableHelper supports concurrent modification
|
||||
return Bindings.createObjectBinding(() -> {
|
||||
V result;
|
||||
boolean refresh;
|
||||
synchronized (this) {
|
||||
result = cache.get(key);
|
||||
if (result == null) {
|
||||
result = fallbackValue;
|
||||
refresh = true;
|
||||
} else {
|
||||
refresh = invalidated.containsKey(key);
|
||||
}
|
||||
}
|
||||
if (!quiet && refresh) {
|
||||
query(key, executor);
|
||||
}
|
||||
return result;
|
||||
}, observable);
|
||||
}
|
||||
|
||||
public void invalidate(K key) {
|
||||
synchronized (this) {
|
||||
if (cache.containsKey(key)) {
|
||||
invalidated.put(key, Boolean.TRUE);
|
||||
}
|
||||
}
|
||||
observable.invalidate();
|
||||
}
|
||||
}
|
|
@ -0,0 +1,54 @@
|
|||
package com.tungsten.fclcore.util.fakefx;
|
||||
|
||||
import com.tungsten.fclcore.util.fakefx.fx.InvalidationListener;
|
||||
import com.tungsten.fclcore.util.fakefx.fx.Observable;
|
||||
|
||||
import java.util.List;
|
||||
import java.util.concurrent.CopyOnWriteArrayList;
|
||||
|
||||
/**
|
||||
* Helper class for implementing {@link Observable}.
|
||||
*/
|
||||
public class ObservableHelper implements Observable, InvalidationListener {
|
||||
|
||||
private List<InvalidationListener> listeners = new CopyOnWriteArrayList<>();
|
||||
private Observable source;
|
||||
|
||||
public ObservableHelper() {
|
||||
this.source = this;
|
||||
}
|
||||
|
||||
public ObservableHelper(Observable source) {
|
||||
this.source = source;
|
||||
}
|
||||
|
||||
/**
|
||||
* This method can be called from any thread.
|
||||
*/
|
||||
@Override
|
||||
public void addListener(InvalidationListener listener) {
|
||||
listeners.add(listener);
|
||||
}
|
||||
|
||||
/**
|
||||
* This method can be called from any thread.
|
||||
*/
|
||||
@Override
|
||||
public void removeListener(InvalidationListener listener) {
|
||||
listeners.remove(listener);
|
||||
}
|
||||
|
||||
public void invalidate() {
|
||||
listeners.forEach(it -> it.invalidated(source));
|
||||
}
|
||||
|
||||
@Override
|
||||
public void invalidated(Observable observable) {
|
||||
this.invalidate();
|
||||
}
|
||||
|
||||
public void receiveUpdatesFrom(Observable observable) {
|
||||
observable.removeListener(this); // remove the previously added listener(if any)
|
||||
observable.addListener(this);
|
||||
}
|
||||
}
|
|
@ -0,0 +1,45 @@
|
|||
package com.tungsten.fclcore.util.fakefx;
|
||||
|
||||
import com.tungsten.fclcore.util.fakefx.fx.ObjectBinding;
|
||||
import com.tungsten.fclcore.util.function.ExceptionalFunction;
|
||||
|
||||
import java.util.Optional;
|
||||
import java.util.concurrent.Executor;
|
||||
import java.util.function.BiConsumer;
|
||||
|
||||
public class ObservableOptionalCache<K, V, E extends Exception> {
|
||||
|
||||
private final ObservableCache<K, Optional<V>, E> backed;
|
||||
|
||||
public ObservableOptionalCache(ExceptionalFunction<K, Optional<V>, E> source, BiConsumer<K, Throwable> exceptionHandler, Executor executor) {
|
||||
backed = new ObservableCache<>(source, exceptionHandler, Optional.empty(), executor);
|
||||
}
|
||||
|
||||
public Optional<V> getImmediately(K key) {
|
||||
return backed.getImmediately(key).flatMap(it -> it);
|
||||
}
|
||||
|
||||
public void put(K key, V value) {
|
||||
backed.put(key, Optional.of(value));
|
||||
}
|
||||
|
||||
public Optional<V> get(K key) {
|
||||
return backed.get(key);
|
||||
}
|
||||
|
||||
public Optional<V> getDirectly(K key) throws E {
|
||||
return backed.getDirectly(key);
|
||||
}
|
||||
|
||||
public ObjectBinding<Optional<V>> binding(K key) {
|
||||
return backed.binding(key);
|
||||
}
|
||||
|
||||
public ObjectBinding<Optional<V>> binding(K key, boolean quiet) {
|
||||
return backed.binding(key, quiet);
|
||||
}
|
||||
|
||||
public void invalidate(K key) {
|
||||
backed.invalidate(key);
|
||||
}
|
||||
}
|
|
@ -0,0 +1,11 @@
|
|||
package com.tungsten.fclcore.util.fakefx.fx;
|
||||
|
||||
public interface Binding<T> extends ObservableValue<T> {
|
||||
|
||||
boolean isValid();
|
||||
|
||||
void invalidate();
|
||||
|
||||
void dispose();
|
||||
|
||||
}
|
|
@ -0,0 +1,30 @@
|
|||
package com.tungsten.fclcore.util.fakefx.fx;
|
||||
|
||||
import java.lang.ref.WeakReference;
|
||||
|
||||
public class BindingHelperObserver implements InvalidationListener, WeakListener {
|
||||
|
||||
private final WeakReference<Binding<?>> ref;
|
||||
|
||||
public BindingHelperObserver(Binding<?> binding) {
|
||||
if (binding == null) {
|
||||
throw new NullPointerException("Binding has to be specified.");
|
||||
}
|
||||
ref = new WeakReference<Binding<?>>(binding);
|
||||
}
|
||||
|
||||
@Override
|
||||
public void invalidated(Observable observable) {
|
||||
final Binding<?> binding = ref.get();
|
||||
if (binding == null) {
|
||||
observable.removeListener(this);
|
||||
} else {
|
||||
binding.invalidate();
|
||||
}
|
||||
}
|
||||
|
||||
@Override
|
||||
public boolean wasGarbageCollected() {
|
||||
return ref.get() == null;
|
||||
}
|
||||
}
|
|
@ -0,0 +1,32 @@
|
|||
package com.tungsten.fclcore.util.fakefx.fx;
|
||||
|
||||
import java.util.concurrent.Callable;
|
||||
|
||||
public final class Bindings {
|
||||
|
||||
private Bindings() {
|
||||
}
|
||||
|
||||
public static <T> ObjectBinding<T> createObjectBinding(final Callable<T> func, final Observable... dependencies) {
|
||||
return new ObjectBinding<T>() {
|
||||
{
|
||||
bind(dependencies);
|
||||
}
|
||||
|
||||
@Override
|
||||
protected T computeValue() {
|
||||
try {
|
||||
return func.call();
|
||||
} catch (Exception e) {
|
||||
e.printStackTrace();
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
@Override
|
||||
public void dispose() {
|
||||
super.unbind(dependencies);
|
||||
}
|
||||
};
|
||||
}
|
||||
}
|
|
@ -0,0 +1,6 @@
|
|||
package com.tungsten.fclcore.util.fakefx.fx;
|
||||
|
||||
public interface ChangeListener<T> {
|
||||
|
||||
void changed(ObservableValue<? extends T> observable, T oldValue, T newValue);
|
||||
}
|
|
@ -0,0 +1,334 @@
|
|||
package com.tungsten.fclcore.util.fakefx.fx;
|
||||
|
||||
import java.util.Arrays;
|
||||
|
||||
public abstract class ExpressionHelper<T> extends ExpressionHelperBase {
|
||||
|
||||
////////////////////////////////////////////////////////////////////////////////////////////////////////////////////
|
||||
// Static methods
|
||||
|
||||
public static <T> ExpressionHelper<T> addListener(ExpressionHelper<T> helper, ObservableValue<T> observable, InvalidationListener listener) {
|
||||
if ((observable == null) || (listener == null)) {
|
||||
throw new NullPointerException();
|
||||
}
|
||||
observable.getValue(); // validate observable
|
||||
return (helper == null)? new SingleInvalidation<T>(observable, listener) : helper.addListener(listener);
|
||||
}
|
||||
|
||||
public static <T> ExpressionHelper<T> removeListener(ExpressionHelper<T> helper, InvalidationListener listener) {
|
||||
if (listener == null) {
|
||||
throw new NullPointerException();
|
||||
}
|
||||
return (helper == null)? null : helper.removeListener(listener);
|
||||
}
|
||||
|
||||
public static <T> ExpressionHelper<T> addListener(ExpressionHelper<T> helper, ObservableValue<T> observable, ChangeListener<? super T> listener) {
|
||||
if ((observable == null) || (listener == null)) {
|
||||
throw new NullPointerException();
|
||||
}
|
||||
return (helper == null)? new SingleChange<T>(observable, listener) : helper.addListener(listener);
|
||||
}
|
||||
|
||||
public static <T> ExpressionHelper<T> removeListener(ExpressionHelper<T> helper, ChangeListener<? super T> listener) {
|
||||
if (listener == null) {
|
||||
throw new NullPointerException();
|
||||
}
|
||||
return (helper == null)? null : helper.removeListener(listener);
|
||||
}
|
||||
|
||||
public static <T> void fireValueChangedEvent(ExpressionHelper<T> helper) {
|
||||
if (helper != null) {
|
||||
helper.fireValueChangedEvent();
|
||||
}
|
||||
}
|
||||
|
||||
////////////////////////////////////////////////////////////////////////////////////////////////////////////////////
|
||||
// Common implementations
|
||||
|
||||
protected final ObservableValue<T> observable;
|
||||
|
||||
private ExpressionHelper(ObservableValue<T> observable) {
|
||||
this.observable = observable;
|
||||
}
|
||||
|
||||
protected abstract ExpressionHelper<T> addListener(InvalidationListener listener);
|
||||
protected abstract ExpressionHelper<T> removeListener(InvalidationListener listener);
|
||||
|
||||
protected abstract ExpressionHelper<T> addListener(ChangeListener<? super T> listener);
|
||||
protected abstract ExpressionHelper<T> removeListener(ChangeListener<? super T> listener);
|
||||
|
||||
protected abstract void fireValueChangedEvent();
|
||||
|
||||
////////////////////////////////////////////////////////////////////////////////////////////////////////////////////
|
||||
// Implementations
|
||||
|
||||
private static class SingleInvalidation<T> extends ExpressionHelper<T> {
|
||||
|
||||
private final InvalidationListener listener;
|
||||
|
||||
private SingleInvalidation(ObservableValue<T> expression, InvalidationListener listener) {
|
||||
super(expression);
|
||||
this.listener = listener;
|
||||
}
|
||||
|
||||
@Override
|
||||
protected ExpressionHelper<T> addListener(InvalidationListener listener) {
|
||||
return new Generic<T>(observable, this.listener, listener);
|
||||
}
|
||||
|
||||
@Override
|
||||
protected ExpressionHelper<T> removeListener(InvalidationListener listener) {
|
||||
return (listener.equals(this.listener))? null : this;
|
||||
}
|
||||
|
||||
@Override
|
||||
protected ExpressionHelper<T> addListener(ChangeListener<? super T> listener) {
|
||||
return new Generic<T>(observable, this.listener, listener);
|
||||
}
|
||||
|
||||
@Override
|
||||
protected ExpressionHelper<T> removeListener(ChangeListener<? super T> listener) {
|
||||
return this;
|
||||
}
|
||||
|
||||
@Override
|
||||
protected void fireValueChangedEvent() {
|
||||
try {
|
||||
listener.invalidated(observable);
|
||||
} catch (Exception e) {
|
||||
Thread.currentThread().getUncaughtExceptionHandler().uncaughtException(Thread.currentThread(), e);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private static class SingleChange<T> extends ExpressionHelper<T> {
|
||||
|
||||
private final ChangeListener<? super T> listener;
|
||||
private T currentValue;
|
||||
|
||||
private SingleChange(ObservableValue<T> observable, ChangeListener<? super T> listener) {
|
||||
super(observable);
|
||||
this.listener = listener;
|
||||
this.currentValue = observable.getValue();
|
||||
}
|
||||
|
||||
@Override
|
||||
protected ExpressionHelper<T> addListener(InvalidationListener listener) {
|
||||
return new Generic<T>(observable, listener, this.listener);
|
||||
}
|
||||
|
||||
@Override
|
||||
protected ExpressionHelper<T> removeListener(InvalidationListener listener) {
|
||||
return this;
|
||||
}
|
||||
|
||||
@Override
|
||||
protected ExpressionHelper<T> addListener(ChangeListener<? super T> listener) {
|
||||
return new Generic<T>(observable, this.listener, listener);
|
||||
}
|
||||
|
||||
@Override
|
||||
protected ExpressionHelper<T> removeListener(ChangeListener<? super T> listener) {
|
||||
return (listener.equals(this.listener))? null : this;
|
||||
}
|
||||
|
||||
@Override
|
||||
protected void fireValueChangedEvent() {
|
||||
final T oldValue = currentValue;
|
||||
currentValue = observable.getValue();
|
||||
final boolean changed = (currentValue == null)? (oldValue != null) : !currentValue.equals(oldValue);
|
||||
if (changed) {
|
||||
try {
|
||||
listener.changed(observable, oldValue, currentValue);
|
||||
} catch (Exception e) {
|
||||
Thread.currentThread().getUncaughtExceptionHandler().uncaughtException(Thread.currentThread(), e);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private static class Generic<T> extends ExpressionHelper<T> {
|
||||
|
||||
private InvalidationListener[] invalidationListeners;
|
||||
private ChangeListener<? super T>[] changeListeners;
|
||||
private int invalidationSize;
|
||||
private int changeSize;
|
||||
private boolean locked;
|
||||
private T currentValue;
|
||||
|
||||
private Generic(ObservableValue<T> observable, InvalidationListener listener0, InvalidationListener listener1) {
|
||||
super(observable);
|
||||
this.invalidationListeners = new InvalidationListener[] {listener0, listener1};
|
||||
this.invalidationSize = 2;
|
||||
}
|
||||
|
||||
private Generic(ObservableValue<T> observable, ChangeListener<? super T> listener0, ChangeListener<? super T> listener1) {
|
||||
super(observable);
|
||||
this.changeListeners = new ChangeListener[] {listener0, listener1};
|
||||
this.changeSize = 2;
|
||||
this.currentValue = observable.getValue();
|
||||
}
|
||||
|
||||
private Generic(ObservableValue<T> observable, InvalidationListener invalidationListener, ChangeListener<? super T> changeListener) {
|
||||
super(observable);
|
||||
this.invalidationListeners = new InvalidationListener[] {invalidationListener};
|
||||
this.invalidationSize = 1;
|
||||
this.changeListeners = new ChangeListener[] {changeListener};
|
||||
this.changeSize = 1;
|
||||
this.currentValue = observable.getValue();
|
||||
}
|
||||
|
||||
@Override
|
||||
protected Generic<T> addListener(InvalidationListener listener) {
|
||||
if (invalidationListeners == null) {
|
||||
invalidationListeners = new InvalidationListener[] {listener};
|
||||
invalidationSize = 1;
|
||||
} else {
|
||||
final int oldCapacity = invalidationListeners.length;
|
||||
if (locked) {
|
||||
final int newCapacity = (invalidationSize < oldCapacity)? oldCapacity : (oldCapacity * 3)/2 + 1;
|
||||
invalidationListeners = Arrays.copyOf(invalidationListeners, newCapacity);
|
||||
} else if (invalidationSize == oldCapacity) {
|
||||
invalidationSize = trim(invalidationSize, invalidationListeners);
|
||||
if (invalidationSize == oldCapacity) {
|
||||
final int newCapacity = (oldCapacity * 3)/2 + 1;
|
||||
invalidationListeners = Arrays.copyOf(invalidationListeners, newCapacity);
|
||||
}
|
||||
}
|
||||
invalidationListeners[invalidationSize++] = listener;
|
||||
}
|
||||
return this;
|
||||
}
|
||||
|
||||
@Override
|
||||
protected ExpressionHelper<T> removeListener(InvalidationListener listener) {
|
||||
if (invalidationListeners != null) {
|
||||
for (int index = 0; index < invalidationSize; index++) {
|
||||
if (listener.equals(invalidationListeners[index])) {
|
||||
if (invalidationSize == 1) {
|
||||
if (changeSize == 1) {
|
||||
return new SingleChange<T>(observable, changeListeners[0]);
|
||||
}
|
||||
invalidationListeners = null;
|
||||
invalidationSize = 0;
|
||||
} else if ((invalidationSize == 2) && (changeSize == 0)) {
|
||||
return new SingleInvalidation<T>(observable, invalidationListeners[1-index]);
|
||||
} else {
|
||||
final int numMoved = invalidationSize - index - 1;
|
||||
final InvalidationListener[] oldListeners = invalidationListeners;
|
||||
if (locked) {
|
||||
invalidationListeners = new InvalidationListener[invalidationListeners.length];
|
||||
System.arraycopy(oldListeners, 0, invalidationListeners, 0, index);
|
||||
}
|
||||
if (numMoved > 0) {
|
||||
System.arraycopy(oldListeners, index+1, invalidationListeners, index, numMoved);
|
||||
}
|
||||
invalidationSize--;
|
||||
if (!locked) {
|
||||
invalidationListeners[invalidationSize] = null; // Let gc do its work
|
||||
}
|
||||
}
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
return this;
|
||||
}
|
||||
|
||||
@Override
|
||||
protected ExpressionHelper<T> addListener(ChangeListener<? super T> listener) {
|
||||
if (changeListeners == null) {
|
||||
changeListeners = new ChangeListener[] {listener};
|
||||
changeSize = 1;
|
||||
} else {
|
||||
final int oldCapacity = changeListeners.length;
|
||||
if (locked) {
|
||||
final int newCapacity = (changeSize < oldCapacity)? oldCapacity : (oldCapacity * 3)/2 + 1;
|
||||
changeListeners = Arrays.copyOf(changeListeners, newCapacity);
|
||||
} else if (changeSize == oldCapacity) {
|
||||
changeSize = trim(changeSize, changeListeners);
|
||||
if (changeSize == oldCapacity) {
|
||||
final int newCapacity = (oldCapacity * 3)/2 + 1;
|
||||
changeListeners = Arrays.copyOf(changeListeners, newCapacity);
|
||||
}
|
||||
}
|
||||
changeListeners[changeSize++] = listener;
|
||||
}
|
||||
if (changeSize == 1) {
|
||||
currentValue = observable.getValue();
|
||||
}
|
||||
return this;
|
||||
}
|
||||
|
||||
@Override
|
||||
protected ExpressionHelper<T> removeListener(ChangeListener<? super T> listener) {
|
||||
if (changeListeners != null) {
|
||||
for (int index = 0; index < changeSize; index++) {
|
||||
if (listener.equals(changeListeners[index])) {
|
||||
if (changeSize == 1) {
|
||||
if (invalidationSize == 1) {
|
||||
return new SingleInvalidation<T>(observable, invalidationListeners[0]);
|
||||
}
|
||||
changeListeners = null;
|
||||
changeSize = 0;
|
||||
} else if ((changeSize == 2) && (invalidationSize == 0)) {
|
||||
return new SingleChange<T>(observable, changeListeners[1-index]);
|
||||
} else {
|
||||
final int numMoved = changeSize - index - 1;
|
||||
final ChangeListener<? super T>[] oldListeners = changeListeners;
|
||||
if (locked) {
|
||||
changeListeners = new ChangeListener[changeListeners.length];
|
||||
System.arraycopy(oldListeners, 0, changeListeners, 0, index);
|
||||
}
|
||||
if (numMoved > 0) {
|
||||
System.arraycopy(oldListeners, index+1, changeListeners, index, numMoved);
|
||||
}
|
||||
changeSize--;
|
||||
if (!locked) {
|
||||
changeListeners[changeSize] = null; // Let gc do its work
|
||||
}
|
||||
}
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
return this;
|
||||
}
|
||||
|
||||
@Override
|
||||
protected void fireValueChangedEvent() {
|
||||
final InvalidationListener[] curInvalidationList = invalidationListeners;
|
||||
final int curInvalidationSize = invalidationSize;
|
||||
final ChangeListener<? super T>[] curChangeList = changeListeners;
|
||||
final int curChangeSize = changeSize;
|
||||
|
||||
try {
|
||||
locked = true;
|
||||
for (int i = 0; i < curInvalidationSize; i++) {
|
||||
try {
|
||||
curInvalidationList[i].invalidated(observable);
|
||||
} catch (Exception e) {
|
||||
Thread.currentThread().getUncaughtExceptionHandler().uncaughtException(Thread.currentThread(), e);
|
||||
}
|
||||
}
|
||||
if (curChangeSize > 0) {
|
||||
final T oldValue = currentValue;
|
||||
currentValue = observable.getValue();
|
||||
final boolean changed = (currentValue == null)? (oldValue != null) : !currentValue.equals(oldValue);
|
||||
if (changed) {
|
||||
for (int i = 0; i < curChangeSize; i++) {
|
||||
try {
|
||||
curChangeList[i].changed(observable, oldValue, currentValue);
|
||||
} catch (Exception e) {
|
||||
Thread.currentThread().getUncaughtExceptionHandler().uncaughtException(Thread.currentThread(), e);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
} finally {
|
||||
locked = false;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
}
|
|
@ -0,0 +1,32 @@
|
|||
package com.tungsten.fclcore.util.fakefx.fx;
|
||||
|
||||
import java.util.function.Predicate;
|
||||
|
||||
public class ExpressionHelperBase {
|
||||
|
||||
protected static int trim(int size, Object[] listeners) {
|
||||
Predicate<Object> p = t -> t instanceof WeakListener &&
|
||||
((WeakListener)t).wasGarbageCollected();
|
||||
int index = 0;
|
||||
for (; index < size; index++) {
|
||||
if (p.test(listeners[index])) {
|
||||
break;
|
||||
}
|
||||
}
|
||||
if (index < size) {
|
||||
for (int src = index + 1; src < size; src++) {
|
||||
if (!p.test(listeners[src])) {
|
||||
listeners[index++] = listeners[src];
|
||||
}
|
||||
}
|
||||
int oldSize = size;
|
||||
size = index;
|
||||
for (; index < oldSize; index++) {
|
||||
listeners[index] = null;
|
||||
}
|
||||
}
|
||||
|
||||
return size;
|
||||
}
|
||||
|
||||
}
|
|
@ -0,0 +1,6 @@
|
|||
package com.tungsten.fclcore.util.fakefx.fx;
|
||||
|
||||
public interface InvalidationListener {
|
||||
|
||||
public void invalidated(Observable observable);
|
||||
}
|
|
@ -0,0 +1,90 @@
|
|||
package com.tungsten.fclcore.util.fakefx.fx;
|
||||
|
||||
public abstract class ObjectBinding<T> extends ObjectExpression<T> implements Binding<T> {
|
||||
|
||||
private T value;
|
||||
private boolean valid = false;
|
||||
private BindingHelperObserver observer;
|
||||
private ExpressionHelper<T> helper = null;
|
||||
|
||||
public ObjectBinding() {
|
||||
}
|
||||
|
||||
@Override
|
||||
public void addListener(InvalidationListener listener) {
|
||||
helper = ExpressionHelper.addListener(helper, this, listener);
|
||||
}
|
||||
|
||||
@Override
|
||||
public void removeListener(InvalidationListener listener) {
|
||||
helper = ExpressionHelper.removeListener(helper, listener);
|
||||
}
|
||||
|
||||
@Override
|
||||
public void addListener(ChangeListener<? super T> listener) {
|
||||
helper = ExpressionHelper.addListener(helper, this, listener);
|
||||
}
|
||||
|
||||
@Override
|
||||
public void removeListener(ChangeListener<? super T> listener) {
|
||||
helper = ExpressionHelper.removeListener(helper, listener);
|
||||
}
|
||||
|
||||
protected final void bind(Observable... dependencies) {
|
||||
if ((dependencies != null) && (dependencies.length > 0)) {
|
||||
if (observer == null) {
|
||||
observer = new BindingHelperObserver(this);
|
||||
}
|
||||
for (final Observable dep : dependencies) {
|
||||
dep.addListener(observer);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
protected final void unbind(Observable... dependencies) {
|
||||
if (observer != null) {
|
||||
for (final Observable dep : dependencies) {
|
||||
dep.removeListener(observer);
|
||||
}
|
||||
observer = null;
|
||||
}
|
||||
}
|
||||
|
||||
@Override
|
||||
public void dispose() {
|
||||
}
|
||||
|
||||
@Override
|
||||
public final T get() {
|
||||
if (!valid) {
|
||||
value = computeValue();
|
||||
valid = true;
|
||||
}
|
||||
return value;
|
||||
}
|
||||
|
||||
protected void onInvalidating() {
|
||||
}
|
||||
|
||||
@Override
|
||||
public final void invalidate() {
|
||||
if (valid) {
|
||||
valid = false;
|
||||
onInvalidating();
|
||||
ExpressionHelper.fireValueChangedEvent(helper);
|
||||
}
|
||||
}
|
||||
|
||||
@Override
|
||||
public final boolean isValid() {
|
||||
return valid;
|
||||
}
|
||||
|
||||
protected abstract T computeValue();
|
||||
|
||||
@Override
|
||||
public String toString() {
|
||||
return valid ? "ObjectBinding [value: " + get() + "]"
|
||||
: "ObjectBinding [invalid]";
|
||||
}
|
||||
}
|
|
@ -0,0 +1,12 @@
|
|||
package com.tungsten.fclcore.util.fakefx.fx;
|
||||
|
||||
public abstract class ObjectExpression<T> implements ObservableObjectValue<T> {
|
||||
|
||||
@Override
|
||||
public T getValue() {
|
||||
return get();
|
||||
}
|
||||
|
||||
public ObjectExpression() {
|
||||
}
|
||||
}
|
|
@ -0,0 +1,8 @@
|
|||
package com.tungsten.fclcore.util.fakefx.fx;
|
||||
|
||||
public interface Observable {
|
||||
|
||||
void addListener(InvalidationListener listener);
|
||||
|
||||
void removeListener(InvalidationListener listener);
|
||||
}
|
|
@ -0,0 +1,6 @@
|
|||
package com.tungsten.fclcore.util.fakefx.fx;
|
||||
|
||||
public interface ObservableObjectValue<T> extends ObservableValue<T> {
|
||||
|
||||
T get();
|
||||
}
|
|
@ -0,0 +1,10 @@
|
|||
package com.tungsten.fclcore.util.fakefx.fx;
|
||||
|
||||
public interface ObservableValue<T> extends Observable {
|
||||
|
||||
void addListener(ChangeListener<? super T> listener);
|
||||
|
||||
void removeListener(ChangeListener<? super T> listener);
|
||||
|
||||
T getValue();
|
||||
}
|
|
@ -0,0 +1,11 @@
|
|||
package com.tungsten.fclcore.util.fakefx.fx;
|
||||
|
||||
public interface WeakListener {
|
||||
/**
|
||||
* Returns {@code true} if the linked listener was garbage-collected.
|
||||
* In this case, the listener can be removed from the observable.
|
||||
*
|
||||
* @return {@code true} if the linked listener was garbage-collected.
|
||||
*/
|
||||
boolean wasGarbageCollected();
|
||||
}
|
Loading…
Reference in New Issue