Pro řadu scénářů v ASP.NET Identity potřebujeme nějaký potvrzovací kód (token) který pošleme uživateli a on pomocí něj potvrdí, že je skutečně tím, za koho se vydává. V tomto článku budeme řešit tři takové situace:

Ve všech zmíněných případech se postupuje následovně:

  1. Pomocí metody např. GeneratePasswordResetTokenAsync vygeneruje UserManager patřičný token.
  2. Vygenerujeme URL, která tento token obsahuje, typicky v query stringu, něco jako https://www.example.com/reset-password?user=johndoe&token=XXXXX.
  3. Zašleme uživateli e-mailem zprávu s vygenerovanou adresou a instrukcí, že má tuto stránku navštívit, aby si zresetoval heslo.
  4. Na této stránce zavoláme metodu např. ResetPasswordAsync, v níž ověříme kód a zresetujeme heslo nebo potvrdíme e-mail.

Strategie generování tokenů

Existují v zásadě dvě možnosti (strategie) jak tokeny generovat.

První možnost je, že token je náhodně vygenerován (je bezvýznamový) a někde na straně serveru je uloženo, že token ABCDEF slouží třeba k potvrzení resetu hesla uživatele XYZ. Výhodou těchto tokenů je, že mohou být (relativně) krátké, protože stačí zařídit, aby nemohlo dojít k brute force útoku nebo konfliktu. Zásadní nevýhodou ovšem je nutnost udržovat na straně serveru odpovídající úložiště.

Druhá možnost je místo úložiště použít kryptografii. Token zaslaný uživateli obsahuje nějaké "hodnotné" informace (třeba že jde o reset hesla uživatele s ID 1234) a tato informace je digitálně podepsána a zašifrována. To zajistí, že token není možné podvrhnout.

Výchozí implementaci v ASP.NET Identity představuje třída DataProtectorTokenProvider, jejíž zdrojový kód najdete na GitHubu. Ta pro ochranu tokenu používá druhý zmíněný způsob. Vygeneruje token obsahující účel a ID uživatele a poté ho ochrání (zašifruje a podepíše) pomocí ASP.NET Data Protection.

Výsledkem je hromádka binárních dat, které se zakódují pomocí Base64.

Problémy s Base64

Base64 je jedna z mnoha možností, jak binární hodnoty zakódovat do tisknutelných znaků. Používá se přitom "šedesátičtyřková" číselná soustava, která obsahuje velká a malá písmena anglické abecedy, číslice a znaky plus a lomítko. Výhodou Base64 je, že jde o univerzální, standardizovaný mechanismus, který je dostupný na všech platformách. Má také relativně malý overhead (33 %).

Má ale i signifikantní nevýhody:

Tyto speciální znaky mohou představovat problém třeba v některých e-mailových programech, které nesprávně automaticky generují z odkazů hyperlinky nebo zalamují řádky.

Base32 a ZBase32

Existují i různé jiné další možnosti kódování. Jedním z nich je Base32. To používá omezenější abecedu o 32 znacích. V závislosti na konkrétní implementaci může obsahovat různou kombinaci písmen a číslic. Často bývá použita kombinace, která neobsahuje znaky, které jsou vizuálně záměnné, jako například písmeno O a nulu 0. To sice vede k většímu overheadu, ale řeší problém se "speciálními" znaky.

Variantou je kódování ZBase32, které jako "human-oriented base-32 encoding" navrhl Phil Zimmermann. Používá pouze běžná písmena a číslice a nepotřebuje padding. Nevýhodou je, že výsledek je delší, než v případě Base64, ale s tím dokážeme žít.

Vlastní implementace Token Providera

Výše zmíněnou implementaci lze nahradit vlastní. Zdrojový kód je k dispozici, takže bychom jej mohli prostě zkopírovat a Base64 kódování nahradit vlastním ZBase32. S licencí není problém, Apache licence takový postup umožňuje. Nicméně museli bychom duplikovat spoustu kódu, navíc implementace využívá interní extensions pro logování... A vlastně ani nechceme do funkce příliš zasahovat, jenom použít jiný druh kódování. Takže jsem napsal wrapper, který volá funkce výchozí implementace a jenom je překóduje do ZBase32.

Zdrojový kód mé třídy ZBase32DataProtectorTokenProvider vypadá takto:

public class ZBase32DataProtectorTokenProvider<TUser> : DataProtectorTokenProvider<TUser> where TUser : class {
    public ZBase32DataProtectorTokenProvider(IDataProtectionProvider dataProtectionProvider, IOptions<DataProtectionTokenProviderOptions> options, ILogger<DataProtectorTokenProvider<TUser>> logger)
        : base(dataProtectionProvider, options, logger) { }

    public async override Task<string> GenerateAsync(string purpose, UserManager<TUser> manager, TUser user) {
        var b64string = await base.GenerateAsync(purpose, manager, user).ConfigureAwait(false);
        var bytes = Convert.FromBase64String(b64string);
        return Base32Encoding.ZBase32.GetString(bytes);
    }

    public override Task<bool> ValidateAsync(string purpose, string token, UserManager<TUser> manager, TUser user) {
        var bytes = Base32Encoding.ZBase32.ToBytes(token);
        var b64string = Convert.ToBase64String(bytes);
        return base.ValidateAsync(purpose, b64string, manager, user);
    }
}

Pro vlastní kódování používám knihovnu Wiry.Base32. Metody GenerateAsync a ValidateAsync jenom obalují bázovou třídu a překódovávají token z/do Base32.

Dále pak je třeba ASP.NET Identity říct, že má náš token provider použít. To se děje při registraci identity, typicky tedy v metodě ConfigureServices třídy Startup:

services.AddIdentity<ApplicationUser, ApplicationRole>(options => {
    options.Tokens.ChangeEmailTokenProvider = "ZBase32";
    options.Tokens.EmailConfirmationTokenProvider = "ZBase32";
    options.Tokens.PasswordResetTokenProvider = "ZBase32";
})
    .AddDefaultTokenProviders()
    .AddTokenProvider<ZBase32DataProtectorTokenProvider<ApplicationUser>>("ZBase32");

V options.Tokens můžeme nastavit, pro jaké typy tokenů se má použít jaký provider. Používají se totiž různé, pro tokeny do URL je třeba jiný mechanismus než třeba pro tokeny zasílané SMSkou, které uživatel bude odesílat. Říkáme tedy, že se má použít provider jménem ZBase32.

Metodou AddDefaultTokenProviders přidáme výchozí implementace pro všechny účely a pak pomocí AddTokenProvider zaregistrujeme pod jménem ZBase32 naši implementaci.

A to je celé. Aplikaci stačí restartovat a místo Base64 se pro generování tokenů použije jednodušší ZBase32.