Reset zapomenutého hesla – jak to dělat správně?

Ačkoliv existují i lepší varianty autentizace uživatelů, hesla stále bezpečně převažují. A uživatelé hesla rádi zapomínají a dobré systémy by s tím měly počítat a měly by tudíž umožnit se ztrátou hesla se nějakým způsobem vypořádat. A to pokud možno bezpečně a automatizovaně. Zdálo by se, že tento úkol je tak jednoduchý, že na něm není co zkazit. Praktická zkušenost z různých webů mne ale přesvědčuje, že realita je bohužel odlišná.

Tento článek se zabývá běžnými internetovými systémy, které mají mnoho víceméně anonymních uživatelů a nespravují data, která by byla z hlediska bezpečnosti extrémně kritická. Jiná bude situace např. v případě, že zapomenete heslo k internetovému bankovnictví a nebo do systému v rámci firemní sítě. V prvním případě je z bezpečnostního hlediska vyloženě žádoucí, aby byla pro reset hesla nutná osobní návštěva pobočky nebo jiné velmi solidní prokázání totožnosti. Ve druhém případě zase patrně existují interní mechanismy, jakými se to řeší.

Základními parametry kvalitního resetu hesla by měla být jednoduchost pro uživatele, jednoduchost implementace provozovatelem a rozumná míra bezpečnosti. Reset hesla přitom sestává ze dvou kroků, přičemž v každém z nich je možné udělat chybu: ověření totožnosti žadatele a vlastní zpracování požadavku.

Ověření totožnosti uživatele

Prvním krokem je nezbytnost nějak si ověřit, že osoba žádající o reset hesla je skutečně oprávněná tak učinit. Existují v zásadě dva mechanismy, které lze obecně použít.

První je mechanismus kontrolní otázky, který je např. vestavěný (jako volitelný) v systému Membership providerů v ASP.NET. Při registraci uživatel kromě hesla vyplňuje také kontrolní otázku a odpověď na ni. Pro bezpečnost celého systému je kritické, nakolik bude správně zvolena bezpečnostní otázka a její odpověď. Pokud necháme volbu otázky na uživateli, je dost pravděpodobné, že většina otázek bude buďto triviálních a nebo nesmyslných, protože uživatelům se často nechce přemýšlet. Proto řada provozovatelů neumožňuje otázku určit zcela volně, ale ze seznamu předdefinovaných možností. Bohužel, ty jsou většinou dost tragické.

Téměř typickou je otázka na matčino křestní jméno za svobodna. Pominu teď rovinu, že by mohla být pokládána za genderově nekorektní, protože implicitně předpokládá, že matka byla vdaná a že přejala jméno svého manžela. Problémů je i tak dost. Možná jsem výjimka, ale já skutečně nevím, jak se moje matka jmenovala za svobodna. Asi bych to dokázal nějak dohledat, ale fakt to nevím, mám dost vágní představu, ale ani nevím, jak se to píše. Tento údaj je také poměrně snadno zjistitelný pomocí rozličných metod sociálního inženýrství. Kromě toho, dneska je na Internetu každý i jeho máma, takže dohledat jméno za svobodna není nijak nepřekonatelný problém. Tudy ne.

Další časté otázky jsou třeba "oblíbená barva". Pravděpodobnost úspěšného útoku letmým odhadem značná, pokud tedy vaše oblíbená barva není třeba starorůžová. To samé pokud se týče dotazů na oblíbené sportovní týmy a podobně. Dotazy typu "jméno prvního psa" jsou opět v době Facebooku a blogů, kde na sebe vykecáme všechno, také bezcenné. Ostatně, v tom podle mého názoru spočívá údajná "nebezpečnost" Facebooku – ne v prozrazení údajů samých, ale v jejich zneužití v systémech, kteté je pokládají za příliš soukromé.

Obecně mechanismus kontrolní otázky pokládám za velmi problematický, protože má-li být skutečně k něčemu, klade velké nároky na uživatele. A to je v otázkách bezpečnosti vždycky špatně. Navíc způsob, jakým je obvykle implementován, tedy výběr z několika špatně vybraných otázek, je vyloženě nebezpečný. I zodpovědný uživatel je nucen buďto poctivě odpovědět na pitomou otázku a nebo napsat jako odpověď nesmysl a připravit se tak o možnost resetu hesla.

