Skip to content
Merged
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
16 changes: 15 additions & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -58,6 +58,7 @@ You can also create your own translation file and, if you want, you can share it
<li>Graphical login/register dialogs, with optional Paper/Folia pre-join dialogs</li>
<li>Restricted users (associate a username with an IP)</li>
<li>Protect player's inventory until correct authentication (requires PacketEvents)</li>
<li><strong>Premium bypass: Mojang-account holders skip password auth (requires PacketEvents)</strong></li>
<li>Saves the quit location of the player</li>
<li>Automatic database backup</li>
<li>Available languages: <a href="https://github.com/AuthMe/AuthMeReloaded/blob/master/docs/translations.md">translations</a></li>
Expand All @@ -75,6 +76,19 @@ AuthMe can display graphical login/register dialogs instead of chat-based prompt
- `settings.registration.usePreJoinDialogUi` enables the **pre-join** dialog flow on **Paper/Folia**.
- Both options are independent: you can enable either one, both, or neither.
- Pre-join dialogs currently require modern dialog-capable server versions such as **Paper/Folia 1.21.11+**.
- Verified premium players skip the pre-join dialog entirely when premium bypass is enabled.

#### Premium bypass
AuthMe can let players with a legitimate Mojang account skip password authentication entirely.
Identity is verified via a cryptographic handshake with Mojang's session server during the
Minecraft login phase — no password prompt is ever shown.

