Skip to content

Commit dc90159

Browse files
committed
Add native client authentication channels for Paper and Velocity
1 parent f5cfd49 commit dc90159

14 files changed

Lines changed: 620 additions & 10 deletions

File tree

‎authgatewayx-paper/src/main/kotlin/pl/syntaxdevteam/authgatewayx/paper/AuthGatewayXPaper.kt‎

Lines changed: 8 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,7 @@ package pl.syntaxdevteam.authgatewayx.paper
22

33
import io.papermc.paper.configuration.GlobalConfiguration
44
import net.kyori.adventure.text.Component
5+
import pl.syntaxdevteam.authgatewayx.paper.client.PaperClientAuthenticationChannel
56
import org.bukkit.plugin.ServicePriority
67
import pl.syntaxdevteam.authgatewayx.api.AuthenticationStatusProvider
78
import pl.syntaxdevteam.authgatewayx.paper.api.PaperAuthenticationStatusProvider
@@ -234,12 +235,16 @@ class AuthGatewayXPaper : JavaPlugin() {
234235
behaviorGate: ConnectionBehaviorGate,
235236
cheapGuard: PaperLoginCheapGuard,
236237
) {
238+
val authenticationStatus = PaperAuthenticationStatusProvider(readiness::acceptsAuthentication, sessions::getActive)
237239
server.servicesManager.register(
238240
AuthenticationStatusProvider::class.java,
239-
PaperAuthenticationStatusProvider(readiness::acceptsAuthentication, sessions::getActive),
241+
authenticationStatus,
240242
this,
241243
ServicePriority.Normal,
242244
)
245+
val clientChannel = PaperClientAuthenticationChannel(this, authenticationStatus)
246+
runtime?.clientChannel = clientChannel
247+
clientChannel.register()
243248
val access = SessionPreAuthAccess(sessions)
244249
val admission = PreAuthAdmission(positive("authentication.maximum-pre-auth-players"))
245250
val registrationAttemptGate = RegistrationAttemptGate(
@@ -699,6 +704,7 @@ private class RuntimeComponents(
699704
val usernameBurstGate: UsernameBurstGate,
700705
val behaviorGate: ConnectionBehaviorGate,
701706
) : AutoCloseable {
707+
@Volatile var clientChannel: PaperClientAuthenticationChannel? = null
702708
@Volatile var staffProof: PaperStaffProof? = null
703709
@Volatile var riskAlerts: OfflineRiskAlerts? = null
704710
@Volatile var networkLookup: ProxycheckIpLookup? = null
@@ -707,6 +713,7 @@ private class RuntimeComponents(
707713
@Volatile var premiumProtocol: StandalonePremiumProtocolInterceptor? = null
708714

709715
override fun close() {
716+
clientChannel?.close()
710717
staffProof?.close()
711718
riskAlerts?.close()
712719
networkLookup?.close()
Lines changed: 70 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,70 @@
1+
package pl.syntaxdevteam.authgatewayx.paper.client
2+
3+
import io.papermc.paper.threadedregions.scheduler.ScheduledTask
4+
import org.bukkit.entity.Player
5+
import org.bukkit.event.EventHandler
6+
import org.bukkit.event.Listener
7+
import org.bukkit.event.player.PlayerQuitEvent
8+
import org.bukkit.plugin.Plugin
9+
import org.bukkit.plugin.messaging.PluginMessageListener
10+
import pl.syntaxdevteam.authgatewayx.api.AuthenticationStatusProvider
11+
import pl.syntaxdevteam.authgatewayx.security.client.ClientAuthenticationProtocol
12+
import java.util.concurrent.ConcurrentHashMap
13+
14+
/** Native AGX transport; it only reads authentication state and never changes PRE_AUTH. */
15+
class PaperClientAuthenticationChannel(
16+
private val plugin: Plugin,
17+
private val status: AuthenticationStatusProvider,
18+
private val clock: () -> Long = System::currentTimeMillis,
19+
) : PluginMessageListener, Listener, AutoCloseable {
20+
private class Pending(val nonce: String, val channel: String, val deadline: Long) {
21+
@Volatile var task: ScheduledTask? = null
22+
@Volatile var confirmed = false
23+
}
24+
private val pending = ConcurrentHashMap<Player, Pending>()
25+
@Volatile private var closed = false
26+
27+
fun register() {
28+
ClientAuthenticationProtocol.channels.forEach { channel ->
29+
plugin.server.messenger.registerIncomingPluginChannel(plugin, channel, this)
30+
plugin.server.messenger.registerOutgoingPluginChannel(plugin, channel)
31+
}
32+
plugin.server.pluginManager.registerEvents(this, plugin)
33+
}
34+
35+
override fun onPluginMessageReceived(channel: String, player: Player, message: ByteArray) {
36+
if (closed || channel !in ClientAuthenticationProtocol.channels) return
37+
val nonce = ClientAuthenticationProtocol.readSubscription(message) ?: return
38+
val request = Pending(nonce, channel, clock() + 300_000)
39+
synchronized(pending) {
40+
if (closed || pending.size >= 5_000 || pending.putIfAbsent(player, request) != null) return
41+
}
42+
request.task = player.scheduler.runAtFixedRate(plugin, { task ->
43+
if (closed || pending[player] !== request || !player.isOnline || clock() >= request.deadline) {
44+
pending.remove(player, request)
45+
task.cancel()
46+
} else if (!request.confirmed && status.isAuthenticated(player.uniqueId)) {
47+
player.sendPluginMessage(plugin, request.channel, ClientAuthenticationProtocol.authenticated(request.nonce))
48+
request.confirmed = true
49+
task.cancel()
50+
}
51+
}, { pending.remove(player, request) }, 1L, 20L)
52+
if (request.task == null) pending.remove(player, request)
53+
if (closed || request.confirmed || pending[player] !== request) request.task?.cancel()
54+
}
55+
56+
@EventHandler
57+
fun onQuit(event: PlayerQuitEvent) { pending.remove(event.player)?.task?.cancel() }
58+
59+
override fun close() {
60+
synchronized(pending) {
61+
closed = true
62+
pending.values.forEach { it.task?.cancel() }
63+
pending.clear()
64+
}
65+
ClientAuthenticationProtocol.channels.forEach { channel ->
66+
plugin.server.messenger.unregisterIncomingPluginChannel(plugin, channel, this)
67+
plugin.server.messenger.unregisterOutgoingPluginChannel(plugin, channel)
68+
}
69+
}
70+
}
Lines changed: 95 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,95 @@
1+
package pl.syntaxdevteam.authgatewayx.paper.client
2+
3+
import io.papermc.paper.threadedregions.scheduler.EntityScheduler
4+
import io.papermc.paper.threadedregions.scheduler.ScheduledTask
5+
import org.bukkit.entity.Player
6+
import org.bukkit.Server
7+
import org.bukkit.event.player.PlayerQuitEvent
8+
import org.bukkit.plugin.Plugin
9+
import org.bukkit.plugin.messaging.Messenger
10+
import pl.syntaxdevteam.authgatewayx.api.AuthenticationStatusProvider
11+
import pl.syntaxdevteam.authgatewayx.security.client.ClientAuthenticationProtocol
12+
import java.lang.reflect.Proxy
13+
import java.util.UUID
14+
import java.util.function.Consumer
15+
import kotlin.test.Test
16+
import kotlin.test.assertEquals
17+
import kotlin.test.assertTrue
18+
19+
class PaperClientAuthenticationChannelTest {
20+
private fun <T> mock(type: Class<T>, handler: (String, Array<out Any?>?) -> Any?): T = type.cast(
21+
Proxy.newProxyInstance(type.classLoader, arrayOf(type)) { self, method, args ->
22+
when (method.name) {
23+
"hashCode" -> System.identityHashCode(self)
24+
"equals" -> self === args?.get(0)
25+
"toString" -> type.simpleName
26+
else -> handler(method.name, args)
27+
}
28+
})
29+
30+
private inner class Fixture {
31+
var authenticated = false
32+
var now = 0L
33+
var scheduled = 0
34+
var cancelled = 0
35+
var tick: Consumer<ScheduledTask>? = null
36+
val replies = mutableListOf<ByteArray>()
37+
val task = mock(ScheduledTask::class.java) { name, _ ->
38+
if (name == "cancel") { cancelled++; null } else null
39+
}
40+
val scheduler = mock(EntityScheduler::class.java) { name, args ->
41+
if (name == "runAtFixedRate") {
42+
scheduled++
43+
@Suppress("UNCHECKED_CAST")
44+
tick = args!![1] as Consumer<ScheduledTask>
45+
task
46+
} else null
47+
}
48+
val messenger = mock(Messenger::class.java) { _, _ -> null }
49+
val server = mock(Server::class.java) { name, _ -> if (name == "getMessenger") messenger else null }
50+
val plugin = mock(Plugin::class.java) { name, _ -> if (name == "getServer") server else null }
51+
val player = mock(Player::class.java) { name, args -> when (name) {
52+
"getUniqueId" -> UUID(0, 1)
53+
"getScheduler" -> scheduler
54+
"isOnline" -> true
55+
"sendPluginMessage" -> { replies += (args!![2] as ByteArray).copyOf(); null }
56+
else -> null
57+
} }
58+
val channel = PaperClientAuthenticationChannel(plugin, AuthenticationStatusProvider { authenticated }, { now })
59+
val nonce = UUID.randomUUID().toString()
60+
fun subscribe() = channel.onPluginMessageReceived(ClientAuthenticationProtocol.CHANNEL, player,
61+
ClientAuthenticationProtocol.subscription(nonce))
62+
}
63+
64+
@Test fun `waits for ACTIVE and repeated subscriptions do not create additional tasks`() {
65+
val f = Fixture()
66+
f.subscribe(); f.subscribe()
67+
assertEquals(1, f.scheduled)
68+
f.tick!!.accept(f.task)
69+
assertTrue(f.replies.isEmpty())
70+
f.authenticated = true
71+
f.tick!!.accept(f.task); f.tick!!.accept(f.task)
72+
assertEquals(1, f.replies.size)
73+
assertTrue(ClientAuthenticationProtocol.confirms(f.replies.single(), f.nonce))
74+
}
75+
76+
@Test fun `client proof is not subscription and disconnect blocks stale callbacks`() {
77+
val f = Fixture()
78+
f.channel.onPluginMessageReceived(ClientAuthenticationProtocol.CHANNEL, f.player,
79+
ClientAuthenticationProtocol.authenticated(f.nonce))
80+
assertEquals(0, f.scheduled)
81+
f.subscribe()
82+
f.channel.onQuit(PlayerQuitEvent(f.player, null as net.kyori.adventure.text.Component?, PlayerQuitEvent.QuitReason.DISCONNECTED))
83+
f.authenticated = true
84+
f.tick!!.accept(f.task)
85+
assertTrue(f.replies.isEmpty())
86+
}
87+
88+
@Test fun `expired subscription and shutdown never send late confirmations`() {
89+
val f = Fixture(); f.subscribe(); f.now = 300_000; f.authenticated = true
90+
f.tick!!.accept(f.task)
91+
assertTrue(f.replies.isEmpty())
92+
f.subscribe(); f.channel.close(); f.tick!!.accept(f.task)
93+
assertTrue(f.replies.isEmpty())
94+
}
95+
}
Lines changed: 61 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,61 @@
1+
package pl.syntaxdevteam.authgatewayx.security.client
2+
3+
import java.io.ByteArrayInputStream
4+
import java.io.ByteArrayOutputStream
5+
import java.io.DataInputStream
6+
import java.io.DataOutputStream
7+
import java.util.UUID
8+
9+
/** Informational client notification, never an authorization token for server actions. */
10+
object ClientAuthenticationProtocol {
11+
const val CHANNEL = "authgatewayx:auth"
12+
const val COMPATIBILITY_CHANNEL = "craftconnect:auth"
13+
val channels = setOf(CHANNEL, COMPATIBILITY_CHANNEL)
14+
15+
fun subscription(nonce: String): ByteArray = encode("subscribe", nonce, "")
16+
17+
fun readSubscription(payload: ByteArray): String? =
18+
decode(payload, "subscribe")?.takeIf { it.second.isEmpty() }?.first
19+
20+
fun authenticated(nonce: String): ByteArray = encode("authenticated", nonce, "AuthGatewayX")
21+
22+
fun confirms(payload: ByteArray, nonce: String): Boolean =
23+
decode(payload, "authenticated")?.let { it.first == nonce && it.second == "AuthGatewayX" } == true
24+
25+
private fun encode(kind: String, nonce: String, provider: String): ByteArray {
26+
require(validNonce(nonce)) { "Invalid client connection nonce" }
27+
return ByteArrayOutputStream().also { bytes ->
28+
DataOutputStream(bytes).use { output ->
29+
output.writeByte(1)
30+
output.writeUTF(kind)
31+
output.writeUTF(nonce)
32+
output.writeUTF(provider)
33+
}
34+
}.toByteArray()
35+
}
36+
37+
private fun decode(payload: ByteArray, kind: String): Pair<String, String>? {
38+
if (payload.size > 256) return null
39+
return runCatching {
40+
DataInputStream(ByteArrayInputStream(payload)).use { input ->
41+
if (input.readUnsignedByte() != 1 || input.readBoundedUtf() != kind) return null
42+
val nonce = input.readBoundedUtf()
43+
if (!validNonce(nonce)) return null
44+
val provider = input.readBoundedUtf()
45+
if (input.available() != 0) return null
46+
nonce to provider
47+
}
48+
}.getOrNull()
49+
}
50+
51+
private fun validNonce(nonce: String): Boolean =
52+
nonce.length == 36 && runCatching { UUID.fromString(nonce).toString() == nonce }.getOrDefault(false)
53+
54+
private fun DataInputStream.readBoundedUtf(): String {
55+
mark(2)
56+
val length = readUnsignedShort()
57+
require(length <= 64 && length <= available()) { "Invalid client message field" }
58+
reset()
59+
return readUTF()
60+
}
61+
}
Lines changed: 37 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,37 @@
1+
package pl.syntaxdevteam.authgatewayx.security.client
2+
3+
import java.io.ByteArrayOutputStream
4+
import java.io.DataOutputStream
5+
import java.util.UUID
6+
import kotlin.test.Test
7+
import kotlin.test.assertEquals
8+
import kotlin.test.assertFalse
9+
import kotlin.test.assertNull
10+
import kotlin.test.assertTrue
11+
12+
class ClientAuthenticationProtocolTest {
13+
@Test fun `subscription and proof match existing CraftConnect wire format`() {
14+
val nonce = UUID.randomUUID().toString()
15+
assertEquals(nonce, ClientAuthenticationProtocol.readSubscription(ClientAuthenticationProtocol.subscription(nonce)))
16+
val expected = ByteArrayOutputStream().also { bytes ->
17+
DataOutputStream(bytes).use {
18+
it.writeByte(1); it.writeUTF("authenticated"); it.writeUTF(nonce); it.writeUTF("AuthGatewayX")
19+
}
20+
}.toByteArray()
21+
assertTrue(expected.contentEquals(ClientAuthenticationProtocol.authenticated(nonce)))
22+
assertTrue(ClientAuthenticationProtocol.confirms(expected, nonce))
23+
assertFalse(ClientAuthenticationProtocol.confirms(expected, UUID.randomUUID().toString()))
24+
assertNull(ClientAuthenticationProtocol.readSubscription(expected))
25+
assertFalse(ClientAuthenticationProtocol.confirms(ClientAuthenticationProtocol.subscription(nonce), nonce))
26+
}
27+
28+
@Test fun `malformed oversized truncated and unknown version messages cannot confirm`() {
29+
val nonce = UUID.randomUUID().toString()
30+
val proof = ClientAuthenticationProtocol.authenticated(nonce)
31+
for (payload in listOf(byteArrayOf(), ByteArray(257), proof.copyOf(proof.size - 1),
32+
proof + byteArrayOf(0), proof.copyOf().also { it[0] = 2 }, byteArrayOf(1, -1, -1))) {
33+
assertNull(ClientAuthenticationProtocol.readSubscription(payload))
34+
assertFalse(ClientAuthenticationProtocol.confirms(payload, nonce))
35+
}
36+
}
37+
}

‎authgatewayx-velocity/src/main/kotlin/pl/syntaxdevteam/authgatewayx/velocity/AuthGatewayXVelocity.kt‎

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -38,6 +38,7 @@ class AuthGatewayXVelocity @Inject constructor(
3838
private var storageExecutor: BoundedTaskExecutor? = null
3939
private var storage: JdbcAccountStorage? = null
4040
private var network: ProxycheckIpLookup? = null
41+
private var clientChannel: VelocityClientAuthenticationChannel? = null
4142
private var staffProof: VelocityStaffProof? = null
4243
private var admin: VelocityRiskAdmin? = null
4344
private var executor: BoundedTaskExecutor? = null
@@ -83,6 +84,9 @@ class AuthGatewayXVelocity @Inject constructor(
8384
phase(VelocityReadinessGuard.Phase.STAFF_PROOF)
8485
val proof = VelocityStaffProof(proxy, this, risk.proofSecret).also { staffProof = it }
8586
proxy.eventManager.register(this, proof)
87+
val clientNotifications = VelocityClientAuthenticationChannel(proxy, this, ready::get)
88+
.also { clientChannel = it }
89+
proxy.eventManager.register(this, clientNotifications)
8690
phase(VelocityReadinessGuard.Phase.IP_LOOKUP)
8791
val ip = if (risk.networkEnabled && risk.networkAction != RiskAction.DISABLED) ProxycheckIpLookup.create(
8892
risk.apiKey, Duration.ofMillis(risk.networkTimeoutMillis), Duration.ofSeconds(risk.cacheTtlSeconds),
@@ -152,6 +156,7 @@ class AuthGatewayXVelocity @Inject constructor(
152156
}
153157

154158
private fun closeResources() {
159+
clientChannel?.close()
155160
staffProof?.close()
156161
admin?.clear()
157162
network?.close()

0 commit comments

Comments
 (0)