Der Quellcode zu den Artikeln ist in diesem GitHub Repository zu finden. Die folgende Liste zeigt alle bislang erschienenen Artikel dieser Serie:
- Netzwerktutorial 1: Allgemeines und Übersicht
- Netzwerktutorial 2: Das "gehende Skelett"
- Netzwerktutorial 3: Login 1 - Der Game-Client
- Netzwerktutorial 4: Login 2 - Gateway- und Authentication-Server
- Netzwerktutorial 5: Login 3 - World-Server
- Netzwerktutorial 6: Verschlüsselte Verbindungen mit Hilfe von DTLS
Im letzten Artikel haben wir den Game-Client soweit erstellt, dass er den Gateway-Server mit Logindaten kontaktieren möchte. In diesem Artikel werden wir genau an dieser Stelle weiter machen und uns um den Gateway- und Authentication-Server kümmern. Bevor wir uns aber um den eigentlichen Loginvorgang kümmern können, müssen wir zuerst Gateway- und Authentication-Server miteinander bekannt machen und dafür sorgen, dass sie einander auch trauen. Wir benutzen dafür einen Mechanismus, welcher eine gegenseitige Authentifizierung bereits während des Verbindungsaufbaus ermöglicht und erst im November 2022 bei Godot 4 eingeführt wurde.
Pre-Authentifizierung
Der Verfasser des Pull-Requests spricht dabei von Pre-Authentifizierung, weil die Authentifizierung bereits während des Verbindungsaufbaus geschieht, während wir im Game-Client die Login-Daten erst versendet haben, nachdem wir schon mit dem Gateway-Server verbunden sind, wir also das Signal connected_to_server
bekommen haben. Trotz kurzer Beschreibung im Pull-Request, habe ich etwas experimentieren müssen, um das gewünschte Verhalten ans Laufen zu bekommen. Ich dachte zuerst, dass sich dabei nur der Client beim Server authentifiziert, aber es ist ein gegenseitiges Authentifizieren, und nur wenn beide Parteien der Verbindung zugestimmt haben, wird sie wirklich aufgebaut.
Um die Pre-Authentifizierung zu aktivieren, müssen wir auf beiden Seiten im SceneMultiplayer
Objekt den auth_callback
mit einem Callable
besetzen, welches die Authentifizierungslogik enthält. Dazu kommt, dass uns während des Verbindungsaufbaus bislang nur die Signale connected_to_server
und connection_failed
als Client bzw. peer_connected
und peer_disconnected
als Server interessiert haben. Im Rahmen der Pre-Authenfizierung beachten wir nun zusätzlich auf beiden Seiten die beiden weiteren Signale peer_authenticating
und peer_authentication_failed
.
Das Signal peer_authenticating
wird emittiert, wenn eine neue Verbindung aufgebaut wird, und sollte genutzt werden, um mit Hilfe von send_auth(id: int, data: PackedByteArray)
die authentifizierungsrelevanten Daten an die Gegenstelle zu senden. Durch send_auth()
wird dann der hinterlegte auth_callback
bei der Gegenstelle ausgelöst, um die gesendeten Daten zu validieren. Der Erfolgsfall wird dann complete_auth(id: int)
signalisiert. Wenn dies beide Parteien innerhalb von 30 Sekunden getan haben, wird die Netzwerkverbindung final aufgebaut und mit Hilfe von connected_to_server
dem Client bzw. peer_connected
dem Server mitgeteilt.
So, genug der Theorie, hier nun der Beispielcode des Gateway-Servers (Client):
1extends Node
2
3@export var authentication_server := "127.0.0.1"
4@export_range(1025, 65536) var authentication_server_port := 1911
5@export var shared_secret := "JustARandomValueYouCantGuess".to_utf8_buffer()
6
7var _crypto := Crypto.new()
8var network := ENetMultiplayerPeer.new()
9var shutdown_hook : Callable
10var gateway_server : Node
11
12
13func startup() -> void:
14 print("Connecting to %s:%d" % [authentication_server, authentication_server_port])
15 var ret = network.create_client(authentication_server, authentication_server_port)
16 var _hash := _crypto.hmac_digest(HashingContext.HASH_SHA256, shared_secret,
17 Time.get_date_string_from_system(true).to_utf8_buffer())
18 if ret == OK:
19 multiplayer.multiplayer_peer = network
20 multiplayer.connection_failed.connect(_on_connection_failed.bind(FAILED))
21
22 multiplayer.connected_to_server.connect(func() -> void:
23 print("Succesfully connected to authentication server %s:%d"
24 % [authentication_server, authentication_server_port]))
25
26 multiplayer.peer_authenticating.connect(func(server_id: int) -> void:
27 print("server_id %d peer_authenticating" % server_id)
28 if server_id == MultiplayerPeer.TARGET_PEER_SERVER:
29 multiplayer.send_auth(server_id, _hash))
30
31 multiplayer.peer_authentication_failed.connect(func(server_id: int) -> void:
32 print("server_id %d peer_authentication_failed" % server_id))
33
34 multiplayer.auth_callback = \
35 func(server_id: int, data: PackedByteArray) -> void:
36 print("server %d sent %s" % [server_id, data.hex_encode()])
37 if _crypto.constant_time_compare(_hash, data):
38 multiplayer.complete_auth(server_id)
39 else:
40 print("Validation was not successful")
41 else:
42 _on_connection_failed(ret)
Im Quellcode sollten alle vorher erwähnten Stellen zu finden sein. Die Validierung der Authentifizierung findet anhand des aktuellen Systemdatums (UTC) statt. Damit (Zeile 17) erstellt der Gateway-Server ein HMAC (Zeile 16). Der Schlüssel muss dafür auf beiden Servern identisch sein. Das aktuelle Systemdatum (UTC) habe ich verwendet, weil beide Server nur ungefähr die gleiche Systemzeit benötigen und es nur während der Minuten um Mitternacht herum zu Problem führen könnte. In der Praxis sollte natürlich sichergestellt sein, dass alle Server eine möglichst genaue Systemzeit haben, sodass man dort die Überprüfung nicht auf Tagesbasis macht, sondern auf Minutenbasis. Dadurch sind Replay-Attacken deutlich schwerer durchzuführen, da sich der erzeugte HMAC jede Minute ändert, und nicht, wie im Beispiel gezeigt, nur jeden Tag.
Der zugehörige Quellcode im Authentication-Server sieht vergleichbar aus. Der größte Unterschied ist wohl, dass hier bei jeder Anfrage bzw. jeder Validierung neue HMACs erzeugt werden, da ja unbekannt ist, ob ein älterer HMAC noch gültig sein kann:
1extends Node
2
3@export_range(1025, 65536) var network_port := 1911
4@export_range(2, 4095) var max_clients := 2
5
6@export var shared_secret := "JustARandomValueYouCantGuess".to_utf8_buffer()
7var _crypto = Crypto.new()
8var peers = []
9
10
11func startup() -> void:
12 var network := ENetMultiplayerPeer.new()
13 var ret := network.create_server(network_port, max_clients)
14 if ret == OK:
15 multiplayer.server_relay = false
16 multiplayer.set_multiplayer_peer(network)
17 print("Server started on port %d, allowing max %d connections"
18 % [network_port, max_clients])
19
20 multiplayer.peer_connected.connect(
21 func(client_id: int) -> void:
22 print("Client %d connected" % client_id))
23
24 multiplayer.peer_disconnected.connect(
25 func(client_id: int) -> void:
26 print("Client %d disconnected" % client_id)
27 peers.erase(client_id))
28
29 multiplayer.peer_authenticating.connect(
30 func(client_id: int) -> void:
31 print("Client %d peer_authenticating" % client_id)
32 var _hash := _crypto.hmac_digest(HashingContext.HASH_SHA256,
33 shared_secret,
34 Time.get_date_string_from_system(true).to_utf8_buffer())
35 multiplayer.send_auth(client_id, _hash))
36
37 multiplayer.peer_authentication_failed.connect(
38 func(client_id: int) -> void:
39 print("Client %d peer_authentication_failed" % client_id))
40
41 multiplayer.auth_callback = \
42 func(client_id: int, data: PackedByteArray) -> void:
43 print("Client %d sent: %s" % [client_id, data.hex_encode()])
44 var _hash := _crypto.hmac_digest(HashingContext.HASH_SHA256,
45 shared_secret,
46 Time.get_date_string_from_system(true).to_utf8_buffer())
47 if _crypto.constant_time_compare(_hash, data):
48 multiplayer.complete_auth(client_id)
49 peers.append(client_id)
50 else:
51 print("Validation was not successful")
52 else:
53 print("Error while starting server: %d" % ret)
54 get_tree().quit(ret)
Gateway-Server
Nachdem nun Gateway- und Authentication-Server schon einmal grundlegend miteinander kommunizieren können, und insgesamt viel wichtiger, einander soweit vertrauen können, fangen wir in diesem Abschnitt an, die eigentliche Aufgabe des Gateway-Servers zu implementieren, nämlich machine-in-the-middle 😉 zwischen Client und Authentication-Server zu sein.

