Unity3d QuickTip – czyli szybkie porady, rozwiązania częstych problemów i sztuczki w Unity3d!
Dzisiejszy odcinek: Komunikacja z serwerem www, część druga.
Uwaga! Jest to poradnik typu QuickTip. Zatem skupia się on na osiągnięciu założonego celu. Zatem zakładamy że użytkownik zna na tyle program Unity3d, aby samodzielnie wykonać najprostsze czynności, jak np. dodanie modelu kostki do sceny czy dodanie modelowi jakiegoś komponentu. Jeżeli brakuje Ci tej podstawowej wiedzy, zapraszam do tutoriala:
Unity Tutorial – Podstawy
Teoria
Aby wykonać ten tutorial, należy zapoznać się z częścią pierwszą, gdzie tworzymy API na serwerze WWW, oraz z częścią drugą, gdzie przygotowujemy formularz w Unity.
Dzisiaj zajmiemy się zabezpieczeniem naszego połączenia. Póki co przesyłamy dane czystym tekstem, w postaci jawnej. Gdyby jakiś haker przechwyciłby nasze przesyłane dane, to z łatwością mógłby dorwać się do konta naszego gracza.
Aby temu zapobiec, dane najczęściej się szyfruje. Wykorzystuje się do tego szyfrowanie jedno lub dwustronne. Szyfrowanie, polega na zakodowaniu danych do postaci, której nie da się odczytać bez dysponowania odpowiednim kluczem. W szyfrowaniu jednostronnym (takim jak AES) istnieje jeden klucz, którym szyfrujemy oraz odszyfrowujemy dane. W przypadku szyfrowania dwustronnego (np. RSA) mamy dwa klucze. Jeden prywatny, który służy do szyfrowania danych, oraz klucz publiczny, który służy do odszyfrowywania danych. Najczęściej prywatny klucz stoi po stronie serwera, a kluczami publicznymi posługują się klienci.
Nasze hasło w bazie danych trzymamy w formie hasha. Jednak czasami, aby dodatkowo zabezpieczyć hasło, stosuje się zasolenie. Tzn. przed stworzeniem hasha, dopisujemy do hasła jakąś sól, czyli właściwie losowy ciąg znaków, lub losowe znaki w pewnych fragmentach hasła. Dzięki temu, gdy haker nawet przechwyci nasze hasło, to będzie miał utrudnione dekodowanie go, bo z hasła “admin” zrobi się “admin@#%”, przez co ataki słownikowe (szukajace hasła na zasadzie porównywania słów ze słowami ze słownika) może takiego hasha nie odnaleźć. Dodatkowo, nawet gdyby się ta sztuka udała, to dalej haker nie wie, co jest hasłem, a co naszą solą.
To co my sobie dodamy dzisiaj to: zasolenie do hasła, oraz szyfrowanie dwustronne z wykorzystaniem algorytmu AES.
Zasolenie hasła
Zaczniemy od zasolenia hasła. Sprawa jest dość prosta i możemy ją rozwiązać dwojako.
Metoda ręczna
Pierwszym rozwiązaniem będzie, rozwiązanie ręczne – mniej polecane, ale załatwi sprawę. Co się na to składa? Gdy tworzymy użytkownika, mamy taki oto kod:
$sql = "INSERT INTO players (login, pass, email) VALUES ('".$login."', '".md5($password)."', '".$email."')";
Wystarczy go zmienić na takie coś:
$sql = "INSERT INTO players (login, pass, email) VALUES ('".$login."', '".md5($password."#&S@")."', '".$email."')";
Oczywiście sól może być inna. Druga sprawa, to fakt, że należy pamiętać o uwzględnieniu soli przy odczytywaniu hasła. Robił to dla nas taki kod:
if(md5($passwd) != $user['pass']) {
Tutaj również należałoby dokonać zmiany:
if(md5($passwd."#&S@") != $user['pass']) {
Optymalnie byłoby tworzyć unikatową sól dla każdego rekordu, jednak wtedy trzeba w bazie danych uwzględnić pole, gdzie taką sól zapiszemy.
Metoda automatyczna
Jednak dzięki nowszym wersjom PHP, możemy zastąpić ręczne hashowanie, tworzeniem hasha przy pomocy specjalnie przygotowanej funkcji. Jej przewaga? Każde hasło otrzymuje unikatową sól, przez co przechwycenie dwóch haseł nie pozwoli odczytać, która część to sól, a dodatkowo zbudowane jest tak, że nie wymaga to przechowywania informacji o soli w oddzielnym wpisie w bazie danych.
Funkcja ta, to:
password_hash($naszeHaslo, PASSWORD_DEFAULT);
Pierwszy parametr to hasło podane przez użytkownika, a drugi to styl kodowania. Druga funkcja którą dziś wykorzystamy to:
password_verify($haslo, $hash)
Funkcja, która służy do sprawdzania czy podane hasło, będzie odpowiadało zadanemu hashowi.
Ogólnie nasz plik PHP po zmianach, będzie wyglądał tak:
<?php $servername = 'localhost'; $username = 'dggbkogp_wp'; $password = 'example123'; $dbname = 'dggbkogp_wordpress'; $page = $_GET['action']; if(is_null($page)) { $page = $_POST['action']; } if($page == "login") { if ($_SERVER['REQUEST_METHOD'] === 'POST') { $login = trim(strip_tags($_POST['user_login'])); $passwd = trim(strip_tags($_POST['user_pass'])); $conn = new mysqli($servername, $username, $password, $dbname); if ($conn->connect_error) { die("Connection failed: " . $conn->connect_error); } $sql = "SELECT * FROM players WHERE login LIKE '" . $login . "'"; $result = $conn->query($sql); if ($result->num_rows > 0) { $user = $result->fetch_assoc(); if(password_verify($passwd, $user['pass'])) { $result = array('status' => 0, 'msg' => "Wrong Password"); } else { $result = array('status' => 1, 'msg' => "Success!"); } } else { $result = array('status' => 0, 'msg' => "There is no such user"); } echo json_encode($result); $conn->close(); die; } } if($page == "register") { if ($_SERVER['REQUEST_METHOD'] === 'POST') { $login = trim(strip_tags($_POST['user_login'])); $password1 = trim(strip_tags($_POST['user_pass_01'])); $password2 = trim(strip_tags($_POST['user_pass_02'])); $email = trim(strip_tags($_POST['user_email'])); if($password1 != $password2) { die("Passwords are different"); } $conn = new mysqli($servername, $username, $password, $dbname); // Check connection if ($conn->connect_error) { die("Connection failed: " . $conn->connect_error); } $sql = "INSERT INTO players (login, pass, email) VALUES ('".$login."', '".password_hash($password, PASSWORD_DEFAULT)."', '".$email."')"; if ($conn->query($sql) === TRUE) { $result = array('status' => 1, 'msg' => "Success"); } else { $result = array('status' => 0, 'msg' => $conn->error); } echo json_encode($result); $conn->close(); die; } } ?> <?php if($page == "login"): ?> <form method="POST" action=""> <input type="hidden" name="action" value="login" /> <input type="text" name="user_login" value="" placeholder="login..." /> <br/> <input type="password" name="user_pass" value="" placeholder="haslo..." /> <br/> <input type="submit" value="Loguj" /> </form> <?php elseif($page == "register"): ?> <form method="POST" action=""> <input type="hidden" name="action" value="register" /> <input type="text" name="user_login" value="" placeholder="login..." /> <br/> <input type="password" name="user_pass_01" value="" placeholder="haslo..." /> <br/> <input type="password" name="user_pass_02" value="" placeholder="powtorz haslo..." /> <br/> <input type="text" name="user_email" value="" placeholder="e-mail..." /> <br/> <input type="submit" value="Rejstruj" /> </form> <?php endif; ?> <br/> <a href="?action=register">Register</a> | <a href="?action=login">Login</a>
Zmienione linijki zaznaczyłem. Dwie drobne zmiany w kodzie, a nasze połączenie już będzie bezpieczniejsze. Również zwiększamy tym bezpieczeństwo danych w samej bazie danych.
Ważna informacja! Ten sposób kodowania wymaga, żeby kolumna w bazie danych dopuszczała maksymalny rozmiar 60 znaków, kiedy my ustawiliśmy wcześniej 34 znaki. Jeśli nie rozszerzymy rozmiaru kolumny w bazie danych, to nasze hasło może się nie zmieścić, a przez to całość nie będzie działać.
Szyfrowanie połączenia
Została druga część czyli zaszyfrowanie połączenia, żeby dane nie latały sobie luzem. Tutaj będziemy mieli dwa etapy prac. Po pierwsze musimy dodać system szyfrowania i odszyfrowywania do Unity, a następnie te same funkcjonalności dać naszemu serwerowi.
Szyfrowanie w Unity
Żeby to wszystko ogarnąć, stworzymy sobie naszą klasę szyfrującą. Zaczynamy od utworzenia nowego pliku Crypt.cs i od razu wchodzimy w jego edycję. Podstawa naszego pliku, będzie wyglądała tak:
using System; using System.Text; using System.Security.Cryptography; public class Crypt { public string encrypt(string plainTextData) { } public string decrypt(string cipherTextData) { } }
Co jest istotne? Klasa będzie klasą C#, bardziej uniwersalną i niezależną od Unity. Dlatego wywalamy biblioteki silnika. Nie będą nam potrzebne. W zamian dodajemy biblioteki: System.Text oraz System.Security.Cryptography. Również nie potrzebujemy innych bajerów Unity, więc usuwamy dziedziczenie po MonoBehavior.
W środku mamy dwie funkcję publiczne. Jedna będzie szyfrować, a druga odszyfrowywać. Obie pryzmują jako parametr string i również string zwracają.
Zaczniemy od funkcji do szyfrowania:
public string encrypt(string plainTextData) { byte[] keyArray = UTF8Encoding.UTF8.GetBytes ("12345678901234567890123456789012"); byte[] toEncryptArray = UTF8Encoding.UTF8.GetBytes (plainTextData); RijndaelManaged rDel = new RijndaelManaged (); rDel.Key = keyArray; rDel.Mode = CipherMode.ECB; rDel.Padding = PaddingMode.PKCS7; ICryptoTransform cTransform = rDel.CreateEncryptor (); byte[] resultArray = cTransform.TransformFinalBlock (toEncryptArray, 0, toEncryptArray.Length); return Convert.ToBase64String (resultArray, 0, resultArray.Length); }
Co tu się dzieje?
byte[] keyArray = UTF8Encoding.UTF8.GetBytes ("12345678901234567890123456789012"); byte[] toEncryptArray = UTF8Encoding.UTF8.GetBytes (plainTextData);
Pierwsza linia to sprowadzenie klucza do tablicy bitów. Ponieważ standardowo w Unity stringi są kodowane za pomocą kodowania UTF8, korzystamy z biblioteki UTF8Encoding. Funkcją GetBytes zmieniamy podany string w tablicę bitów, bo w takiej formie musimy podać klucz. Również kodowany tekst musimy podać w formie tablicy bitów, dlatego z tekstem do szyfrowania (podanym jako parametr) robimy dokładnie to samo.
[stextbox id=”info” defcaption=”true”]Klucz w idealnym rozwiązaniu powinien być jakimś losowym ciągiem znaków, generowanym wg. znanemu programiście wzoru w taki sposób, żeby zarówno nasza gra w Unity jak i serwer w PHP były w stanie wygenerować ten sam klucz. Może się on opierać np. na odpowiednich wycinkach nazwy gry, aktualnej dacie etc. Im bardziej złożony i zmienny, tym bardziej bezpieczny. Trzeba pamiętać że przy takim szyfrowaniu, dysponując kluczem, haker może łatwo odszyfrować nasze dane. Dlatego klucz jest tutaj świętością. Ja podałem prosty i jawny klucz, bo pokazuje jak wykonać samo szyfrowanie.
Warto też się zatrzymać przy samym kluczu, gdyż jego rozmiar nie jest dowolny. W zależności od tego jakiego algorytmu szyfrowania będziemy używać, wytyczne wielkości klucza mogą być różne. Często zależą od rozmiaru bloku szyfrowania. Standardowo algorytm AES ma 128 bitowy blok, natomiast klucz może mieć: 128, 192 lub 256 bitowy klucz. My korzystamy ze 256 bitowego (32 bajty = tyle znaków ma klucz). Dlatego mówimy, że wykorzystujemy algorytm AES-256.[/stextbox]
RijndaelManaged rDel = new RijndaelManaged (); rDel.Key = keyArray; rDel.Mode = CipherMode.ECB; rDel.Padding = PaddingMode.PKCS7;
Pierwsza linijka to inicjacja klasy szyfrującej. Nazwa Rijndael stosowana jest zamiennie z AES, a pochodzi o nazwiska twórcy algorytmu, dlatego nie ma się tu czego bać, ani czemu dziwić.
Teraz przygotowujemy managera szyfrowania do szyfrowania. Podajemy mu przygotowany klucz, metodę szyfrowania, oraz wypełnienie.
[stextbox id=”info” defcaption=”true”]Metody szyfrowania dodatkowo komplikują proces szyfrowania. ECB pochodzi od skrótu: “Electronic Codebook”. Polega na tym, że nasza wiadomość dzielona jest na bloki, a każdy blok jest szyfrowany oddzielnie. (tutaj wyjaśnia się, czym jest rozmiar bloku w algorytmie szyfrowania). Mamy też inne metody szyfrowania, np.: CBC (Cipher Block Chaining), który również dzieli wiadomość na bloki, ale nie szyfruje każdego bloku oddzielnie. Każdy kolejny blok, jest łączony z tym co już zostało zaszyfrowane i tak powstała konstrukcja jest dopiero szyfrowana.
Takich metod jest więcej i jeśli kogoś to interesuje, to zachęcam do sięgnięcia po fachową literaturę. [/stextbox]
[stextbox id=”info” defcaption=”true”]Bardziej dociekliwa osoba, zauważyła pewnie, że mamy te bloki po 128 bitów, ale nasza wiadomość, nie zawsze tyle ma. Dlatego dodano algorytm wypełnienia, który uzupełnia luki w niepełnych blokach, tak aby miały wymagany rozmiar.
Tutaj również mamy różne metody wypełniania. Zastosowane przez nas PKCS7 wypełnia blok, bajtami o wartości równej liczbie brakujących bajtów. Tzn. gdyby w bloku brakowało 4 bajtów, to każdy brakujący bajt otrzyma wartość 04, gdyby brakowało jednego, to ten jeden dostałby wartość 01. [/stextbox]
ICryptoTransform cTransform = rDel.CreateEncryptor (); byte[] resultArray = cTransform.TransformFinalBlock (toEncryptArray, 0, toEncryptArray.Length);
Czas wykonać samo szyfrowanie. Przygotowujemy sobie enkryptor, na podstawie przygotowanej wcześniej klasy szyfrującej. Metoda CreateEncryptor zapewnia nam przygotowanie enkryptora.
Następnie metoda TransformFinalBlock, wykonuje dla nasz szyfrowanie, zwracając tablicę bitową z szyfrem. Interesujące mogą być tu parametry. Jako pierwszy podajemy tablicę bitową, którą chcemy szyfrować, następnie podajemy od którego bitu chcemy szyfrować, do którego – my podajemy rozmiar tablicy, bo chcemy zaszyfrować wszystko.
return Convert.ToBase64String (resultArray, 0, resultArray.Length);
Na sam koniec naszą tablicę (znów podając początek i koniec) kodujemy do postaci stringa, za pomocą algorytmu Base64.
[stextbox id=”info” defcaption=”true”]Warto go sobie zapamiętać, bo jest wielce przydatny w programowaniu. Często gdy musimy przesłać jakieś dane ich postać nie pozwala na jawne przesłanie, bo moglibyśmy wiele danych stracić. Najbezpieczniejszy do przesyłania jest format stringowy. Base64 nam go zapewnia, dzięki czemu nie utracimy żadnych danych w trakcie ich przesyłania. Gdybyśmy chcieli jawnie przesłać przez sieć tablicę bitów, warstwy sieciowe, routery i cała reszta mogłaby mieć problem z rozszyfrowaniem, która część wiadomości to kod sterujący, gdzie jest faktyczna wiadomość, a gdzie zamknięcie połączenia. Przez to, moglibyśmy część faktycznej wiadomości utracić, albo w ogóle wysłać ją nie tam gdzie trzeba. Oczywiście, to wszystko w wielkim uproszczeniu. [/stextbox]
OK. Jesteśmy gotowi zaszyfrować wiadomość, ale trzeba też takową odebrać i odszyfrować. Dlatego mamy też funkcję do odszyfrowywania:
public string decrypt(string cipherTextData) { byte[] keyArray = UTF8Encoding.UTF8.GetBytes ("12345678901234567890123456789012"); byte[] toEncryptArray = Convert.FromBase64String (cipherTextData); RijndaelManaged rDel = new RijndaelManaged (); rDel.Key = keyArray; rDel.Mode = CipherMode.ECB; rDel.Padding = PaddingMode.Zeros; ICryptoTransform cTransform = rDel.CreateDecryptor (); byte[] resultArray = cTransform.TransformFinalBlock (toEncryptArray, 0, toEncryptArray.Length); return UTF8Encoding.UTF8.GetString (resultArray); }
Jednak tej funkcji nie będę już omawiał tak dokładnie, z bardzo prostego powodu. Różni się 4 linijkami. Po pierwsze zanim weźmiemy się za odszyfrowywanie, to musimy rozkodować nasze Base64. Po drugie, zmieniamy typ wypełniania na zeros. Dlatego, że PHP kodując domyślnie używa tego wypełniania. Jesto to o tyle śmieszne, że potrafi rozszyfrować kod z dowolnym wypełnieniem, ale samo wypełnia tylko zerami. Po trzecie, zamiast CreateEncryptor mamy Create Decryptor. Po czwarte, konwertujemy naszą tablicę bitów, którą otrzymaliśmy po odszyfrowaniu do postaci Stringa w kodowaniu UTF-8.
W zasadzie brak zmian w reszcie kodu jest istotny. Dlaczego? Bo żeby szyfrowanie się udało, to szyfrowania i odszyfrowywania musimy użyć tego samego algorytmu, o tym samym rozmiarze bloku, tym samym rozmiarze klucza oraz jego treści, korzystając z tej samej metody szyfrowania i tego samego uzupełniania.
Czas dodać ten kod do wysyłanej wiadomości. Najpierw musimy sobie zainicjować naszą klasę:
private Crypt crypt; void Start() { crypt = new Crypt (); }
A następnie zaszyfrowałem oddzielnie, każdy z naszych parametrów, które przesyłamy:
form.AddField( "user_login", crypt.encrypt (login_input.text )); form.AddField( "user_pass", crypt.encrypt (password_input.text ));
OK. To wszystko w Unity. Czas przygotować nasz serwer do odebrania tych danych.
Szyfrowanie w PHP
W PHP jest nieco łatwiej, bo wszystko za nas załatwią dwie funkcję. Zaczniemy od dekodowania.
mcrypt_decrypt (MCRYPT_RIJNDAEL_128, $key, base64_decode($_GET['user_login']), MCRYPT_MODE_ECB);
Co tu mamy?
Najpierw podajemy algorytm szyfrowania. Znów nazwa Rijndael, ale z opisem 128. Dlatego, że tutaj podajemy tak rozmiar bloku, a nie klucza. Drugi parametr, to sam klucz. Trzeci, to wiadomość, którą musimy sobie odkodować z formatu Base64. Na koniec podajemy metodę kodowania.
[stextbox id=”info” defcaption=”true”]Niektóre metody kodowania, np. CBC wymagają podania jeszcze wektora inicjującego. On decyduje o tym jak zacznie się nasze kodowanie, a tym samym o efekcie końcowym. Ten wektor najczęściej przesyła się wraz z wiadomością. My wykorzystaliśmy szyfrowanie metodą ECB, wiec ten parametr nas nie dotyczy. [/stextbox]
$toEncrypt = mcrypt_encrypt (MCRYPT_RIJNDAEL_128, $key, $tekst, MCRYPT_MODE_ECB); $toSend = base64_encode($toEncrypt);
Jeśli chodzi o wysłanie wiadomości, to zmienia się niewiele. Słówko decrypt zmienia się w encrypt, treść podajemy jawnie. Dopiero zwrócony przez funkcję mcrypt_encrypt kod, kodujemy za pomocą base64. Po tym w zmiennej $toSend mamy gotową do wysłania wiadomość.
Czas odebrać wiadomość od Unity. Tam gdzie deklarowaliśmy wcześniej zmienne umieszczamy nasz klucz:
$key = "12345678901234567890123456789012";
Uwaga na boku: Jak wspominałem, klucz powinien być generowany. Jeśli jest stały, powinien być jakoś dodatkowo zabezpieczony. Trzymanie go jawnie w pliku, nie będzie nigdy dobrym pomysłem.
Wysyłając, kodowaliśmy każdy z parametrów oddzielnie. Dlatego, żeby to odczytać, potrzebujemy drobnej zmiany w kodzie:
$login_dec = mcrypt_decrypt (MCRYPT_RIJNDAEL_128, $key, base64_decode($_GET['user_login']), MCRYPT_MODE_ECB); $pass_dec = mcrypt_decrypt (MCRYPT_RIJNDAEL_128, $key, base64_decode($_GET['user_pass']), MCRYPT_MODE_ECB); $login = trim(strip_tags($login_dec)); $passwd = trim(strip_tags($pass_dec));
Najpierw odszyfrowujemy komunikat, a potem trimujemy i usuwamy tagi, jak miało to miejsce wcześniej. Reszta kodu się nie zmienia.
Teraz wypadałoby zakodować wiadomość zwrotną. Nasze pojedyncze echo, zmieniamy w coś takiego:
$toEncrypt = mcrypt_encrypt (MCRYPT_RIJNDAEL_128, $key, json_encode($result), MCRYPT_MODE_ECB); $toSend = base64_encode($toEncrypt); echo $toSend;
Nasz komunikat w formacie JSON, szyfrujemy, kodujemy do postaci Base64 i wypisujemy.
Tutaj może się nasunąć pytanie, czemu Base64, a nie JSON? Base64 został stworzony do kodowanie danych binarnych i przesyłania ich przez sieć. JSON jest wygodnym formatem do zapisu bardziej skomplikowanych struktur (jak tablica czy obiekt) w formie tekstowej. Więc jeśli coś przesyłamy przez sieć, to rozsądniejszy zawsze będzie Base64, dodatkowo jest on prostszy i obsługiwany przez większą liczbę języków.
Odbieramy dane w Unity
OK. Wysłaliśmy zaszyfrowane zapytanie w Unity, odebraliśmy je w PHP, przetworzyliśmy i odesłaliśmy zaszyfrowaną odpowiedź do Unity. Czas ją odebrać.
Nasze odbieranie wiadomości wygląda na razie tak:
string json = www.text; ResponseClass response = JsonUtility.FromJson<ResponseClass> (json); output.text = response.msg;
Potrzebna jest drobna zmiana, dokładnie w drugiej linijce:
ResponseClass response = JsonUtility.FromJson<ResponseClass> (crypt.decrypt(json));
Przed zdekodowaniem formatu JSON, odszyfrowujemy nasz komunikat. Nie bawimy się tu Base64, bo zadbaliśmy o to w klasie Crypt.
Została jeszcze jedna rzecz. Jest szansa, że mimo wszystko, Unity będzie się czepiać rzucając błędami. Wynika to z wykorzystywanej wersji .NET. Domyślnie, jest to .NET 2.0 Subset, który ma ograniczenia w kwestii dostępnych bibliotek .NET. Aby wszystko działało, trzeba zmienić wykorzystywane biblioteki. Aby to zrobić, trzeba wejść w: [Edit -> Project Settings -> Player].
W oknie Inspector rozwijamy kartę: Other Settings i znajdujemy opcję API Compatibility Level i ustawiamy tam: .NET 2.0
Wszystko! Po wgraniu pliku PHP na serwer i odpaleniu gry, wszystko dalej powinno działać. Rejestracja będzie zrobiona analogicznie.
Pamiętajcie, że jeśli utworzyliście użytkownika na pierwszej wersji, to po zmienia sposobu kodowania hasła, ten użytkownik nie przejdzie już poprawnie etapu logowania.
Również pamiętajcie, żeby dobrze zabezpieczać klucz szyfrowania, bo to on jest tutaj najistotniejszy i jeśli wyjdzie na światło dzienne, to całe szyfrowanie nie ma sensu.