Skip to content

Commit aeca282

Browse files
committed
feat(premium): cryptographic Mojang session verification for premium bypass
- fix(messages): restore UTF-8 encoding in de/br/et translation files - fix(premium): avoid removing fresh cache entry on expiry check - fix(premium): use Bukkit async scheduler and refresh proxy carrier player - fix(premium): cache updating workflow was broken - fix(premium): Re-order session handling, so premium user don't consume session uselessly - fix(premium): Correctly select translated the kick message - feat(proxy): send premium ids list in chunk for large lists
1 parent 44990d3 commit aeca282

104 files changed

Lines changed: 3692 additions & 76 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: 162 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -10,14 +10,19 @@
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;
1821
import net.md_5.bungee.event.EventHandler;
1922
import net.md_5.bungee.event.EventPriority;
2023

24+
import java.util.ArrayList;
25+
import java.util.List;
2126
import java.util.Locale;
2227
import java.util.Map;
2328
import java.util.Set;
@@ -37,6 +42,11 @@ public final class BungeeProxyBridge implements Listener {
3742
private static final String PERFORM_LOGIN_MESSAGE = "perform.login";
3843
private static final String PERFORM_LOGIN_ACK_MESSAGE = "perform.login.ack";
3944
private static final String PROXY_STARTED_MESSAGE = "proxy.started";
45+
private static final String PREMIUM_SET_MESSAGE = "premium.set";
46+
private static final String PREMIUM_UNSET_MESSAGE = "premium.unset";
47+
private static final String PREMIUM_LIST_MESSAGE = "premium.list";
48+
private static final String PREMIUM_LIST_CHUNK_MESSAGE = "premium.list.chunk";
49+
private static final String PREMIUM_PENDING_SET_MESSAGE = "premium.pending.set";
4050
private static final String PROXY_IDENTITY = "bungee";
4151
private static final int MAX_RETRIES = 3;
4252

@@ -46,6 +56,12 @@ public final class BungeeProxyBridge implements Listener {
4656
private final BungeeAuthenticationStore authenticationStore;
4757
private final Map<String, AtomicInteger> pendingAutoLogins = new ConcurrentHashMap<>();
4858
private final Set<String> notifiedAuthServers = ConcurrentHashMap.newKeySet();
59+
private volatile Set<String> premiumUsernames = ConcurrentHashMap.newKeySet();
60+
private List<String> premiumListBuffer = new ArrayList<>();
61+
// Players with a pending premium verification (ran /premium but not yet confirmed via reconnect)
62+
private volatile Set<String> pendingPremiumUsernames = ConcurrentHashMap.newKeySet();
63+
// Players whose Mojang UUID was confirmed by the proxy during the login phase (LoginSuccess with UUID v4)
64+
private final Set<String> proxyVerifiedPremium = ConcurrentHashMap.newKeySet();
4965
private final ScheduledExecutorService retryScheduler = Executors.newSingleThreadScheduledExecutor(r -> {
5066
Thread t = new Thread(r, "authme-bungee-retry");
5167
t.setDaemon(true);
@@ -60,6 +76,11 @@ public final class BungeeProxyBridge implements Listener {
6076
this.authenticationStore = authenticationStore;
6177
}
6278

79+
private void markProxyVerifiedPremium(String normalizedName) {
80+
proxyVerifiedPremium.add(normalizedName);
81+
logger.info("Proxy-verified premium: '" + normalizedName + "' authenticated online-mode with Mojang");
82+
}
83+
6384
void reload(BungeeProxyConfiguration configuration) {
6485
this.configuration = configuration;
6586
logger.info("Configuration reloaded");
@@ -145,6 +166,7 @@ public void onPluginMessage(PluginMessageEvent event) {
145166
+ server.getInfo().getName() + "'");
146167
authenticationStore.markAuthenticated(parsedMessage.playerName());
147168
sendAutoLoginIfAlreadySwitched(parsedMessage.playerName(), server.getInfo());
169+
redirectToLoginServer(parsedMessage.playerName());
148170
} else if (pendingAutoLogins.containsKey(parsedMessage.playerName())) {
149171
// Implicit ACK: login from non-auth server confirms perform.login was processed
150172
logger.info("Auto-login confirmed for " + parsedMessage.playerName()
@@ -158,6 +180,52 @@ public void onPluginMessage(PluginMessageEvent event) {
158180
logger.info("Auto-login ACK received for " + parsedMessage.playerName()
159181
+ " from server '" + server.getInfo().getName() + "'");
160182
cancelPendingLogin(parsedMessage.playerName());
183+
} else if (PREMIUM_SET_MESSAGE.equals(parsedMessage.typeId())) {
184+
premiumUsernames.add(parsedMessage.playerName());
185+
pendingPremiumUsernames.remove(parsedMessage.playerName());
186+
logger.fine(() -> "Premium enabled for '" + parsedMessage.playerName() + "' (proxy cache updated)");
187+
} else if (PREMIUM_UNSET_MESSAGE.equals(parsedMessage.typeId())) {
188+
premiumUsernames.remove(parsedMessage.playerName());
189+
pendingPremiumUsernames.remove(parsedMessage.playerName());
190+
logger.fine(() -> "Premium disabled for '" + parsedMessage.playerName() + "' (proxy cache updated)");
191+
} else if (PREMIUM_PENDING_SET_MESSAGE.equals(parsedMessage.typeId())) {
192+
pendingPremiumUsernames.add(parsedMessage.playerName());
193+
logger.fine(() -> "Pending premium verification started for '" + parsedMessage.playerName() + "'");
194+
} else if (PREMIUM_LIST_MESSAGE.equals(parsedMessage.typeId())) {
195+
Set<String> newPremiumSet = ConcurrentHashMap.newKeySet();
196+
if (!parsedMessage.playerName().isEmpty()) {
197+
for (String name : parsedMessage.playerName().split(",")) {
198+
if (!name.isEmpty()) {
199+
newPremiumSet.add(name.trim());
200+
}
201+
}
202+
}
203+
premiumUsernames = newPremiumSet;
204+
logger.info("Premium list received from backend: " + premiumUsernames.size() + " premium player(s)");
205+
} else if (PREMIUM_LIST_CHUNK_MESSAGE.equals(parsedMessage.typeId())) {
206+
String[] parts = parsedMessage.playerName().split(":", 3);
207+
if (parts.length < 3) {
208+
logger.warning("Malformed premium.list.chunk payload: " + parsedMessage.playerName());
209+
return;
210+
}
211+
if ("0".equals(parts[0])) {
212+
premiumListBuffer = new ArrayList<>();
213+
}
214+
String csv = parts[2];
215+
if (!csv.isEmpty()) {
216+
for (String name : csv.split(",")) {
217+
if (!name.isEmpty()) {
218+
premiumListBuffer.add(name.trim());
219+
}
220+
}
221+
}
222+
if ("1".equals(parts[1])) {
223+
Set<String> newPremiumSet = ConcurrentHashMap.newKeySet();
224+
newPremiumSet.addAll(premiumListBuffer);
225+
premiumUsernames = newPremiumSet;
226+
premiumListBuffer = new ArrayList<>();
227+
logger.info("Premium list received from backend: " + premiumUsernames.size() + " premium player(s)");
228+
}
161229
}
162230
}
163231

@@ -173,7 +241,7 @@ public void onServerSwitch(ServerSwitchEvent event) {
173241
return;
174242
}
175243

176-
if (currentServer == null || !authenticationStore.isAuthenticated(player)) {
244+
if (currentServer == null) {
177245
return;
178246
}
179247

@@ -184,6 +252,21 @@ public void onServerSwitch(ServerSwitchEvent event) {
184252
}
185253

186254
String normalizedName = normalizeName(player.getName());
255+
256+
// Pending players have passed Mojang auth at the proxy, but we must NOT send PERFORM_LOGIN
257+
// for them: the backend needs to run canBypassWithPremium() to finalize (persist) the premium
258+
// UUID. Only confirmed premium players (premiumUsernames) trigger the auto-login bypass.
259+
boolean isPremiumJoin = connectingToAuthServer
260+
&& proxyVerifiedPremium.contains(normalizedName)
261+
&& !pendingPremiumUsernames.contains(normalizedName);
262+
if (!authenticationStore.isAuthenticated(player) && !isPremiumJoin) {
263+
return;
264+
}
265+
if (isPremiumJoin) {
266+
logger.fine("Proxy-verified premium player " + normalizedName
267+
+ " joining auth server — sending perform.login immediately");
268+
}
269+
187270
String serverName = currentServer.getInfo().getName();
188271
logger.info("Sending auto-login request to server '" + serverName + "' for player " + normalizedName);
189272
currentServer.getInfo().sendData(AUTHME_CHANNEL, createPerformLoginMessage(normalizedName), false);
@@ -254,6 +337,53 @@ public void onPlayerDisconnect(PlayerDisconnectEvent event) {
254337
}
255338
cancelPendingLogin(normalizedName);
256339
authenticationStore.clear(event.getPlayer());
340+
proxyVerifiedPremium.remove(normalizedName);
341+
pendingPremiumUsernames.remove(normalizedName);
342+
}
343+
344+
@EventHandler
345+
public void onPreLogin(PreLoginEvent event) {
346+
String normalizedName = normalizeName(event.getConnection().getName());
347+
if (premiumUsernames.contains(normalizedName) || pendingPremiumUsernames.contains(normalizedName)) {
348+
event.getConnection().setOnlineMode(true);
349+
logger.fine("Forcing online-mode for premium player '" + normalizedName + "'");
350+
}
351+
}
352+
353+
/**
354+
* Fires after the proxy has finished the Mojang authentication phase for a connecting player.
355+
* If the connection ended up in online mode (real Mojang account verified at the proxy), the
356+
* player is recorded as proxy-verified premium so the auto-login bypass on the auth server
357+
* will fire on {@link ServerSwitchEvent}.
358+
*/
359+
@EventHandler
360+
public void onLogin(LoginEvent event) {
361+
if (event.isCancelled()) {
362+
return;
363+
}
364+
if (!event.getConnection().isOnlineMode()) {
365+
return;
366+
}
367+
String normalizedName = normalizeName(event.getConnection().getName());
368+
markProxyVerifiedPremium(normalizedName);
369+
}
370+
371+
/**
372+
* Fallback: if for any reason the {@link LoginEvent} hook did not flag the player (e.g. the
373+
* proxy is in global online mode and {@code isOnlineMode()} on PendingConnection is reported
374+
* after {@code LoginEvent}), {@link PostLoginEvent} still gives us the verified UUID from the
375+
* proxy. A version-4 UUID means Mojang verified the identity.
376+
*/
377+
@EventHandler
378+
public void onPostLogin(PostLoginEvent event) {
379+
ProxiedPlayer player = event.getPlayer();
380+
if (player.getUniqueId() != null && player.getUniqueId().version() == 4) {
381+
String normalizedName = normalizeName(player.getName());
382+
if (proxyVerifiedPremium.add(normalizedName)) {
383+
logger.info("Proxy-verified premium (PostLogin fallback): '" + normalizedName
384+
+ "' has a Mojang UUID");
385+
}
386+
}
257387
}
258388

259389
void shutdown() {
@@ -350,16 +480,45 @@ private ParsedPluginMessage parsePluginMessage(byte[] data) {
350480
try {
351481
String typeId = input.readUTF();
352482
if (!LOGIN_MESSAGE.equals(typeId) && !LOGOUT_MESSAGE.equals(typeId)
353-
&& !PERFORM_LOGIN_ACK_MESSAGE.equals(typeId)) {
483+
&& !PERFORM_LOGIN_ACK_MESSAGE.equals(typeId)
484+
&& !PREMIUM_SET_MESSAGE.equals(typeId)
485+
&& !PREMIUM_UNSET_MESSAGE.equals(typeId)
486+
&& !PREMIUM_LIST_MESSAGE.equals(typeId)
487+
&& !PREMIUM_LIST_CHUNK_MESSAGE.equals(typeId)
488+
&& !PREMIUM_PENDING_SET_MESSAGE.equals(typeId)) {
354489
return ParsedPluginMessage.ignored();
355490
}
356-
return new ParsedPluginMessage(typeId, normalizeName(input.readUTF()));
491+
// premium.list and premium.list.chunk carry non-player-name data; read as-is
492+
String argument = input.readUTF();
493+
return new ParsedPluginMessage(typeId,
494+
(PREMIUM_LIST_MESSAGE.equals(typeId) || PREMIUM_LIST_CHUNK_MESSAGE.equals(typeId))
495+
? argument : normalizeName(argument));
357496
} catch (IllegalStateException e) {
358497
logger.warning("Received malformed AuthMe plugin message on the authme:main channel");
359498
return ParsedPluginMessage.ignored();
360499
}
361500
}
362501

502+
private void redirectToLoginServer(String normalizedPlayerName) {
503+
if (configuration.loginServer().isEmpty()) {
504+
return;
505+
}
506+
ProxiedPlayer player = proxyServer.getPlayer(normalizedPlayerName);
507+
if (player == null) {
508+
logger.fine("Cannot redirect " + normalizedPlayerName + " to loginServer: player no longer on proxy");
509+
return;
510+
}
511+
ServerInfo targetServer = proxyServer.getServerInfo(configuration.loginServer());
512+
if (targetServer == null) {
513+
logger.warning("loginServer '" + configuration.loginServer()
514+
+ "' is not registered on the proxy; cannot redirect " + normalizedPlayerName);
515+
return;
516+
}
517+
logger.info("Redirecting " + normalizedPlayerName + " to login server '"
518+
+ configuration.loginServer() + "' after authentication");
519+
player.connect(targetServer);
520+
}
521+
363522
private void redirectLoggedOutPlayer(String normalizedPlayerName) {
364523
if (!configuration.sendOnLogoutEnabled()) {
365524
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",

0 commit comments

Comments
 (0)