- Enable with `settings.enablePremium: true` in `config.yml`.
- Players opt in with `/premium` and out with `/freemium` (must be logged in). Admins can enrol or remove players with `/authme premium <player>` / `/authme freemium <player>`.
- **Direct-connection (offline-mode, no proxy):** requires [PacketEvents](https://github.com/retrooper/packetevents) 2.x. Without it, premium bypass is disabled at startup (fail-closed).
- **Behind an online-mode proxy (Velocity / BungeeCord):** the proxy authenticates with Mojang and forwards the verified UUID — no PacketEvents needed on the backend. Set `Hooks.bungeecord: true` on the backend.
- **Behind an offline-mode proxy:** install `authme-velocity` or `authme-bungee` on the proxy; premium players are authenticated per-player by the proxy and the verified UUID is forwarded to the backend.
- Full documentation: [docs/premium.md](docs/premium.md)

#### Commands
[Command list and usage](https://github.com/AuthMe/AuthMeReloaded/blob/master/docs/commands.md)
Expand Down Expand Up @@ -145,7 +159,7 @@ AuthMe can display graphical login/register dialogs instead of chat-based prompt
> - `AuthMe-*-Spigot-1.21.jar` (Spigot 1.20.x – 1.21.x)
> - `AuthMe-*-Paper.jar` (Paper 1.21+)
> - `AuthMe-*-Folia.jar` (Folia 1.21+)
>- PacketEvents (optional, required by some features)
>- [PacketEvents](https://github.com/retrooper/packetevents) 2.x (optional plugin; required for inventory protection, tab-complete blocking, and premium bypass)

## Credits

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,7 @@ public void onEnable() {
configManager = new BungeeConfigManager(getDataFolder().toPath());
BungeeAuthenticationStore authenticationStore = new BungeeAuthenticationStore();
proxyBridge = new BungeeProxyBridge(getProxy(), getLogger(), configManager.getConfiguration(), authenticationStore);

getProxy().getPluginManager().registerListener(this, proxyBridge);
getProxy().getPluginManager().registerCommand(this, new BungeeReloadCommand(configManager, proxyBridge));
proxyBridge.logConfigurationDetails();
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -10,14 +10,19 @@
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.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;
import net.md_5.bungee.event.EventHandler;
import net.md_5.bungee.event.EventPriority;

import java.util.ArrayList;
import java.util.List;
import java.util.Locale;
import java.util.Map;
import java.util.Set;
Expand All @@ -37,6 +42,11 @@ public final class BungeeProxyBridge implements Listener {
private static final String PERFORM_LOGIN_MESSAGE = "perform.login";
private static final String PERFORM_LOGIN_ACK_MESSAGE = "perform.login.ack";
private static final String PROXY_STARTED_MESSAGE = "proxy.started";
private static final String PREMIUM_SET_MESSAGE = "premium.set";
private static final String PREMIUM_UNSET_MESSAGE = "premium.unset";
private static final String PREMIUM_LIST_MESSAGE = "premium.list";
private static final String PREMIUM_LIST_CHUNK_MESSAGE = "premium.list.chunk";
private static final String PREMIUM_PENDING_SET_MESSAGE = "premium.pending.set";
private static final String PROXY_IDENTITY = "bungee";
private static final int MAX_RETRIES = 3;

Expand All @@ -46,6 +56,12 @@ public final class BungeeProxyBridge implements Listener {
private final BungeeAuthenticationStore authenticationStore;
private final Map<String, AtomicInteger> pendingAutoLogins = new ConcurrentHashMap<>();
private final Set<String> notifiedAuthServers = ConcurrentHashMap.newKeySet();
private volatile Set<String> premiumUsernames = ConcurrentHashMap.newKeySet();
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 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 ScheduledExecutorService retryScheduler = Executors.newSingleThreadScheduledExecutor(r -> {
Thread t = new Thread(r, "authme-bungee-retry");
t.setDaemon(true);
Expand All @@ -60,6 +76,11 @@ public final class BungeeProxyBridge implements Listener {
this.authenticationStore = authenticationStore;
}

private void markProxyVerifiedPremium(String normalizedName) {
proxyVerifiedPremium.add(normalizedName);
logger.info("Proxy-verified premium: '" + normalizedName + "' authenticated online-mode with Mojang");
}

void reload(BungeeProxyConfiguration configuration) {
this.configuration = configuration;
logger.info("Configuration reloaded");
Expand Down Expand Up @@ -145,6 +166,7 @@ public void onPluginMessage(PluginMessageEvent event) {
+ server.getInfo().getName() + "'");
authenticationStore.markAuthenticated(parsedMessage.playerName());
sendAutoLoginIfAlreadySwitched(parsedMessage.playerName(), server.getInfo());
redirectToLoginServer(parsedMessage.playerName());
} else if (pendingAutoLogins.containsKey(parsedMessage.playerName())) {
// Implicit ACK: login from non-auth server confirms perform.login was processed
logger.info("Auto-login confirmed for " + parsedMessage.playerName()
Expand All @@ -158,6 +180,52 @@ public void onPluginMessage(PluginMessageEvent event) {
logger.info("Auto-login ACK received for " + parsedMessage.playerName()
+ " from server '" + server.getInfo().getName() + "'");
cancelPendingLogin(parsedMessage.playerName());
} else if (PREMIUM_SET_MESSAGE.equals(parsedMessage.typeId())) {
premiumUsernames.add(parsedMessage.playerName());
pendingPremiumUsernames.remove(parsedMessage.playerName());
logger.fine(() -> "Premium enabled for '" + parsedMessage.playerName() + "' (proxy cache updated)");
} else if (PREMIUM_UNSET_MESSAGE.equals(parsedMessage.typeId())) {
premiumUsernames.remove(parsedMessage.playerName());
pendingPremiumUsernames.remove(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());
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());
}
}
}
premiumUsernames = newPremiumSet;
logger.info("Premium list received from backend: " + premiumUsernames.size() + " premium player(s)");
} else if (PREMIUM_LIST_CHUNK_MESSAGE.equals(parsedMessage.typeId())) {
String[] parts = parsedMessage.playerName().split(":", 3);
if (parts.length < 3) {
logger.warning("Malformed premium.list.chunk payload: " + parsedMessage.playerName());
return;
}
if ("0".equals(parts[0])) {
premiumListBuffer = new ArrayList<>();
}
String csv = parts[2];
if (!csv.isEmpty()) {
for (String name : csv.split(",")) {
if (!name.isEmpty()) {
premiumListBuffer.add(name.trim());
}
}
}
if ("1".equals(parts[1])) {
Set<String> newPremiumSet = ConcurrentHashMap.newKeySet();
newPremiumSet.addAll(premiumListBuffer);
premiumUsernames = newPremiumSet;
premiumListBuffer = new ArrayList<>();
logger.info("Premium list received from backend: " + premiumUsernames.size() + " premium player(s)");
}
}
}

Expand All @@ -173,7 +241,7 @@ public void onServerSwitch(ServerSwitchEvent event) {
return;
}

if (currentServer == null || !authenticationStore.isAuthenticated(player)) {
if (currentServer == null) {
return;
}

Expand All @@ -184,6 +252,21 @@ 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);
if (!authenticationStore.isAuthenticated(player) && !isPremiumJoin) {
return;
}
if (isPremiumJoin) {
logger.fine("Proxy-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);
Expand Down Expand Up @@ -254,6 +337,53 @@ public void onPlayerDisconnect(PlayerDisconnectEvent event) {
}
cancelPendingLogin(normalizedName);
authenticationStore.clear(event.getPlayer());
proxyVerifiedPremium.remove(normalizedName);
pendingPremiumUsernames.remove(normalizedName);
}

@EventHandler
public void onPreLogin(PreLoginEvent event) {
String normalizedName = normalizeName(event.getConnection().getName());
if (premiumUsernames.contains(normalizedName) || pendingPremiumUsernames.contains(normalizedName)) {
event.getConnection().setOnlineMode(true);
logger.fine("Forcing online-mode for 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());
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");
}
}
}

void shutdown() {
Expand Down Expand Up @@ -350,16 +480,45 @@ private ParsedPluginMessage parsePluginMessage(byte[] data) {
try {
String typeId = input.readUTF();
if (!LOGIN_MESSAGE.equals(typeId) && !LOGOUT_MESSAGE.equals(typeId)
&& !PERFORM_LOGIN_ACK_MESSAGE.equals(typeId)) {
&& !PERFORM_LOGIN_ACK_MESSAGE.equals(typeId)
&& !PREMIUM_SET_MESSAGE.equals(typeId)
&& !PREMIUM_UNSET_MESSAGE.equals(typeId)
&& !PREMIUM_LIST_MESSAGE.equals(typeId)
&& !PREMIUM_LIST_CHUNK_MESSAGE.equals(typeId)
&& !PREMIUM_PENDING_SET_MESSAGE.equals(typeId)) {
return ParsedPluginMessage.ignored();
}
return new ParsedPluginMessage(typeId, normalizeName(input.readUTF()));
// 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))
? argument : normalizeName(argument));
} catch (IllegalStateException e) {
logger.warning("Received malformed AuthMe plugin message on the authme:main channel");
return ParsedPluginMessage.ignored();
}
}

private void redirectToLoginServer(String normalizedPlayerName) {
if (configuration.loginServer().isEmpty()) {
return;
}
ProxiedPlayer player = proxyServer.getPlayer(normalizedPlayerName);
if (player == null) {
logger.fine("Cannot redirect " + normalizedPlayerName + " to loginServer: player no longer on proxy");
return;
}
ServerInfo targetServer = proxyServer.getServerInfo(configuration.loginServer());
if (targetServer == null) {
logger.warning("loginServer '" + configuration.loginServer()
+ "' is not registered on the proxy; cannot redirect " + normalizedPlayerName);
return;
}
logger.info("Redirecting " + normalizedPlayerName + " to login server '"
+ configuration.loginServer() + "' after authentication");
player.connect(targetServer);
}

private void redirectLoggedOutPlayer(String normalizedPlayerName) {
if (!configuration.sendOnLogoutEnabled()) {
return;
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -21,14 +21,15 @@ final class BungeeProxyConfiguration {
private final boolean autoLoginEnabled;
private final boolean sendOnLogoutEnabled;
private final String sendOnLogoutTarget;
private final String loginServer;
private final String sharedSecret;

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

Expand All @@ -54,6 +56,7 @@ static BungeeProxyConfiguration from(SettingsManager settingsManager) {
settingsManager.getProperty(BungeeConfigProperties.AUTOLOGIN),
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));
}

Expand Down Expand Up @@ -93,6 +96,10 @@ String sendOnLogoutTarget() {
return sendOnLogoutTarget;
}

String loginServer() {
return loginServer;
}

String sharedSecret() {
return sharedSecret;
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -52,6 +52,13 @@ public final class BungeeConfigProperties implements SettingsHolder {
public static final Property<String> SEND_ON_LOGOUT_TARGET =
newProperty("unloggedUserServer", "");

@Comment({
"Server to redirect players to after successful authentication on an auth server.",
"Leave empty to disable proxy-side login redirect (backend handles it via BUNGEECORD_SERVER)."
})
public static final Property<String> LOGIN_SERVER =
newProperty("loginServer", "");

@Comment({
"Shared secret used to sign perform.login messages sent to backend servers.",
"Generated automatically on first start — copy this value to the Hooks.proxySharedSecret",
Expand Down
Loading
Loading