Synchrone Loginsequenz
Im letzten Artikel haben wir als Game-Client die Funktion s_login_request()
auf dem Gateway-Server mit den eingegebenen Logindaten aufgerufen. Damit ist dies nun unser Einstiegspunkt. Das Sequenzdiagramm zur synchronen Loginsequenz ist abermals unser Vorbild und wir können dem Diagramm entnehmen, dass wir als nächsten Schritt den Login-Request des Game-Clients an den Authentication-Server weiterleiten. Dafür implementieren wir zunächst die aufgerufene Funktion innerhalb von gateway_server.gd
des Gateway-Servers:
48@rpc("call_remote", "any_peer", "reliable")
49func s_login_request(username: String, password: String) -> void:
50 var player_id := multiplayer.get_remote_sender_id()
51 print("login request received by %d" % player_id)
52 authentication_server.authenticate_player(username, password, player_id)
Wie wir sehen, wird dort neben einer Debug-Konsolenausgabe die Funktion authenticate_player()
innerhalb von authentication_server.gd
aufgerufen. Als Parameter werden die übermittelten Logindaten sowie die Network-ID des Game-Clients übergeben. Diese Network-ID wird zuvor in Zeile 50 durch den Aufruf von multiplayer.get_remote_sender_id()
ermittelt. Wie man in dem nachfolgenden Code sieht, werden durch den Aufruf von authenticate_player()
in authentication_server.gd
eigentlich nur die übergebenen Parameter an den Authentication-Server übermittelt. Der Authentication-Server benötigt zwar nur die Logindaten und nicht die Network-ID des Game-Clients, aber wir schleusen sie dennoch über den Authentication-Server, damit wir bei der späteren Weiterleitung der Antwort an den Game-Client noch wissen, wie wir ihn erreichen können.
55func authenticate_player(username: String, password: String, player_id: int) -> void:
56 print("sending authentication request for player_id: %d" % player_id)
57 s_authenticate_player.rpc_id(MultiplayerPeer.TARGET_PEER_SERVER,
58 username, password, player_id)
59
60
61@rpc("call_remote", "any_peer", "reliable")
62func s_authenticate_player(_username: String, _password: String, _player_id: int) \
63 -> void:
64 pass # on authentication server
Wie im Sequenzdiagramm gezeigt und im Absatz zuvor angesprochen, hat der Gateway-Server ebenfalls die Aufgabe, die Antwort des Authentication-Servers an den Game-Client weiterzuleiten. Dafür stellt der Gateway-Server die Funktion c_authentication_result()
in authentication_server.gd
bereit. Diese wird dann vom Authentication-Server via RPC aufgerufen. Als Parameter werden das Resultat, die Network-ID des Game-Clients und im Erfolgsfall ein Token übermittelt. Wie zuvor gilt weiterhin, dass der Gateway-Server keine eigene Logik hat, sondern nur als Vermittler zwischen Game-Client und Authentication-Server fungiert, daher wird auch hier neben Debug-Konsolenmeldungen nur die Funktion return_login_request()
von gateway_server.gd
mit den erhaltenen Parametern aufgerufen (Zeile 68).
65@rpc("call_remote", "authority", "reliable")
66func c_authentication_result(result: bool, player_id: int, token: String) -> void:
67 print("authentication result for player_id: %d is %s" % [player_id, str(result)])
68 gateway_server.return_login_request(result, token, player_id)
Wie zu erwarten, leitet die Funktion return_login_request()
in gateway_server.gd
alle Parameter via RPC an den ursprünglichen Game-Client weiter (Zeile 42). Sollte nach diesem RPC-Aufruf noch eine Verbindung mit dem Game-Client bestehen, so wird sie getrennt, sobald alle vorgesehenen Nachrichten wirklich versendet wurden (Zeile 43-45). Es kann in der Praxis auch vorkommen, dass der Game-Client die Verbindung trennt, während der Gateway-Server gerade dabei ist, Zeile 199 auszuführen. In diesem Fall führt Zeile 200 zu einer Fehlermeldung, die wir dann ignorieren können.
41func return_login_request(result: bool, token: String, player_id: int) -> void:
42 c_login_response.rpc_id(player_id, result, token)
43 var peer: ENetPacketPeer = multiplayer.multiplayer_peer.get_peer(player_id)
44 if peer != null:
45 peer.peer_disconnect_later()
Damit haben wir die komplette Funktionalität des Gateway-Servers fertig implementiert.
Authentication-Server
Die zentrale Aufgabe unseres Authentication-Servers ist die Verifikation von übermittelten Logindaten. Sollten die Logindaten gültig sein, kommt die Erzeugung eines Tokens, der vom World-Server akzeptiert wird, dazu. Wie man im nachfolgenden Code-Snippet erkennen kann, mache ich mir das Leben mal wieder etwas einfacher, indem aktuell alle Logindaten gültig sind, deren Benutzername und Passwort übereinstimmen (Zeile 73).
Wie wir in Zeile 78 sehen können, wird hier noch kein Token erzeugt, damit beschäftigen wir uns gezielt im nächsten Abschnitt. In Zeile 79 werden dann Resultat, player_id
(Network-ID des Game-Clients) und ein Token per RPC an den Gateway-Server geschickt, von dem der Request kam.
69@rpc("call_remote", "any_peer", "reliable")
70func s_authenticate_player(username: String, password: String, player_id: int) -> void:
71 print("authentication requested for player_id: %d" % player_id)
72 var gateway_id := multiplayer.get_remote_sender_id()
73 var result = username == password # add real user accounts and real check
74 print("return authentication result (%s) for player_id: %d to gateway_id: %d"
75 % [str(result), player_id, gateway_id])
76 var token := ""
77 if result:
78 pass # Create Token
79 c_authentication_result.rpc_id(gateway_id, result, player_id, token)
Tokenerzeugung
Ich habe die Tokenerzeugung in einen Extraabschnitt verlagert, da ich mir vorstellen kann, dass nicht jeder bereits mit JWTs vertraut ist und ich daher etwas allgemeiner mit dem Thema anfangen möchte. JWT (ausgesprochen “jot”) ist die Abkürzung für JSON Web Token und wird in RFC 7519 definiert bzw. standardisiert. In RFC 7519 steht folgende Kurzbeschreibung für JWTs:
JSON Web Token (JWT) is a compact, URL-safe means of representing claims to be transferred between two parties. The claims in a JWT are encoded as a JSON object that is used as the payload of a JSON Web Signature (JWS) structure or as the plaintext of a JSON Web Encryption (JWE) structure, enabling the claims to be digitally signed or integrity protected with a Message Authentication Code (MAC) and/or encrypted.
Wir haben es also mit einem kompakten URL-sicheren Mittel zur Übertragung von Claims zwischen zwei Parteien zu tun. Die Claims werden als JSON-Objekte enkodiert und können innerhalb von JSON Web Signaturen (JWS) oder JSON Web Verschlüsselungen (JWE) benutzt werden. Wir werden sie als Teil von JSON Web Signaturen einsetzen und schützen sie vor Veränderung (dazu dann später mehr). Bei Claims handelt es sich ganz abstrakt um Aussagen oder Zuweisungen über eine bestimmte Partei oder ein Objekt und sie können lapidar als Informationen beschrieben werden. Wir haben somit ein standardisiertes Format, um Informationen zwischen zwei Parteien zu übertragen und können diese Informationen vor Veränderung schützen. Genau das, was wir benötigen, um dem Game-Client den Zugriff auf den World-Server zu erlauben, ohne dass der World-Server die Identität des Game-Clients überprüfen kann.
Für den Schutz vor Veränderung setzen wir auf den Algorithmus RSA SHA256
, wir erzeugen also mit Hilfe von SHA256
einen Hashwert unserer Claims und signieren diesen mit Hilfe von RSA. Das hat den Vorteil, dass wir durch den Einsatz des asymmetrischen RSA-Verfahrens zwei verschiedene Schlüssel für die Signierung und Signaturprüfung verwenden. Dadurch bleibt auch bei Kompromittierung des World-Servers, also Diebstahl, Hack o.ä., weiterhin der Authentication-Server die einzige Partei, die unsere Tokens ausstellen kann.
Grundlegend lässt sich ein JWT in drei Teile Aufteilen: Header, Payload und Signature, die jeweils mit einem .
getrennt werden. Header und Payload werden Base64-URL enkodiert, damit es beim Transfer via HTTP keine Probleme gibt. Was in einem JWT an Informationen steckt, lässt sich bspw. im Debugger von jwt.io überprüfen. Wer noch weitere Informationen zum Thema JWT haben möchte, kann sich natürlich im RFC 7519 oder auf jwt.io informieren oder aber einen Artikel lesen, den ich vor Jahren mal dazu geschrieben habe.
So, genug der Theorie, wenden wir es nun in der Praxis an. Bevor wir damit aber starten können, müssen wir beim Authentication-Server noch Support für JWTs in Form eines AddOns nachrüsten. Dafür laden wir zunächst das aktuellste Release für Godot 4 herunter und entpacken den Ordner addons
in unser Godot-Projekt des Authentication-Servers. Anschließend muss das AddOn evtl. noch in den Projekteinstellungen -> Plugins
aktiviert werden. Um alles in Betrieb nehmen zu können, benötigen wir noch ein Schlüsselpaar im PEM-Format. Da ich überwiegend unter Linux arbeite, nutze ich hier die entsprechenden Linux-Befehle, aber mit einer kurzen Recherche sollte man auch auf die passenden Befehle für Windows stoßen können:
authentication_server> mkdir crypto
authentication_server> cd crypto
authentication_server/crypto> ssh-keygen -t rsa -b 4096 -m pem -f jwt_rsa.key
authentication_server/crypto> ssh-keygen -f jwt_rsa.key.pub -e -m pem > jwt_rsa.pem
authentication_server/crypto> rm jwt_rsa.key.pub
Nun sind wir endlich wirklich soweit, den Authentication-Server um die Tokenerzeugung zu erweitern. Der erste Schritt hierfür ist es, eine zentrale Variable vom Typ JWTAlgorithm
anzulegen (Zeile 10), welche wir anschließend in _ready()
initialisieren:
10var jwt_algorithm: JWTAlgorithm
11
12
13func _ready() -> void:
14 var private_key := CryptoKey.new()
15 var load_ret := private_key.load("res://crypto/jwt_rsa.key")
16 if load_ret == OK:
17 jwt_algorithm = JWTAlgorithm.new()
18 jwt_algorithm._private_crypto = private_key
19 jwt_algorithm._alg = JWTAlgorithm.Type.RSA256
20 else:
21 print("Error while reading RSA private key: %d" % load_ret)
22 get_tree().quit(load_ret)
Bevor wir die Initialisierung von jwt_algorithm
vornehmen können, müssen wir zuerst unseren privaten Schlüssel laden (Zeilen 14-15). Sollte dies geklappt haben, erzeugen wir ein neues Objekt vom Typ JWTAlgorithm
(Zeile 17) und initialisieren es mit dem privaten Schlüssel und unserem gewählten Hash-/Signierungsverfahren (Zeilen 18-19). Sollte beim Laden des privaten Schlüssels etwas schiefgelaufen sein, beenden wir den Authentication-Server, da er ohne privaten Schlüssel nicht in der Lage sein wird, Tokens zu erzeugen.
Um nun einen wirklichen Token zu erzeugen, ersetzen wir das bisherige pass
von Zeile 78 in s_authenticate_player()
des vorigen Abschnitts, durch die folgenden Zeilen:
78 var now := int(Time.get_unix_time_from_system())
79 token = JWT.create(jwt_algorithm) \
80 .with_issued_at(now) \
81 .with_expires_at(now + 30) \
82 .with_claim("acc", username) \
83 .sign(jwt_algorithm)
Dadurch erzeugen wir einen Token mit einer Gültigkeit von 30 Sekunden, welcher im Claim acc
den Accountnamen des authentifizierten Game-Clients enthält. Der Game-Client hat somit ca. 30 Sekunden Zeit, sich beim World-Server mit diesem Token zu authentifizieren, sofern die Systemzeiten von Authentication- und World-Server annähernd übereinstimmen.
Der nächste Schritt
Im nächsten Artikel wird es um den nächsten Schritt gehen: Der Game-Client sendet den erhaltenen Token an den World-Server, welcher den Token validiert und im Erfolgsfall die Verbindung aufrechterhält.