somethinglikegames.de

Mein Blog über Spieleentwicklung und das Drumherum


Kategorien


Tags

Der Quellcode zu den Artikeln ist in diesem GitHub Repository zu finden. Die folgende Liste zeigt alle bislang erschienenen Artikel dieser Serie:

  1. Netzwerktutorial 1: Allgemeines und Übersicht
  2. Netzwerktutorial 2: Das "gehende Skelett"
  3. Netzwerktutorial 3: Login 1 - Der Game-Client
  4. Netzwerktutorial 4: Login 2 - Gateway- und Authentication-Server
  5. Netzwerktutorial 5: Login 3 - World-Server
  6. 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)
Aus Sicherheitsgründen sollte in einem Produktivszenario natürlich kein Passwort/Shared-Secret o.ä. direkt im Quellcode hinterlegt sein, sondern als Ressource oder per Konfigurationsdatei geladen werden. Aber für das Tutorial war es bequemer, es direkt im Quellcode zu hinterlegen.

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

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).

In der Realität würde man dies anders machen und stattdessen den Passworthash, welcher mit einem sicheren Verfahren erzeugt wurde, zum übermittelten Benutzernamen aus der Datenbank auslesen und anschließend das übermittelte Passwort mit dem Passworthash der Datenbankabfrage prüfen. Ich habe vor, auf dieses Thema in einem späteren Artikel genauer einzugehen, aber noch ist nicht die passende Zeit dafür.

Zumal Godot 4 zum Zeitpunkt der Artikelveröffentlichung nicht einmal 2 Wochen alt ist und es daher aktuell noch keine stabilen AddOns für Datenbankclients gibt, auch wenn godot-sqlite und PostgreSQLClient bereits erste Testversionen für Godot 4 bereitstellen.

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 wie wir hier JWTs einsetzen, also als Teil von JWS, sind die enthaltenen Informationen für jeden lesbar und sollten somit KEINE sensiblen Informationen beinhalten!

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
Da ich mir nur wenige Fälle vorstellen kann, wo es nicht fahrlässig wäre, (private) Schlüssel in ein Repository zu speisen, habe ich den Ordner crypto/ in der zugehörigen .gitignore des Authentication-Servers hinzugefügt.

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

Für einen Produktivbetrieb muss die gesamte Netzwerkkommunikation verschlüsselt werden, schließlich werden Passwörter übermittelt. Das ist mit DTLS möglich. Bei DTLS handelt es sich um das UDP-Äquivalent zu TLS, was bspw. bei HTTPS genutzt wird. Wir werden uns in einem späteren Teil dieser Tutorialserie mit der Integration von DTLS beschäftigen.

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.