ASP.NET Identity při přihlášení vyžaduje uživatelské jméno. Jak ale zařídit, aby se uživatel mohl přihlásit i bez něj, pouze pomocí e-mailové adresy? Nebo aby separátní uživatelské jméno vůbec neměl? Ukážu vám třídy UserManager a SignInManager, jejichž změnou můžete v aplikaci udělat velké věci.

Toto je jedna ze dvou změn, které jsem od live streamu udělal v ukázkové aplikaci FutLabIS (tou druhou je přidání modálních dialogů pomocí CSS). Změnu najdete jako commit ec797e8.

Vlastnosti identifikující uživatele

ASP.NET Identity o uživatelích schraňuje tři údaje, použitelné pro jejich jednoznačnou identifikaci: Id, UserName a EmailAddress.

Id

Vlastnost Id je jedinečný automaticky generovaný identifikátor uživatele, který se po celou dobu jeho existence nemění. Ve výchozím nastavení je typu string (v MSSQL databázi má typ nvarchar(max)) a má hodnotu ve formátu GUID (ale uloženou jako řetězec). Typ lze snadno změnit, například na opravdový Guid nebo na int. Stačí podědit třídu reprezentující uživatele od IdentityUser<TKey> kde TKey je typ primárního klíče.

Například v ukázkové aplikaci FutLabIS kterou teď v live streamu píšu je třída ApplicationUser definována takto:

namespace Altairis.FutLabIS.Data {
    public class ApplicationUser : IdentityUser<int> {

        public bool Enabled { get; set; } = true;

        [Required, MinLength(2), MaxLength(2)]
        public string Language { get; set; }

    }
}

Jako primární klíč používá automaticky generovaný int (a má navíc vlastnosti Enabled a Language). ID uživatele byste měli používat všude, kde potřebujete uživatele na cokoliv relačně navázat. ASP.NET Identity ho používá třeba pro přiřazení rolí, externích loginů a podobně. Zatímco ostatní dva identifikátory (uživatelské jméno a e-mailová adresa) se případně mohou v čase měnit, ID se měnit nikdy nebude.

UserName

Vlastnost UserName je přihlašovací jméno. Na rozdíl od některých jiných systémů ASP.NET Identity umožňuje, aby si uživatel své jméno změnil. Je samozřejmě na vás, zda mu to dovolíte a dáte mu k tomu odpovídající UI, ale API to umožňuje. Raději ve své aplikaci s možností změny počítejte a uživatelské jméno nepoužívejte pro vazbu na jiné objekty.

EmailAddress

Vlastnost EmailAddress je e-mailová adresa, kterou ASP.NET Identity používá. Má specifické API pro potvrzení její změny a patrně budete chtít e-mailovou adresu využít třeba pro reset zapomenutého hesla. Pozor, ve výchozím nastavení není adresa potvrzována a nemusí být unikátní, tj. pod jednou adresou se může zaregistrovat několik uživatelů.

Pokud to nepokládáte za dobrý nápad (třeba při použití dále popisovaných řešení), můžete nastavení změnit při registraci služby v ConfigureServices:

services.AddIdentity<ApplicationUser, ApplicationRole>(options => {
    options.User.RequireUniqueEmail = true;
    options.SignIn.RequireConfirmedEmail = true;
})

Samostatný UserName: ano či ne?

Je otázkou, zda by vaše aplikace vůbec měla samostatné uživatelské jméno vyžadovat. Kloním se k závěru, že obecně nikoliv. Většina moderních systémů používá místo uživatelského jména e-mailovou adresu, vycházeje z premisy, že si uživatel nebude muset pamatovat další údaj.

Tento přístup má nicméně i dvě nevýhody:

E-mailová adresa místo uživatelského jména

