ASP.NET Identity 2.1 do MVC projektu s vlastní implementací UserStore (přes repositories)

Tento post popisuje jeden z mých prvních pokusů s ASP.NET Identity, které jsem chtěl zapojit do N-tier projektu s odděleným doménovým modelem, datovou vrstvou nad EntityFrameworkem, repositories, unit-of-work, atp. Webový projekt zde je abstrahován od Entity Frameworku a nemá přístup k DbContextu (nemá referenci na EntityFramework.dll).

Rychle se tedy ukázalo, že se musím vydat cestou vlastní implementace UserStore (resp. custom storage provider), který by na rozdíl od Microsoftí implementace (z balíčku Microsoft.AspNet.Identity.EntityFramework) neměl dependency na DbContext, ale na Repositories.

Relevantní články

Před zahájením implementace doporučuji zorientovat se trochu v problematice:

Pozor také, že ASP.NET Identity existuje v několika verzích a starší články se odkazují na starší verze. Verze 1.0 byla pouze základem, např, s omezením na string-klíče na IUser a IRole. Verze 2.x je aktuální podoba pro ASP.NET 4.x a mimo IUser<TKey>, IRole<TKey> interface s volbou typu klíče doplňuje i podporu zamykání účtů a spoustu dalších drobností. ASP.NET Identity 3.x je ve vývoji pro ASP.NET 5. Svět ASP.NET je nyní hodně dynamický a „kvalita“ dokumentace tomu bohužel odpovídá.

Výchozí struktura projektů

DomainClasses

  • POCO entity
  • nereferencuje skoro nic (rozhodně ne EntityFramework)
  • sem budeme umisťovat naše entity User, Role, …

DataLayer

  • persistence doménových tříd pomocí EntityFrameworku
  • mapování entit na DB, DbContext
  • referencuje DomainClasses, Entity Framework
  • sem musíme našim entitám User, Role, … nastavit mapování na DB

Repositories

  • prostě repositories :-) a související (UnitOfWork, atp.
  • referencuje DomainClasses, DataLayer
  • sem doplníme UserRepository, RoleRepository, …

Services

  • aplikační logika, WCF služby, fasády pro UI, …
  • referencuje DomainClasses, Repositories
  • sem budeme umisťovat naši implementaci UserStore s napojením na Repositories

Web

  • klasický ASP.NET MVC projekt
  • referencuje DomainClassses, Services, Repositories, nikoliv však EntityFramework
  • …a tady „to“ celé pomocí UserManageru použijeme
  • a propojíme s OWIN authentizací (cookies-based)

1. Instalace NuGet balíčků

Nejprve musíme do naší solution nainstalovat příslušné NuGet balíčky.

Microsoft.AspNet.Identity.Core

Tento NuGet balíček je základem celého ASP.NET Identity a musíme ho dostat skoro všude (bohužel!)

  • do DomainClasses – budeme potřebovat interfaces IUser, IRole. Dalo by se obejít bez těchto interfaces, ale museli bychom potom buď dodefinovat interfaces na nějakém potomkovi vytvořeném v Services pro účely propojení s ASP.NET Identity, nebo bychom museli pro ASP.NET Identity vytvořit třídy nové a na doménové třídy (entity) je mapovat (např. pomocí AutoMapperu)
  • dp DataLayer, Repositories – pořád kvůli interfaces IUser, IRole
  • do Services – budeme potřebovat pro IUserStore, IRoleStore, IUserRoleStore, …
  • do Web – sem hlavně ;-)

Balíček Microsoft.AspNet.Identity.Core naštěstí nemá žádné další závislosti, takže nám do DomainClasses, DAL, Repositories a Services nedotáhne žádný další „bordel“.

Microsoft.AspNet.Identity.Owin

OWIN implementace ASP.NET Identity do projektu Web.

Přitáhne sebou spoustu dependencies jako OWIN a další související balíčky Microsoft.Owin.Security.

Microsoft.Owin.Host.SystemWeb

Aby nám OWIN fungoval na „legacy“ System.Web. :-))´

2. Vytvoření entit User, Role + persistence do DB v DAL + Repositories

Do projektu DomainClasses vytvoříme entity User a Role.

User implementuje rozhraní IUser<TKey>, které naštěstí předepisuje jen TKey Id a string UserName. Další properties jsou na nás, později sem přidáme třeba něco na uložení hashovaného hesla, atp.

public interface IUser<out TKey>
{
    TKey Id { get; }
    string UserName { get; set; }
}

