From a559112d67e49f30e81def00eb2df6d46d31d59a Mon Sep 17 00:00:00 2001 From: Anastasia Calico Date: Thu, 19 Jun 2025 02:14:48 +1000 Subject: [PATCH 1/8] feat: Mostly finish Minecraft OAuth. --- .../modgarden/backend/ModGardenBackend.java | 3 + .../v1/discord/DiscordBotLinkHandler.java | 62 ++-- .../v1/discord/DiscordBotOAuthHandler.java | 306 ++++++++++++++++-- .../v1/discord/DiscordBotUnlinkHandler.java | 44 +-- .../modgarden/backend/oauth/OAuthService.java | 14 +- .../client/MinecraftServicesOAuthClient.java | 31 ++ 6 files changed, 355 insertions(+), 105 deletions(-) create mode 100644 src/main/java/net/modgarden/backend/oauth/client/MinecraftServicesOAuthClient.java diff --git a/src/main/java/net/modgarden/backend/ModGardenBackend.java b/src/main/java/net/modgarden/backend/ModGardenBackend.java index 41b54b0..81a9cb8 100644 --- a/src/main/java/net/modgarden/backend/ModGardenBackend.java +++ b/src/main/java/net/modgarden/backend/ModGardenBackend.java @@ -35,6 +35,7 @@ import java.io.InputStreamReader; import java.lang.reflect.Type; import java.net.http.HttpClient; +import java.security.NoSuchAlgorithmException; import java.sql.Connection; import java.sql.DriverManager; import java.sql.PreparedStatement; @@ -108,6 +109,7 @@ public static void main(String[] args) { app.error(422, BackendError::handleError); app.error(500, BackendError::handleError); app.start(7070); + LOG.info("Mod Garden Backend Started!"); } @@ -138,6 +140,7 @@ public static void v1(Javalin app) { get(app, 1, "discord/oauth/modrinth", DiscordBotOAuthHandler::authModrinthAccount); get(app, 1, "discord/oauth/minecraft", DiscordBotOAuthHandler::authMinecraftAccount); + get(app, 1, "discord/oauth/minecraft/challenge", DiscordBotOAuthHandler::getMicrosoftCodeChallenge); post(app, 1, "discord/submission/create", DiscordBotSubmissionHandler::submitModrinth); diff --git a/src/main/java/net/modgarden/backend/handler/v1/discord/DiscordBotLinkHandler.java b/src/main/java/net/modgarden/backend/handler/v1/discord/DiscordBotLinkHandler.java index e0f9aed..f63a42e 100644 --- a/src/main/java/net/modgarden/backend/handler/v1/discord/DiscordBotLinkHandler.java +++ b/src/main/java/net/modgarden/backend/handler/v1/discord/DiscordBotLinkHandler.java @@ -1,22 +1,16 @@ package net.modgarden.backend.handler.v1.discord; import com.mojang.serialization.Codec; -import com.mojang.serialization.JsonOps; import com.mojang.serialization.codecs.RecordCodecBuilder; import io.javalin.http.Context; import net.modgarden.backend.ModGardenBackend; import net.modgarden.backend.data.LinkCode; import net.modgarden.backend.data.profile.User; -import net.modgarden.backend.util.ExtraCodecs; -import java.math.BigInteger; import java.sql.Connection; import java.sql.ResultSet; import java.sql.SQLException; -import java.util.ArrayList; -import java.util.List; import java.util.Locale; -import java.util.UUID; public class DiscordBotLinkHandler { public static void link(Context ctx) { @@ -54,10 +48,10 @@ public static void link(Context ctx) { } if (body.service.equals(LinkCode.Service.MODRINTH.serializedName())) { - handleModrinth(ctx, connection, body.discordId, accountId, capitalisedService); + handleModrinth(ctx, connection, body.discordId, accountId); return; } else if (body.service.equals(LinkCode.Service.MINECRAFT.serializedName())) { - handleMinecraft(ctx, connection, body.discordId, accountId, capitalisedService); + handleMinecraft(ctx, connection, body.discordId, accountId); return; } ctx.result("Invalid link code service '" + capitalisedService + "'."); @@ -72,15 +66,14 @@ public static void link(Context ctx) { private static void handleModrinth(Context ctx, Connection connection, String discordId, - String accountId, - String capitalisedService) throws SQLException { + String accountId) throws SQLException { try (var accountCheckStatement = connection.prepareStatement("SELECT 1 FROM users WHERE modrinth_id = ?"); var userCheckStatement = connection.prepareStatement("SELECT 1 FROM users WHERE discord_id = ? AND modrinth_id IS NOT NULL"); var insertStatement = connection.prepareStatement("UPDATE users SET modrinth_id = ? WHERE discord_id = ?")) { accountCheckStatement.setString(1, accountId); ResultSet accountCheckResult = accountCheckStatement.executeQuery(); if (accountCheckResult.isBeforeFirst() && accountCheckResult.getBoolean(1)) { - ctx.result("The specified " + capitalisedService + " account has already been linked to a Mod Garden account."); + ctx.result("The specified Modrinth account has already been linked to a Mod Garden account."); ctx.status(422); return; } @@ -88,7 +81,7 @@ private static void handleModrinth(Context ctx, userCheckStatement.setString(1, discordId); ResultSet userCheckResult = userCheckStatement.executeQuery(); if (userCheckResult.isBeforeFirst() && userCheckResult.getBoolean(1)) { - ctx.result("The specified Mod Garden account is already linked with " + capitalisedService + "."); + ctx.result("The specified Mod Garden account is already linked with Modrinth."); ctx.status(422); return; } @@ -97,7 +90,7 @@ private static void handleModrinth(Context ctx, insertStatement.setString(2, discordId); insertStatement.execute(); - ctx.result("Successfully linked " + capitalisedService + " account to Mod Garden account associated with Discord ID '" + discordId + "'."); + ctx.result("Successfully linked Modrinth account to Mod Garden account associated with Discord ID '" + discordId + "'."); ctx.status(201); } } @@ -105,18 +98,9 @@ private static void handleModrinth(Context ctx, private static void handleMinecraft(Context ctx, Connection connection, String discordId, - String uuid, - String capitalisedService) throws SQLException { - try (var accountCheckStatement = connection.prepareStatement("SELECT 1 FROM users WHERE instr(minecraft_accounts, ?) > 0"); - var insertStatement = connection.prepareStatement("UPDATE users SET minecraft_accounts = ? WHERE discord_id = ?")) { - accountCheckStatement.setString(1, uuid); - ResultSet accountCheckResult = accountCheckStatement.executeQuery(); - if (accountCheckResult.isBeforeFirst() && accountCheckResult.getBoolean(1)) { - ctx.result("The specified " + capitalisedService + " account has already been linked to a Mod Garden account."); - ctx.status(422); - return; - } - + String uuid) throws SQLException { + try (var accountCheckStatement = connection.prepareStatement("SELECT user_id FROM minecraft_accounts WHERE uuid = ?"); + var insertStatement = connection.prepareStatement("INSERT INTO minecraft_accounts (uuid, user_id) VALUES (?, ?)")) { User user = User.query(discordId, "discord"); if (user == null) { ctx.result("Could not find user from Discord ID '" + discordId + "'."); @@ -124,26 +108,24 @@ private static void handleMinecraft(Context ctx, return; } - List uuids = new ArrayList<>(user.minecraftAccounts()); - uuids.add(new UUID( - new BigInteger(uuid.substring(0, 16), 16).longValue(), - new BigInteger(uuid.substring(16), 16).longValue() - )); - - var dataResult = ExtraCodecs.UUID_CODEC.listOf().encodeStart(JsonOps.INSTANCE, uuids); - - if (!dataResult.hasResultOrPartial()) { - ModGardenBackend.LOG.error("Failed to create Minecraft account data. {}", dataResult.error().orElseThrow().message()); - ctx.result("Failed to create Minecraft account data."); - ctx.status(500); + accountCheckStatement.setString(1, uuid); + ResultSet accountCheckResult = accountCheckStatement.executeQuery(); + if (accountCheckResult.isBeforeFirst() && accountCheckResult.getString(1) != null) { + if (accountCheckResult.getString(1).equals(user.id())) { + ctx.result("Your Minecraft account is already linked to your Mod Garden account."); + ctx.status(200); + return; + } + ctx.result("The specified Minecraft account has already been linked to a Mod Garden account."); + ctx.status(422); return; } - accountCheckStatement.setString(1, dataResult.getOrThrow().toString()); - accountCheckStatement.setString(2, discordId); + insertStatement.setString(1, uuid); + insertStatement.setString(2, user.id()); insertStatement.execute(); - ctx.result("Successfully linked " + capitalisedService + " account to Mod Garden account associated with Discord ID '" + discordId + "'."); + ctx.result("Successfully linked Minecraft account to Mod Garden account associated with Discord ID '" + discordId + "'."); ctx.status(201); } } diff --git a/src/main/java/net/modgarden/backend/handler/v1/discord/DiscordBotOAuthHandler.java b/src/main/java/net/modgarden/backend/handler/v1/discord/DiscordBotOAuthHandler.java index 9d611b4..5ddcc22 100644 --- a/src/main/java/net/modgarden/backend/handler/v1/discord/DiscordBotOAuthHandler.java +++ b/src/main/java/net/modgarden/backend/handler/v1/discord/DiscordBotOAuthHandler.java @@ -1,7 +1,8 @@ package net.modgarden.backend.handler.v1.discord; -import com.google.gson.JsonElement; -import com.google.gson.JsonParser; +import com.google.common.cache.Cache; +import com.google.common.cache.CacheBuilder; +import com.google.gson.*; import io.javalin.http.Context; import net.modgarden.backend.ModGardenBackend; import net.modgarden.backend.data.LinkCode; @@ -10,10 +11,17 @@ import java.io.IOException; import java.io.InputStreamReader; +import java.net.URI; import java.net.http.HttpRequest; import java.net.http.HttpResponse; +import java.nio.charset.StandardCharsets; +import java.security.MessageDigest; +import java.security.NoSuchAlgorithmException; +import java.security.SecureRandom; +import java.util.Base64; import java.util.HashMap; import java.util.Map; +import java.util.concurrent.TimeUnit; public class DiscordBotOAuthHandler { public static void authModrinthAccount(Context ctx) { @@ -77,14 +85,43 @@ public static void authModrinthAccount(Context ctx) { } } - public static void authMinecraftAccount(Context ctx) { - // FIXME: Remove when implemented. - if (true) { - ctx.status(500); - ctx.result("Minecraft account linking not implemented yet."); + private static final Cache CODE_CHALLENGE_TO_VERIFIER = CacheBuilder.newBuilder() + .expireAfterWrite(15, TimeUnit.MINUTES) + .build(); + + public static void getMicrosoftCodeChallenge(Context ctx) { + if (!("Basic " + ModGardenBackend.DOTENV.get("DISCORD_OAUTH_SECRET")).equals(ctx.header("Authorization"))) { + ctx.result("Unauthorized."); + ctx.status(401); return; } + ctx.status(200); + try { + ctx.result(createCodeChallenge()); + } catch (NoSuchAlgorithmException e) { + ctx.result("Failed to generate code challenge, this shouldn't happen."); + ctx.status(500); + } + } + + public static String createCodeChallenge() throws NoSuchAlgorithmException { + byte[] bytes = new byte[32]; + new SecureRandom().nextBytes(bytes); + var codeVerifier = Base64.getUrlEncoder().withoutPadding().encodeToString(bytes); + + String codeChallenge; + codeChallenge = Base64.getUrlEncoder().withoutPadding().encodeToString(MessageDigest.getInstance("SHA-256") + .digest(codeVerifier.getBytes(StandardCharsets.US_ASCII))); + CODE_CHALLENGE_TO_VERIFIER.put(codeChallenge, codeVerifier); + + ModGardenBackend.LOG.debug("Code Verifier: {}", codeVerifier); + ModGardenBackend.LOG.debug("Code Challenge: {}", codeChallenge); + + return codeChallenge; + } + + public static void authMinecraftAccount(Context ctx) { String code = ctx.queryParam("code"); if (code == null) { ctx.status(422); @@ -92,17 +129,200 @@ public static void authMinecraftAccount(Context ctx) { return; } - // FIXME: Microsoft -> Minecraft Java Account API Authentication. + String challengeCode = ctx.queryParam("state"); + if (challengeCode == null) { + ctx.status(422); + ctx.result("Code challenge state is not specified."); + return; + } + String verifier = CODE_CHALLENGE_TO_VERIFIER.getIfPresent(challengeCode); + if (verifier == null) { + ctx.status(422); + ctx.result("Code challenge verifier has expired. Please retry."); + return; + } + CODE_CHALLENGE_TO_VERIFIER.invalidate(challengeCode); + try { - String userId = null; + String microsoftToken = null; + var microsoftTokenRequest = HttpRequest.newBuilder(URI.create("https://login.microsoftonline.com/consumers/oauth2/v2.0/token")) + .header("Content-Type", "application/x-www-form-urlencoded") + .headers("Origin", ModGardenBackend.URL + "/v1/discord/oauth/modrinth") + .POST(HttpRequest.BodyPublishers.ofString(AuthUtil.createBody(getMicrosoftAuthorizationBody(code, verifier)))); + var microsoftTokenResponse = ModGardenBackend.HTTP_CLIENT.send(microsoftTokenRequest.build(), HttpResponse.BodyHandlers.ofInputStream()); + + try (InputStreamReader microsoftTokenReader = new InputStreamReader(microsoftTokenResponse.body())) { + JsonElement microsoftTokenJson = JsonParser.parseReader(microsoftTokenReader); + if (microsoftTokenJson.isJsonObject()) { + JsonPrimitive accessToken = microsoftTokenJson.getAsJsonObject().getAsJsonPrimitive("access_token"); + if (accessToken != null && accessToken.isString()) { + microsoftToken = accessToken.getAsString(); + } + } + } + + if (microsoftToken == null) { + ctx.status(500); + ctx.result("Failed to get Microsoft access token from OAuth code."); + return; + } + + String xblToken = null; + String userHash = null; + var xblUserRequest = HttpRequest.newBuilder(URI.create("https://user.auth.xboxlive.com/user/authenticate")) + .header("Content-Type", "application/json") + .POST(HttpRequest.BodyPublishers.ofString(getXboxLiveAuthenticationBody(microsoftToken))); + var xblUserResponse = ModGardenBackend.HTTP_CLIENT.send(xblUserRequest.build(), HttpResponse.BodyHandlers.ofInputStream()); + + try (InputStreamReader xblUserReader = new InputStreamReader(xblUserResponse.body())) { + JsonElement xblUserJson = JsonParser.parseReader(xblUserReader); + if (xblUserJson.isJsonObject()) { + JsonElement token = xblUserJson.getAsJsonObject().get("Token"); + if (token != null && token.isJsonPrimitive() && token.getAsJsonPrimitive().isString()) { + xblToken = token.getAsString(); + } + JsonElement displayClaims = xblUserJson.getAsJsonObject().get("DisplayClaims"); + if (displayClaims != null && displayClaims.isJsonObject() && displayClaims.getAsJsonObject().get("xui").isJsonArray()) { + JsonArray xui = displayClaims.getAsJsonObject().getAsJsonArray("xui"); + JsonElement uhs = xui.get(0); + if (uhs.isJsonObject() && uhs.getAsJsonObject().getAsJsonPrimitive("uhs").isString()) { + userHash = uhs.getAsJsonObject().getAsJsonPrimitive("uhs").getAsString(); + } + } + } + } + + if (xblToken == null) { + ctx.status(500); + ctx.result("Failed to get Xbox Live access token from Microsoft access token."); + return; + } + if (userHash == null) { + ctx.status(500); + ctx.result("Failed to get user hash from Microsoft access token."); + return; + } + + String xstsToken = null; + var xblXstsRequest = HttpRequest.newBuilder(URI.create("https://xsts.auth.xboxlive.com/xsts/authorize")) + .header("Content-Type", "application/json") + .POST(HttpRequest.BodyPublishers.ofString(getXboxLiveAuthorizationBody(xblToken))); + var xblXstsResponse = ModGardenBackend.HTTP_CLIENT.send(xblXstsRequest.build(), HttpResponse.BodyHandlers.ofInputStream()); + if (xblXstsResponse.statusCode() == 401) { + String errorResponse = "Could not authorize with Xbox Live."; + try (InputStreamReader xerrReader = new InputStreamReader(xblXstsResponse.body())) { + JsonElement xerrJson = JsonParser.parseReader(xerrReader); + if (xerrJson.isJsonObject()) { + JsonPrimitive xErr = xerrJson.getAsJsonObject().getAsJsonPrimitive("xErr"); + if (xErr.isNumber()) { + long err = xErr.getAsLong(); + // Why can't longs be used in switch statements... + if (err == 2148916227L) { + errorResponse = "You are banned from Xbox."; + } + if (err == 2148916233L) { + errorResponse = "You do not have an Xbox account."; + } + if (err == 2148916235L) { + errorResponse = "This account is from a country where Xbox Live is not available or banned."; + } + if (err == 2148916236L || err == 2148916237L) { + errorResponse = "This account needs adult verification on the Xbox page. (Required in South Korea)"; + } + if (err == 2148916238L) { + errorResponse = "This account is owned by somebody under 18 years old and cannot proceed unless added to a family by an adult."; + } + } + } + } + ctx.status(401); + ctx.result(errorResponse); + return; + } + + try (InputStreamReader xblXstsReader = new InputStreamReader(xblXstsResponse.body())) { + JsonElement xblXstsJson = JsonParser.parseReader(xblXstsReader); + if (xblXstsJson.isJsonObject()) { + JsonPrimitive token = xblXstsJson.getAsJsonObject().getAsJsonPrimitive("Token"); + if (token.getAsJsonPrimitive().isString()) { + xstsToken = token.getAsString(); + } + JsonObject displayClaims = xblXstsJson.getAsJsonObject().getAsJsonObject("DisplayClaims"); + JsonArray xui = displayClaims.getAsJsonArray("xui"); + JsonElement uhs = xui.get(0); + if (uhs.isJsonPrimitive() && uhs.getAsJsonPrimitive().isString()) { + if (!uhs.getAsString().equals(userHash)) { + ctx.status(500); + ctx.result("User hash between authentication and authorization do not match."); + return; + } + } + } + } + + if (xstsToken == null) { + ctx.status(500); + ctx.result("Failed to get XSTS token from Microsoft access token."); + return; + } + + var minecraftServices = OAuthService.MINECRAFT_SERVICES.authenticate(); - String linkToken = AuthUtil.insertTokenIntoDatabase(ctx, userId, LinkCode.Service.MINECRAFT); + String minecraftAccessToken = null; + var minecraftAuthResponse = minecraftServices.post( + "authentication/login_with_xbox", + HttpRequest.BodyPublishers.ofString(getMinecraftAuthenticationBody(userHash, xstsToken)), + HttpResponse.BodyHandlers.ofInputStream(), + "Content-Type", "application/json", + "Accept", "application/json" + ); + + try (InputStreamReader minecraftAuthReader = new InputStreamReader(minecraftAuthResponse.body())) { + JsonElement minecraftAuthJson = JsonParser.parseReader(minecraftAuthReader); + if (minecraftAuthJson.isJsonObject()) { + JsonPrimitive accessToken = minecraftAuthJson.getAsJsonObject().getAsJsonPrimitive("access_token"); + if (accessToken.isString()) { + minecraftAccessToken = accessToken.getAsString(); + } + } + ctx.status(200); + ctx.json(minecraftAuthJson); + } + + var minecraftEntitlementsResponse = minecraftServices.get("entitlements/mcstore", + HttpResponse.BodyHandlers.ofInputStream(), + "Authorization", "Bearer " + minecraftAccessToken); + try (InputStreamReader minecraftEntitlementsReader = new InputStreamReader(minecraftEntitlementsResponse.body())) { + JsonElement minecraftEntitlementsJson = JsonParser.parseReader(minecraftEntitlementsReader); + + // TODO: Verify whether the Minecraft account owns the game. + if (minecraftEntitlementsJson.isJsonObject()) { + JsonArray items = minecraftEntitlementsJson.getAsJsonObject().getAsJsonArray("items"); + } + + ModGardenBackend.LOG.debug(minecraftEntitlementsJson.toString()); + } + + + String uuid = null; + var minecraftProfileResponse = minecraftServices.get("minecraft/profile", + HttpResponse.BodyHandlers.ofInputStream(), + "Authorization", "Bearer " + minecraftAccessToken); + try (InputStreamReader minecraftProfileReader = new InputStreamReader(minecraftProfileResponse.body())) { + JsonElement minecraftProfileJson = JsonParser.parseReader(minecraftProfileReader); + if (minecraftProfileJson.isJsonObject()) { + uuid = minecraftProfileJson.getAsJsonObject().getAsJsonPrimitive("id").getAsString(); + } + } + + String linkToken = AuthUtil.insertTokenIntoDatabase(ctx, uuid, LinkCode.Service.MINECRAFT); if (linkToken == null) { ctx.status(500); ctx.result("Internal error whilst generating token."); return; } ctx.status(200); + ctx.header("Content-Type", ""); ctx.result("Successfully created link code for Minecraft account.\n\n" + "Your link code is: " + linkToken + "\n\n" + "This code will expire when used or in approximately 15 minutes.\n\n" + @@ -115,20 +335,60 @@ public static void authMinecraftAccount(Context ctx) { } private static Map getModrinthAuthorizationBody(String code) { - var params = new HashMap(); - params.put("code", code); - params.put("client_id", OAuthService.MODRINTH.clientId); - params.put("redirect_uri", ModGardenBackend.URL + "/v1/discord/oauth/modrinth"); - params.put("grant_type", "authorization_code"); - return params; + var body = new HashMap(); + body.put("code", code); + body.put("client_id", OAuthService.MODRINTH.clientId); + body.put("redirect_uri", ModGardenBackend.URL + "/v1/discord/oauth/modrinth"); + body.put("grant_type", "authorization_code"); + return body; + } + + private static Map getMicrosoftAuthorizationBody(String code, String verifier) { + var body = new HashMap(); + body.put("code", code); + body.put("client_id", OAuthService.MINECRAFT_SERVICES.clientId); + body.put("scope", "XboxLive.signIn"); + body.put("grant_type", "authorization_code"); + body.put("redirect_uri", ModGardenBackend.URL + "/v1/discord/oauth/minecraft"); + body.put("code_verifier", verifier); + return body; + } + + private static String getXboxLiveAuthenticationBody(String code) { + var body = new JsonObject(); + var properties = new JsonObject(); + + properties.addProperty("AuthMethod", "RPS"); + properties.addProperty("SiteName", "user.auth.xboxlive.com"); + properties.addProperty("RpsTicket", "d=" + code); + + body.add("Properties", properties); + body.addProperty("RelyingParty", "http://auth.xboxlive.com"); + body.addProperty("TokenType", "JWT"); + + return body.toString(); + } + + private static String getXboxLiveAuthorizationBody(String xblToken) { + var body = new JsonObject(); + var properties = new JsonObject(); + + var userTokens = new JsonArray(); + userTokens.add(xblToken); + + properties.addProperty("SandboxId", "RETAIL"); + properties.add("UserTokens", userTokens); + + body.add("Properties", properties); + body.addProperty("RelyingParty", "rp://api.minecraftservices.com/"); + body.addProperty("TokenType", "JWT"); + + return body.toString(); } - private static Map getMinecraftAuthorizationBody(String code) { - var params = new HashMap(); - params.put("code", code); - params.put("client_id", OAuthService.MODRINTH.clientId); - params.put("redirect_uri", ModGardenBackend.URL + "/v1/discord/oauth/minecraft"); - params.put("grant_type", "authorization_code"); - return params; + private static String getMinecraftAuthenticationBody(String userHash, String xstsToken) { + var body = new JsonObject(); + body.addProperty("identityToken", "XBL3.0 x=" + userHash + ";" + xstsToken); + return body.toString(); } } diff --git a/src/main/java/net/modgarden/backend/handler/v1/discord/DiscordBotUnlinkHandler.java b/src/main/java/net/modgarden/backend/handler/v1/discord/DiscordBotUnlinkHandler.java index e8693bd..665227b 100644 --- a/src/main/java/net/modgarden/backend/handler/v1/discord/DiscordBotUnlinkHandler.java +++ b/src/main/java/net/modgarden/backend/handler/v1/discord/DiscordBotUnlinkHandler.java @@ -29,8 +29,6 @@ public static void unlink(Context ctx) { Body body = ctx.bodyAsClass(Body.class); - String capitalisedService = body.service.substring(0, 1).toUpperCase(Locale.ROOT) + body.service.substring(1); - try (Connection connection = ModGardenBackend.createDatabaseConnection()) { if (body.service.equals(LinkCode.Service.MODRINTH.serializedName())) { try (var deleteStatement = connection.prepareStatement("UPDATE users SET modrinth_id = NULL WHERE discord_id = ?")) { @@ -38,11 +36,11 @@ public static void unlink(Context ctx) { int resultSet = deleteStatement.executeUpdate(); if (resultSet == 0) { - ctx.result("Mod Garden account associated with Discord ID '" + body.discordId + "' does not have a " + capitalisedService + " account linked."); + ctx.result("Mod Garden account associated with Discord ID '" + body.discordId + "' does not have a Modrinth account linked."); ctx.status(200); } - ctx.result("Successfully unlinked " + capitalisedService + " account from Mod Garden account associated with Discord ID '" + body.discordId + "'."); + ctx.result("Successfully unlinked Modrinth account from Mod Garden account associated with Discord ID '" + body.discordId + "'."); ctx.status(201); } return; @@ -54,43 +52,17 @@ public static void unlink(Context ctx) { return; } - try (var insertStatement = connection.prepareStatement("UPDATE users SET minecraft_accounts = ? WHERE discord_id = ?"); - var deleteStatement = connection.prepareStatement("UPDATE users SET minecraft_accounts = NULL WHERE discord_id = ?")) { - User user = User.query(body.discordId, "discord"); - if (user == null) { - ctx.result("Could not find user from Discord ID '" + body.discordId + "'."); - ctx.status(422); - return; - } + try (var deleteStatement = connection.prepareStatement("DELETE FROM minecraft_accounts WHERE uuid = ?")) { + deleteStatement.setString(1, body.minecraftUuid.get().toString().replace("-", "")); + int resultSet = deleteStatement.executeUpdate(); - List uuids = new ArrayList<>(user.minecraftAccounts()); - if (!uuids.contains(body.minecraftUuid.get())) { - ctx.result("Minecraft account " + body.minecraftUuid.get() + " is not linked with user '" + user.username() + "'."); + if (resultSet == 0) { + ctx.result("Mod Garden account associated with Discord ID '" + body.discordId + "' does not have the specified Minecraft account linked to it."); ctx.status(200); return; } - uuids.remove(body.minecraftUuid.get()); - - if (uuids.isEmpty()) { - deleteStatement.setString(1, body.discordId); - ctx.result("Successfully unlinked " + capitalisedService + " account " + body.minecraftUuid.get() + " from Mod Garden account associated with Discord ID '" + body.discordId + "'."); - ctx.status(201); - return; - } - - var dataResult = ExtraCodecs.UUID_CODEC.listOf().encodeStart(JsonOps.INSTANCE, uuids); - if (!dataResult.hasResultOrPartial()) { - ModGardenBackend.LOG.error("Failed to create Minecraft account data. {}", dataResult.error().orElseThrow().message()); - ctx.result("Failed to create Minecraft account data."); - ctx.status(500); - return; - } - - insertStatement.setString(1, dataResult.getOrThrow().toString()); - insertStatement.setString(2, body.discordId); - insertStatement.execute(); - ctx.result("Successfully unlinked " + capitalisedService + " account " + body.minecraftUuid.get() + " from Mod Garden account associated with Discord ID '" + body.discordId + "'."); + ctx.result("Successfully unlinked Minecraft account " + body.minecraftUuid.get() + " from Mod Garden account associated with Discord ID '" + body.discordId + "'."); ctx.status(201); } } diff --git a/src/main/java/net/modgarden/backend/oauth/OAuthService.java b/src/main/java/net/modgarden/backend/oauth/OAuthService.java index 84f2fa8..801d5c0 100644 --- a/src/main/java/net/modgarden/backend/oauth/OAuthService.java +++ b/src/main/java/net/modgarden/backend/oauth/OAuthService.java @@ -2,11 +2,7 @@ import io.jsonwebtoken.Jwts; import io.jsonwebtoken.SignatureAlgorithm; -import net.modgarden.backend.oauth.client.DiscordOAuthClient; -import net.modgarden.backend.oauth.client.ModrinthOAuthClient; -import net.modgarden.backend.oauth.client.OAuthClientSupplier; -import net.modgarden.backend.oauth.client.OAuthClient; -import net.modgarden.backend.oauth.client.GithubOAuthClient; +import net.modgarden.backend.oauth.client.*; import net.modgarden.backend.util.KeyUtils; import org.jetbrains.annotations.NotNull; @@ -22,7 +18,8 @@ public enum OAuthService { DISCORD("1305609404837527612", OAuthService::authenticateDiscord), MODRINTH("Q2tuKyb4", OAuthService::authenticateModrinth), - GITHUB("Iv23li4vLb7sDuZOiRmf", OAuthService::authenticateGithub); + GITHUB("Iv23li4vLb7sDuZOiRmf", OAuthService::authenticateGithub), + MINECRAFT_SERVICES(" e7ee42f6-e542-4ce6-9f7b-1d31941e84c6", OAuthService::authenticateMinecraftServices); public final String clientId; private final OAuthClientSupplier authSupplier; @@ -38,6 +35,7 @@ static OAuthClient authenticateDiscord(String unused) { return new DiscordOAuthClient(); } + @NotNull static OAuthClient authenticateModrinth(String unused) { return new ModrinthOAuthClient(); } @@ -62,6 +60,10 @@ static OAuthClient authenticateGithub(String clientId) { } } + static OAuthClient authenticateMinecraftServices(String unused) { + return new MinecraftServicesOAuthClient(); + } + @NotNull public T authenticate() { return (T) authSupplier.authenticate(clientId); diff --git a/src/main/java/net/modgarden/backend/oauth/client/MinecraftServicesOAuthClient.java b/src/main/java/net/modgarden/backend/oauth/client/MinecraftServicesOAuthClient.java new file mode 100644 index 0000000..852d07e --- /dev/null +++ b/src/main/java/net/modgarden/backend/oauth/client/MinecraftServicesOAuthClient.java @@ -0,0 +1,31 @@ +package net.modgarden.backend.oauth.client; + +import net.modgarden.backend.ModGardenBackend; + +import java.io.IOException; +import java.net.URI; +import java.net.http.HttpRequest; +import java.net.http.HttpResponse; + +public record MinecraftServicesOAuthClient() implements OAuthClient { + public static final String API_URL = "https://api.minecraftservices.com/"; + + @Override + public HttpResponse get(String endpoint, HttpResponse.BodyHandler bodyHandler, String... headers) throws IOException, InterruptedException { + var req = HttpRequest.newBuilder(URI.create(API_URL + endpoint)); + if (headers.length > 0) + req.headers(headers); + + return ModGardenBackend.HTTP_CLIENT.send(req.build(), bodyHandler); + } + + @Override + public HttpResponse post(String endpoint, HttpRequest.BodyPublisher bodyPublisher, HttpResponse.BodyHandler bodyHandler, String... headers) throws IOException, InterruptedException { + var req = HttpRequest.newBuilder(URI.create(API_URL + endpoint)); + if (headers.length > 0) + req.headers(headers); + req.POST(bodyPublisher); + + return ModGardenBackend.HTTP_CLIENT.send(req.build(), bodyHandler); + } +} From a8838aa1723dd52e1d0fda33f5df0c5479ded398 Mon Sep 17 00:00:00 2001 From: Anastasia Calico Date: Thu, 19 Jun 2025 12:51:16 +1000 Subject: [PATCH 2/8] feat: Verify Minecraft: Java Edition ownership wtihin authentication. --- .editorconfig | 5 +- .../v1/discord/DiscordBotOAuthHandler.java | 66 ++++++++++++++++--- src/main/resources/mojang_public.key | 14 ++++ 3 files changed, 75 insertions(+), 10 deletions(-) create mode 100644 src/main/resources/mojang_public.key diff --git a/.editorconfig b/.editorconfig index 7cbe71b..9bab0b1 100644 --- a/.editorconfig +++ b/.editorconfig @@ -13,4 +13,7 @@ trim_trailing_whitespace = true # Gets rid of unnecessary indentation, and IJ ig # Note: Turn on Smart Tabs! ij_java_continuation_indent_size = 8 # Should be twice the tab width (8 for 4 columns, 16 for 8) ij_json_continuation_indent_size = 4 # Should be the tab size -ij_toml_continuation_indent_size = 4 # Should be the tab size \ No newline at end of file +ij_toml_continuation_indent_size = 4 # Should be the tab size + +[*.key] +insert_final_newline = false # Don't do this for any publickeys. \ No newline at end of file diff --git a/src/main/java/net/modgarden/backend/handler/v1/discord/DiscordBotOAuthHandler.java b/src/main/java/net/modgarden/backend/handler/v1/discord/DiscordBotOAuthHandler.java index 5ddcc22..56d087f 100644 --- a/src/main/java/net/modgarden/backend/handler/v1/discord/DiscordBotOAuthHandler.java +++ b/src/main/java/net/modgarden/backend/handler/v1/discord/DiscordBotOAuthHandler.java @@ -4,6 +4,7 @@ import com.google.common.cache.CacheBuilder; import com.google.gson.*; import io.javalin.http.Context; +import io.jsonwebtoken.Jwts; import net.modgarden.backend.ModGardenBackend; import net.modgarden.backend.data.LinkCode; import net.modgarden.backend.oauth.OAuthService; @@ -12,15 +13,16 @@ import java.io.IOException; import java.io.InputStreamReader; import java.net.URI; +import java.net.URL; import java.net.http.HttpRequest; import java.net.http.HttpResponse; +import java.nio.charset.Charset; import java.nio.charset.StandardCharsets; -import java.security.MessageDigest; -import java.security.NoSuchAlgorithmException; -import java.security.SecureRandom; -import java.util.Base64; -import java.util.HashMap; -import java.util.Map; +import java.nio.file.Files; +import java.nio.file.Path; +import java.security.*; +import java.security.spec.X509EncodedKeySpec; +import java.util.*; import java.util.concurrent.TimeUnit; public class DiscordBotOAuthHandler { @@ -121,6 +123,8 @@ public static String createCodeChallenge() throws NoSuchAlgorithmException { return codeChallenge; } + private static PublicKey minecraftPublicKey = null; + public static void authMinecraftAccount(Context ctx) { String code = ctx.queryParam("code"); if (code == null) { @@ -289,20 +293,64 @@ public static void authMinecraftAccount(Context ctx) { ctx.json(minecraftAuthJson); } + boolean ownsGame = false; var minecraftEntitlementsResponse = minecraftServices.get("entitlements/mcstore", HttpResponse.BodyHandlers.ofInputStream(), "Authorization", "Bearer " + minecraftAccessToken); try (InputStreamReader minecraftEntitlementsReader = new InputStreamReader(minecraftEntitlementsResponse.body())) { JsonElement minecraftEntitlementsJson = JsonParser.parseReader(minecraftEntitlementsReader); - // TODO: Verify whether the Minecraft account owns the game. if (minecraftEntitlementsJson.isJsonObject()) { JsonArray items = minecraftEntitlementsJson.getAsJsonObject().getAsJsonArray("items"); - } + Optional javaSignaturePrimitive = items.asList().stream().filter(jsonElement -> { + if (!jsonElement.isJsonObject()) + return false; + JsonPrimitive name = jsonElement.getAsJsonObject().getAsJsonPrimitive("name"); + if (name == null || !name.isString()) + return false; + return "product_minecraft".equals(name.getAsString()); + }).map(jsonElement -> jsonElement.getAsJsonObject().getAsJsonPrimitive("signature")).filter(Objects::nonNull).findAny(); + + if (javaSignaturePrimitive.isPresent() && javaSignaturePrimitive.get().isString()) { + String javaSignature = javaSignaturePrimitive.get().getAsString(); + + if (minecraftPublicKey == null) { + URL resource = ModGardenBackend.class.getResource("/mojang_public.key"); + if (resource == null) { + ctx.status(500); + ctx.result("Mojang public key is not specified internally."); + return; + } + String key = Files.readString(Path.of(resource.toURI()), Charset.defaultCharset()); + + key = key.replace("-----BEGIN PUBLIC KEY-----", "") + .replaceAll("\n", "") + .replace("-----END PUBLIC KEY-----", ""); + + byte[] bytes = Base64.getDecoder().decode(key); + var keyFactory = KeyFactory.getInstance("RSA"); + var keySpec = new X509EncodedKeySpec(bytes); + minecraftPublicKey = keyFactory.generatePublic(keySpec); + } - ModGardenBackend.LOG.debug(minecraftEntitlementsJson.toString()); + try { + Jwts.parserBuilder() + .setSigningKey(minecraftPublicKey) + .build() + .parseClaimsJws(javaSignature); + ownsGame = true; + } catch (Exception ignored) { + // The account cannot be verified with Mojang's publickey, therefore they probably don't own the game. + } + } + } } + if (!ownsGame) { + ctx.status(401); + ctx.result("You do not own a copy of Minecraft. Please purchase a copy of the game to proceed."); + return; + } String uuid = null; var minecraftProfileResponse = minecraftServices.get("minecraft/profile", diff --git a/src/main/resources/mojang_public.key b/src/main/resources/mojang_public.key new file mode 100644 index 0000000..657eda4 --- /dev/null +++ b/src/main/resources/mojang_public.key @@ -0,0 +1,14 @@ +-----BEGIN PUBLIC KEY----- +MIICIjANBgkqhkiG9w0BAQEFAAOCAg8AMIICCgKCAgEAtz7jy4jRH3psj5AbVS6W +NHjniqlr/f5JDly2M8OKGK81nPEq765tJuSILOWrC3KQRvHJIhf84+ekMGH7iGlO +4DPGDVb6hBGoMMBhCq2jkBjuJ7fVi3oOxy5EsA/IQqa69e55ugM+GJKUndLyHeNn +X6RzRzDT4tX/i68WJikwL8rR8Jq49aVJlIEFT6F+1rDQdU2qcpfT04CBYLM5gMxE +fWRl6u1PNQixz8vSOv8pA6hB2DU8Y08VvbK7X2ls+BiS3wqqj3nyVWqoxrwVKiXR +kIqIyIAedYDFSaIq5vbmnVtIonWQPeug4/0spLQoWnTUpXRZe2/+uAKN1RY9mmaB +pRFV/Osz3PDOoICGb5AZ0asLFf/qEvGJ+di6Ltt8/aaoBuVw+7fnTw2BhkhSq1S/ +va6LxHZGXE9wsLj4CN8mZXHfwVD9QG0VNQTUgEGZ4ngf7+0u30p7mPt5sYy3H+Fm +sWXqFZn55pecmrgNLqtETPWMNpWc2fJu/qqnxE9o2tBGy/MqJiw3iLYxf7U+4le4 +jM49AUKrO16bD1rdFwyVuNaTefObKjEMTX9gyVUF6o7oDEItp5NHxFm3CqnQRmch +HsMs+NxEnN4E9a8PDB23b4yjKOQ9VHDxBxuaZJU60GBCIOF9tslb7OAkheSJx5Xy +EYblHbogFGPRFU++NrSQRX0CAwEAAQ== +-----END PUBLIC KEY----- \ No newline at end of file From 688eda943bf567f93e8fada9dca9e5f46acd2aa0 Mon Sep 17 00:00:00 2001 From: Anastasia Calico Date: Tue, 15 Jul 2025 08:51:44 +1000 Subject: [PATCH 3/8] feat: Handle review changes. --- .../v1/discord/DiscordBotLinkHandler.java | 12 +++---- .../v1/discord/DiscordBotOAuthHandler.java | 31 ++++++++++++------- .../v1/discord/DiscordBotProfileHandler.java | 18 +++++------ .../discord/DiscordBotSubmissionHandler.java | 18 +++++------ .../v1/discord/DiscordBotUnlinkHandler.java | 2 +- 5 files changed, 44 insertions(+), 37 deletions(-) diff --git a/src/main/java/net/modgarden/backend/handler/v1/discord/DiscordBotLinkHandler.java b/src/main/java/net/modgarden/backend/handler/v1/discord/DiscordBotLinkHandler.java index f63a42e..2021a8f 100644 --- a/src/main/java/net/modgarden/backend/handler/v1/discord/DiscordBotLinkHandler.java +++ b/src/main/java/net/modgarden/backend/handler/v1/discord/DiscordBotLinkHandler.java @@ -43,7 +43,7 @@ public static void link(Context ctx) { deleteStatement.execute(); if (accountId == null) { ctx.result("Invalid link code for " + capitalisedService + "."); - ctx.status(422); + ctx.status(400); return; } @@ -55,7 +55,7 @@ public static void link(Context ctx) { return; } ctx.result("Invalid link code service '" + capitalisedService + "'."); - ctx.status(422); + ctx.status(400); } catch (SQLException ex) { ModGardenBackend.LOG.error("Exception in SQL query.", ex); ctx.result("Internal Error."); @@ -74,7 +74,7 @@ private static void handleModrinth(Context ctx, ResultSet accountCheckResult = accountCheckStatement.executeQuery(); if (accountCheckResult.isBeforeFirst() && accountCheckResult.getBoolean(1)) { ctx.result("The specified Modrinth account has already been linked to a Mod Garden account."); - ctx.status(422); + ctx.status(400); return; } @@ -82,7 +82,7 @@ private static void handleModrinth(Context ctx, ResultSet userCheckResult = userCheckStatement.executeQuery(); if (userCheckResult.isBeforeFirst() && userCheckResult.getBoolean(1)) { ctx.result("The specified Mod Garden account is already linked with Modrinth."); - ctx.status(422); + ctx.status(400); return; } @@ -104,7 +104,7 @@ private static void handleMinecraft(Context ctx, User user = User.query(discordId, "discord"); if (user == null) { ctx.result("Could not find user from Discord ID '" + discordId + "'."); - ctx.status(422); + ctx.status(400); return; } @@ -117,7 +117,7 @@ private static void handleMinecraft(Context ctx, return; } ctx.result("The specified Minecraft account has already been linked to a Mod Garden account."); - ctx.status(422); + ctx.status(400); return; } diff --git a/src/main/java/net/modgarden/backend/handler/v1/discord/DiscordBotOAuthHandler.java b/src/main/java/net/modgarden/backend/handler/v1/discord/DiscordBotOAuthHandler.java index 56d087f..030f67b 100644 --- a/src/main/java/net/modgarden/backend/handler/v1/discord/DiscordBotOAuthHandler.java +++ b/src/main/java/net/modgarden/backend/handler/v1/discord/DiscordBotOAuthHandler.java @@ -29,7 +29,7 @@ public class DiscordBotOAuthHandler { public static void authModrinthAccount(Context ctx) { String code = ctx.queryParam("code"); if (code == null) { - ctx.status(422); + ctx.status(400); ctx.result("Modrinth access code is not specified."); return; } @@ -46,7 +46,7 @@ public static void authModrinthAccount(Context ctx) { try (InputStreamReader reader = new InputStreamReader(tokenResponse.body())) { JsonElement tokenJson = JsonParser.parseReader(reader); if (!tokenJson.isJsonObject() || !tokenJson.getAsJsonObject().has("access_token")) { - ctx.status(422); + ctx.status(400); ctx.result("Invalid Modrinth access token."); return; } @@ -128,20 +128,20 @@ public static String createCodeChallenge() throws NoSuchAlgorithmException { public static void authMinecraftAccount(Context ctx) { String code = ctx.queryParam("code"); if (code == null) { - ctx.status(422); + ctx.status(400); ctx.result("Microsoft access code is not specified."); return; } String challengeCode = ctx.queryParam("state"); if (challengeCode == null) { - ctx.status(422); + ctx.status(400); ctx.result("Code challenge state is not specified."); return; } String verifier = CODE_CHALLENGE_TO_VERIFIER.getIfPresent(challengeCode); if (verifier == null) { - ctx.status(422); + ctx.status(400); ctx.result("Code challenge verifier has expired. Please retry."); return; } @@ -220,7 +220,6 @@ public static void authMinecraftAccount(Context ctx) { JsonPrimitive xErr = xerrJson.getAsJsonObject().getAsJsonPrimitive("xErr"); if (xErr.isNumber()) { long err = xErr.getAsLong(); - // Why can't longs be used in switch statements... if (err == 2148916227L) { errorResponse = "You are banned from Xbox."; } @@ -289,16 +288,19 @@ public static void authMinecraftAccount(Context ctx) { minecraftAccessToken = accessToken.getAsString(); } } - ctx.status(200); - ctx.json(minecraftAuthJson); + } + if (minecraftAccessToken == null) { + ctx.status(500); + ctx.result("Internal error whilst generating token."); + return; } boolean ownsGame = false; - var minecraftEntitlementsResponse = minecraftServices.get("entitlements/mcstore", + var entitlementsResponse = minecraftServices.get("entitlements/mcstore", HttpResponse.BodyHandlers.ofInputStream(), "Authorization", "Bearer " + minecraftAccessToken); - try (InputStreamReader minecraftEntitlementsReader = new InputStreamReader(minecraftEntitlementsResponse.body())) { - JsonElement minecraftEntitlementsJson = JsonParser.parseReader(minecraftEntitlementsReader); + try (InputStreamReader entitlementsReader = new InputStreamReader(entitlementsResponse.body())) { + JsonElement minecraftEntitlementsJson = JsonParser.parseReader(entitlementsReader); if (minecraftEntitlementsJson.isJsonObject()) { JsonArray items = minecraftEntitlementsJson.getAsJsonObject().getAsJsonArray("items"); @@ -362,6 +364,11 @@ public static void authMinecraftAccount(Context ctx) { uuid = minecraftProfileJson.getAsJsonObject().getAsJsonPrimitive("id").getAsString(); } } + if (uuid == null) { + ctx.status(500); + ctx.result("Internal error whilst generating token."); + return; + } String linkToken = AuthUtil.insertTokenIntoDatabase(ctx, uuid, LinkCode.Service.MINECRAFT); if (linkToken == null) { @@ -370,7 +377,7 @@ public static void authMinecraftAccount(Context ctx) { return; } ctx.status(200); - ctx.header("Content-Type", ""); + ctx.header("Content-Type", "application/json"); ctx.result("Successfully created link code for Minecraft account.\n\n" + "Your link code is: " + linkToken + "\n\n" + "This code will expire when used or in approximately 15 minutes.\n\n" + diff --git a/src/main/java/net/modgarden/backend/handler/v1/discord/DiscordBotProfileHandler.java b/src/main/java/net/modgarden/backend/handler/v1/discord/DiscordBotProfileHandler.java index 5e70399..c6ccdbb 100644 --- a/src/main/java/net/modgarden/backend/handler/v1/discord/DiscordBotProfileHandler.java +++ b/src/main/java/net/modgarden/backend/handler/v1/discord/DiscordBotProfileHandler.java @@ -35,22 +35,22 @@ public static void modifyUsername(Context ctx) { if (body.value.length() < 3) { ctx.result("Username is too short."); - ctx.status(422); + ctx.status(400); return; } if (body.value.length() > 32) { ctx.result("Username is too long."); - ctx.status(422); + ctx.status(400); return; } if (!body.value.matches(User.USERNAME_REGEX)) { ctx.result("Username has invalid characters."); - ctx.status(422); + ctx.status(400); return; } if (body.value.equals(oldUsername)) { ctx.result("Your username is already '" + body.value + "'."); - ctx.status(200); + ctx.status(400); return; } @@ -58,7 +58,7 @@ public static void modifyUsername(Context ctx) { ResultSet existingUser = existingUserStatement.executeQuery(); if (existingUser.getBoolean(1)) { ctx.result("Username '" + body.value + " ' has already been taken."); - ctx.status(422); + ctx.status(400); return; } @@ -100,12 +100,12 @@ public static void modifyDisplayName(Context ctx) { if (body.value.isBlank()) { ctx.result("Display name cannot be exclusively whitespace."); - ctx.status(422); + ctx.status(400); return; } if (body.value.length() > 32) { ctx.result("Display name is too long."); - ctx.status(422); + ctx.status(400); return; } @@ -147,7 +147,7 @@ public static void modifyPronouns(Context ctx) { if (body.value.isBlank()) { ctx.result("Pronouns cannot be exclusively whitespace."); - ctx.status(422); + ctx.status(400); return; } if (body.value.equals(oldPronouns)) { @@ -191,7 +191,7 @@ public static void modifyAvatarUrl(Context ctx) { if (!ModGardenBackend.SAFE_URL_REGEX.matches(body.value)) { ctx.result("Avatar URL has invalid characters."); - ctx.status(422); + ctx.status(400); return; } diff --git a/src/main/java/net/modgarden/backend/handler/v1/discord/DiscordBotSubmissionHandler.java b/src/main/java/net/modgarden/backend/handler/v1/discord/DiscordBotSubmissionHandler.java index d797ee7..963a6e9 100644 --- a/src/main/java/net/modgarden/backend/handler/v1/discord/DiscordBotSubmissionHandler.java +++ b/src/main/java/net/modgarden/backend/handler/v1/discord/DiscordBotSubmissionHandler.java @@ -51,7 +51,7 @@ public static void submitModrinth(Context ctx) { JsonElement bodyJson = JsonParser.parseReader(bodyReader); var bodyResult = Body.CODEC.parse(JsonOps.INSTANCE, bodyJson); if (bodyResult.isError()) { - ctx.status(422); + ctx.status(400); ctx.result(bodyResult.error().orElseThrow().message()); return; } @@ -59,7 +59,7 @@ public static void submitModrinth(Context ctx) { String modrinthSlug = body.slug().toLowerCase(Locale.ROOT); if (!modrinthSlug.matches(REGEX)) { - ctx.status(422); + ctx.status(400); ctx.result("Invalid Modrinth slug."); return; } @@ -89,14 +89,14 @@ public static void submitModrinth(Context ctx) { } if (event == null) { - ctx.status(422); + ctx.status(400); ctx.result("Could not find an event to submit to."); return; } var projectStream = modrinthClient.get("v2/project/" + modrinthSlug, HttpResponse.BodyHandlers.ofInputStream()); if (projectStream.statusCode() != 200) { - ctx.status(422); + ctx.status(400); ctx.result("Could not find Modrinth project."); return; } @@ -104,7 +104,7 @@ public static void submitModrinth(Context ctx) { try (InputStreamReader projectReader = new InputStreamReader(projectStream.body())) { JsonElement projectJson = JsonParser.parseReader(projectReader); if (!projectJson.isJsonObject() || !projectJson.getAsJsonObject().has("id") || !projectJson.getAsJsonObject().has("versions") || !projectJson.getAsJsonObject().has("title")) { - ctx.status(422); + ctx.status(400); ctx.result("Invalid Modrinth project."); return; } @@ -152,7 +152,7 @@ public static void submitModrinth(Context ctx) { String modrinthVersion = getModrinthVersion(projectJson.getAsJsonObject(), modrinthClient, event.minecraftVersion(), event.loader()); if (modrinthVersion == null) { - ctx.status(422); + ctx.status(400); ctx.result("Could not find a valid Modrinth version for " + toFriendlyLoaderString(event.loader()) + " on Minecraft " + event.minecraftVersion() + "."); return; } @@ -214,7 +214,7 @@ public static void unsubmit(Context ctx) { JsonElement bodyJson = JsonParser.parseReader(bodyReader); var bodyResult = Body.CODEC.parse(JsonOps.INSTANCE, bodyJson); if (bodyResult.isError()) { - ctx.status(422); + ctx.status(400); ctx.result(bodyResult.error().orElseThrow().message()); return; } @@ -222,7 +222,7 @@ public static void unsubmit(Context ctx) { String slug = body.slug().toLowerCase(Locale.ROOT); if (!slug.matches(REGEX)) { - ctx.status(422); + ctx.status(400); ctx.result("Invalid Modrinth slug."); return; } @@ -249,7 +249,7 @@ public static void unsubmit(Context ctx) { } if (event == null) { - ctx.status(422); + ctx.status(400); ctx.result("Could not find an event to unsubmit from."); return; } diff --git a/src/main/java/net/modgarden/backend/handler/v1/discord/DiscordBotUnlinkHandler.java b/src/main/java/net/modgarden/backend/handler/v1/discord/DiscordBotUnlinkHandler.java index 665227b..1ce0cd1 100644 --- a/src/main/java/net/modgarden/backend/handler/v1/discord/DiscordBotUnlinkHandler.java +++ b/src/main/java/net/modgarden/backend/handler/v1/discord/DiscordBotUnlinkHandler.java @@ -48,7 +48,7 @@ public static void unlink(Context ctx) { if (body.service.equals(LinkCode.Service.MINECRAFT.serializedName())) { if (body.minecraftUuid.isEmpty()) { ctx.result("'minecraft_uuid' field was not specified."); - ctx.status(422); + ctx.status(400); return; } From f9e419b5c6d1671e345d769761764553cad7f398 Mon Sep 17 00:00:00 2001 From: Anastasia Calico Date: Tue, 15 Jul 2025 08:52:24 +1000 Subject: [PATCH 4/8] style: Suppress warnings. --- src/main/java/net/modgarden/backend/oauth/OAuthService.java | 1 + 1 file changed, 1 insertion(+) diff --git a/src/main/java/net/modgarden/backend/oauth/OAuthService.java b/src/main/java/net/modgarden/backend/oauth/OAuthService.java index 801d5c0..3f1b5ae 100644 --- a/src/main/java/net/modgarden/backend/oauth/OAuthService.java +++ b/src/main/java/net/modgarden/backend/oauth/OAuthService.java @@ -64,6 +64,7 @@ static OAuthClient authenticateMinecraftServices(String unused) { return new MinecraftServicesOAuthClient(); } + @SuppressWarnings("unchecked") @NotNull public T authenticate() { return (T) authSupplier.authenticate(clientId); From 2aead58d2cd83e4d911d38cc16cded0fb204963c Mon Sep 17 00:00:00 2001 From: Anastasia Calico Date: Tue, 15 Jul 2025 08:57:50 +1000 Subject: [PATCH 5/8] fix: Remove accidental Content-Type header. --- .../backend/handler/v1/discord/DiscordBotOAuthHandler.java | 1 - 1 file changed, 1 deletion(-) diff --git a/src/main/java/net/modgarden/backend/handler/v1/discord/DiscordBotOAuthHandler.java b/src/main/java/net/modgarden/backend/handler/v1/discord/DiscordBotOAuthHandler.java index 030f67b..84c2ec8 100644 --- a/src/main/java/net/modgarden/backend/handler/v1/discord/DiscordBotOAuthHandler.java +++ b/src/main/java/net/modgarden/backend/handler/v1/discord/DiscordBotOAuthHandler.java @@ -377,7 +377,6 @@ public static void authMinecraftAccount(Context ctx) { return; } ctx.status(200); - ctx.header("Content-Type", "application/json"); ctx.result("Successfully created link code for Minecraft account.\n\n" + "Your link code is: " + linkToken + "\n\n" + "This code will expire when used or in approximately 15 minutes.\n\n" + From a415ac7403b5c652be73cb4ceea7cf801d2573b7 Mon Sep 17 00:00:00 2001 From: Anastasia Calico Date: Tue, 15 Jul 2025 09:00:11 +1000 Subject: [PATCH 6/8] feat: Expire the code challenge verifier after the code is used rather than immediately. --- .../v1/discord/DiscordBotLinkHandler.java | 7 ++++--- .../v1/discord/DiscordBotOAuthHandler.java | 19 +++++++++++++++---- 2 files changed, 19 insertions(+), 7 deletions(-) diff --git a/src/main/java/net/modgarden/backend/handler/v1/discord/DiscordBotLinkHandler.java b/src/main/java/net/modgarden/backend/handler/v1/discord/DiscordBotLinkHandler.java index 2021a8f..0c233ac 100644 --- a/src/main/java/net/modgarden/backend/handler/v1/discord/DiscordBotLinkHandler.java +++ b/src/main/java/net/modgarden/backend/handler/v1/discord/DiscordBotLinkHandler.java @@ -52,6 +52,7 @@ public static void link(Context ctx) { return; } else if (body.service.equals(LinkCode.Service.MINECRAFT.serializedName())) { handleMinecraft(ctx, connection, body.discordId, accountId); + DiscordBotOAuthHandler.invalidateFromUuid(body.linkCode); return; } ctx.result("Invalid link code service '" + capitalisedService + "'."); @@ -96,9 +97,9 @@ private static void handleModrinth(Context ctx, } private static void handleMinecraft(Context ctx, - Connection connection, - String discordId, - String uuid) throws SQLException { + Connection connection, + String discordId, + String uuid) throws SQLException { try (var accountCheckStatement = connection.prepareStatement("SELECT user_id FROM minecraft_accounts WHERE uuid = ?"); var insertStatement = connection.prepareStatement("INSERT INTO minecraft_accounts (uuid, user_id) VALUES (?, ?)")) { User user = User.query(discordId, "discord"); diff --git a/src/main/java/net/modgarden/backend/handler/v1/discord/DiscordBotOAuthHandler.java b/src/main/java/net/modgarden/backend/handler/v1/discord/DiscordBotOAuthHandler.java index 84c2ec8..983349c 100644 --- a/src/main/java/net/modgarden/backend/handler/v1/discord/DiscordBotOAuthHandler.java +++ b/src/main/java/net/modgarden/backend/handler/v1/discord/DiscordBotOAuthHandler.java @@ -87,10 +87,21 @@ public static void authModrinthAccount(Context ctx) { } } + private static final Cache LINK_CODE_TO_CODE_CHALLENGE = CacheBuilder.newBuilder() + .expireAfterWrite(15, TimeUnit.MINUTES) + .build(); private static final Cache CODE_CHALLENGE_TO_VERIFIER = CacheBuilder.newBuilder() .expireAfterWrite(15, TimeUnit.MINUTES) .build(); + public static void invalidateFromUuid(String linkCode) { + String codeChallenge = LINK_CODE_TO_CODE_CHALLENGE.getIfPresent(linkCode); + if (codeChallenge != null) { + CODE_CHALLENGE_TO_VERIFIER.invalidate(codeChallenge); + } + LINK_CODE_TO_CODE_CHALLENGE.invalidate(linkCode); + } + public static void getMicrosoftCodeChallenge(Context ctx) { if (!("Basic " + ModGardenBackend.DOTENV.get("DISCORD_OAUTH_SECRET")).equals(ctx.header("Authorization"))) { ctx.result("Unauthorized."); @@ -133,19 +144,18 @@ public static void authMinecraftAccount(Context ctx) { return; } - String challengeCode = ctx.queryParam("state"); - if (challengeCode == null) { + String codeChallenge = ctx.queryParam("state"); + if (codeChallenge == null) { ctx.status(400); ctx.result("Code challenge state is not specified."); return; } - String verifier = CODE_CHALLENGE_TO_VERIFIER.getIfPresent(challengeCode); + String verifier = CODE_CHALLENGE_TO_VERIFIER.getIfPresent(codeChallenge); if (verifier == null) { ctx.status(400); ctx.result("Code challenge verifier has expired. Please retry."); return; } - CODE_CHALLENGE_TO_VERIFIER.invalidate(challengeCode); try { String microsoftToken = null; @@ -376,6 +386,7 @@ public static void authMinecraftAccount(Context ctx) { ctx.result("Internal error whilst generating token."); return; } + LINK_CODE_TO_CODE_CHALLENGE.put(linkToken, codeChallenge); ctx.status(200); ctx.result("Successfully created link code for Minecraft account.\n\n" + "Your link code is: " + linkToken + "\n\n" + From 9f756431fdd38372fbcf7b5a6026a7b4c3d5f466 Mon Sep 17 00:00:00 2001 From: Anastasia Calico Date: Tue, 15 Jul 2025 09:02:55 +1000 Subject: [PATCH 7/8] feat: Log Microsoft code challenge exceptions. --- .../backend/handler/v1/discord/DiscordBotOAuthHandler.java | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/src/main/java/net/modgarden/backend/handler/v1/discord/DiscordBotOAuthHandler.java b/src/main/java/net/modgarden/backend/handler/v1/discord/DiscordBotOAuthHandler.java index 983349c..05ad33f 100644 --- a/src/main/java/net/modgarden/backend/handler/v1/discord/DiscordBotOAuthHandler.java +++ b/src/main/java/net/modgarden/backend/handler/v1/discord/DiscordBotOAuthHandler.java @@ -111,7 +111,8 @@ public static void getMicrosoftCodeChallenge(Context ctx) { ctx.status(200); try { ctx.result(createCodeChallenge()); - } catch (NoSuchAlgorithmException e) { + } catch (NoSuchAlgorithmException ex) { + ModGardenBackend.LOG.error("Failed to generate code challenge.", ex); ctx.result("Failed to generate code challenge, this shouldn't happen."); ctx.status(500); } From 4635eab1205f732b9ed54dae740377625c0c55b1 Mon Sep 17 00:00:00 2001 From: Anastasia Calico Date: Tue, 15 Jul 2025 09:09:57 +1000 Subject: [PATCH 8/8] fix: Remove `/r` in Mojang publickey string. --- .../backend/handler/v1/discord/DiscordBotOAuthHandler.java | 1 + 1 file changed, 1 insertion(+) diff --git a/src/main/java/net/modgarden/backend/handler/v1/discord/DiscordBotOAuthHandler.java b/src/main/java/net/modgarden/backend/handler/v1/discord/DiscordBotOAuthHandler.java index 05ad33f..0f964b5 100644 --- a/src/main/java/net/modgarden/backend/handler/v1/discord/DiscordBotOAuthHandler.java +++ b/src/main/java/net/modgarden/backend/handler/v1/discord/DiscordBotOAuthHandler.java @@ -338,6 +338,7 @@ public static void authMinecraftAccount(Context ctx) { key = key.replace("-----BEGIN PUBLIC KEY-----", "") .replaceAll("\n", "") + .replaceAll("\r", "") .replace("-----END PUBLIC KEY-----", ""); byte[] bytes = Base64.getDecoder().decode(key);