将到skins.minecraft.net的请求导向本地, fix #7

This commit is contained in:
yushijinhun 2018-03-31 22:16:12 +08:00
parent 42aefc9f4a
commit 77105062de
No known key found for this signature in database
GPG key ID: 5BC167F73EA558E4
5 changed files with 269 additions and 22 deletions

View file

@ -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<ClassFileTransformer> transformerRegistry) {
if (booted) {
if (!booted.compareAndSet(false, true)) {
info("already booted, skipping");
return;
}
booted = true;
Optional<YggdrasilConfiguration> 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<YggdrasilConfiguration> 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())));

View file

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

View file

@ -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/(?<username>[^/]+)\\.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<Response> processAsSkin(IHTTPSession session) {
Matcher matcher = URL_SKINS.matcher(session.getUri());
if (!matcher.find()) return empty();
String username = matcher.group("username");
Optional<String> 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<String> 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<String> 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<String> 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();
}
}
}

View file

@ -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>\/.*)$
// => <localApiRoot>${path}
public static final Pattern REGEX = Pattern.compile("^https?:\\/\\/(skins\\.minecraft\\.net)(?<path>\\/.*)$");
public DeprecatedApiTransformUnit(Supplier<String> 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";
}
}

View file

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