Druhým možným mechanismem je využití e-mailové adresy. Vycházíme při něm z předpokladu, že pouze oprávněný uživatel má přístup k e-mailové schránce, kterou uvedl při registraci. Tento předpoklad sice nemusí být stoprocentně pravdivý, ale u běžných internetových služeb zpravidla žádný lepší nemáme. Na e-mailovou adresu tedy pošleme heslo nebo nějaký kód, který umožní jeho reset.

Vlastní zpracování požadavku

Poté, co jsme si ověřili totožnost uživatele, je nezbytné se nějakým způsobem vypořádat se zapomenutým heslem. Existují v zásadě tři způsoby, jakými tak lze učinit.

První způsob je, že uživateli sdělíme (třeba pošleme e-mailem) jeho heslo. Tento způsob je nejméně vhodný, a to hned z několika důvodů. V první řadě znamená, že předmětný systém heslo uchovává v otevřeném formátu. Pokud by došlo k narušení jeho bezpečnosti, mohou hesla uživatelů v otevřeném tvaru uniknout. S výjimkou velmi speciálních případů (kdy jsou používány autentizační metody, které vyžadují znalost hesla v otevřeném tvaru) je takový postup velmi nevhodný, mnohem rozumnější je hesla uchovávat pouze ve formě hashů se solí. Zasílání hesel e-mailem je obecně velmi nevhodné, protože uživatelé mají tendence e-maily archivovat a hesla používat dlouhodobě a opakovaně. Vyzrazení, ke kterému může dojít snadno, může mít dalekosáhlé následky, protože ohrozí i ostatní účty uživatele v jiných systémech, které samy o sobě mohou být zabezpečeny dostatečně.

Druhý způsob spočívá v tom, že po obdržení žádosti o reset hesla systém vygeneruje náhodné heslo a to pošle e-mailem na registrovanou adresu. Tento postup je lepší v tom, že nevyzrazuje původní heslo, ale fakticky se může stát nástrojem pro obtěžování uživatele a nebo pro formu Denial of Service útoku. Pokud systém bude reagovat na žádost o reset hesla tím, že ho okamžitě změní, lze kterémukoliv uživateli znepřístupnit systém (nebo mu jeho použití minimálně znepříjemnit) prostě tím, že budu opakovaně automaticky posílat žádosti o reset hesla.

Nejvhodnější postup tedy spočívá v tom, že se heslo ve skutečnosti nezmění, ale e-mailem se pošle jenom kód, po jehož zadání bude uživatel moci nastavit heslo nové. Zpravidla se to realizuje tak, že se uživateli pošle odkaz "tady na téhle adrese si můžete nastavit nové heslo", přičemž URL stránky obsahuje kód pro změnu hesla.

Tento kód by měl být generován dostatečně bezpečným způsobem, tedy nesmí být možné ho odhadnout nebo ovlivnit. Jeho platnost by také měla být omezená, aby nemohl být použit s přílišným časovým odstupem a nebo opakovaně. Jednou z možností je generovat kód náhodně a uchovávat jej v nějaké databázi. Mnohem elegantnější a na logistiku méně náročnou možností je použití mé oblíbené techniky HMAC – Hash Message Authentication Code, o níž jsem na ASPNET.CZ již psal před lety.

Sada extension methods pro generování kódu pro reset hesla

Napsal jsem drobnou třídu, která bezpečný proces resetu hesla výrazně usnadňuje:

using System;
using System.Configuration;
using System.IO;
using System.Web.Security;

public static class ExtensionMethods {

    public static string CreatePasswordResetCode(this MembershipUser user) {
        if (user == null) throw new ArgumentNullException("user");

        // Prepare data to compute hash from (username + date)
        byte[] data;
        using (var ms = new MemoryStream()) {
            using (var bw = new BinaryWriter(ms)) {
                bw.Write(user.UserName.ToLower());
                bw.Write(Math.Max(user.LastActivityDate.ToBinary(), user.LastLoginDate.ToBinary()));
            }
            data = ms.ToArray();
        }

        // Compute hash
        using (var hmac = new System.Security.Cryptography.HMACSHA1()) {
            hmac.Key = GetMacKey();
            var hash = hmac.ComputeHash(data);
            return hash.ToUrlSafeBase64String();
        }
    }

    public static bool VerifyPasswordResetCode(this MembershipUser user, string code) {
        if (user == null) throw new ArgumentNullException("user");
        if (code == null) throw new ArgumentNullException("code");

        return code.Equals(user.CreatePasswordResetCode(), StringComparison.Ordinal);
    }

    public static string ToUrlSafeBase64String(this byte[] data) {
        var s = Convert.ToBase64String(data);
        s = s.Replace('+', '-');
        s = s.Replace('/', '_');
        s = s.TrimEnd('=');
        return s;
    }

