Trápí vás, že během přihlašování se posílá nezabezpečené heslo na server a kdokoliv (man-in-the-middle) ho může odchytit a zneužít? Můžete využít JWT, neboli JSON Web Token, který může obsahovat čitelná data a zároveň je chráněn soukromým klíčem proti změnám. Pokud jako klíč použijete uživatelské heslo a někdo bude odposlouchávat přihlášení, nedozví se dané heslo a nebude ho moci zneužít.
Poznámka: JWT se zpravidla používá jako ověřovací token až poté, co se uživatel přihlásí a proto je obvykle šifrován privátním klíčem serveru (RSA). Tento článek se ale zabývá vytvořením klíče zašifrovaného pomocí hesla (HMAC).
Vytvoření JWT (pomocí JS)
Pro vytvoření JWT potřebujete na klientovy znát přihlašovací jméno a heslo uživatele – které uživatel jednoduše zadá do přihlašovacího formuláře. Pro ověření pak musíte mít na serveru uloženo dané jméno a MD5 součet pro jeho heslo (není tedy potřeba mít heslo v čitelné formě).
Pro větší bezpečnost můžete samozřejmě použít i jinou metodu šifrování, kterou jste schopni v Javascriptu vypočítat (třeba SHA1). Pamatujte ale na to, že čím větší bude klíč, tím větší bude i výsledný JWT.
Klíč pro podepsání
Pro výpočet MD5 můžete použít polyfill string.md5.js:
var password = document .getElementById('password'), key = password.value.md5();
Takto získaný klíč z hesla pak můžete využít pro podepsání JWT.
Níže uvedená JS knihovna ale očekává zadání klíče v hexadecimálním tvaru, takže budete ještě potřebovat jeden převod pomocí string.toNumberString.js:
//key je MD5 hesla key.toNumberString(16);
Data pro JWT
JWT musí mít hlavičku (header), která určuje, že jde o JWT, a jaká metoda byla použita pro jeho vytvoření:
var header = { typ: 'JWT', alg: 'HS256' };
Proměnná typ
(zkratka z type) určuje, že jde právě o JWT, alg
(zkratka z algorithm) je použitá metoda. Existuje celá řada metod, ale HS256 musí podporovat všichni, kdo chtějí JWT používat (ostatní metody jsou volitelné).
Další část JWT je tzv. payload, česky užitečné zatížení neboli to, co chcete přepravovat (uložit). V payload může být cokoliv, co lze zakódovat do JSON:
var payload = { name: 'Jan Novák', mail: 'novak@server.cz' };
U JWT je pravidlo uvádět jména proměnných co nejkratší, protože se následně kódují do JSON a Base64, což jsou poměrně nenasytné formáty a pak by byl JWT zbytečně dlouhý. Proto se type zkracuje na typ, username se zkracuje na name a třeba Issued At Time (čas vytvoření) na iat. Názvy by měly být v angličtině.
Vytvoření JWT
Teď již můžete stáhnout jsrsasign-latest-all-min.js, což je zkompilovaná knihovna jsrsasign (JavaScript RSA Signing), a pomocí ní JWT vytvořit:
var //přihlášení emailem a heslem email = document .getElementById('email'), password = document .getElementById('password'), //příprava dat pro JWT key = password.value.md5(), header = { typ: 'JWT', alg: 'HS256' }, payload = { mail: email }, jwt; //generování JWT jwt = KJUR.jws.JWS .sign(header.alg, JSON.stringify(header), JSON.stringify(payload), key.toNumberString(16) ) ;
Práce s jsrsasign je trochu složitější, protože je to obecná knihovna pro různé šifrovací formáty a JWT je do ní přidáno jen okrajově a tak nemá úplně zvládnuté všechny detaily (např. automatickou konverzi vstupních parametrů).
Jako první parametr uvádíte šifrovací metodu, což je ta, která je uvedena v hlavičce, další dva parametry jsou hlavička a payload převedené do JSON formátu. Pro podporu starých prohlížečů (IE7, FF3, apod.) budete muset použít JSON2 knihovnu. Poslední parametr je šifrovací klíč převedený do hexadecimální podoby.
Metoda má ještě jeden parametr, který je potřeba, pokud použijete metodu RS256 a k šifrování použijete zaheslovaný privátní klíč. Pak musíte uvést i heslo ke klíči. To ale zde nepoužijeme.
Teď už máte JWT, který bude vypadat nějak takhle:
eyJ0eXAiOiJKV1QiLCJhbGciOiJIUzI1NiJ9.eyJtYWlsIjoidGVzdEBzZXJ2ZXIuY29tIn0.h7YWKIaUrvfd7AaQcoK5kwP5q5RwwUGI0FBZUACZg8s
(V tomto JWT je zakódován email test@server.com
a heslo 123456
převedené do md5 e10adc3949ba59abbe56e057f20f883e
).
Tento JWT můžete poslat na server, který si pak v databázi podle emailu najde uživatele a pomocí uloženého MD5 hesla ověří, že uživatel zadal správné heslo. Pokud totiž uživatel zadá špatné heslo, nebude server schopen JWT ověřit pomocí klíče (MD5 hesla), který má uložen (viz dále).
Base64 v JWT
V základní specifikaci obsahuje base64 znaky A-Z, a-z, 0-9 a ještě znaky ‚+‘, ‚/‘ a ‚=‘. Jelikož je ale JWT určen pro přenos webovými kanály (REST
, GET
, POST
, apod.), které znaky ‚+‘, ‚/‘ a ‚=‘ používají k vlastní potřebě (‚/‘ oddděluje cestu v REST adrese, ‚+‘ je mezera v GETu a ‚=‘ odděluje proměnnou a hodnotu v GETu), používá se tzv. base64url, která místo ‚+‘ používá ‚-‚ (mínus) a místo ‚/‘ je ‚_‘ (podtržítko). Znak ‚=‘ se používá pouze na konci base64, protože to musí mít délku dělitelnou 4, ale díky této znalosti se dají rovnítka snadno na konec doplnit a není potřeba je přenášet.
Pokud pro dekódování použijete PHP, není potřeba rovnítka ani doplňovat, protože PHP metoda base64_decode
je automaticky ignoruje.
Přihlášení (v PHP)
Příklad uvedu pro PHP a knihovnu Namshi/JOSE (JSON Object Signing and Encryption), ale obdobně můžete použít jakýkoliv jazyk a knihovnu uvedenou na jwt.io/#libraries.
Pro ověření JWT potřebujete znát MD5 hesla (nebo obdobný hash). Ten budete muset najít v databázi uživatelů a k tomu potřebujete jméno nebo email, který uživatel zadal.
Dekódování JWT
Payload z JWT přečtete pomocí JOSE knihovny jednoduše (v jiných to může být trochu složitější):
<?php use Namshi\JOSE\JWS; //Knihovna používá namespace Namshi\JOSE* /pokud nemáte, přidejte autoload funkci pro //načítání souborů podle jména třídy //pro PHP s OpenSSL modulem $jws = JWS::load($token); //pro PHP bez OpenSLL modulu $jws = JWS::load($token, false, null, 'SecLib'); //získání payload jako pole $payload = $jws->getPayload();
Funkce load()
vytvoří objekt a automaticky dekóduje Base64 a převede JSON na PHP pole, které můžete přečíst metodou getPayload()
.
Pak stačí najít uživatele v databázi (abstraktní příklad):
$users = $DB ->select('*') ->from('users') ->where('email = ' . $payload['mail']) ;
Ověření JWT
Když máte uživatele s příslušným emailem (správně by měl být jen jeden, ale databáze většinou vrací pole řádek, takže je jedno, kolik jich je), můžete pomocí JWS objektu (získaného výše) a MD5 hesla (vytaženého z DB) ověřit, zda JWT souhlasí:
foreach ($users as $user) { if ($jws->verify($user['password'])) { User::loginById($user['id']); header('Location: /'); die(); } } throw new Exception('Invalid JWT');
V případě, že metoda verify()
vrátí true
, znamená to, že email (nebo jméno) uložené v JWT je platné a bylo podepsáno platným heslem uživatele. V takovém případě pak můžete uživatele přihlásit (zde naznačeno funkcí User::loginById()
) a poslat ho, kam je potřeba (obvykle index.php
nebo my_account.php
, apod.).
Pokud naopak nenajdete žádného uživatele, pro něhož by JWT platil, znamená to, že buď jméno uložené v JWT neexistuje nebo pro dané jméno nesouhlasí heslo, které bylo použito pro podepsání JWT. Pak můžete zobrazit nějakou chybovou hlášku o neplatném hesle (zde naznačeno vyhozením výjimky).
Zde použitý JWT je platný po celou dobu platnosti hesla a jediný způsob, jak ho znevalidnit je změna hesla. Toho lze využít v případě podezření na zneužití JWT => změnou hesla se všechny staré JWT stávají neplatnými.
Vytvoření JWT (v PHP)
Pokud chcete JWT využít pro autologin (přes COOKIE nebo jako odkaz v emailu) nebo jinou autorizaci uživatelských požadavků, můžete JWT vygenerovat v PHP a pak po použít:
//získání dat uživatele $users = $DB ->select('email, password') ->from('users') ->where('id = ' . $userId) ; $user = $users[0]; //ID je unikátní //příprava dat $header = array( //standardní hlavička 'typ' => 'JWT', 'alg' => 'HS256', ); $payload = array('mail' => $user['email']); //Vytvoření JWT use Namshi\JOSE\JWS; //pro PHP s OpenSSL modulem $jws = new JWS($header); //pro PHP bez OpenSSL modulu $jws = new JWS($header, 'SecLib'); $jws->setPayload($payload); $jws->sign($user['password']); $jwt = $jws->getTokenString();
Práce s JOSE knihovnou je opět jednoduchá. Stačí vytvořit JWS s použitím hlavičky (která knihovně řekne, jaký algoritmus má použít) a následně nastavit payload a podepsat s použitím hesla
(což je na straně serveru jeho MD5 nebo jiný hash).
Knihovna automaticky převede hlavičku a payload do JSON a metoda getTokenString()
vygeneruje JWT zakódované do Base64.
Následně již stačí JWT uložit do COOKIE nebo připojit za URL a poslat emailem:
//automatické přihlášení na 14 dní setcookie('autologin', $jwt, strtotime('+14 days'); //přihlášení přes URL $url = "https://' . $_SERVER[HTTP_HOST] . '/login/?' . $jwt;
URL pro automatické přihlášení pak může vypadat takhle: https://my.server.com/login/?eyJ0eXAiOiJKV1QiLCJhbGciOiJIUzI1NiJ9.eyJtYWlsIjoidGVzdEBzZXJ2ZXIuY29tIn0.h7YWKIaUrvfd7AaQcoK5kwP5q5RwwUGI0FBZUACZg8s.
Pro použití COOKIE pak samozřejmě budete muset doplnit kód, který existenci COOKIE ověří a provede login; například:
if (!User::isLogged() && array_key_exists('autologin', $_COOKIE) { header('Location: /login/?' . $_COOKIE['autologin']; die(); }
Kontrola v JS
Javascript, nebo jiný klient může platnost JWT ověřit tak, že uživatele požádá o zadání hesla, ze kterého si pak znovu vytvoří MD5.
To je podobná praxe, jako můžete vidět na některých eshopech nebo internetovém bankovnictví, kde musíte zadat znovu heslo před tím, než můžete např. změnit svůj profil, koupit nějaké zboží nebo zadat platební příkaz.
Následně je klient schopen ověřit, že údaje uložené v JWT jsou platné a lze např. věřit hodnotě expirace a provést požadovanou operaci (v opačném případě by musel požádat server o zaslání nového JWT s novým datem expirace).
Metody zabezpečení
Expirace
První metodou zabezpečení je expirace. Tu definuje JWT specifikace jako dvě hodnoty iat
(Issued At Time = čas vytvoření) a exp
(Expiration time = čas vypršení).
V tomto případě je důležité, aby obě strany (např. JS a PHP) měli stejný čas nebo si byly vědomy časového posunu mezi nimi. Ten lze snadno spočítat, pokud server do stránky nebo odpovědi přidá svůj čas a klient si spočte posun (předpokládá se, že přenos po internetu trvá max. pár sekund, takže rozdíl v minutách nebo hodinách to neovlivní). Případně může klient rovnou ignorovat svůj čas a k času poslanému ze serveru připočítat dobu platnosti.
Příklad (bez výpočtu posunu a s vlastním časem):
var //přihlášení emailem a heslem email = document .getElementById('email'), password = document .getElementById('password'), created = Date.now(), expire = created + 3600000, //+1 hodina //příprava dat pro JWT key = password.value.md5(), header = { typ: 'JWT', alg: 'HS256' }, payload = { mail: email, iat: created, exp: expire }, jwt; //generování JWT jwt = KJUR.jws.JWS .sign(header.alg, JSON.stringify(header), JSON.stringify(payload), key.toNumberString(16) ) ;
Serveru pak stačí podle času expirace ověřit, zda je JWT stále platný a podle toho se rozhodnout, zda ho přijme či zamítne.
Alternativně může použít čas vytvoření a nebo rozdíl obou časů, aby ověřil autenticitu JWT. Například pokud dostane JWT s časem vytvoření v budoucnosti nebo s příliš dlouhou dobou platnosti (např. platnost 1 rok, když obvyklé je 1 hodina), můžete daný JWT zamítnout pro podezření ze zneužití.
Čas vytvoření a expirace samozřejmě nelze změnit, protože by bylo nutné JWT znovu podepsat, což útočník nedokáže.
Pozor na to, že čas (timestamp) v JavaScriptu je v milisekundách, zatímco v PHP je v sekundách!
Vystavitel
Druhá metoda zabezpečení, na kterou pamatuje i JWT specifikace, je vystavitel – anglicky Issuer, což se zkracuje na iss
.
Toto zabezpečení zabraňuje využití stejného JWT na různých serverech. Např. pokud je uživatel přihlášen na 2 servery se stejným jménem a heslem a oba servery podporují přihlášení přes JWT (se stejným způsobem podepisování), lze základní JWT použít bez omezení na obou.
Stačí ale do JWT přidat hodnotu iss
, která by podle specifikace měla obsahovat URL serveru, a server pak snadno pozná, že daný JWT byl vygenerován někým jiným a může se rozhodnout ho tedy zamítnout.
var //přihlášení emailem a heslem email = document .getElementById('email'), password = document .getElementById('password'), //příprava dat pro JWT key = password.value.md5(), header = { typ: 'JWT', alg: 'HS256' }, payload = { mail: email, iss: 'https://muj.server.cz/', iat: Date.now() }, jwt; //generování JWT jwt = KJUR.jws.JWS .sign(header.alg, JSON.stringify(header), JSON.stringify(payload), key.toNumberString(16) ) ;
Pokud by útočník získal takto vygenerovaný klíč a zkusil ho použít pro přihlášení na my.server.com, bude serveru jasné, že jde o zneužití.
Samozřejmě pokud jsou server.cz a server.com oba vaše a nabízí tutéž službu, jen v jiných jazycích, můžete se rozhodnout cizí JWT přijmout.
Challenge
Pro další zvýšení bezpečnost JWT můžete použít tzv. challenge (výzva). Ten spočívá v tom, že do přihlašovacího formuláře přidáte nějaký (skrytý) text, který pak bude muset klient připojit do JWT (nebo alternativně použít ho pro výpočet podepisovacího klíče).
Pokud probíhá přihlášení přes AJAX, může si klient o challenge požádat před tím, než bude JWT generovat.
var //přihlášení emailem a heslem email = document .getElementById('email'), password = document .getElementById('password'), challenge = document .getElementById('secret'), //příprava dat pro JWT key = password.value.md5(), header = { typ: 'JWT', alg: 'HS256' }, payload = { mail: email, secret: secret }, jwt; //generování JWT jwt = KJUR.jws.JWS .sign(header.alg, JSON.stringify(header), JSON.stringify(payload), key.toNumberString(16) ) ;
Server musí mít přehled o tom, komu jaký text poslal, aby pak následně mohl ověřit, že se vrátil správně. Po úspěšném ověření si pak musí zapamatovat, že daný text byl již použit a je nadále neplatný. Díky tomu lze každý JWT použít jen jednou, protože změna secret
hodnoty by vyžadovala nové podepsání heslem (které útočník nebude znát, protože se po síti neposílá).
Pokud chcete challenge použít pro autologin přes COOKIE, znamenalo by to při každém přihlášení vygenerovat nové COOKIE.
Předmět
JWT specifikace uvádí ještě jednu hodnotu, která již neslouží k zabezpečení, ale pouze k unifikaci všech JWT. Tím je subject (předmět), který se zkracuje sub
. Měl by obsahovat unikátní klíč objektu, který je v payload uložen (takže například ID uživatele).
Všechny specifikované hodnoty (iss
, iat
, exp
a sub
) jsou volitelné a není nutné je uvádět. Pokud ale budete chtít uvést podobnou hodnotu (URL serveru, id uživatele, apod.), měli byste použít tyto názvy.