User pak může vypadat třeba takto:

public class User : IUser<int>
{
  [Required]
  public int Id { get; set; }

  [Required]
  public string UserName { get; set; }

  public ICollection<Role> Roles { get; set; }
  
  public string PasswordHash { get; set; }
}

Role implementuje obdobné rozhraní IRole<TKey>, které předpisuje properties TKey Id a string Name.

public interface IRole<out TKey>
{
    TKey Id { get; }
    string Name { get; set; }
}

Role pak může vypadat takto:

public class Role : IRole<int>
{
  [Required]
  public int Id { get; set; }

  [Required]
  public string Name { get; set; }
}

Dle vlastního způsobu implementace přidáme do projektu DataLayer persistenci nových entit (mapování EntityTypeConfiguration<>, IDbSet<> do DbContext, atp.). Stejně tak svým způsobem vytvoříme příslušné Repositories. Ani jedno není předmětem tohoto článku – pěkně v kostce viz třeba PluralSight: Entity Framework in the Enterprise (Julie Lerman).

3. Implementace UserStore, popř. RoleStore

UserStore opět není nic jiného, než naše třída, která implementuje jedno nebo více rozhraní, podle toho, co všechno od ASP.NET Identity očekáváme. Jedná se víceméně o CRUD operace, popř. několik dalších „storage“ operací, jako např. vyhledávání dle UserName, atp., vše pěkně připraveno pro async:

public interface IUserStore<TUser, in TKey> : IDisposable where TUser: class, IUser<TKey>
{
    Task CreateAsync(TUser user);
    Task DeleteAsync(TUser user);
    Task<TUser> FindByIdAsync(TKey userId);
    Task<TUser> FindByNameAsync(string userName);
    Task UpdateAsync(TUser user);
}

UserStore následně předhazujeme jako dependency do UserManageru, což je středobod ASP.NET Identity. UserManager obsahuje většinu aplikační logiky a kdo zná starší MembershipProvidery, tak si jej může představit jako novou podobu třídy Membership.

Nemá smysl, abych zde vypisoval celý UserStore, stačí malá ochutnávka, ostatní metody jsou implementovány stejně, zpravidla jako prosté volání metody příslušné Repository, nebo něco málo nad tím. Pro ukázku uvádím podobu volání vůči Repository, která nemá async podporu. Pokud máte async Repository, potom si jistě dokážete představit, že by kód byl ještě jednodušší.

public class UserStore : IUserStore<User, int>
{
  private readonly IUserRepository userRepository;
  private readonly IRoleRepository roleRepository;

  public UserStore(IUserRepository userRepository, IRoleRepository roleRepository)
  {
    this.userRepository = userRepository;
    this.roleRepository = roleRepository;
  }

  /// <summary>
  /// Insert a new user
  /// </summary>
  public Task CreateAsync(User user)
  {
    Contract.Requires<ArgumentNullException>(user != null);
    
    userRepository.AddNew(user);

    return Task.FromResult<object>(null);
  }

  

  /// <summary>
  /// Finds a user
  /// </summary>
  public Task<User> FindByIdAsync(int userId)
  {
    return Task.FromResult(userRepository.GetByID(userId));
  }

  /// <summary>
  /// Find a user by name
  /// </summary>
  public Task<User> FindByNameAsync(string userName)
  {
    Contract.Requires<ArgumentException>(!String.IsNullOrWhiteSpace(userName));

    return Task.FromResult(userRepository.GetByUserName(userName));
  }
  
  ...

}

Pro úplnost uvádím, že UserStore zde počítá s dependency-injection repositories přes constructor. Custom implementace zde může být jiná.

4. Nastavení OWIN autentizace ve Startup.cs

Dostáváme se pomalu k projektu Web a použití ASP.NET Identity v něm. Samotné ASP.,NET Identity authentizaci neimplementuje, stejně tak jako nebyla implementována v MembershipProviderech. ASP.NET Identity poskytuje pouze určité služby pro obsluhu uživatelů, jejich členství v rolích, ukládání hesel, vazeb na externí authentizační služby ála Google/Facebook/Twitter, atp.

Samotný authentizační mechanizmus použijeme z OWIN, konkrétně Microsoft.Owin.Security.Cookies, což je něco jako nástupce FormsAuthentication a jeho FormAuthenticationTicketu v cookie.

Do webového projektu tedy přidáme novou položku „OWIN Startup class“ (pokud ji tam už nemáme), je to klasická item-template ve Visual Studiu 2013 (tuším od Update 2 nebo 3).