    // Private helper methods

    private static byte[] GetMacKey() {
        return Convert.FromBase64String(ConfigurationManager.AppSettings["PasswordResetKey"]);
    }

}

Obsahuje čtyři metody:

  • CreatePasswordResetCode je extension metoda, která  rozšiřuje třídu MembershipUser. Vygeneruje string, který je založen na uživatelském jménu, datumu poslední aktivity a tajném klíči. Tento string je nutné zaslat uživateli e-mailem.
  • VerifyPasswordResetCode je další extension metoda, která rozšiřuje třídu MembershipUser. Ověří, zda je předaný string platný kód pro reset hesla (je triviální, jenom porovná předaný řetězec s tím, který vygeneruje předchozí třída).
  • ToUrlSafeBase64String je interně používaná metoda, která převede pole bajtů na URL-safe Base64. Běžné Base64 kódování převede libovolné pole bajtů na tisknutelné znaky. Base64 abeceda nicméně používá znaky "+" a "/", které mají v URL speciální význam. Proto se běžně nahrazují znaky "-" a "_", které jsou pro použití v rámci URL bezpečné. Base64 kódování také kóduje bajty "po třech" a pokud jich není správný počet, přidá na konec jeden nebo dva znaky "=" jako padding. Vzhledem k tomu, že hashe mají konstantní délku, můžeme v tomto případě tyto znaky z konce řetězce odstranit. Tato metoda se používá pro vytvoření kódu, může být ale užitečná i jinak, proto jsem ji zpřístupnil jako extension metodu pro pole bajtů.
  • GetMacKey je privátní metoda, která vrací soukromý klíč. To je jakákoliv náhodně vygenerovaná hromádka bajtů, která musí mít jenom dvě vlastnosti: nesmí být odhadnutelná a nesmí se mezi vygenerováním kódu a jeho ověřením změnit. Pro účely použití třídou HMACSHA1 může mít jakoukoliv délku, já osobně používám obvykle délku shodnou s délkou výsledného hashe (v tomto případě tedy 160 bitů). Typicky je takový klíč součástí konfigurace. V mém případě jsem pro ukládání použil kolekci appSettings, kam jsem uložil náhodně vygenerovanou hodnotu (k jejímu vytvoření můžete použít např. ASP.NET Chaos Generator).

Použití této třídy je jednoduché. Na základě uživatelem zadaných údajů (typicky uživatelské jméno nebo e-mail) si z membership providera vytáhnete přes metodu Membership.GetUser instanci třídy MembershipUser, která reprezentuje daného uživatele. Na této instanci zavoláte extension metodu CreatePasswordResetCode a výsledný kód pošlete uživateli e-mailem. Na potvrzovací stránce opět získáte patřičného uživatele a ověříte si, že je kód správný, pomocí metody VerifyPasswordResetCode. Pokud ano, umožníte uživateli, aby nastavil nové heslo.

Kód pro změnu hesla je závislý na dvou neměnných údajích (tajný klíč a uživatelské jméno) a jednom proměnlivém. Tím je datum posledního použití účtu. Vybírám to větší (novější) datum z data poslední aktivity a data posledního přihlášení. Někteří membership provideři totiž datum poslední aktivity neaktualizují (třeba z výkonových důvodů). Použití datumu je nicméně důležité, protože zajistí, že kód se stane automaticky neplatným při prvním úspěšném přihlášení, tj. po změně hesla a nebo i pokud si uživatel mezitím na heslo vzpomněl a přihlásil se pod starým.

Trochu diskutabilní je ve výše uvedeném kódu použití SHA-1, tedy algoritmu, který je obecně pokládán za již mírně zastaralý. Bylo by snadné (a z bezpečnostního hlediska vhodnější) použít novější algoritmy z rodiny SHA-2, třeba SHA-256 nebo SHA-512. Ty by ale generovaly výrazně delší potvrzovací kódy, což by mohlo znamenat problémy při tvorbě potvrzovacího URL (problémy se zalomením řádků v e-mailových klientech a podobně). Vzhledem k tomu, že použití SHA-1 nepředstavuje bezprostřední hrozbu a tento scénář závislý na e-mailu se stejně nehodí pro "high security" aplikace, dal jsem přednost kratšímu hashi. Nicméně v případě potřeby je možné jenom zaměnit třídu HMACSHA1 např. za HMACSHA256.

  • Altairis
  • Nemesis
  • Microsoft MVP
  • IIS
  • ASP.NET