Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 5 additions & 0 deletions authme-bungee/pom.xml
Original file line number Diff line number Diff line change
Expand Up @@ -31,6 +31,11 @@
<artifactId>configme</artifactId>
<scope>compile</scope>
</dependency>
<dependency>
<groupId>com.github.retrooper</groupId>
<artifactId>packetevents-bungeecord</artifactId>
<scope>provided</scope>
</dependency>
<dependency>
<groupId>org.junit.jupiter</groupId>
<artifactId>junit-jupiter</artifactId>
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -3,18 +3,18 @@
import com.google.common.io.ByteArrayDataInput;
import com.google.common.io.ByteArrayDataOutput;
import com.google.common.io.ByteStreams;
import fr.xephi.authme.bungee.premium.BungeePremiumOnlineModeHandler;
import fr.xephi.authme.bungee.premium.BungeePremiumVerificationManager;
import net.md_5.bungee.api.ChatColor;
import net.md_5.bungee.api.chat.TextComponent;
import net.md_5.bungee.api.ProxyServer;
import net.md_5.bungee.api.config.ServerInfo;
import net.md_5.bungee.api.connection.ProxiedPlayer;
import net.md_5.bungee.api.connection.Server;
import net.md_5.bungee.api.event.ChatEvent;
import net.md_5.bungee.api.event.LoginEvent;
import net.md_5.bungee.api.event.PlayerDisconnectEvent;
import net.md_5.bungee.api.event.PlayerHandshakeEvent;
import net.md_5.bungee.api.event.PluginMessageEvent;
import net.md_5.bungee.api.event.PostLoginEvent;
import net.md_5.bungee.api.event.PreLoginEvent;
import net.md_5.bungee.api.event.ServerConnectEvent;
import net.md_5.bungee.api.event.ServerSwitchEvent;
import net.md_5.bungee.api.plugin.Listener;
Expand All @@ -31,6 +31,7 @@
import java.util.concurrent.ScheduledExecutorService;
import java.util.concurrent.TimeUnit;
import java.util.concurrent.atomic.AtomicInteger;
import java.util.UUID;
import java.util.logging.Logger;

