Rest webapi sa PHP-om? Zvuči nemoguće? Ali zašto ne? Današnje moderne client-side-rendering JS React/Vue/Svelte web stranice se koriste sa podacima sa “treće strane” odnosno sa drugih servera. Podatci se servera se uzimaju sa HTTP request-om dok se za sigurnost i valjanost podataka koriste sigurnosni tokeni.

Sigurnost (imam-kod VS nije-testirano)

Zašto ne framework? Pa zato što haker može pročitati source kod i jednostavno google-ti propuste i ako ste na nešto starijoj verziji – nahebali ste. A s druge strane, privatni kod može imati propuste ali to haker mora sam tražiti pa preko logova možete i sami vidjeti ‘ko radi i gdje šta radi. Kad se dogodi propust, rollback pa popravi. Ako griješim, svakako me u komentarima ispravite!

Token prijave, što je?

Najjednostavnija usporedba je s onom o zip arhivama. Naprimjer, imamo datoteku i stavimo ju u lozikom zaštićenu (enkriptiranu) zip arhivu. Da bi imali pristup datoteci moramo unijeti lozinku. Tako je i tokenom, imamo JSON string kojeg enkriptiramo i pristupamo mu sa lozinkom.

Kad se korisnik registrira, mi u bazu podataka pohranimo posebni unikatni id (npr. UUID v4) koji je vezan u novog korisnika. Kada se korisnik prijavi, mi mu pošaljemo token sa id-em i ostalim važnim (skrivenim) podacima sesije, poput isteka sesije kao dio HTTP zaglavlja. Taj token spremamo u preglednikov webstorage. Na izlazu iz prijave (logout) brišemo token iz webstorage-a. Ako nema tokena, korisnik nije prijavljen.

Kako napraviti token?

Možemo koristiti JWT composer paketić (preporučeno) ili zbog bloga, napraviti svoje funkcije koje koriste ove osnovne elemente:

$data = [
	'userId' => 'ef2e2af0-2830-451f-bb12-cfc13b798ccc',
	'expiresAt' => 1645069921,
];

$jsonData = json_encode($data);

$serverKey = 'mU+q(b!Sk\2)j;<Q>+8ea8#$8u>]{fa3WRcJuWv^?#8)_!nu5(>.Z';
$iv = '~h*w#^L4*YjNE:=S';


$encryption = base64_encode(openssl_encrypt($jsonData, "AES-128-CTR", $serverKey, 0, $iv));
echo $encryption;

echo "<br>";

$decryption = base64_decode(openssl_decrypt($encryption, "AES-128-CTR", $serverKey, 0, $iv));
echo $decryption;

$serverKey i $iv su varijable sa nešto većim nasumičnim stringom i te varijable moraju biti nedostupni iz browsera. Znači ako koristimo ‘public’ kao server root, ove varijable moraju biti u datoteci putanju prije (‘./../’). $encryption varijabla je token koji šaljemo u preglednik a $decryption varijabla sa dekriptiranim podacima.

Token šaljemo kako?

Token šaljemo kao dio header-a zahtjeva:

   'Authorization': 'Bearer ' + 'mojTokenEyJ0eXAiOiJKV1Q',

A u PHP-u uzimamo header sa:

$headers = getallheaders();
if (isset($headers['Authorization']))
{
	echo $headers['Authorization'];
}

Šta će mi token kad imam sesiju i HttpOnly kolačić?!

Recimo da koristimo curl u terminalu ili alat koji nije internet preglednik. Ako nudimo api uslugu sa podacima ne možemo dobiti sigurni HttpOnly kolačić. Da podsjetim, HttpOnly kolačić je posebni cookie koji je dostupan samo iznutra internet pregledniku a do njega se nemože doći sa JS-om.

Da, ali PHP nije “kul”

Tehnički, nema nikakve razlike između PHP servera i servera sa Nodom, Pythonom ili Lua-om. Moguće je napraviti REST server sa bash skriptom ili C jeziku. Jednostavno, imamo programčić koji “promatra” određeni TCP port i kad primi HTTP zahtjev, pošalje nazad string odgovora… Osim toga, rješenje sa cPanel/PHP/MariaDb je jeftinije i de-centralizrano rješenje. A što se tiče sigurnosti, kao i u svakom jeziku, validacija podataka iz svakog ulaza i eskejpanje izlaza je dovoljna/dovoljno.

Ulazna točka

Za probu, napraviti ćemo ‘api.php’ datoteku. I sa nekim od REST klijenata ćemo pristupati ulaznoj točki.

mkdir ./myPhpApi
mkdir ./myPhpApi/public
touch ./myPhpApi/public/api.php
echo "<?php echo 'hello';" > ./myPhpApi/public/api.php
cd ./myPhpApi/public/
php -S localhost:7777

Poštar je pretežak

Postman je pun svega i svačega, pa ćemo ga zaobići. Koristimo “RESTClient” dodatak za Firefox.

Kao GET metodu zovemo ‘http://localhost:7777/api.php’ dobivamo:

Komunikacija se odvija u JSON formatu. Pa ćemo ubaciti zaglavlje:

header('Content-type:application/json;charset=utf-8');
echo 'hello';
die();

die‘ funkciju dodajemo da prekinemo daljnje izvođenje zahtjeva.

Da bi konvertirali php object/array elemente u JSON, koristimo ‘json_encode‘ funkciju.

header('Content-type:application/json;charset=utf-8');
echo json_encode(['hello'=>'world']);
die();

Tip zahtjeva

