From f6f6aaf1de8717be66e325853693bdb0607eb8c7 Mon Sep 17 00:00:00 2001 From: games647 Date: Mon, 5 Oct 2015 19:58:58 +0200 Subject: [PATCH] Fix thread safety for fake start packets (Bukkit.getOfflinePlayer doesn't look like to be thread-safe) + More documentation --- .../github/games647/fastlogin/Encryption.java | 2 + .../github/games647/fastlogin/FastLogin.java | 27 ++++++++----- .../games647/fastlogin/PremiumCommand.java | 5 +++ .../listener/EncryptionPacketListener.java | 38 +++++++++++++------ .../fastlogin/listener/PlayerListener.java | 15 +++----- .../listener/StartPacketListener.java | 16 +++++--- 6 files changed, 67 insertions(+), 36 deletions(-) diff --git a/src/main/java/com/github/games647/fastlogin/Encryption.java b/src/main/java/com/github/games647/fastlogin/Encryption.java index 28fcb5ea..81929d98 100644 --- a/src/main/java/com/github/games647/fastlogin/Encryption.java +++ b/src/main/java/com/github/games647/fastlogin/Encryption.java @@ -24,6 +24,8 @@ import javax.crypto.spec.IvParameterSpec; import javax.crypto.spec.SecretKeySpec; /** + * Encryption and decryption minecraft util for connection between servers and paid minecraft account clients + * * Source: https://github.com/bergerkiller/CraftSource/blob/master/net.minecraft.server/MinecraftEncryption.java * * Remapped by: https://github.com/Techcable/MinecraftMappings/tree/master/1.8 diff --git a/src/main/java/com/github/games647/fastlogin/FastLogin.java b/src/main/java/com/github/games647/fastlogin/FastLogin.java index 2e3b9aea..01f015af 100644 --- a/src/main/java/com/github/games647/fastlogin/FastLogin.java +++ b/src/main/java/com/github/games647/fastlogin/FastLogin.java @@ -22,12 +22,17 @@ import java.util.logging.Level; import org.bukkit.plugin.java.JavaPlugin; +/** + * This plugin checks if a player has a paid account and if so + * tries to skip offline mode authentication. + */ public class FastLogin extends JavaPlugin { - private static final int TIMEOUT = 15000; + //http connection, read timeout and user agent for a connection to mojang api servers + private static final int TIMEOUT = 10 * 1000; private static final String USER_AGENT = "Premium-Checker"; - //provide a immutable key pair to be thread safe + //provide a immutable key pair to be thread safe | used for encrypting and decrypting traffic private final KeyPair keyPair = Encryption.generateKeyPair(); //we need a thread-safe set because we access it async in the packet listener @@ -36,9 +41,9 @@ public class FastLogin extends JavaPlugin { //this map is thread-safe for async access (Packet Listener) //SafeCacheBuilder is used in order to be version independent private final ConcurrentMap session = SafeCacheBuilder.newBuilder() - //mapped by ip:port - .expireAfterWrite(2, TimeUnit.MINUTES) //2 minutes should be enough as a timeout for bad internet connection (Server, Client and Mojang) + .expireAfterWrite(2, TimeUnit.MINUTES) + //mapped by ip:port -> PlayerSession .build(new CacheLoader() { @Override @@ -52,6 +57,7 @@ public class FastLogin extends JavaPlugin { public void onLoad() { //online mode is only changeable after a restart so check it here if (getServer().getOnlineMode()) { + //we need to require offline to prevent a session request for a offline player getLogger().severe("Server have to be in offline mode"); setEnabled(false); @@ -69,8 +75,8 @@ public class FastLogin extends JavaPlugin { protocolManager.addPacketListener(new EncryptionPacketListener(this, protocolManager)); protocolManager.addPacketListener(new StartPacketListener(this, protocolManager)); - //register commands - getCommand("premium").setExecutor(new PremiumCommand(this)); + //register commands using a unique name + getCommand(getName()).setExecutor(new PremiumCommand(this)); } @Override @@ -81,8 +87,8 @@ public class FastLogin extends JavaPlugin { } /** - * Gets a thread-safe map about players which are connecting to the server are being checked to be premium (paid - * account) + * Gets a thread-safe map about players which are connecting to the server are being + * checked to be premium (paid account) * * @return a thread-safe session map */ @@ -91,7 +97,8 @@ public class FastLogin extends JavaPlugin { } /** - * Gets the server KeyPair + * Gets the server KeyPair. This is used to encrypt or decrypt traffic between + * the client and server * * @return the server KeyPair */ @@ -157,7 +164,7 @@ public class FastLogin extends JavaPlugin { return false; } - //We found a supporting plugin - we can now register a forwarding listener + //We found a supporting plugin - we can now register a forwarding listener to skip authentication from them getServer().getPluginManager().registerEvents(new PlayerListener(this, authPluginHook), this); return true; } diff --git a/src/main/java/com/github/games647/fastlogin/PremiumCommand.java b/src/main/java/com/github/games647/fastlogin/PremiumCommand.java index d4807780..d3afc6d1 100644 --- a/src/main/java/com/github/games647/fastlogin/PremiumCommand.java +++ b/src/main/java/com/github/games647/fastlogin/PremiumCommand.java @@ -6,6 +6,11 @@ import org.bukkit.command.CommandExecutor; import org.bukkit.command.CommandSender; import org.bukkit.entity.Player; +/** + * Let users activate fast login by command. This only be accessible if + * the user has access to it's account. So we can make sure that not another + * person with a paid account and the same username can steal his account. + */ public class PremiumCommand implements CommandExecutor { private final FastLogin plugin; diff --git a/src/main/java/com/github/games647/fastlogin/listener/EncryptionPacketListener.java b/src/main/java/com/github/games647/fastlogin/listener/EncryptionPacketListener.java index 84e1c0d6..63d7c0d1 100644 --- a/src/main/java/com/github/games647/fastlogin/listener/EncryptionPacketListener.java +++ b/src/main/java/com/github/games647/fastlogin/listener/EncryptionPacketListener.java @@ -22,16 +22,24 @@ import java.math.BigInteger; import java.net.HttpURLConnection; import java.security.PrivateKey; import java.util.Arrays; +import java.util.UUID; import java.util.logging.Level; import javax.crypto.SecretKey; -import org.bukkit.Bukkit; import org.bukkit.entity.Player; import org.json.simple.JSONObject; import org.json.simple.JSONValue; /** + * Handles incoming encryption responses from connecting clients. + * It prevents them from reaching the server because that cannot handle + * it in offline mode. + * + * Moreover this manages a started premium check from + * this plugin. So check if all data is correct and we can prove him as a + * owner of a paid minecraft account. + * * Receiving packet information: * http://wiki.vg/Protocol#Encryption_Response * @@ -40,6 +48,7 @@ import org.json.simple.JSONValue; */ public class EncryptionPacketListener extends PacketAdapter { + //mojang api check to prove a player is logged in minecraft and made a join server request private static final String HAS_JOINED_URL = "https://sessionserver.mojang.com/session/minecraft/hasJoined?"; private final ProtocolManager protocolManager; @@ -73,18 +82,18 @@ public class EncryptionPacketListener extends PacketAdapter { PacketContainer packet = packetEvent.getPacket(); Player player = packetEvent.getPlayer(); - //the player name is unknown to ProtocolLib - now uses ip:port as key + //the player name is unknown to ProtocolLib (so getName() doesn't work) - now uses ip:port as key String uniqueSessionKey = player.getAddress().toString(); PlayerSession session = plugin.getSessions().get(uniqueSessionKey); if (session == null) { disconnect(packetEvent, "Invalid request", Level.FINE - , "Player {0} tried to send encryption response" - + "on an invalid connection state" + , "Player {0} tried to send encryption response on an invalid connection state" , player.getAddress()); return; } byte[] sharedSecret = packet.getByteArrays().read(0); + //encrypted verify token byte[] clientVerify = packet.getByteArrays().read(1); PrivateKey privateKey = plugin.getKeyPair().getPrivate(); @@ -123,13 +132,12 @@ public class EncryptionPacketListener extends PacketAdapter { String username = session.getUsername(); if (hasJoinedServer(username, serverId)) { - session.setVerified(true); - plugin.getLogger().log(Level.FINE, "Player {0} has a verified premium account", username); + session.setVerified(true); receiveFakeStartPacket(username, player); } else { - //user tried to fake a authentification + //user tried to fake a authentication disconnect(packetEvent, "Invalid session", Level.FINE , "Player {0} ({1}) tried to log in with an invalid session ServerId: {2}" , session.getUsername(), player.getAddress(), serverId); @@ -161,6 +169,7 @@ public class EncryptionPacketListener extends PacketAdapter { } } + //try to get the networkManager from ProtocolLib private Object getNetworkManager(Player player) throws SecurityException, IllegalAccessException, NoSuchFieldException { Object injector = TemporaryPlayerFactory.getInjectorFromPlayer(player); @@ -184,9 +193,14 @@ public class EncryptionPacketListener extends PacketAdapter { String line = reader.readLine(); if (!line.equals("null")) { //validate parsing - JSONObject object = (JSONObject) JSONValue.parseWithException(line); - String uuid = (String) object.get("id"); - String name = (String) object.get("name"); + //http://wiki.vg/Protocol_Encryption#Server + JSONObject userData = (JSONObject) JSONValue.parseWithException(line); + String uuid = (String) userData.get("id"); + String name = (String) userData.get("name"); + + JSONObject properties = (JSONObject) userData.get("properties"); + //base64 encoded skin data + String encodedSkin = (String) properties.get("value"); return true; } @@ -199,12 +213,12 @@ public class EncryptionPacketListener extends PacketAdapter { return false; } + //fake a new login packet in order to let the server handle all the other stuff private void receiveFakeStartPacket(String username, Player from) { - //fake a new login packet //see StartPacketListener for packet information PacketContainer startPacket = protocolManager.createPacket(PacketType.Login.Client.START, true); - WrappedGameProfile fakeProfile = WrappedGameProfile.fromOfflinePlayer(Bukkit.getOfflinePlayer(username)); + WrappedGameProfile fakeProfile = new WrappedGameProfile(UUID.randomUUID(), username); startPacket.getGameProfiles().write(0, fakeProfile); try { //we don't want to handle our own packets so ignore filters diff --git a/src/main/java/com/github/games647/fastlogin/listener/PlayerListener.java b/src/main/java/com/github/games647/fastlogin/listener/PlayerListener.java index 7fb4a043..45f91e15 100644 --- a/src/main/java/com/github/games647/fastlogin/listener/PlayerListener.java +++ b/src/main/java/com/github/games647/fastlogin/listener/PlayerListener.java @@ -12,6 +12,11 @@ import org.bukkit.event.EventHandler; import org.bukkit.event.Listener; import org.bukkit.event.player.PlayerJoinEvent; +/** + * This listener tells authentication plugins if the player + * has a premium account and we checked it successfully. So the + * plugin can skip authentication. + */ public class PlayerListener implements Listener { private final FastLogin plugin; @@ -30,15 +35,7 @@ public class PlayerListener implements Listener { //removing the session because we now use it PlayerSession session = plugin.getSessions().remove(address); //check if it's the same player as we checked before - if (session != null && session.getUsername().equals(player.getName()) - && session.isVerified()) { -//java 8 -// Bukkit.getScheduler().runTaskLater(plugin, () -> { -// if (player.isOnline()) { -// plugin.getLogger().log(Level.FINER, "Logging player {0} in", player.getName()); -// authPlugin.forceLogin(player); -// } - //java 7+ + if (session != null && player.getName().equals(session.getUsername()) && session.isVerified()) { Bukkit.getScheduler().runTaskLater(plugin, new Runnable() { @Override diff --git a/src/main/java/com/github/games647/fastlogin/listener/StartPacketListener.java b/src/main/java/com/github/games647/fastlogin/listener/StartPacketListener.java index de26f0fa..38d4aef9 100644 --- a/src/main/java/com/github/games647/fastlogin/listener/StartPacketListener.java +++ b/src/main/java/com/github/games647/fastlogin/listener/StartPacketListener.java @@ -20,6 +20,11 @@ import java.util.regex.Pattern; import org.bukkit.entity.Player; /** + * Handles incoming start packets from connecting clients. It + * checks if we can start checking if the player is premium and + * start a request to the client that it should start online mode + * login. + * * Receiving packet information: * http://wiki.vg/Protocol#Login_Start * @@ -27,7 +32,7 @@ import org.bukkit.entity.Player; */ public class StartPacketListener extends PacketAdapter { - //only premium (paid account) users have a uuid from there + //only premium (paid account) users have a uuid from here private static final String UUID_LINK = "https://api.mojang.com/users/profiles/minecraft/"; //this includes a-zA-Z1-9_ private static final String VALID_PLAYERNAME = "^\\w{2,16}$"; @@ -42,7 +47,7 @@ public class StartPacketListener extends PacketAdapter { private final Pattern playernameMatcher = Pattern.compile(VALID_PLAYERNAME); public StartPacketListener(FastLogin plugin, ProtocolManager protocolManger) { - //run async in order to not block the server, because we make api calls to Mojang + //run async in order to not block the server, because we are making api calls to Mojang super(params(plugin, PacketType.Login.Client.START).optionAsync()); this.plugin = plugin; @@ -65,16 +70,16 @@ public class StartPacketListener extends PacketAdapter { PacketContainer packet = packetEvent.getPacket(); Player player = packetEvent.getPlayer(); - //this includes ip and port. Should be unique for 2 Minutes + //this includes ip:port. Should be unique for an incoming login request with a timeout of 2 minutes String sessionKey = player.getAddress().toString(); //remove old data every time on a new login in order to keep the session only for one person plugin.getSessions().remove(sessionKey); + //player.getName() won't work at this state String username = packet.getGameProfiles().read(0).getName(); plugin.getLogger().log(Level.FINER, "Player {0} with {1} connecting to the server" , new Object[]{sessionKey, username}); - //do premium login process if (plugin.getEnabledPremium().contains(username) && isPremium(username)) { //minecraft server implementation //https://github.com/bergerkiller/CraftSource/blob/master/net.minecraft.server/LoginListener.java#L161 @@ -83,7 +88,7 @@ public class StartPacketListener extends PacketAdapter { } private boolean isPremium(String playerName) { - //check if it's a valid playername and the user activated fast logins + //check if it's a valid playername if (playernameMatcher.matcher(playerName).matches()) { //only make a API call if the name is valid existing mojang account try { @@ -115,6 +120,7 @@ public class StartPacketListener extends PacketAdapter { .createPacket(PacketType.Login.Server.ENCRYPTION_BEGIN, true); newPacket.getSpecificModifier(PublicKey.class).write(0, plugin.getKeyPair().getPublic()); + //generate a random token which should be the same when we receive it from the client byte[] verifyToken = new byte[4]; random.nextBytes(verifyToken); newPacket.getByteArrays().write(0, verifyToken);