[assembly: OwinStartup(typeof(Havit.HealthGuard.Web.Startup))]

namespace Havit.HealthGuard.Web
{
	public class Startup
	{
		public void Configuration(IAppBuilder app)
		{
			app.UseCookieAuthentication(new CookieAuthenticationOptions()
				{
					AuthenticationType = DefaultAuthenticationTypes.ApplicationCookie,
					LoginPath = new PathString("/authentication/login")
				});
		}
	}
}

5. Nastavení autorizace stránek

Aby celé naše snažení mělo nějaký smysl, musíme samozřejmě nejenom zajistit autentizaci (ověření identity), ale i autorizaci (nastavení přístupových práv). Jelikož zde předpokládám MVC projekt (WebForms viz jeden z článků odkazovaných výše), použijeme atribut [Authorize], pro začátek třeba v podobě globálního filtru (z Global.asax):

protected void Application_Start(object sender, EventArgs e)
{
	...
	FilterConfig.RegisterGlobalFilters(GlobalFilters.Filters);
	...
}

public static void RegisterGlobalFilters(GlobalFilterCollection filters)
{
	...
	filters.Add(new AuthorizeAttribute());
	...
}		 

…čímž omezíme přístup na všechny controllery pouze na přihlášené uživatele

6. Přihlašovací stránka

Dostáváme se k jádru věci – přihlašovací stránce. Nebudu zde popisovat zjevnou podobu View, ani jeho ViewModel, příslušná Action vypadá takto (userManager je zde přes dependency-injection z constructoru controlleru, ale můžete si samozřejmě jeho instanci postavit prostým constructorem):

[AllowAnonymous]
[HttpPost]
[ValidateAntiForgeryToken]
public ActionResult Login(string returnUrl, AuthenticationLoginViewModel loginModel)
{
	if (ModelState.IsValid)
	{

		User user = userManager.Find(loginModel.UserName, loginModel.Password);
		if (user != null)
		{
			IAuthenticationManager authenticationManager = HttpContext.GetOwinContext().Authentication;
			authenticationManager.SignOut(DefaultAuthenticationTypes.ExternalCookie);

			ClaimsIdentity identity = userManager.CreateIdentity(user, DefaultAuthenticationTypes.ApplicationCookie);
			AuthenticationProperties props = new AuthenticationProperties();
			props.IsPersistent = loginModel.IsPersistent;
			authenticationManager.SignIn(props, identity);
					
			if (Url.IsLocalUrl(returnUrl))
			{
				return Redirect(returnUrl);
			}
			return this.RedirectToAction<HomeController>(c => c.Index());
		}
		ModelState.AddModelError("", "Invalid username or password.");
	}

	return View(loginModel);
}

Klíčovým řádkem je zde volání userManager.Find(string userName, string password), kterážto metoda vrací naší třídu User, resp. null, pokud přihlášení není úspěšné. Zbylé řádky jsou již jen tanečky kolem OWIN AuthenticationManageru, které nejsou předmětem tohoto článku.

Pokud nyní aplikaci pustíme a přihlašování zkusíme, pak budeme dostávat hlášku „Invalid username or password.“, pokud jsme do tabulky User ještě žádného uživatele nepřidali. Pokud ho tam přidáme, pak při vyplnění správného UserName dostaneme:

System.NotSupportedException: Store does not implement IUserPasswordStore<TUser>.

7. Rozšiřování UserStore

Dostali jsme se přesně do bodu, kde se ukazuje, jak je zamýšlen způsob implementace vlastních „storage-providerů“. ASP.NET Identity přichází se smečkou rozhraní a jednotlivé funkce UserManageru fungují/nefungují podle toho, kolik z těchto rozhraní UserStore implementuje.

Chceme-li použít lokální účty s lokálními hesly, musíme na náš UserStore přidat a implementovat rozhraní IUserPasswordStore.

public interface IUserPasswordStore<TUser, in TKey> : IUserStore<TUser, TKey>, IDisposable where TUser: class, IUser<TKey>
{
    Task<string> GetPasswordHashAsync(TUser user);
    Task<bool> HasPasswordAsync(TUser user);
    Task SetPasswordHashAsync(TUser user, string passwordHash);
}

