Skip to content
This repository was archived by the owner on May 5, 2026. It is now read-only.

Commit 2cc2adb

Browse files
committed
feat(premium): cryptographic Mojang session verification for premium bypass
1 parent 5a11c6f commit 2cc2adb

103 files changed

Lines changed: 3615 additions & 277 deletions

File tree

Some content is hidden

Large Commits have some content hidden by default. Use the searchbox below for content that may be hidden.

README.md

Lines changed: 15 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -58,6 +58,7 @@ You can also create your own translation file and, if you want, you can share it
5858
<li>Graphical login/register dialogs, with optional Paper/Folia pre-join dialogs</li>
5959
<li>Restricted users (associate a username with an IP)</li>
6060
<li>Protect player's inventory until correct authentication (requires PacketEvents)</li>
61+
<li><strong>Premium bypass: Mojang-account holders skip password auth (requires PacketEvents)</strong></li>
6162
<li>Saves the quit location of the player</li>
6263
<li>Automatic database backup</li>
6364
<li>Available languages: <a href="https://github.com/AuthMe/AuthMeReloaded/blob/master/docs/translations.md">translations</a></li>
@@ -75,6 +76,19 @@ AuthMe can display graphical login/register dialogs instead of chat-based prompt
7576
- `settings.registration.usePreJoinDialogUi` enables the **pre-join** dialog flow on **Paper/Folia**.
7677
- Both options are independent: you can enable either one, both, or neither.
7778
- Pre-join dialogs currently require modern dialog-capable server versions such as **Paper/Folia 1.21.11+**.
79+
- Verified premium players skip the pre-join dialog entirely when premium bypass is enabled.
80+
81+
#### Premium bypass
82+
AuthMe can let players with a legitimate Mojang account skip password authentication entirely.
83+
Identity is verified via a cryptographic handshake with Mojang's session server during the
84+
Minecraft login phase — no password prompt is ever shown.
85+
86+
- Enable with `settings.enablePremium: true` in `config.yml`.
87+
- 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>`.
88+
- **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).
89+
- **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.
90+
- **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.
91+
- Full documentation: [docs/premium.md](docs/premium.md)
7892

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

authme-bungee/src/main/java/fr/xephi/authme/bungee/AuthMeBungeePlugin.java

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -10,6 +10,7 @@ public void onEnable() {
1010
configManager = new BungeeConfigManager(getDataFolder().toPath());
1111
BungeeAuthenticationStore authenticationStore = new BungeeAuthenticationStore();
1212
proxyBridge = new BungeeProxyBridge(getProxy(), getLogger(), configManager.getConfiguration(), authenticationStore);
13+
1314
getProxy().getPluginManager().registerListener(this, proxyBridge);
1415
getProxy().getPluginManager().registerCommand(this, new BungeeReloadCommand(configManager, proxyBridge));
1516
proxyBridge.logConfigurationDetails();

authme-bungee/src/main/java/fr/xephi/authme/bungee/BungeeProxyBridge.java

Lines changed: 132 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -10,8 +10,11 @@
1010
import net.md_5.bungee.api.connection.ProxiedPlayer;
1111
import net.md_5.bungee.api.connection.Server;
1212
import net.md_5.bungee.api.event.ChatEvent;
13+
import net.md_5.bungee.api.event.LoginEvent;
1314
import net.md_5.bungee.api.event.PlayerDisconnectEvent;
1415
import net.md_5.bungee.api.event.PluginMessageEvent;
16+
import net.md_5.bungee.api.event.PostLoginEvent;
17+
import net.md_5.bungee.api.event.PreLoginEvent;
1518
import net.md_5.bungee.api.event.ServerConnectEvent;
1619
import net.md_5.bungee.api.event.ServerSwitchEvent;
1720
import net.md_5.bungee.api.plugin.Listener;
@@ -37,6 +40,10 @@ public final class BungeeProxyBridge implements Listener {
3740
private static final String PERFORM_LOGIN_MESSAGE = "perform.login";
3841
private static final String PERFORM_LOGIN_ACK_MESSAGE = "perform.login.ack";
3942
private static final String PROXY_STARTED_MESSAGE = "proxy.started";
43+
private static final String PREMIUM_SET_MESSAGE = "premium.set";
44+
private static final String PREMIUM_UNSET_MESSAGE = "premium.unset";
45+
private static final String PREMIUM_LIST_MESSAGE = "premium.list";
46+
private static final String PREMIUM_PENDING_SET_MESSAGE = "premium.pending.set";
4047
private static final String PROXY_IDENTITY = "bungee";
4148
private static final int MAX_RETRIES = 3;
4249

@@ -46,6 +53,11 @@ public final class BungeeProxyBridge implements Listener {
4653
private final BungeeAuthenticationStore authenticationStore;
4754
private final Map<String, AtomicInteger> pendingAutoLogins = new ConcurrentHashMap<>();
4855
private final Set<String> notifiedAuthServers = ConcurrentHashMap.newKeySet();
56+
private volatile Set<String> premiumUsernames = ConcurrentHashMap.newKeySet();
57+
// Players with a pending premium verification (ran /premium but not yet confirmed via reconnect)
58+
private volatile Set<String> pendingPremiumUsernames = ConcurrentHashMap.newKeySet();
59+
// Players whose Mojang UUID was confirmed by the proxy during the login phase (LoginSuccess with UUID v4)
60+
private final Set<String> proxyVerifiedPremium = ConcurrentHashMap.newKeySet();
4961
private final ScheduledExecutorService retryScheduler = Executors.newSingleThreadScheduledExecutor(r -> {
5062
Thread t = new Thread(r, "authme-bungee-retry");
5163
t.setDaemon(true);
@@ -60,6 +72,11 @@ public final class BungeeProxyBridge implements Listener {
6072
this.authenticationStore = authenticationStore;
6173
}
6274

75+
private void markProxyVerifiedPremium(String normalizedName) {
76+
proxyVerifiedPremium.add(normalizedName);
77+
logger.info("Proxy-verified premium: '" + normalizedName + "' authenticated online-mode with Mojang");
78+
}
79+
6380
void reload(BungeeProxyConfiguration configuration) {
6481
this.configuration = configuration;
6582
logger.info("Configuration reloaded");
@@ -145,6 +162,7 @@ public void onPluginMessage(PluginMessageEvent event) {
145162
+ server.getInfo().getName() + "'");
146163
authenticationStore.markAuthenticated(parsedMessage.playerName());
147164
sendAutoLoginIfAlreadySwitched(parsedMessage.playerName(), server.getInfo());
165+
redirectToLoginServer(parsedMessage.playerName());
148166
} else if (pendingAutoLogins.containsKey(parsedMessage.playerName())) {
149167
// Implicit ACK: login from non-auth server confirms perform.login was processed
150168
logger.info("Auto-login confirmed for " + parsedMessage.playerName()
@@ -158,6 +176,28 @@ public void onPluginMessage(PluginMessageEvent event) {
158176
logger.info("Auto-login ACK received for " + parsedMessage.playerName()
159177
+ " from server '" + server.getInfo().getName() + "'");
160178
cancelPendingLogin(parsedMessage.playerName());
179+
} else if (PREMIUM_SET_MESSAGE.equals(parsedMessage.typeId())) {
180+
premiumUsernames.add(parsedMessage.playerName());
181+
pendingPremiumUsernames.remove(parsedMessage.playerName());
182+
logger.fine(() -> "Premium enabled for '" + parsedMessage.playerName() + "' (proxy cache updated)");
183+
} else if (PREMIUM_UNSET_MESSAGE.equals(parsedMessage.typeId())) {
184+
premiumUsernames.remove(parsedMessage.playerName());
185+
pendingPremiumUsernames.remove(parsedMessage.playerName());
186+
logger.fine(() -> "Premium disabled for '" + parsedMessage.playerName() + "' (proxy cache updated)");
187+
} else if (PREMIUM_PENDING_SET_MESSAGE.equals(parsedMessage.typeId())) {
188+
pendingPremiumUsernames.add(parsedMessage.playerName());
189+
logger.fine(() -> "Pending premium verification started for '" + parsedMessage.playerName() + "'");
190+
} else if (PREMIUM_LIST_MESSAGE.equals(parsedMessage.typeId())) {
191+
Set<String> newPremiumSet = ConcurrentHashMap.newKeySet();
192+
if (!parsedMessage.playerName().isEmpty()) {
193+
for (String name : parsedMessage.playerName().split(",")) {
194+
if (!name.isEmpty()) {
195+
newPremiumSet.add(name.trim());
196+
}
197+
}
198+
}
199+
premiumUsernames = newPremiumSet;
200+
logger.info("Premium list received from backend: " + premiumUsernames.size() + " premium player(s)");
161201
}
162202
}
163203

@@ -173,7 +213,7 @@ public void onServerSwitch(ServerSwitchEvent event) {
173213
return;
174214
}
175215

176-
if (currentServer == null || !authenticationStore.isAuthenticated(player)) {
216+
if (currentServer == null) {
177217
return;
178218
}
179219

@@ -184,6 +224,21 @@ public void onServerSwitch(ServerSwitchEvent event) {
184224
}
185225

186226
String normalizedName = normalizeName(player.getName());
227+
228+
// Pending players have passed Mojang auth at the proxy, but we must NOT send PERFORM_LOGIN
229+
// for them: the backend needs to run canBypassWithPremium() to finalize (persist) the premium
230+
// UUID. Only confirmed premium players (premiumUsernames) trigger the auto-login bypass.
231+
boolean isPremiumJoin = connectingToAuthServer
232+
&& proxyVerifiedPremium.contains(normalizedName)
233+
&& !pendingPremiumUsernames.contains(normalizedName);
234+
if (!authenticationStore.isAuthenticated(player) && !isPremiumJoin) {
235+
return;
236+
}
237+
if (isPremiumJoin) {
238+
logger.fine("Proxy-verified premium player " + normalizedName
239+
+ " joining auth server — sending perform.login immediately");
240+
}
241+
187242
String serverName = currentServer.getInfo().getName();
188243
logger.info("Sending auto-login request to server '" + serverName + "' for player " + normalizedName);
189244
currentServer.getInfo().sendData(AUTHME_CHANNEL, createPerformLoginMessage(normalizedName), false);
@@ -254,6 +309,53 @@ public void onPlayerDisconnect(PlayerDisconnectEvent event) {
254309
}
255310
cancelPendingLogin(normalizedName);
256311
authenticationStore.clear(event.getPlayer());
312+
proxyVerifiedPremium.remove(normalizedName);
313+
pendingPremiumUsernames.remove(normalizedName);
314+
}
315+
316+
@EventHandler
317+
public void onPreLogin(PreLoginEvent event) {
318+
String normalizedName = normalizeName(event.getConnection().getName());
319+
if (premiumUsernames.contains(normalizedName) || pendingPremiumUsernames.contains(normalizedName)) {
320+
event.getConnection().setOnlineMode(true);
321+
logger.fine("Forcing online-mode for premium player '" + normalizedName + "'");
322+
}
323+
}
324+
325+
/**
326+
* Fires after the proxy has finished the Mojang authentication phase for a connecting player.
327+
* If the connection ended up in online mode (real Mojang account verified at the proxy), the
328+
* player is recorded as proxy-verified premium so the auto-login bypass on the auth server
329+
* will fire on {@link ServerSwitchEvent}.
330+
*/
331+
@EventHandler
332+
public void onLogin(LoginEvent event) {
333+
if (event.isCancelled()) {
334+
return;
335+
}
336+
if (!event.getConnection().isOnlineMode()) {
337+
return;
338+
}
339+
String normalizedName = normalizeName(event.getConnection().getName());
340+
markProxyVerifiedPremium(normalizedName);
341+
}
342+
343+
/**
344+
* Fallback: if for any reason the {@link LoginEvent} hook did not flag the player (e.g. the
345+
* proxy is in global online mode and {@code isOnlineMode()} on PendingConnection is reported
346+
* after {@code LoginEvent}), {@link PostLoginEvent} still gives us the verified UUID from the
347+
* proxy. A version-4 UUID means Mojang verified the identity.
348+
*/
349+
@EventHandler
350+
public void onPostLogin(PostLoginEvent event) {
351+
ProxiedPlayer player = event.getPlayer();
352+
if (player.getUniqueId() != null && player.getUniqueId().version() == 4) {
353+
String normalizedName = normalizeName(player.getName());
354+
if (proxyVerifiedPremium.add(normalizedName)) {
355+
logger.info("Proxy-verified premium (PostLogin fallback): '" + normalizedName
356+
+ "' has a Mojang UUID");
357+
}
358+
}
257359
}
258360

259361
void shutdown() {
@@ -350,16 +452,43 @@ private ParsedPluginMessage parsePluginMessage(byte[] data) {
350452
try {
351453
String typeId = input.readUTF();
352454
if (!LOGIN_MESSAGE.equals(typeId) && !LOGOUT_MESSAGE.equals(typeId)
353-
&& !PERFORM_LOGIN_ACK_MESSAGE.equals(typeId)) {
455+
&& !PERFORM_LOGIN_ACK_MESSAGE.equals(typeId)
456+
&& !PREMIUM_SET_MESSAGE.equals(typeId)
457+
&& !PREMIUM_UNSET_MESSAGE.equals(typeId)
458+
&& !PREMIUM_LIST_MESSAGE.equals(typeId)
459+
&& !PREMIUM_PENDING_SET_MESSAGE.equals(typeId)) {
354460
return ParsedPluginMessage.ignored();
355461
}
356-
return new ParsedPluginMessage(typeId, normalizeName(input.readUTF()));
462+
// premium.list carries a CSV in the second field, not a player name; read as-is
463+
String argument = input.readUTF();
464+
return new ParsedPluginMessage(typeId,
465+
PREMIUM_LIST_MESSAGE.equals(typeId) ? argument : normalizeName(argument));
357466
} catch (IllegalStateException e) {
358467
logger.warning("Received malformed AuthMe plugin message on the authme:main channel");
359468
return ParsedPluginMessage.ignored();
360469
}
361470
}
362471

472+
private void redirectToLoginServer(String normalizedPlayerName) {
473+
if (configuration.loginServer().isEmpty()) {
474+
return;
475+
}
476+
ProxiedPlayer player = proxyServer.getPlayer(normalizedPlayerName);
477+
if (player == null) {
478+
logger.fine("Cannot redirect " + normalizedPlayerName + " to loginServer: player no longer on proxy");
479+
return;
480+
}
481+
ServerInfo targetServer = proxyServer.getServerInfo(configuration.loginServer());
482+
if (targetServer == null) {
483+
logger.warning("loginServer '" + configuration.loginServer()
484+
+ "' is not registered on the proxy; cannot redirect " + normalizedPlayerName);
485+
return;
486+
}
487+
logger.info("Redirecting " + normalizedPlayerName + " to login server '"
488+
+ configuration.loginServer() + "' after authentication");
489+
player.connect(targetServer);
490+
}
491+
363492
private void redirectLoggedOutPlayer(String normalizedPlayerName) {
364493
if (!configuration.sendOnLogoutEnabled()) {
365494
return;

authme-bungee/src/main/java/fr/xephi/authme/bungee/BungeeProxyConfiguration.java

Lines changed: 8 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -21,14 +21,15 @@ final class BungeeProxyConfiguration {
2121
private final boolean autoLoginEnabled;
2222
private final boolean sendOnLogoutEnabled;
2323
private final String sendOnLogoutTarget;
24+
private final String loginServer;
2425
private final String sharedSecret;
2526

2627
BungeeProxyConfiguration(Set<String> authServers, boolean allServersAreAuthServers,
2728
boolean commandsRequireAuth, Set<String> commandWhitelist,
2829
boolean chatRequiresAuth, boolean serverSwitchRequiresAuth,
2930
String serverSwitchKickMessage, boolean autoLoginEnabled,
3031
boolean sendOnLogoutEnabled, String sendOnLogoutTarget,
31-
String sharedSecret) {
32+
String loginServer, String sharedSecret) {
3233
this.authServers = authServers;
3334
this.allServersAreAuthServers = allServersAreAuthServers;
3435
this.commandsRequireAuth = commandsRequireAuth;
@@ -39,6 +40,7 @@ final class BungeeProxyConfiguration {
3940
this.autoLoginEnabled = autoLoginEnabled;
4041
this.sendOnLogoutEnabled = sendOnLogoutEnabled;
4142
this.sendOnLogoutTarget = normalizeServerName(sendOnLogoutTarget);
43+
this.loginServer = normalizeServerName(loginServer);
4244
this.sharedSecret = sharedSecret;
4345
}
4446

@@ -54,6 +56,7 @@ static BungeeProxyConfiguration from(SettingsManager settingsManager) {
5456
settingsManager.getProperty(BungeeConfigProperties.AUTOLOGIN),
5557
settingsManager.getProperty(BungeeConfigProperties.ENABLE_SEND_ON_LOGOUT),
5658
settingsManager.getProperty(BungeeConfigProperties.SEND_ON_LOGOUT_TARGET),
59+
settingsManager.getProperty(BungeeConfigProperties.LOGIN_SERVER),
5760
settingsManager.getProperty(BungeeConfigProperties.PROXY_SHARED_SECRET));
5861
}
5962

@@ -93,6 +96,10 @@ String sendOnLogoutTarget() {
9396
return sendOnLogoutTarget;
9497
}
9598

99+
String loginServer() {
100+
return loginServer;
101+
}
102+
96103
String sharedSecret() {
97104
return sharedSecret;
98105
}

authme-bungee/src/main/java/fr/xephi/authme/bungee/config/BungeeConfigProperties.java

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -52,6 +52,13 @@ public final class BungeeConfigProperties implements SettingsHolder {
5252
public static final Property<String> SEND_ON_LOGOUT_TARGET =
5353
newProperty("unloggedUserServer", "");
5454

55+
@Comment({
56+
"Server to redirect players to after successful authentication on an auth server.",
57+
"Leave empty to disable proxy-side login redirect (backend handles it via BUNGEECORD_SERVER)."
58+
})
59+
public static final Property<String> LOGIN_SERVER =
60+
newProperty("loginServer", "");
61+
5562
@Comment({
5663
"Shared secret used to sign perform.login messages sent to backend servers.",
5764
"Generated automatically on first start — copy this value to the Hooks.proxySharedSecret",

authme-bungee/src/test/java/fr/xephi/authme/bungee/BungeeProxyBridgeTest.java

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -155,7 +155,7 @@ void shouldRedirectPlayerOnLogoutWhenConfigured() {
155155
BungeeProxyBridge bridge = new BungeeProxyBridge(
156156
proxyServer, logger, new BungeeProxyConfiguration(
157157
Set.of("lobby"), false, true, Set.of("/login"), true, true,
158-
"Authentication required.", true, true, "limbo", ""),
158+
"Authentication required.", true, true, "limbo", "", ""),
159159
new BungeeAuthenticationStore());
160160
bridge.onPluginMessage(pluginMessageEvent);
161161

@@ -441,7 +441,7 @@ void shouldSendAutoLoginImmediatelyWhenPlayerAlreadySwitchedBeforeLoginMessage()
441441
private static BungeeProxyConfiguration createConfiguration() {
442442
return new BungeeProxyConfiguration(
443443
Set.of("lobby"), false, true, Set.of("/login", "/register", "/l", "/reg", "/email", "/captcha", "/2fa", "/totp", "/log"),
444-
true, true, "Authentication required.", true, false, "", "test-secret");
444+
true, true, "Authentication required.", true, false, "", "", "test-secret");
445445
}
446446

447447
private static byte[] createAuthMePayload(String typeId, String playerName) {

authme-bungee/src/test/java/fr/xephi/authme/bungee/BungeeReloadCommandTest.java

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -28,7 +28,7 @@ class BungeeReloadCommandTest {
2828
void shouldReloadConfigAndProxyBridge() {
2929
BungeeProxyConfiguration configuration = new BungeeProxyConfiguration(
3030
Set.of("lobby"), false, true, Set.of("/login"), true, true,
31-
"Authentication required.", true, false, "", "");
31+
"Authentication required.", true, false, "", "", "");
3232
given(configManager.reload()).willReturn(configuration);
3333

3434
BungeeReloadCommand command = new BungeeReloadCommand(configManager, proxyBridge);

0 commit comments

Comments
 (0)