public final class BungeeProxyBridge implements Listener {
Expand Down Expand Up @@ -60,11 +61,8 @@ public final class BungeeProxyBridge implements Listener {
private List<String> premiumListBuffer = new ArrayList<>();
// Players with a pending premium verification (ran /premium but not yet confirmed via reconnect)
private volatile Set<String> pendingPremiumUsernames = ConcurrentHashMap.newKeySet();
// Players for whom we already forced online-mode once to verify premium status; if they appear
// in onPreLogin again without having reached onLogin, Mojang auth failed → cancel the request.
private final Set<String> pendingVerificationAttempted = ConcurrentHashMap.newKeySet();
// Players whose Mojang UUID was confirmed by the proxy during the login phase (LoginSuccess with UUID v4)
private final Set<String> proxyVerifiedPremium = ConcurrentHashMap.newKeySet();
private final BungeePremiumOnlineModeHandler premiumOnlineModeHandler;
private final BungeePremiumVerificationManager premiumVerificationManager;
private final ScheduledExecutorService retryScheduler = Executors.newSingleThreadScheduledExecutor(r -> {
Thread t = new Thread(r, "authme-bungee-retry");
t.setDaemon(true);
Expand All @@ -77,15 +75,17 @@ public final class BungeeProxyBridge implements Listener {
this.logger = logger;
this.configuration = configuration;
this.authenticationStore = authenticationStore;
}

private void markProxyVerifiedPremium(String normalizedName) {
proxyVerifiedPremium.add(normalizedName);
logger.info("Proxy-verified premium: '" + normalizedName + "' authenticated online-mode with Mojang");
this.premiumOnlineModeHandler = new BungeePremiumOnlineModeHandler(this::requiresPremiumVerification);
this.premiumVerificationManager =
new BungeePremiumVerificationManager(proxyServer, logger,
this::requiresPremiumVerification, this::isPendingPremiumVerification,
this::clearPendingPremiumVerification,
() -> this.configuration.keepOfflineUuidCompatibility());
}

void reload(BungeeProxyConfiguration configuration) {
this.configuration = configuration;
premiumVerificationManager.refreshRegistration();
logger.info("Configuration reloaded");
}

Expand All @@ -104,6 +104,9 @@ void logConfigurationDetails() {
logger.info("autoLogin is disabled");
}

logger.info("premium.keepOfflineUuidCompatibility is "
+ (configuration.keepOfflineUuidCompatibility() ? "enabled" : "disabled"));

if (configuration.sendOnLogoutEnabled() && configuration.sendOnLogoutTarget().isEmpty()) {
logger.warning("sendOnLogout is enabled but unloggedUserServer is empty; logout redirects will be skipped");
}
Expand All @@ -113,6 +116,30 @@ void registerChannels() {
proxyServer.registerChannel(AUTHME_CHANNEL);
logger.info("Registered AuthMe BungeeCord bridge channel");
broadcastProxyStartedHandshake();
premiumVerificationManager.register();
}

@EventHandler(priority = EventPriority.HIGHEST)
public void onPlayerHandshake(PlayerHandshakeEvent event) {
if (!configuration.keepOfflineUuidCompatibility()) {
premiumOnlineModeHandler.enableOnlineModeIfRequired(event.getConnection());
}
}

private boolean requiresPremiumVerification(String normalizedName) {
return premiumUsernames.contains(normalizedName) || pendingPremiumUsernames.contains(normalizedName);
}

private boolean isPendingPremiumVerification(String normalizedName) {
return pendingPremiumUsernames.contains(normalizedName);
}

private void clearPendingPremiumVerification(String normalizedName) {
if (pendingPremiumUsernames.remove(normalizedName)) {
premiumVerificationManager.clearVerifiedPremium(normalizedName);
logger.warning("Cleared pending premium verification for '" + normalizedName
+ "' after a failed proxy-side premium handshake");
}
}

void broadcastProxyStartedHandshake() {
Expand Down Expand Up @@ -190,16 +217,18 @@ public void onPluginMessage(PluginMessageEvent event) {
} else if (PREMIUM_UNSET_MESSAGE.equals(parsedMessage.typeId())) {
premiumUsernames.remove(parsedMessage.playerName());
pendingPremiumUsernames.remove(parsedMessage.playerName());
premiumVerificationManager.clearVerifiedPremium(parsedMessage.playerName());
logger.fine(() -> "Premium disabled for '" + parsedMessage.playerName() + "' (proxy cache updated)");
} else if (PREMIUM_PENDING_SET_MESSAGE.equals(parsedMessage.typeId())) {
pendingPremiumUsernames.add(parsedMessage.playerName());
premiumVerificationManager.clearVerifiedPremium(parsedMessage.playerName());
logger.fine(() -> "Pending premium verification started for '" + parsedMessage.playerName() + "'");
} else if (PREMIUM_LIST_MESSAGE.equals(parsedMessage.typeId())) {
Set<String> newPremiumSet = ConcurrentHashMap.newKeySet();
if (!parsedMessage.playerName().isEmpty()) {
for (String name : parsedMessage.playerName().split(",")) {
if (!name.isEmpty()) {
newPremiumSet.add(name.trim());
newPremiumSet.add(normalizeName(name.trim()));
}
}
}
Expand All @@ -218,7 +247,7 @@ public void onPluginMessage(PluginMessageEvent event) {
if (!csv.isEmpty()) {
for (String name : csv.split(",")) {
if (!name.isEmpty()) {
premiumListBuffer.add(name.trim());
premiumListBuffer.add(normalizeName(name.trim()));
}
}
}
Expand Down Expand Up @@ -255,24 +284,20 @@ public void onServerSwitch(ServerSwitchEvent event) {
}

String normalizedName = normalizeName(player.getName());

// Pending players have passed Mojang auth at the proxy, but we must NOT send PERFORM_LOGIN
// for them: the backend needs to run canBypassWithPremium() to finalize (persist) the premium
// UUID. Only confirmed premium players (premiumUsernames) trigger the auto-login bypass.
boolean isPremiumJoin = connectingToAuthServer
&& proxyVerifiedPremium.contains(normalizedName)
&& !pendingPremiumUsernames.contains(normalizedName);
UUID verifiedPremiumUuid = premiumVerificationManager.getVerifiedPremiumUuid(normalizedName);
boolean isPremiumJoin = connectingToAuthServer && verifiedPremiumUuid != null;
if (!authenticationStore.isAuthenticated(player) && !isPremiumJoin) {
return;
}
if (isPremiumJoin) {
logger.fine("Proxy-verified premium player " + normalizedName
logger.fine("PacketEvents-verified premium player " + normalizedName
+ " joining auth server — sending perform.login immediately");
}

String serverName = currentServer.getInfo().getName();
logger.info("Sending auto-login request to server '" + serverName + "' for player " + normalizedName);
currentServer.getInfo().sendData(AUTHME_CHANNEL, createPerformLoginMessage(normalizedName), false);
currentServer.getInfo().sendData(
AUTHME_CHANNEL, createPerformLoginMessage(normalizedName, verifiedPremiumUuid), false);
initiatePendingLogin(normalizedName);
}

Expand Down Expand Up @@ -340,73 +365,12 @@ public void onPlayerDisconnect(PlayerDisconnectEvent event) {
}
cancelPendingLogin(normalizedName);
authenticationStore.clear(event.getPlayer());
proxyVerifiedPremium.remove(normalizedName);
pendingVerificationAttempted.remove(normalizedName);
}

@EventHandler
public void onPreLogin(PreLoginEvent event) {
String normalizedName = normalizeName(event.getConnection().getName());
if (premiumUsernames.contains(normalizedName)) {
event.getConnection().setOnlineMode(true);
logger.fine("Forcing online-mode for premium player '" + normalizedName + "'");
} else if (pendingPremiumUsernames.contains(normalizedName)) {
if (pendingVerificationAttempted.contains(normalizedName)) {
// The player was already given a forced online-mode attempt but never reached onLogin —
// meaning Mojang rejected them. Cancel the premium request so they can reconnect normally.
pendingPremiumUsernames.remove(normalizedName);
pendingVerificationAttempted.remove(normalizedName);
logger.info("Pending premium verification failed for '" + normalizedName
+ "' (Mojang auth rejected) — premium request cancelled");
} else {
// First attempt: force online-mode and track that the attempt is in progress.
pendingVerificationAttempted.add(normalizedName);
event.getConnection().setOnlineMode(true);
logger.fine("Forcing online-mode for pending premium player '" + normalizedName + "'");
}
}
}

/**
* Fires after the proxy has finished the Mojang authentication phase for a connecting player.
* If the connection ended up in online mode (real Mojang account verified at the proxy), the
* player is recorded as proxy-verified premium so the auto-login bypass on the auth server
* will fire on {@link ServerSwitchEvent}.
*/
@EventHandler
public void onLogin(LoginEvent event) {
if (event.isCancelled()) {
return;
}
if (!event.getConnection().isOnlineMode()) {
return;
}
String normalizedName = normalizeName(event.getConnection().getName());
// Mojang auth succeeded: the attempt tracking entry is no longer needed.
pendingVerificationAttempted.remove(normalizedName);
markProxyVerifiedPremium(normalizedName);
}

/**
* Fallback: if for any reason the {@link LoginEvent} hook did not flag the player (e.g. the
* proxy is in global online mode and {@code isOnlineMode()} on PendingConnection is reported
* after {@code LoginEvent}), {@link PostLoginEvent} still gives us the verified UUID from the
* proxy. A version-4 UUID means Mojang verified the identity.
*/
@EventHandler
public void onPostLogin(PostLoginEvent event) {
ProxiedPlayer player = event.getPlayer();
if (player.getUniqueId() != null && player.getUniqueId().version() == 4) {
String normalizedName = normalizeName(player.getName());
if (proxyVerifiedPremium.add(normalizedName)) {
logger.info("Proxy-verified premium (PostLogin fallback): '" + normalizedName
+ "' has a Mojang UUID");
}
}
premiumVerificationManager.clearVerifiedPremium(normalizedName);
}

void shutdown() {
proxyServer.unregisterChannel(AUTHME_CHANNEL);
premiumVerificationManager.shutdown();
retryScheduler.shutdownNow();
}

Expand All @@ -429,7 +393,9 @@ private void sendAutoLoginIfAlreadySwitched(String normalizedName, ServerInfo au
String currentServerName = currentConn.getInfo().getName();
logger.info("Player " + normalizedName + " already on server '" + currentServerName
+ "' when login message arrived — sending auto-login immediately");
currentConn.getInfo().sendData(AUTHME_CHANNEL, createPerformLoginMessage(normalizedName), false);
UUID verifiedPremiumUuid = premiumVerificationManager.getVerifiedPremiumUuid(normalizedName);
currentConn.getInfo().sendData(
AUTHME_CHANNEL, createPerformLoginMessage(normalizedName, verifiedPremiumUuid), false);
initiatePendingLogin(normalizedName);
}

Expand Down Expand Up @@ -488,7 +454,9 @@ private void scheduleRetry(String normalizedName) {
String serverName = server.getInfo().getName();
logger.fine("Retrying auto-login for " + normalizedName + " on server '" + serverName
+ "' (attempt " + (current + 1) + "/" + MAX_RETRIES + ")");
server.getInfo().sendData(AUTHME_CHANNEL, createPerformLoginMessage(normalizedName), false);
UUID verifiedPremiumUuid = premiumVerificationManager.getVerifiedPremiumUuid(normalizedName);
server.getInfo().sendData(
AUTHME_CHANNEL, createPerformLoginMessage(normalizedName, verifiedPremiumUuid), false);
scheduleRetry(normalizedName);
}, 1, TimeUnit.SECONDS);
}
Expand All @@ -507,7 +475,6 @@ private ParsedPluginMessage parsePluginMessage(byte[] data) {
&& !PREMIUM_PENDING_SET_MESSAGE.equals(typeId)) {
return ParsedPluginMessage.ignored();
}
// premium.list and premium.list.chunk carry non-player-name data; read as-is
String argument = input.readUTF();
return new ParsedPluginMessage(typeId,
(PREMIUM_LIST_MESSAGE.equals(typeId) || PREMIUM_LIST_CHUNK_MESSAGE.equals(typeId))
Expand Down Expand Up @@ -563,13 +530,15 @@ private void redirectLoggedOutPlayer(String normalizedPlayerName) {
player.connect(targetServer);
}

private byte[] createPerformLoginMessage(String normalizedName) {
private byte[] createPerformLoginMessage(String normalizedName, UUID verifiedPremiumUuid) {
long timestamp = System.currentTimeMillis();
String hmac = ProxyMessageSecurity.computeHmac(configuration.sharedSecret(), normalizedName, timestamp);
String hmac = ProxyMessageSecurity.computeHmac(
configuration.sharedSecret(), normalizedName, timestamp, verifiedPremiumUuid);
ByteArrayDataOutput output = ByteStreams.newDataOutput();
output.writeUTF(PERFORM_LOGIN_MESSAGE);
output.writeUTF(normalizedName);
output.writeLong(timestamp);
output.writeUTF(verifiedPremiumUuid == null ? "" : verifiedPremiumUuid.toString());
output.writeUTF(hmac);
return output.toByteArray();
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -23,13 +23,15 @@ final class BungeeProxyConfiguration {
private final String sendOnLogoutTarget;
private final String loginServer;
private final String sharedSecret;
private final boolean keepOfflineUuidCompatibility;

BungeeProxyConfiguration(Set<String> authServers, boolean allServersAreAuthServers,
boolean commandsRequireAuth, Set<String> commandWhitelist,
boolean chatRequiresAuth, boolean serverSwitchRequiresAuth,
String serverSwitchKickMessage, boolean autoLoginEnabled,
boolean sendOnLogoutEnabled, String sendOnLogoutTarget,
String loginServer, String sharedSecret) {
String loginServer, String sharedSecret,
boolean keepOfflineUuidCompatibility) {
this.authServers = authServers;
this.allServersAreAuthServers = allServersAreAuthServers;
this.commandsRequireAuth = commandsRequireAuth;
Expand All @@ -42,6 +44,7 @@ final class BungeeProxyConfiguration {
this.sendOnLogoutTarget = normalizeServerName(sendOnLogoutTarget);
this.loginServer = normalizeServerName(loginServer);
this.sharedSecret = sharedSecret;
this.keepOfflineUuidCompatibility = keepOfflineUuidCompatibility;
}

static BungeeProxyConfiguration from(SettingsManager settingsManager) {
Expand All @@ -57,7 +60,8 @@ static BungeeProxyConfiguration from(SettingsManager settingsManager) {
settingsManager.getProperty(BungeeConfigProperties.ENABLE_SEND_ON_LOGOUT),
settingsManager.getProperty(BungeeConfigProperties.SEND_ON_LOGOUT_TARGET),
settingsManager.getProperty(BungeeConfigProperties.LOGIN_SERVER),
settingsManager.getProperty(BungeeConfigProperties.PROXY_SHARED_SECRET));
settingsManager.getProperty(BungeeConfigProperties.PROXY_SHARED_SECRET),
settingsManager.getProperty(BungeeConfigProperties.PREMIUM_KEEP_OFFLINE_UUID_COMPATIBILITY));
}

Set<String> authServers() {
Expand Down Expand Up @@ -104,6 +108,10 @@ String sharedSecret() {
return sharedSecret;
}

boolean keepOfflineUuidCompatibility() {
return keepOfflineUuidCompatibility;
}

boolean isAuthServer(ServerInfo serverInfo) {
return allServersAreAuthServers || authServers.contains(normalizeServerName(serverInfo.getName()));
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,7 @@
import java.security.MessageDigest;
import java.security.NoSuchAlgorithmException;
import java.util.HexFormat;
import java.util.UUID;

final class ProxyMessageSecurity {

Expand All @@ -16,11 +17,12 @@ final class ProxyMessageSecurity {
private ProxyMessageSecurity() {
}

static String computeHmac(String secret, String playerName, long timestamp) {
static String computeHmac(String secret, String playerName, long timestamp, UUID verifiedPremiumUuid) {
try {
Mac mac = Mac.getInstance(HMAC_ALGO);
mac.init(new SecretKeySpec(secret.getBytes(StandardCharsets.UTF_8), HMAC_ALGO));
byte[] hmacBytes = mac.doFinal((playerName + ":" + timestamp).getBytes(StandardCharsets.UTF_8));
String payload = playerName + ":" + timestamp + ":" + (verifiedPremiumUuid == null ? "" : verifiedPremiumUuid);
byte[] hmacBytes = mac.doFinal(payload.getBytes(StandardCharsets.UTF_8));
return HexFormat.of().formatHex(hmacBytes);
} catch (NoSuchAlgorithmException | InvalidKeyException e) {
throw new IllegalStateException("Failed to compute HMAC-SHA256", e);
Expand All @@ -31,7 +33,7 @@ static boolean verifyHmac(String secret, String playerName, long timestamp, Stri
if (Math.abs(System.currentTimeMillis() - timestamp) > MAX_AGE_MILLIS) {
return false;
}
String expectedHmac = computeHmac(secret, playerName, timestamp);
String expectedHmac = computeHmac(secret, playerName, timestamp, null);
return MessageDigest.isEqual(
expectedHmac.getBytes(StandardCharsets.UTF_8),
providedHmac.getBytes(StandardCharsets.UTF_8));
Expand Down
Loading
Loading