…k rozhraní IUserStore<>, které již nemusíme explicitně uvádět, přidává několik metod, které pro práci s hesly slouží. Odbočkou se dostáváme k tomu, že UserStore je zde opravdu odpovědný pouze za perzistenci hashe, nikoliv jeho samotný výpočet. Ten svojí aplikační logiku zajišťuje UserManager, a pokud ho chceme ovlivnit, můžeme vyměnit UserManager.PasswordHasher za jinou/vlastní implementaci IPasswordHasher.

Pro UserStore se nabízejí následující interfaces:

V našem případě budeme chtít implementovat IUserPasswordStore pro ukládání hesel a IUserRoleStore pro přiřazování rolí uživatelům:

public class UserStore : IUserStore<User, int>, IUserPasswordStore<User, int>, IUserRoleStore<User, int>
{

  ...

  /// <summary>
  /// Set the user password hash
  /// </summary>
  public Task SetPasswordHashAsync(User user, string passwordHash)
  {
    Contract.Requires<ArgumentNullException>(user != null);

    user.PasswordHash = passwordHash;
  
    return UpdateAsync(user);
  }

  /// <summary>
  /// Get the user password hash
  /// </summary>
  public Task<string> GetPasswordHashAsync(User user)
  {
    Contract.Requires<ArgumentNullException>(user != null);

    return Task.FromResult(user.PasswordHash);
  }

  /// <summary>
  /// Returns true if a user has a password set
  /// </summary>
  public Task<bool> HasPasswordAsync(User user)
  {
    Contract.Requires<ArgumentNullException>(user != null);
    
    return Task.FromResult(!String.IsNullOrWhiteSpace(user.PasswordHash));
  }

  /// <summary>
  /// Adds a user to a role
  /// </summary>
  public Task AddToRoleAsync(User user, string roleName)
  {
    Contract.Requires<ArgumentNullException>(user != null);
    Contract.Requires<ArgumentException>(!String.IsNullOrWhiteSpace(roleName));

    Role role = roleRepository.GetByName(roleName);
    if (role == null)
    {
      throw new InvalidOperationException(String.Format("Unrecognized role name: {0}", roleName));
    }

    if (!user.Roles.Contains(role))
    {
      user.Roles.Add(role);
    }

    return UpdateAsync(user);
  }

  /// <summary>
  /// Removes the role for the user
  /// </summary>
  public Task RemoveFromRoleAsync(User user, string roleName)
  {
    Contract.Requires<ArgumentNullException>(user != null);
    Contract.Requires<ArgumentException>(!String.IsNullOrWhiteSpace(roleName));

    user.Roles.Where(r => (r.Name == roleName)).ForEach(r => user.Roles.Remove(r));

    return UpdateAsync(user);
  }

  /// <summary>
  /// Returns the roles for this user
  /// </summary>
  public Task<IList<string>> GetRolesAsync(User user)
  {
    Contract.Requires<ArgumentNullException>(user != null);

    var roles = user.Roles.Select(r => r.Name).ToArray();

    return Task.FromResult<IList<string>>(roles);
  }

  /// <summary>
  /// Returns true if a user is in the role
  /// </summary>
  public Task<bool> IsInRoleAsync(User user, string roleName)
  {
    Contract.Requires<ArgumentNullException>(user != null);
    Contract.Requires<ArgumentException>(!String.IsNullOrWhiteSpace(roleName));

    bool result = user.Roles.Any(r => (r.Name == roleName));

    return Task.FromResult(result);
  }
}

RoleStore, RoleManager

Nakonec nutno dodat, že vedle IUserStore existuje ještě IRoleStore pro podporu perzistence rolí, tedy jejich samotných definicí, nikoliv jejich přiřazení uživatelům (což řeší výše uvedený IUserRolesStore).

K RoleStore samozřejmě existuje i RoleManager, který obsahuje aplikační logiku kolem rolí.

Závěr

Postavit vlastní UserStore pro ASP.NET Identity, který by používal Repositories, je jednoduché a velmi přímočaré. Jedinou vadou na kráse je absolutní nedostatek seriózní MSFT-dokumentace, na což si bohužel budeme muset zvykat.

1 thought on “ASP.NET Identity 2.1 do MVC projektu s vlastní implementací UserStore (přes repositories)

Zanechat odpověď

Vyplňte detaily níže nebo klikněte na ikonu pro přihlášení:

Logo WordPress.com

Komentujete pomocí vašeho WordPress.com účtu. Odhlásit /  Změnit )

Facebook photo

Komentujete pomocí vašeho Facebook účtu. Odhlásit /  Změnit )

Připojování k %s