Skip to content

Commit cb75bc4

Browse files
committed
feat(premium): cryptographic Mojang session verification for premium bypass
1 parent 4d630a1 commit cb75bc4

102 files changed

Lines changed: 2961 additions & 64 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: 14 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 via cryptographic session verification (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,18 @@ 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+
- **Requires the [PacketEvents](https://github.com/retrooper/packetevents) plugin** (installed separately). Without it, the feature is disabled at startup (fail-closed).
87+
- Enable with `settings.premium.enabled: true` in `config.yml`.
88+
- 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>`.
89+
- Designed for **direct-connection offline-mode servers**. Behind an online-mode proxy (Velocity, BungeeCord in online mode), the backend cannot intercept the client's raw encryption response — disable premium bypass on the backend in that case and rely on the proxy's own Mojang auth.
90+
- Full documentation: [docs/premium.md](docs/premium.md)
7891

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

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: 116 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,9 @@ 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";
4046
private static final String PROXY_IDENTITY = "bungee";
4147
private static final int MAX_RETRIES = 3;
4248

@@ -46,6 +52,9 @@ public final class BungeeProxyBridge implements Listener {
4652
private final BungeeAuthenticationStore authenticationStore;
4753
private final Map<String, AtomicInteger> pendingAutoLogins = new ConcurrentHashMap<>();
4854
private final Set<String> notifiedAuthServers = ConcurrentHashMap.newKeySet();
55+
private final Set<String> premiumUsernames = ConcurrentHashMap.newKeySet();
56+
// Players whose Mojang UUID was confirmed by the proxy during the login phase (LoginSuccess with UUID v4)
57+
private final Set<String> proxyVerifiedPremium = ConcurrentHashMap.newKeySet();
4958
private final ScheduledExecutorService retryScheduler = Executors.newSingleThreadScheduledExecutor(r -> {
5059
Thread t = new Thread(r, "authme-bungee-retry");
5160
t.setDaemon(true);
@@ -60,6 +69,11 @@ public final class BungeeProxyBridge implements Listener {
6069
this.authenticationStore = authenticationStore;
6170
}
6271

72+
private void markProxyVerifiedPremium(String normalizedName) {
73+
proxyVerifiedPremium.add(normalizedName);
74+
logger.info("Proxy-verified premium: '" + normalizedName + "' authenticated online-mode with Mojang");
75+
}
76+
6377
void reload(BungeeProxyConfiguration configuration) {
6478
this.configuration = configuration;
6579
logger.info("Configuration reloaded");
@@ -145,6 +159,7 @@ public void onPluginMessage(PluginMessageEvent event) {
145159
+ server.getInfo().getName() + "'");
146160
authenticationStore.markAuthenticated(parsedMessage.playerName());
147161
sendAutoLoginIfAlreadySwitched(parsedMessage.playerName(), server.getInfo());
162+
redirectToLoginServer(parsedMessage.playerName());
148163
} else if (pendingAutoLogins.containsKey(parsedMessage.playerName())) {
149164
// Implicit ACK: login from non-auth server confirms perform.login was processed
150165
logger.info("Auto-login confirmed for " + parsedMessage.playerName()
@@ -158,6 +173,22 @@ public void onPluginMessage(PluginMessageEvent event) {
158173
logger.info("Auto-login ACK received for " + parsedMessage.playerName()
159174
+ " from server '" + server.getInfo().getName() + "'");
160175
cancelPendingLogin(parsedMessage.playerName());
176+
} else if (PREMIUM_SET_MESSAGE.equals(parsedMessage.typeId())) {
177+
premiumUsernames.add(parsedMessage.playerName());
178+
logger.fine("Premium enabled for '" + parsedMessage.playerName() + "' (proxy cache updated)");
179+
} else if (PREMIUM_UNSET_MESSAGE.equals(parsedMessage.typeId())) {
180+
premiumUsernames.remove(parsedMessage.playerName());
181+
logger.fine("Premium disabled for '" + parsedMessage.playerName() + "' (proxy cache updated)");
182+
} else if (PREMIUM_LIST_MESSAGE.equals(parsedMessage.typeId())) {
183+
premiumUsernames.clear();
184+
if (!parsedMessage.playerName().isEmpty()) {
185+
for (String name : parsedMessage.playerName().split(",")) {
186+
if (!name.isEmpty()) {
187+
premiumUsernames.add(name.trim());
188+
}
189+
}
190+
}
191+
logger.info("Premium list received from backend: " + premiumUsernames.size() + " premium player(s)");
161192
}
162193
}
163194

@@ -173,7 +204,7 @@ public void onServerSwitch(ServerSwitchEvent event) {
173204
return;
174205
}
175206

176-
if (currentServer == null || !authenticationStore.isAuthenticated(player)) {
207+
if (currentServer == null) {
177208
return;
178209
}
179210

@@ -184,6 +215,16 @@ public void onServerSwitch(ServerSwitchEvent event) {
184215
}
185216

186217
String normalizedName = normalizeName(player.getName());
218+
219+
boolean isPremiumJoin = connectingToAuthServer && proxyVerifiedPremium.contains(normalizedName);
220+
if (!authenticationStore.isAuthenticated(player) && !isPremiumJoin) {
221+
return;
222+
}
223+
if (isPremiumJoin) {
224+
logger.fine("Proxy-verified premium player " + normalizedName
225+
+ " joining auth server — sending perform.login immediately");
226+
}
227+
187228
String serverName = currentServer.getInfo().getName();
188229
logger.info("Sending auto-login request to server '" + serverName + "' for player " + normalizedName);
189230
currentServer.getInfo().sendData(AUTHME_CHANNEL, createPerformLoginMessage(normalizedName), false);
@@ -254,6 +295,52 @@ public void onPlayerDisconnect(PlayerDisconnectEvent event) {
254295
}
255296
cancelPendingLogin(normalizedName);
256297
authenticationStore.clear(event.getPlayer());
298+
proxyVerifiedPremium.remove(normalizedName);
299+
}
300+
301+
@EventHandler
302+
public void onPreLogin(PreLoginEvent event) {
303+
String normalizedName = normalizeName(event.getConnection().getName());
304+
if (premiumUsernames.contains(normalizedName)) {
305+
event.getConnection().setOnlineMode(true);
306+
logger.fine("Forcing online-mode for premium player '" + normalizedName + "'");
307+
}
308+
}
309+
310+
/**
311+
* Fires after the proxy has finished the Mojang authentication phase for a connecting player.
312+
* If the connection ended up in online mode (real Mojang account verified at the proxy), the
313+
* player is recorded as proxy-verified premium so the auto-login bypass on the auth server
314+
* will fire on {@link ServerSwitchEvent}.
315+
*/
316+
@EventHandler
317+
public void onLogin(LoginEvent event) {
318+
if (event.isCancelled()) {
319+
return;
320+
}
321+
if (!event.getConnection().isOnlineMode()) {
322+
return;
323+
}
324+
String normalizedName = normalizeName(event.getConnection().getName());
325+
markProxyVerifiedPremium(normalizedName);
326+
}
327+
328+
/**
329+
* Fallback: if for any reason the {@link LoginEvent} hook did not flag the player (e.g. the
330+
* proxy is in global online mode and {@code isOnlineMode()} on PendingConnection is reported
331+
* after {@code LoginEvent}), {@link PostLoginEvent} still gives us the verified UUID from the
332+
* proxy. A version-4 UUID means Mojang verified the identity.
333+
*/
334+
@EventHandler
335+
public void onPostLogin(PostLoginEvent event) {
336+
ProxiedPlayer player = event.getPlayer();
337+
if (player.getUniqueId() != null && player.getUniqueId().version() == 4) {
338+
String normalizedName = normalizeName(player.getName());
339+
if (proxyVerifiedPremium.add(normalizedName)) {
340+
logger.info("Proxy-verified premium (PostLogin fallback): '" + normalizedName
341+
+ "' has a Mojang UUID");
342+
}
343+
}
257344
}
258345

259346
void shutdown() {
@@ -350,16 +437,42 @@ private ParsedPluginMessage parsePluginMessage(byte[] data) {
350437
try {
351438
String typeId = input.readUTF();
352439
if (!LOGIN_MESSAGE.equals(typeId) && !LOGOUT_MESSAGE.equals(typeId)
353-
&& !PERFORM_LOGIN_ACK_MESSAGE.equals(typeId)) {
440+
&& !PERFORM_LOGIN_ACK_MESSAGE.equals(typeId)
441+
&& !PREMIUM_SET_MESSAGE.equals(typeId)
442+
&& !PREMIUM_UNSET_MESSAGE.equals(typeId)
443+
&& !PREMIUM_LIST_MESSAGE.equals(typeId)) {
354444
return ParsedPluginMessage.ignored();
355445
}
356-
return new ParsedPluginMessage(typeId, normalizeName(input.readUTF()));
446+
// premium.list carries a CSV in the second field, not a player name; read as-is
447+
String argument = input.readUTF();
448+
return new ParsedPluginMessage(typeId,
449+
PREMIUM_LIST_MESSAGE.equals(typeId) ? argument : normalizeName(argument));
357450
} catch (IllegalStateException e) {
358451
logger.warning("Received malformed AuthMe plugin message on the authme:main channel");
359452
return ParsedPluginMessage.ignored();
360453
}
361454
}
362455

456+
private void redirectToLoginServer(String normalizedPlayerName) {
457+
if (configuration.loginServer().isEmpty()) {
458+
return;
459+
}
460+
ProxiedPlayer player = proxyServer.getPlayer(normalizedPlayerName);
461+
if (player == null) {
462+
logger.fine("Cannot redirect " + normalizedPlayerName + " to loginServer: player no longer on proxy");
463+
return;
464+
}
465+
ServerInfo targetServer = proxyServer.getServerInfo(configuration.loginServer());
466+
if (targetServer == null) {
467+
logger.warning("loginServer '" + configuration.loginServer()
468+
+ "' is not registered on the proxy; cannot redirect " + normalizedPlayerName);
469+
return;
470+
}
471+
logger.info("Redirecting " + normalizedPlayerName + " to login server '"
472+
+ configuration.loginServer() + "' after authentication");
473+
player.connect(targetServer);
474+
}
475+
363476
private void redirectLoggedOutPlayer(String normalizedPlayerName) {
364477
if (!configuration.sendOnLogoutEnabled()) {
365478
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)