Authenticate through Mojang's session server; check client version
Stop sending the Minecraft access token to the Saturn server. The connect loop now: 1. fetches GET /versions and compares this client's mod version: supported -> connect; deprecated -> warn, connect; neither -> warn, don't connect (warnings are shown once per status change) 2. requests auth_challenge with the username to get a server id 3. calls Mojang's session `join` with the access token and server id (the token only goes to Mojang); a refused token gives up 4. requests auth_verify, which the server confirms with `hasJoined` Add SaturnProvider.getModVersion() and showWarning() for the version check and its toast. Refs saturnclientmc/saturnclient#7 Co-Authored-By: Claude Opus 5.5 <[email protected]>
This commit is contained in:
@@ -0,0 +1,60 @@
|
|||||||
|
package org.saturnclient.client;
|
||||||
|
|
||||||
|
import java.net.URI;
|
||||||
|
import java.net.http.HttpClient;
|
||||||
|
import java.net.http.HttpRequest;
|
||||||
|
import java.net.http.HttpResponse;
|
||||||
|
import java.time.Duration;
|
||||||
|
import java.util.UUID;
|
||||||
|
|
||||||
|
import com.fasterxml.jackson.databind.ObjectMapper;
|
||||||
|
import com.fasterxml.jackson.databind.node.ObjectNode;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Mojang's session server "join" call, the same one vanilla makes when joining
|
||||||
|
* an online-mode server. The access token is only ever sent to Mojang; the
|
||||||
|
* Saturn server then confirms the join with {@code hasJoined}.
|
||||||
|
*/
|
||||||
|
public final class MojangSession {
|
||||||
|
private static final URI JOIN_URI = URI.create("https://sessionserver.mojang.com/session/minecraft/join");
|
||||||
|
private static final ObjectMapper MAPPER = new ObjectMapper();
|
||||||
|
|
||||||
|
public enum JoinResult {
|
||||||
|
JOINED,
|
||||||
|
/** Mojang refused the access token; retrying won't help. */
|
||||||
|
REJECTED,
|
||||||
|
/** Network or server error; worth retrying. */
|
||||||
|
FAILED
|
||||||
|
}
|
||||||
|
|
||||||
|
private MojangSession() {
|
||||||
|
}
|
||||||
|
|
||||||
|
public static JoinResult join(HttpClient http, String accessToken, UUID profile, String serverId) {
|
||||||
|
ObjectNode body = MAPPER.createObjectNode()
|
||||||
|
.put("accessToken", accessToken)
|
||||||
|
.put("selectedProfile", profile.toString().replace("-", ""))
|
||||||
|
.put("serverId", serverId);
|
||||||
|
|
||||||
|
HttpRequest request = HttpRequest.newBuilder(JOIN_URI)
|
||||||
|
.timeout(Duration.ofSeconds(10))
|
||||||
|
.header("Content-Type", "application/json")
|
||||||
|
.POST(HttpRequest.BodyPublishers.ofString(body.toString()))
|
||||||
|
.build();
|
||||||
|
|
||||||
|
try {
|
||||||
|
int status = http.send(request, HttpResponse.BodyHandlers.discarding()).statusCode();
|
||||||
|
|
||||||
|
if (status == 204 || status == 200) {
|
||||||
|
return JoinResult.JOINED;
|
||||||
|
}
|
||||||
|
|
||||||
|
return status == 401 || status == 403 ? JoinResult.REJECTED : JoinResult.FAILED;
|
||||||
|
} catch (InterruptedException e) {
|
||||||
|
Thread.currentThread().interrupt();
|
||||||
|
return JoinResult.FAILED;
|
||||||
|
} catch (Exception e) {
|
||||||
|
return JoinResult.FAILED;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,5 +1,9 @@
|
|||||||
package org.saturnclient.client;
|
package org.saturnclient.client;
|
||||||
|
|
||||||
|
import java.net.URI;
|
||||||
|
import java.net.http.HttpClient;
|
||||||
|
import java.net.http.HttpRequest;
|
||||||
|
import java.net.http.HttpResponse;
|
||||||
import java.time.Duration;
|
import java.time.Duration;
|
||||||
import java.util.UUID;
|
import java.util.UUID;
|
||||||
import java.util.concurrent.CompletionException;
|
import java.util.concurrent.CompletionException;
|
||||||
@@ -13,6 +17,8 @@ import org.saturnclient.common.ref.game.MinecraftClientRef;
|
|||||||
import org.saturnclient.cosmetics.Cloaks;
|
import org.saturnclient.cosmetics.Cloaks;
|
||||||
import org.saturnclient.cosmetics.Hats;
|
import org.saturnclient.cosmetics.Hats;
|
||||||
|
|
||||||
|
import com.fasterxml.jackson.databind.ObjectMapper;
|
||||||
|
|
||||||
import dev.selimaj.session.Session;
|
import dev.selimaj.session.Session;
|
||||||
import dev.selimaj.session.types.SessionErrorException;
|
import dev.selimaj.session.types.SessionErrorException;
|
||||||
|
|
||||||
@@ -31,6 +37,22 @@ public class ServiceClient {
|
|||||||
private static final AtomicBoolean CONNECTING = new AtomicBoolean();
|
private static final AtomicBoolean CONNECTING = new AtomicBoolean();
|
||||||
private static volatile boolean stopping = false;
|
private static volatile boolean stopping = false;
|
||||||
|
|
||||||
|
private static final HttpClient HTTP = HttpClient.newBuilder()
|
||||||
|
.connectTimeout(Duration.ofSeconds(CONNECT_TIMEOUT_SECONDS))
|
||||||
|
.build();
|
||||||
|
private static final ObjectMapper MAPPER = new ObjectMapper();
|
||||||
|
|
||||||
|
private enum VersionStatus {
|
||||||
|
SUPPORTED,
|
||||||
|
/** Still accepted, but the player should update. */
|
||||||
|
DEPRECATED,
|
||||||
|
/** Not accepted; don't connect. */
|
||||||
|
UNSUPPORTED
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Last status the player was warned about, so reconnects don't repeat the toast. */
|
||||||
|
private static volatile VersionStatus warnedStatus = VersionStatus.SUPPORTED;
|
||||||
|
|
||||||
public static void initialize() {
|
public static void initialize() {
|
||||||
Providers.saturn.getClient().onClientStopping(() -> {
|
Providers.saturn.getClient().onClientStopping(() -> {
|
||||||
stopping = true;
|
stopping = true;
|
||||||
@@ -130,6 +152,19 @@ public class ServiceClient {
|
|||||||
}
|
}
|
||||||
|
|
||||||
uuid = playerUuid;
|
uuid = playerUuid;
|
||||||
|
|
||||||
|
VersionStatus version;
|
||||||
|
try {
|
||||||
|
version = checkVersion();
|
||||||
|
} catch (Exception e) {
|
||||||
|
Providers.saturn.logError("Unable to fetch the Saturn version manifest: " + rootMessage(e));
|
||||||
|
return ConnectResult.RETRY;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (version == VersionStatus.UNSUPPORTED) {
|
||||||
|
return ConnectResult.GIVE_UP;
|
||||||
|
}
|
||||||
|
|
||||||
Providers.saturn.logInfo("Authenticating with UUID: " + playerUuid);
|
Providers.saturn.logInfo("Authenticating with UUID: " + playerUuid);
|
||||||
|
|
||||||
Session s;
|
Session s;
|
||||||
@@ -142,17 +177,46 @@ public class ServiceClient {
|
|||||||
|
|
||||||
ServiceMethods.Types.Player response;
|
ServiceMethods.Types.Player response;
|
||||||
try {
|
try {
|
||||||
response = s.request(ServiceMethods.Authenticate, accessToken, REQUEST_TIMEOUT).get();
|
// 1. The server hands out a one-time server id for this connection.
|
||||||
} catch (ExecutionException e) {
|
String serverId;
|
||||||
s.close();
|
try {
|
||||||
|
serverId = s.request(ServiceMethods.AuthChallenge, username, REQUEST_TIMEOUT).get();
|
||||||
|
} catch (ExecutionException e) {
|
||||||
|
s.close();
|
||||||
|
|
||||||
if (e.getCause() instanceof SessionErrorException rejected) {
|
if (e.getCause() instanceof SessionErrorException rejected) {
|
||||||
Providers.saturn.logError("Saturn server rejected authentication: " + rejected.getMessage());
|
Providers.saturn.logError("Saturn server rejected authentication: " + rejected.getMessage());
|
||||||
return ConnectResult.GIVE_UP;
|
return ConnectResult.GIVE_UP;
|
||||||
|
}
|
||||||
|
|
||||||
|
Providers.saturn.logError("Authentication failed: " + rootMessage(e));
|
||||||
|
return ConnectResult.RETRY;
|
||||||
}
|
}
|
||||||
|
|
||||||
Providers.saturn.logError("Authentication failed: " + rootMessage(e));
|
// 2. Join with it at Mojang; the access token only goes to Mojang.
|
||||||
return ConnectResult.RETRY;
|
switch (MojangSession.join(HTTP, accessToken, playerUuid, serverId)) {
|
||||||
|
case JOINED -> {
|
||||||
|
}
|
||||||
|
case REJECTED -> {
|
||||||
|
s.close();
|
||||||
|
Providers.saturn.logError("Mojang rejected the Minecraft session");
|
||||||
|
return ConnectResult.GIVE_UP;
|
||||||
|
}
|
||||||
|
case FAILED -> {
|
||||||
|
s.close();
|
||||||
|
Providers.saturn.logError("Unable to reach the Mojang session server");
|
||||||
|
return ConnectResult.RETRY;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 3. The server confirms the join with Mojang and logs us in.
|
||||||
|
try {
|
||||||
|
response = s.request(ServiceMethods.AuthVerify, null, REQUEST_TIMEOUT).get();
|
||||||
|
} catch (ExecutionException e) {
|
||||||
|
s.close();
|
||||||
|
Providers.saturn.logError("Authentication failed: " + rootMessage(e));
|
||||||
|
return ConnectResult.RETRY;
|
||||||
|
}
|
||||||
} catch (InterruptedException e) {
|
} catch (InterruptedException e) {
|
||||||
s.close();
|
s.close();
|
||||||
return ConnectResult.GIVE_UP;
|
return ConnectResult.GIVE_UP;
|
||||||
@@ -183,6 +247,59 @@ public class ServiceClient {
|
|||||||
return ConnectResult.CONNECTED;
|
return ConnectResult.CONNECTED;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Checks this client's version against the server's manifest and warns the
|
||||||
|
* player if it's deprecated or unsupported.
|
||||||
|
*/
|
||||||
|
private static VersionStatus checkVersion() throws Exception {
|
||||||
|
URI server = URI.create(SERVER_URI);
|
||||||
|
String scheme = "wss".equals(server.getScheme()) ? "https" : "http";
|
||||||
|
URI manifestUri = new URI(scheme, server.getAuthority(), "/versions", null, null);
|
||||||
|
|
||||||
|
HttpResponse<String> res = HTTP.send(
|
||||||
|
HttpRequest.newBuilder(manifestUri).timeout(REQUEST_TIMEOUT).GET().build(),
|
||||||
|
HttpResponse.BodyHandlers.ofString());
|
||||||
|
|
||||||
|
if (res.statusCode() != 200) {
|
||||||
|
throw new IllegalStateException("GET /versions returned " + res.statusCode());
|
||||||
|
}
|
||||||
|
|
||||||
|
ServiceMethods.VersionManifest manifest = MAPPER.readValue(res.body(), ServiceMethods.VersionManifest.class);
|
||||||
|
|
||||||
|
// Drop build metadata such as "+1.21.11": the manifest lists mod versions.
|
||||||
|
String version = Providers.saturn.getModVersion().split("\\+", 2)[0];
|
||||||
|
|
||||||
|
VersionStatus status;
|
||||||
|
if (manifest.supported() != null && manifest.supported().contains(version)) {
|
||||||
|
status = VersionStatus.SUPPORTED;
|
||||||
|
} else if (manifest.deprecated() != null && manifest.deprecated().contains(version)) {
|
||||||
|
status = VersionStatus.DEPRECATED;
|
||||||
|
} else {
|
||||||
|
status = VersionStatus.UNSUPPORTED;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (status != warnedStatus) {
|
||||||
|
warnedStatus = status;
|
||||||
|
|
||||||
|
switch (status) {
|
||||||
|
case DEPRECATED -> {
|
||||||
|
Providers.saturn.logError("Saturn Client " + version + " is deprecated");
|
||||||
|
Providers.saturn.showWarning("Saturn Client update available",
|
||||||
|
"Version " + version + " is deprecated and will stop working soon. Please update.");
|
||||||
|
}
|
||||||
|
case UNSUPPORTED -> {
|
||||||
|
Providers.saturn.logError("Saturn Client " + version + " is no longer supported, not connecting");
|
||||||
|
Providers.saturn.showWarning("Saturn Client is outdated",
|
||||||
|
"Version " + version + " is no longer supported. Update to use cosmetics and emotes.");
|
||||||
|
}
|
||||||
|
case SUPPORTED -> {
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return status;
|
||||||
|
}
|
||||||
|
|
||||||
private static void onDisconnected(Session closed) {
|
private static void onDisconnected(Session closed) {
|
||||||
if (session != closed) {
|
if (session != closed) {
|
||||||
return;
|
return;
|
||||||
|
|||||||
@@ -43,11 +43,25 @@ public class ServiceMethods {
|
|||||||
// =========================
|
// =========================
|
||||||
// AUTH
|
// AUTH
|
||||||
// =========================
|
// =========================
|
||||||
public static final Method<String, Types.Player, String> Authenticate = new Method<>("auth",
|
/** Username in, random server id out, to pass to Mojang's session join. */
|
||||||
|
public static final Method<String, String, String> AuthChallenge = new Method<>("auth_challenge",
|
||||||
String.class,
|
String.class,
|
||||||
|
String.class,
|
||||||
|
String.class);
|
||||||
|
|
||||||
|
/** After joining at Mojang: the server verifies with hasJoined. */
|
||||||
|
public static final Method<Void, Types.Player, String> AuthVerify = new Method<>("auth_verify",
|
||||||
|
Void.class,
|
||||||
Types.Player.class,
|
Types.Player.class,
|
||||||
String.class);
|
String.class);
|
||||||
|
|
||||||
|
// =========================
|
||||||
|
// VERSION MANIFEST (GET /versions)
|
||||||
|
// =========================
|
||||||
|
@com.fasterxml.jackson.annotation.JsonIgnoreProperties(ignoreUnknown = true)
|
||||||
|
public record VersionManifest(java.util.List<String> supported, java.util.List<String> deprecated) {
|
||||||
|
}
|
||||||
|
|
||||||
// =========================
|
// =========================
|
||||||
// EQUIP
|
// EQUIP
|
||||||
// =========================
|
// =========================
|
||||||
|
|||||||
@@ -18,4 +18,10 @@ public interface SaturnProvider {
|
|||||||
public void logError(String message, Throwable throwable);
|
public void logError(String message, Throwable throwable);
|
||||||
|
|
||||||
public void registerBufferedImageTexture(IdentifierRef i, BufferedImage bi);
|
public void registerBufferedImageTexture(IdentifierRef i, BufferedImage bi);
|
||||||
|
|
||||||
|
/** The Saturn Client mod version, e.g. {@code 0.1.0-beta3+1.21.11}. */
|
||||||
|
public String getModVersion();
|
||||||
|
|
||||||
|
/** Shows a warning toast. Safe to call from any thread. */
|
||||||
|
public void showWarning(String title, String message);
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user