add fakefx utils & import auth part

This commit is contained in:
Tungstend 2022-10-21 18:20:10 +08:00
parent c1c0925e6f
commit 6c33c0054a
66 changed files with 5332 additions and 0 deletions

View File

@ -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();
}
}

View File

@ -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);
}

View File

@ -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 {
}
}

View File

@ -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);
}
}

View File

@ -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() {
}
}

View File

@ -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;
}

View File

@ -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;
}

View File

@ -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);
}
}

View File

@ -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() {
}
}

View File

@ -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() {
}
}

View File

@ -0,0 +1,4 @@
package com.tungsten.fclcore.auth;
public class NotLoggedInException extends AuthenticationException {
}

View File

@ -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;
}
}

View File

@ -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;
}
}
}

View File

@ -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);
}
}

View File

@ -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);
}
}

View File

@ -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);
}
}

View File

@ -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);
}
}

View File

@ -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 + "]";
}
}

View File

@ -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();
}

View File

@ -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);
}
}

View File

@ -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;
}
}

View File

@ -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;
}
}

View File

@ -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;
}
}
}

View File

@ -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);
}
}

View File

@ -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();
}
}
}

View File

@ -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);
}
}

View File

@ -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);
}
}

View File

@ -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();
}

View File

@ -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;
}
}
}

View File

@ -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);
}
}

View File

@ -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;
}
}
}

View File

@ -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;
}
}
}
}

View File

@ -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());
}
}

View File

@ -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());
}
}

View File

@ -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");
}
}

View File

@ -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");
}
}

View File

@ -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";
}
}

View File

@ -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;
}
}

View File

@ -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();
}
}

View File

@ -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;
}
}

View File

@ -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;
}
}

View File

@ -0,0 +1,5 @@
package com.tungsten.fclcore.auth.yggdrasil;
public enum TextureType {
SKIN, CAPE
}

View File

@ -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.");
}
}

View File

@ -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);
}
}

View File

@ -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);
}
}

View File

@ -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 &lt;access token&gt;
*
* 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;
}

View File

@ -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";
}

View File

@ -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();
}

View File

@ -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";
}
}

View File

@ -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;
}
}
}

View File

@ -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();
}
}

View File

@ -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);
}
}

View File

@ -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);
}
}

View File

@ -0,0 +1,11 @@
package com.tungsten.fclcore.util.fakefx.fx;
public interface Binding<T> extends ObservableValue<T> {
boolean isValid();
void invalidate();
void dispose();
}

View File

@ -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;
}
}

View File

@ -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);
}
};
}
}

View File

@ -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);
}

View File

@ -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;
}
}
}
}

View File

@ -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;
}
}

View File

@ -0,0 +1,6 @@
package com.tungsten.fclcore.util.fakefx.fx;
public interface InvalidationListener {
public void invalidated(Observable observable);
}

View File

@ -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]";
}
}

View File

@ -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() {
}
}

View File

@ -0,0 +1,8 @@
package com.tungsten.fclcore.util.fakefx.fx;
public interface Observable {
void addListener(InvalidationListener listener);
void removeListener(InvalidationListener listener);
}

View File

@ -0,0 +1,6 @@
package com.tungsten.fclcore.util.fakefx.fx;
public interface ObservableObjectValue<T> extends ObservableValue<T> {
T get();
}

View File

@ -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();
}

View File

@ -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();
}