Chcete-li používat e-mailovou adresu místo uživatelského jména, můžete to v ASP.NET Identity vyřešit pomocí vlastní třídy poděděné od UserManager<TUser>. V ní přepište metody pro práci s uživateli tak, aby při jejich vytváření nebo změně prostě vygenerovaly uživatelské jméno na základě e-mailové adresy. Já jsem si udělal metodu PrepareUser a tu volám všude, kde se s uživateli pracuje.

Třída ApplicationUserManager pak může vypadat třeba takto:

public class ApplicationUserManager : UserManager<ApplicationUser> {
    private readonly IDateProvider dateProvider;

    public ApplicationUserManager(IUserStore<ApplicationUser> store, IOptions<IdentityOptions> optionsAccessor, IPasswordHasher<ApplicationUser> passwordHasher, IEnumerable<IUserValidator<ApplicationUser>> userValidators, Enumerable<IPasswordValidator<ApplicationUser>> passwordValidators, ILookupNormalizer keyNormalizer, IdentityErrorDescriber errors, IServiceProvider services, ILogger<UserManager<ApplicationUser>> logger)
        : base(store, optionsAccessor, passwordHasher, userValidators, passwordValidators, keyNormalizer, errors, services, logger) {
    }

    public override Task<IdentityResult> CreateAsync(ApplicationUser user) {
        if (user is null) throw new ArgumentNullException(nameof(user));

        return base.CreateAsync(this.PrepareUser(user));
    }

    public override Task<IdentityResult> CreateAsync(ApplicationUser user, string password) {
        if (user is null) throw new ArgumentNullException(nameof(user));
        if (password == null) throw new ArgumentNullException(nameof(password));
        if (string.IsNullOrWhiteSpace(password)) throw new ArgumentException("Value cannot be empty or whitespace only string.", nameof(password));

        return base.CreateAsync(this.PrepareUser(user), password);
    }

    public override Task<IdentityResult> UpdateAsync(ApplicationUser user) {
        if (user is null) throw new ArgumentNullException(nameof(user));

        return base.UpdateAsync(this.PrepareUser(user));
    }

    public override Task<IdentityResult> ChangeEmailAsync(ApplicationUser user, string newEmail, string token) {
        if (user is null) throw new ArgumentNullException(nameof(user));
        if (newEmail == null) throw new ArgumentNullException(nameof(newEmail));
        if (string.IsNullOrWhiteSpace(newEmail)) throw new ArgumentException("Value cannot be empty or whitespace only string.", nameof(newEmail));
        if (token == null) throw new ArgumentNullException(nameof(token));
        if (string.IsNullOrWhiteSpace(token)) throw new ArgumentException("Value cannot be empty or whitespace only string.", nameof(token));

        return base.ChangeEmailAsync(this.PrepareUser(user, newEmail), newEmail, token);
    }

    private ApplicationUser PrepareUser(ApplicationUser user, string newEmail = null) {
        // Use e-mail as username
        if (string.IsNullOrEmpty(user.Email)) throw new ArgumentException("E-mail address must be specified.", nameof(user));
        user.UserName = newEmail?.ToLowerInvariant() ?? user.Email.ToLowerInvariant();
        return user;
    }

}

Pokud ji chcete použít všude, kde si necháte nainjectovat UserManager<ApplicationUser>, zaregistrujte ji do IoC/DI kontejneru pomocí metody AddUserManager:

services.AddIdentity<ApplicationUser, ApplicationRole>(...)
    .AddUserManager<Services.ApplicationUserManager>();

Ponechání uživatelského jména, ale umožnění přihlášení i pomocí e-mailu

V ukázkové aplikaci FutLabIS výše uvedené řešení použít nechci, protože tam uživatelé budou v rámci systému vystupovat pod svými přezdívkami (uživatelé vidí cizí rezervace). Nicméně pokládám za vhodné, aby uživatelé mohli pro přihlášení použít i svou e-mailovou adresu, nejenom uživatelské jméno.

V takovém případě je řešení podobné: vytvoříme vlastního potomka třídy SignInManager<TUser> a dopíšeme do něj požadovanou funkcionalitu. Podívejte se, jak vypadá v ukázkové aplikaci třída ApplicationSignInManager:

namespace Altairis.FutLabIS.Web.Services {
    public class ApplicationSignInManager : SignInManager<ApplicationUser> {
        public ApplicationSignInManager(UserManager<ApplicationUser> userManager, IHttpContextAccessor contextAccessor, IUserClaimsPrincipalFactory<ApplicationUser> claimsFactory, IOptions<IdentityOptions> optionsAccessor, ILogger<SignInManager<ApplicationUser>> logger, IAuthenticationSchemeProvider schemes, IUserConfirmation<ApplicationUser> confirmation) : base(userManager, contextAccessor, claimsFactory, optionsAccessor, logger, schemes, confirmation) { }

        public override async Task<SignInResult> PasswordSignInAsync(string userName, string password, bool isPersistent, bool lockoutOnFailure) {
            var result = await base.PasswordSignInAsync(userName, password, isPersistent, lockoutOnFailure);

            // Allow sign-in using e-mail address, not only user name
            if (result == SignInResult.Failed && userName.Contains('@')) {
                var userFoundByEmail = await this.UserManager.FindByEmailAsync(userName);
                if (userFoundByEmail != null) result = await base.PasswordSignInAsync(userFoundByEmail, password, isPersistent, lockoutOnFailure);
            }
            return result;
        }

        public override async Task<bool> CanSignInAsync(ApplicationUser user) {
            if (user is null) throw new System.ArgumentNullException(nameof(user));

            return await base.CanSignInAsync(user).ConfigureAwait(false)
                ? user.Enabled
                : false;
        }

    }
}

Přepsal jsem v ní dvě metody. Metoda CanSignInAsync je mimo téma tohoto článku a zajišťuje, že se smí přihlásit pouze uživatel, jehož vlastnost Enabled je nastavena na true. ASP.NET Identity ve výchozím nastavení nepodporuje blokování uživatelů a tímto způsobem ho lze zařídit.

Metoda CanSignInAsync je jednoduché API, které umožní zablokovat přihlášení uživatele tím, že vrátí false. Neumožňuje ale sdělit volajícímu kódu důvod proč nebyl uživatel přihlášen, vrátí prostě SignInResult.NotAllowed. Pokud byste chtěli dál poslat zdůvodnění, přepište místo toho metodu PreSignInCheck, která umí vrátit konkrétní důvod (vlastní SignInResult).

Pro nás je podstatná metoda PasswordSignInAsync, která se pokusí uživatele přihlásit pomocí kombinace zadaného uživatelského jména a hesla. V ní se nejdříve pokusím uživatele přihlásit standardním systémem, voláním bázové metody. Pokud se mi to nepovede, výsledek je Failed. Což může být způsobeno několika příčinami, z nichž jednou je neexistence uživatele.

Pokud bylo přihlášení neúspěšné a uživatelské jméno obsahuje znak @, pojme můj kód podezření, že uživatel nezadal své jméno ale e-mailovou adresu. V takovém případě se pokusí najít uživatele s danou e-mailovou adresou voláním metody FindByEmailAsync a pokud se mu to podaří, pokusí se uživatele přihlásit se zadaným heslem.

Naši vlastní třídu pak samozřejmě musíme zaregistrovat do IoC/DI kontejneru:

services.AddIdentity<ApplicationUser, ApplicationRole>(...)
    .AddSignInManager<Services.ApplicationSignInManager>();

Závěr

Vše výše uvedené můžeme udělat "jednodušeji" přímo v obsluze akce přihlášení, vytváření uživatele atd. Nicméně to není dobrý nápad, protože se může stát, že uživatele budeme zakládat nebo přihlašovat jinak, třeba při hromadném importu nebo tak něco, a aplikace se bude chovat nekonzistentně.

Mnohem vhodnější je použít předem připravené extensibility pointy, které nám ASP.NET Identity nabízí v podobě možnosti dědit z výše uvedených tříd.