Da odredimo s kojim tipom zahtjeva radimo, koristimo:

if ($_SERVER['REQUEST_METHOD'] === 'GET') {
    echo json_encode(['forbidden'=> "Method Not Allowed"]);  
    http_response_code(405);
    die();
}

Tako da mi, na primjer, gore blokiramo zahtjeve tipa ‘GET’.

Putanje zahtjeva

Prvo uzimamo dio URL-a koji nas zanima:

$requestUri = $_SERVER['REQUEST_URI']; 

I sada možemo modelirati putanju zahtjeva:

if ($requestUri === '/api.php/success')
{
    $postData = json_decode(file_get_contents('php://input'));
    echo json_encode(['success'=> json_encode($postData)]);  
    die();  
}

Pa ćemo u RESTClient-u, prebaciti na POST, ubaciti u Body: {“hello”:”world”} i pozvati URL ‘http://localhost:7777/api.php/success’;

Šifra statusa

Kao dio zahtjeva, možemo vratiti i HTTP status codes sa ‘http_response_code()’ funkcijom.
Listu http šifri statusa imate ovdje.

CORS

Cross-origin resource sharing (CORS) mehanizam je koji omogućuje da se ograničeni resursi na web stranici zatraže s druge domene izvan domene s koje je prvi resurs poslužen.

Da bi CORS omogućili, moramo dodati u zaglavlje:

header('Access-Control-Allow-Origin: *');
header('Access-Control-Allow-Methods: GET, POST'); //dodaj druge
header("Access-Control-Allow-Headers: X-Requested-With");

Query string

…ili onaj-dio-url-stringa-iza-?, u PHP-u ga hvatamo sa:

$queries = array();
parse_str($_SERVER['QUERY_STRING'], $queries);
print_r($queries);

A ti bi i da možeš pohraniti podatke na poslužitelju?

‘api.php’ je u ‘public’ diru. Pa pravimo datoteku izvan ‘public’ direktorija. Pa ćemo napraviti ‘config.json’ a pristupati ćemo mu sa putanjom:

__DIR__ . '/../config.json' 

Odnosno.

$data = file_get_contents(__DIR__ . '/../config.json'); //učitaj podatke
$myConfig = ['alpha'=>'beta'];
file_put_contents(__DIR__ . '/../config.json', json_encode($myConfig)); //spremi podatke

Ovo je OK za manji projekt gdje imate nešto manje postavki. Spremati podatke u JSON baš i nije najsigurnije pa json string možemo enkriptirati (kao što smo token) prije nego što ga spremimo.

Naravno za ozbiljne stvari, treba nam prava baza.

Baza podataka

Sigurnost

Za sigurnost koristimo PDO i “prepared statements”. Možemo i dodatno provjeriti da li je zahtjev upućen dozvoljenoj (sql table) tablici. Bilo bi poželjno da odvojimo tablice administratora i običnih korisnika. Te da funkcije/metode modela obavljaju samo jedan sql query na samo jednoj sql tablici.

Validacija

Za validaciju podataka možemo napisati vlastite funkcije ili koristiti neke od composer validatora (npr. ‘respect/validation‘). Na primjer, korisničko ime ili lozinku možemo ograničiti na dozvoljene znakove
i provjeriti prije unosa u bazu, dali je string unosa ispravan.

API ograničenja

Broj zahtjeva i rate limiter

Možemo dozvoliti pristup API-u samo određeni broj puta. Tako što možemo ugraditi brojač http zahtjeva i pratiti ga u sesiji a kasnije (odjava) spremiti u bazu podataka. Isto tako možemo pratiti broj zahtjeva po sekundi. Tako da ako dođe do prekoračenja, da zabranimo pristup na određeni period ili u potpunosti.

Primjer

Github primjer je nešto stariji od ovog bloga ali može se iskoristiti sa ovim gore napisanim.

<?php
/*
* Return data as JSON.
*/
header('Content-type:application/json;charset=utf-8');


/*
* If request method is GET return 405
*/
if ($_SERVER['REQUEST_METHOD'] === 'GET') {
    echo json_encode(['forbidden'=> "Method Not Allowed"]);  
    http_response_code(405);
    die();
}


/*
// Un-comment for Cross-Origin Request Headers
// https://developer.mozilla.org/en-US/docs/Web/HTTP/CORS
header('Access-Control-Allow-Origin: *');
header('Access-Control-Allow-Methods: GET, POST');
header("Access-Control-Allow-Headers: X-Requested-With");
*/


/*
* Get route part of URL.
*/
$requestUri = $_SERVER['REQUEST_URI'];

/*
* If route is matched do..., else return 404.
*/
if ($requestUri === '/api.php/success')
{
    /*
    * Get post data
    */
    
    $postData = json_decode(file_get_contents('php://input'));
        
    /*
    * On controller success return: {success:'data'} 
    */
    echo json_encode(['success'=> 'someSuccessData => ' .  json_encode($postData)]);  
    die();  
}
if ($requestUri === '/api.php/queryString?imQuery=imString')
{
    /*
    * Handle query string
    */
    
    $queries = array();
    parse_str($_SERVER['QUERY_STRING'], $queries);
    
    echo json_encode(['success'=> $queries]);  
    die();  
}
else if ($requestUri === '/api.php/fail')
{
    /*
    * On controller fail (for example: "Database data not found") return: {fail:'Database data not found'} 
    */
    echo json_encode(['fail'=> 'someFailData']); 
    die(); 
}
else
{
    http_response_code(404);
    die();
}