diff --git a/src/main/java/org/to2mbn/authlibinjector/AuthlibInjector.java b/src/main/java/org/to2mbn/authlibinjector/AuthlibInjector.java index a175d72..ada48b2 100644 --- a/src/main/java/org/to2mbn/authlibinjector/AuthlibInjector.java +++ b/src/main/java/org/to2mbn/authlibinjector/AuthlibInjector.java @@ -3,14 +3,17 @@ package org.to2mbn.authlibinjector; import static java.nio.charset.StandardCharsets.UTF_8; import static java.util.Optional.empty; import static java.util.Optional.of; -import static org.to2mbn.authlibinjector.util.IOUtils.readURL; +import static org.to2mbn.authlibinjector.util.IOUtils.asString; +import static org.to2mbn.authlibinjector.util.IOUtils.getURL; import static org.to2mbn.authlibinjector.util.IOUtils.removeNewLines; import java.io.IOException; import java.lang.instrument.ClassFileTransformer; import java.text.MessageFormat; import java.util.Base64; import java.util.Optional; +import java.util.concurrent.atomic.AtomicBoolean; import java.util.function.Consumer; +import org.to2mbn.authlibinjector.httpd.DeprecatedApiHandle; import org.to2mbn.authlibinjector.transform.ClassTransformer; import org.to2mbn.authlibinjector.transform.SkinWhitelistTransformUnit; import org.to2mbn.authlibinjector.transform.YggdrasilApiTransformUnit; @@ -26,7 +29,7 @@ public final class AuthlibInjector { private AuthlibInjector() {} - private static boolean booted = false; + private static AtomicBoolean booted = new AtomicBoolean(false); private static boolean debug = "true".equals(System.getProperty("org.to2mbn.authlibinjector.debug")); public static void info(String message, Object... args) { @@ -40,19 +43,17 @@ public final class AuthlibInjector { } public static void bootstrap(Consumer transformerRegistry) { - if (booted) { + if (!booted.compareAndSet(false, true)) { info("already booted, skipping"); return; } - booted = true; Optional optionalConfig = configure(); - if (!optionalConfig.isPresent()) { + if (optionalConfig.isPresent()) { + transformerRegistry.accept(createTransformer(optionalConfig.get())); + } else { info("no config available"); - return; } - - transformerRegistry.accept(createTransformer(optionalConfig.get())); } private static Optional configure() { @@ -66,7 +67,7 @@ public final class AuthlibInjector { if (prefetched == null) { info("fetching metadata"); try { - metadataResponse = readURL(apiRoot); + metadataResponse = asString(getURL(apiRoot)); } catch (IOException e) { info("unable to fetch metadata: {0}", e); return empty(); @@ -106,8 +107,14 @@ public final class AuthlibInjector { for (String ignore : nonTransformablePackages) transformer.ignores.add(ignore); + if (!"true".equals(System.getProperty("org.to2mbn.authlibinjector.httpd.disable"))) { + transformer.units.add(DeprecatedApiHandle.createTransformUnit(config)); + } + transformer.units.add(new YggdrasilApiTransformUnit(config.getApiRoot())); + transformer.units.add(new SkinWhitelistTransformUnit(config.getSkinDomains().toArray(new String[0]))); + config.getDecodedPublickey().ifPresent( key -> transformer.units.add(new YggdrasilKeyTransformUnit(key.getEncoded()))); diff --git a/src/main/java/org/to2mbn/authlibinjector/httpd/DeprecatedApiHandle.java b/src/main/java/org/to2mbn/authlibinjector/httpd/DeprecatedApiHandle.java new file mode 100644 index 0000000..4e2009b --- /dev/null +++ b/src/main/java/org/to2mbn/authlibinjector/httpd/DeprecatedApiHandle.java @@ -0,0 +1,54 @@ +package org.to2mbn.authlibinjector.httpd; + +import static org.to2mbn.authlibinjector.AuthlibInjector.info; +import java.io.IOException; +import org.to2mbn.authlibinjector.YggdrasilConfiguration; +import org.to2mbn.authlibinjector.transform.DeprecatedApiTransformUnit; +import org.to2mbn.authlibinjector.transform.TransformUnit; + +public class DeprecatedApiHandle { + + public static TransformUnit createTransformUnit(YggdrasilConfiguration configuration) { + DeprecatedApiHandle handle = new DeprecatedApiHandle(configuration); + return new DeprecatedApiTransformUnit(() -> { + handle.ensureStarted(); + return "http://127.0.0.1:" + handle.getLocalApiPort(); + }); + } + + private boolean started = false; + private YggdrasilConfiguration configuration; + private DeprecatedApiHttpd httpd; + + private final Object _lock = new Object(); + + public DeprecatedApiHandle(YggdrasilConfiguration configuration) { + this.configuration = configuration; + } + + public void ensureStarted() { + if (started) + return; + synchronized (_lock) { + if (started) + return; + if (configuration == null) + throw new IllegalStateException("configuration hasn't been set yet"); + httpd = new DeprecatedApiHttpd(0, configuration); + try { + httpd.start(); + } catch (IOException e) { + throw new IllegalStateException("httpd failed to start"); + } + info("httpd is running on port {0,number,#}", getLocalApiPort()); + started = true; + } + } + + public int getLocalApiPort() { + if (httpd == null) + return -1; + return httpd.getListeningPort(); + } + +} diff --git a/src/main/java/org/to2mbn/authlibinjector/httpd/DeprecatedApiHttpd.java b/src/main/java/org/to2mbn/authlibinjector/httpd/DeprecatedApiHttpd.java new file mode 100644 index 0000000..bcfbcd5 --- /dev/null +++ b/src/main/java/org/to2mbn/authlibinjector/httpd/DeprecatedApiHttpd.java @@ -0,0 +1,134 @@ +package org.to2mbn.authlibinjector.httpd; + +import static java.nio.charset.StandardCharsets.UTF_8; +import static java.util.Optional.empty; +import static java.util.Optional.of; +import static org.to2mbn.authlibinjector.AuthlibInjector.debug; +import static org.to2mbn.authlibinjector.AuthlibInjector.info; +import static org.to2mbn.authlibinjector.util.IOUtils.asString; +import static org.to2mbn.authlibinjector.util.IOUtils.getURL; +import static org.to2mbn.authlibinjector.util.IOUtils.postURL; +import java.io.ByteArrayInputStream; +import java.io.IOException; +import java.io.UncheckedIOException; +import java.util.Base64; +import java.util.Optional; +import java.util.regex.Matcher; +import java.util.regex.Pattern; +import org.to2mbn.authlibinjector.YggdrasilConfiguration; +import org.to2mbn.authlibinjector.internal.org.json.JSONArray; +import org.to2mbn.authlibinjector.internal.org.json.JSONException; +import org.to2mbn.authlibinjector.internal.org.json.JSONObject; +import fi.iki.elonen.NanoHTTPD; +import fi.iki.elonen.NanoHTTPD.Response.Status; + +public class DeprecatedApiHttpd extends NanoHTTPD { + + public static final String CONTENT_TYPE_JSON = "application/json; charset=utf-8"; + + // ^/MinecraftSkins/([^/]+)\.png$ + private static final Pattern URL_SKINS = Pattern.compile("^/MinecraftSkins/(?[^/]+)\\.png$"); + + private YggdrasilConfiguration configuration; + + public DeprecatedApiHttpd(int port, YggdrasilConfiguration configuration) { + super("127.0.0.1", port); + this.configuration = configuration; + } + + @Override + public Response serve(IHTTPSession session) { + return processAsSkin(session) + .orElseGet(() -> super.serve(session)); + } + + private Optional processAsSkin(IHTTPSession session) { + Matcher matcher = URL_SKINS.matcher(session.getUri()); + if (!matcher.find()) return empty(); + String username = matcher.group("username"); + + Optional skinUrl; + try { + skinUrl = queryCharacterUUID(username) + .flatMap(uuid -> queryCharacterProperty(uuid, "textures")) + .map(encoded -> asString(Base64.getDecoder().decode(encoded))) + .flatMap(texturesPayload -> obtainTextureUrl(texturesPayload, "SKIN")); + } catch (UncheckedIOException | JSONException e) { + info("[httpd] unable to fetch skin for {0}: {1}", username, e); + return of(newFixedLengthResponse(Status.INTERNAL_ERROR, null, null)); + } + + if (skinUrl.isPresent()) { + String url = skinUrl.get(); + debug("[httpd] retrieving skin for {0} from {1}", username, url); + byte[] data; + try { + data = getURL(url); + } catch (IOException e) { + info("[httpd] unable to retrieve skin from {0}: {1}", url, e); + return of(newFixedLengthResponse(Status.NOT_FOUND, null, null)); + } + info("[httpd] retrieved skin for {0} from {1}, {2} bytes", username, url, data.length); + return of(newFixedLengthResponse(Status.OK, "image/png", new ByteArrayInputStream(data), data.length)); + + } else { + info("[httpd] no skin found for {0}", username); + return of(newFixedLengthResponse(Status.NOT_FOUND, null, null)); + } + } + + private Optional queryCharacterUUID(String username) throws UncheckedIOException, JSONException { + String responseText; + try { + responseText = asString(postURL( + configuration.getApiRoot() + "api/profiles/minecraft", + CONTENT_TYPE_JSON, + new JSONArray(new String[] { username }) + .toString().getBytes(UTF_8))); + } catch (IOException e) { + throw new UncheckedIOException(e); + } + debug("[httpd] query uuid of username {0}, response: {1}", username, responseText); + JSONArray response = new JSONArray(responseText); + if (response.length() == 0) { + return empty(); + } else if (response.length() == 1) { + return of(response.getJSONObject(0).getString("id")); + } else { + throw new JSONException("Unexpected response length"); + } + } + + private Optional queryCharacterProperty(String uuid, String property) throws UncheckedIOException, JSONException { + String responseText; + try { + responseText = asString(getURL( + configuration.getApiRoot() + "sessionserver/session/minecraft/profile/" + uuid)); + } catch (IOException e) { + throw new UncheckedIOException(e); + } + if (responseText.isEmpty()) { + debug("[httpd] query profile of {0}, not found", uuid); + return empty(); + } + debug("[httpd] query profile of {0}, response: {1}", uuid, responseText); + JSONObject response = new JSONObject(responseText); + for (Object element_ : response.getJSONArray("properties")) { + JSONObject element = (JSONObject) element_; + if (property.equals(element.getString("name"))) { + return of(element.getString("value")); + } + } + return empty(); + } + + private Optional obtainTextureUrl(String texturesPayload, String textureType) throws JSONException { + JSONObject textures = new JSONObject(texturesPayload).getJSONObject("textures"); + if (textures.has(textureType)) { + return of(textures.getJSONObject(textureType).getString("url")); + } else { + return empty(); + } + } + +} diff --git a/src/main/java/org/to2mbn/authlibinjector/transform/DeprecatedApiTransformUnit.java b/src/main/java/org/to2mbn/authlibinjector/transform/DeprecatedApiTransformUnit.java new file mode 100644 index 0000000..a5c34d3 --- /dev/null +++ b/src/main/java/org/to2mbn/authlibinjector/transform/DeprecatedApiTransformUnit.java @@ -0,0 +1,30 @@ +package org.to2mbn.authlibinjector.transform; + +import static java.util.Optional.empty; +import static java.util.Optional.of; +import java.util.function.Supplier; +import java.util.regex.Matcher; +import java.util.regex.Pattern; + +public class DeprecatedApiTransformUnit extends LdcTransformUnit { + + // ^https?:\/\/(skins\.minecraft\.net)(?\/.*)$ + // => ${path} + public static final Pattern REGEX = Pattern.compile("^https?:\\/\\/(skins\\.minecraft\\.net)(?\\/.*)$"); + + public DeprecatedApiTransformUnit(Supplier localApiRoot) { + super(string -> { + Matcher matcher = REGEX.matcher(string); + if (matcher.find()) { + return of(matcher.replaceAll(localApiRoot.get() + "${path}")); + } else { + return empty(); + } + }); + } + + @Override + public String toString() { + return "deprecated-api-transform"; + } +} diff --git a/src/main/java/org/to2mbn/authlibinjector/util/IOUtils.java b/src/main/java/org/to2mbn/authlibinjector/util/IOUtils.java index 4fd275f..ceaaa32 100644 --- a/src/main/java/org/to2mbn/authlibinjector/util/IOUtils.java +++ b/src/main/java/org/to2mbn/authlibinjector/util/IOUtils.java @@ -1,30 +1,52 @@ package org.to2mbn.authlibinjector.util; import static java.nio.charset.StandardCharsets.UTF_8; -import java.io.CharArrayWriter; +import java.io.ByteArrayOutputStream; import java.io.IOException; import java.io.InputStream; -import java.io.InputStreamReader; -import java.io.Reader; +import java.io.OutputStream; +import java.net.HttpURLConnection; import java.net.URL; public final class IOUtils { - public static String readURL(String url) throws IOException { + public static byte[] getURL(String url) throws IOException { try (InputStream in = new URL(url).openStream()) { - return asString(in); + return asBytes(in); } } - public static String asString(InputStream in) throws IOException { - CharArrayWriter w = new CharArrayWriter(); - Reader reader = new InputStreamReader(in, UTF_8); - char[] buf = new char[4096]; // 8192 bytes - int read; - while ((read = reader.read(buf)) != -1) { - w.write(buf, 0, read); + public static byte[] postURL(String url, String contentType, byte[] payload) throws IOException { + HttpURLConnection conn = (HttpURLConnection) new URL(url).openConnection(); + conn.setRequestMethod("POST"); + conn.setRequestProperty("Content-Type", contentType); + conn.setRequestProperty("Content-Length", String.valueOf(payload.length)); + conn.setDoOutput(true); + try { + conn.connect(); + try (OutputStream out = conn.getOutputStream()) { + out.write(payload); + } + try (InputStream in = conn.getInputStream()) { + return asBytes(in); + } + } finally { + conn.disconnect(); } - return new String(w.toCharArray()); + } + + public static byte[] asBytes(InputStream in) throws IOException { + ByteArrayOutputStream out = new ByteArrayOutputStream(); + byte[] buf = new byte[8192]; + int read; + while ((read = in.read(buf)) != -1) { + out.write(buf, 0, read); + } + return out.toByteArray(); + } + + public static String asString(byte[] bytes) { + return new String(bytes, UTF_8); } public static String removeNewLines(String input) {