Bezpečné přihlašování uživatelů

12. 4. 2006
Doba čtení: 5 minut

Sdílet

Aktualizováno: Aktualizováno (13. 4. 2006 0:00)
Pomocí protokolu HTTPS lze zajistit šifrovaný přenos všech informací a ideálně se tak hodí mimo jiné pro přihlašovací formuláře. Pokud tento protokol nemůžete použít (u malých projektů proto, že vám nevyjde vstříc hosting, u velkých z výkonnostních důvodů), přenáší se všechna data nešifrovaně a zdatný uživatel je může po cestě odposlouchávat. Jak jistě víte, nedávno byl obětí takového útoku Seznam.cz. Bezpečné přihlašování se ale dá zajistit i na nezabezpečeném protokolu.

Technika výzva-odpověď funguje tak, že server pošle klientovi výzvu, klient k této výzvě připojí své heslo a serveru pošle otisk tohoto spojení. Server na své straně provede totéž a pokud výsledky odpovídají, tak uživatele přihlásí, jinak ho odmítne. Bezpečnost tohoto řešení je založena na tom, že server každou výzvu posílá jen jednou a pokud se útočníkovi podaří odpověď klienta zachytit, k ničemu mu to neposlouží, protože stejnou výzvu už server nikdy nepošle.

Realizace pomocí PHP, MySQL a JavaScriptu

K technické realizaci tohoto řešení budeme potřebovat na straně serveru i klienta funkci na výpočet otisku hesla spojeného s výzvou. V PHP i dalších serverových jazycích jsou hashovací funkce k dispozici již v základu, takže máme situaci poměrně jednoduchou, na straně klienta budeme muset sáhnout po externí knihovně – např. JavaScript pro MD5 i SHA-1 nabízí v BSD licenci Paul Johnston.

Pro spojení výzvy a hesla by se dalo použít prosté zřetězení, o něco bezpečnější by ale mělo být použití kódu HMAC. V JavaScriptové knihovně je tento algoritmus už implementován, v PHP si funkci budeme muset napsat sami, naštěstí je poměrně jednoduchá:

<?php
function hmac_md5($key, $data) {
    $blocksize = 64;
    if (strlen($key) > $blocksize) {
        $key = pack("H*", md5($key));
    }
    $key = str_pad($key, $blocksize, chr(0x00));
    $k_ipad = $key ^ str_repeat(chr(0x36), $blocksize);
    $k_opad = $key ^ str_repeat(chr(0x5c), $blocksize);
    return md5($k_opad . pack("H*", md5($k_ipad . $data)));
}
?> 

Dále musíme zajistit ukládání použitých výzev. Pokud nám nevadí, že výzvy budou ze spojité řady (takže kdokoliv bude moci poznat, kolikrát se náš přihlašovací formulář použil), stačí nám k tomu jednoduchá tabulka:

CREATE TABLE challenges (
    id int NOT NULL AUTO_INCREMENT,
    created datetime NOT NULL,
    PRIMARY KEY (id)
); 

Do této tabulky budeme při každém zobrazení přihlašovacího formuláře vkládat nový řádek. Při jeho odeslání se do této tabulky podíváme a pokud v ní výzvu nalezneme, ověříme heslo uživatele. Pokud souhlasí, tak výzvu smažeme, což je možné provádět i u zastaralých řádků (např. starších než 1 den), aby velikost tabulky zůstávala v rozumných mezích.

Zbývá vytvořit HTML formulář a celé to spojit dohromady:

<script type="text/javascript" src="md5.js"></script>
<script type="text/javascript">
function md5form(f)
{
    f['password_hmac'].value = hex_hmac_md5(hex_md5(f['password'].value), f['challenge'].value);
    f['password'].disabled = true;
    f.submit();
    f['password'].disabled = false;
    return false;
}
</script>
<form action="" method="post" onsubmit="return md5form(this);">
<fieldset>
<?php
mysql_query("INSERT INTO challenges (created) VALUES (NOW())");
$challenge = mysql_insert_id();
?>
<input type="hidden" name="challenge" value="<?php echo $challenge; ?>" />
<input type="hidden" name="password_hmac" value="" />
Login: <input name="login" />
Heslo: <input type="password" name="password" />
<input type="submit" value="Přihlásit se" />
</fieldset>
</form> 

Při zapnutém JavaScriptu se do skrytého formulářového pole password_hmac vloží otisk kombinace výzvy a MD5 hesla. MD5 hesla se používá proto, že na serveru je vhodné mít z bezpečnostních důvodů uložen pouze otisk hesla, takže při použití samotného hesla by server neměl jak spočítat výsledný otisk. Poté se zakáže pole se zadaným heslem (což způsobí, že se toto pole s formulářovými daty nebude posílat) a formulář se odešle. Po jeho odeslání se pole s heslem opět povolí, což se dělá jen kvůli tomu, aby se uživatel po neúspěšném přihlášení mohl vrátit v historii a heslo opravit. Pokud má uživatel JavaScript vypnutý, přenese se heslo jako prostý text, pomocí značky <noscript> je možné ho na toto riziko upozornit.

Na straně serveru můžeme heslo ověřit tímto kódem:

<?php
$logged = false;
$login = (get_magic_quotes_gpc() ? $_POST["login"] : addslashes($_POST["login"]));
$row = mysql_fetch_assoc(mysql_query("SELECT password_md5 FROM users WHERE login = '$login'"));
if ($_POST["password_hmac"]) {
    $valid = (hmac_md5($row["password_md5"], $_POST["challenge"]) == $_POST["password_hmac"]);
} else {
    $valid = ($row["password_md5"] == md5($_POST["password"]));
}
if ($valid) {
    mysql_query("DELETE FROM challenges WHERE id = " . intval($_POST["challenge"]));
    if (mysql_affected_rows()) {
        $logged = true;
    }
}
?> 

Pokud klient poslal pole password_hmac, ověříme heslo na jeho základě, jinak se spokojíme s textovým tvarem hesla. Za přihlášeného uživatele označíme tehdy, pokud souhlasí hesla a v tabulce challenges se nám podaří smazat zaslanou výzvu.

Poznámka k heslům s diakritikou

Funkce charCodeAt, kterou používá JavaScriptová knihovna, pracuje s Unicodovými kódy znaků. Knihovna z těchto kódů ve výchozím nastavení bere jen spodních 8 bitů (takže řada znaků koliduje), snadno se dá ale přenastavit tak, aby pracovala se 16 bity. Pokud již ale máte uložené otisky hesel uživatelů v jiném kódování, musí se kódování poměrně pracně převést – knihovna MD5 upravená pro Latin-2.

ict ve školství 24

Závěr

S vynaložením nijak zvláštního úsilí můžeme zabezpečit své přihlašovací formuláře proti odposlechu. Použití HTTPS má samozřejmě i nadále svůj smysl, protože jednak šifruje všechna přenášená data a jednak dovolí ověřit i identitu protistrany. Technikou výzva-odpověď se ale dá bezpečnost přihlašovacích formulářů zlepšit i tam, kde použití HTTPS není z jakéhokoliv důvodu možné.

Kromě bezpečného přenášení hesla je vhodné zaměřit se i na jeho bezpečné ukládání na straně serveru a na vhodný způsob pamatování informace o přihlášenosti uživatele. Odchytávání hesel a jejich zveřejnění je ale jistě mediálně nejvděčnější…

Doplnění

Jak správně poznamenali čtenáři v diskusi, je tato technika velice citlivá na bezpečnost dat uložených v databázi. Proto nabízím její vylepšení:

  1. U každého uživatele bude uložen login, challenge a  md5(hmac_md5(password, challenge)).
  2. Při přihlašování se AJAXem zjistí, jaký challenge uživatel naposledy použil, a pošle se hmac_md5(password, old_challenge)md5(hmac_md5(password, new_challenge)).
  3. Na serveru se navíc ověří, jestli md5(old_hmac) souhlasí s tím, co je uloženo v databázi, a pokud ano, přepíše se to novými hodnotami.

Autorem této myšlenky je Paul Jonhston. Přikládám Proof of Concept.

Posílání výzev ze souvislé řady má kromě již zmíněné možnosti zjištění počtu zobrazení přihlašovacího formuláře ještě jednu nevýhodu – skript se může stát obětí útoku DoS. Pokud tomu chceme zabránit a nechceme si navždy pamatovat všechny náhodně vygenerované výzvy, můžeme výzvu ukládat do session proměnné.

Autor článku

Autor se živí programováním v PHP, podílí se na jeho oficiální dokumentaci, vyučuje ho na MFF UK a vede odborná školení. Poznámky si zapisuje na weblog PHP triky.