De ASP.NET Core Identity user heeft een aantal standaard velden, zoals email, naam, en telefoonnummer. Maar wat als je daar meta data aan wil toevoegen? Bijvoorbeeld IsManager, of AfdelingsNummer.
Je kan dat op twee manieren doen. 1) met claims, en 2) met database velden (met dynamische claims)
- Met Claims kan je willekeurige data toevoegen aan een gebruiker, deze kan je vooral gebruiken bij autorisatie, maar met enige volhardendheid ook in de rest van je code
- Informatie in extra database velden kan je zowel als claims gebruiken bij autorisatie, maar ook makkelijk de inhoud zetten en afvragen in je code.
1) Data in Claims
Iedere claim heeft een claimType, een value en een userid (de Id van de user in AspNetUsers) en tenslotte een unieke Id van de regel. De conventie is om de claimType uniek te maken, bijvoorbeeld met je domein naam:
public const string AfdelingsNummer = "http://mydomain.nl/afdelingsnummer";
Als je dan claims van verschillende bronnen hebt, dan zijn ze in ieder geval uniek. Voor de rest maakt het niets uit wat de claimType van de claim is.
Je kan een claim toevoegen:
await userManager.AddClaimAsync(user, new Claim(claimType, value));
en je kan een claim verwijderen:
await userManager.RemoveClaimAsync(user, new Claim(claimType, value));
Als je een claim voor de tweede keer toevoegt, krijg je twee claims. Je kan een bestaande claim wijzigen met ReplaceClaimAsync.
Toevoegen als de claim nog niet bestaat of wijzigen als hij wel bestaat gaat niet in een operatie, je moet eerst zoeken of de claim er is, en dan toevoegen of wijzigen.
Claims worden in de tabel AspNetUserClaims bewaard. De UserId is de FK naar de AspNetUsers tabel. Je kan een user niet verwijderen als er nog claims open staan.
Met GetUsersForClaimAsync kan je een lijst van users krijgen met een bepaalde claim (claimType en value). Je kan niet zoeken op iedereen die een claimType heeft ongeacht de value.
De volgende extensie method verwijdert een gebruiker effectief:
public static class UserManagerExt { //extend userManager public static async Task DeleteUserAsync(this UserManager<MyUser> userManager, MyUser user) { //Gets list of Roles associated with current user var rolesForUser = await userManager.GetRolesAsync(user); if (rolesForUser.Any()) await userManager.RemoveFromRolesAsync(user, rolesForUser); var claimsForUser = await userManager.GetClaimsAsync(user); if (claimsForUser.Any()) await userManager.RemoveClaimsAsync(user, claimsForUser); //Delete User await userManager.DeleteAsync(user); } } //Je kan deze method zo gebruiken: await userManager.DeleteUserAsync(user);
Claims in Cookies
Alle claims worden bij het inloggen in een encrypted cookie opgeslagen.
var myClaimvalue= User.FindFirstValue(claimType); //geeft de value (string) van de eerste claim van het type claimType, en null als er geen gevonden wordt.
Let op de hoofdletter bij User, dit is de ClaimsPrincipal en die is standaard aanwezig voor ingelogde gebruikers. Je hoeft dus niet de claim uit de database te vissen, hij is direct beschikbaar. Alle claims staan in de ClaimsPrincipal User, ook de rollen zijn als claim hierin opgeslagen.
Er zijn wat speciale functies om met de claims te werken:
bool User.IsInRole(rolename) //gebruikt de claim cookie voor de rol bool User.HasClaim(claimType) //true als de claim aanwezig is Claim User.FindFirst(claimType) //geeft de hele claim terug in plaats van alleen de waarde. IEnumerable Claim User.FindAll(c=>c.Type=claimType) // Zoals hier geschreven is het hetzelfde als FindFirst, alleen wordt hier een IEnumerable // met alle claims gegeven. Je kan op alle claim eigenschappen selecteren in de predicate.
De Id van de user staat als claim in de ClaimsPrincipal:
//de Id claim wordt gebruikt om de Id (guid) op te halen van de ingelogde gebruiker: var userId = _userManager.GetUserId(User) //het kan ook zo: var userid =User.FindFirstValue(ClaimTypes.NameIdentifier);
Policies
Vervolgens kan je de claim gebruiken in de toegangsrechten:
public void ConfigureServices(IServiceCollection services) { services.AddAuthorization(options => { //only managers allowed options.AddPolicy("ManagersOnly", policy => { //MyClaimTypes is een public class met public const string properties, //zodat ik zeker weet dat ik overal dezelfde spelling gebruik policy.RequireClaim(MyClaimTypes.IsManager, "true"); //MyRoles, net als MyClaimTypes een public class policy.RequireRole(MyRoles.Examiner); //RequireAuthenticatedUser moet er vreemd genoeg bij, //omdat de default policy niet meer geldt als je een custom policy gebruik policy.RequireAuthenticatedUser(); }); }); }
Vervolgens gebruik je de policy op Controller niveau of op Action niveau:
[Authorize(Policy = "ManagersOnly")]
Door het standaard gebruik van cookies voor de claims, betekent dit dat iedere claim heen en weer naar de browser wordt gestuurd, bij ieder request. Dat kan oplopen, maar zal voor de meeste toepassingen geen probleem zijn. (Bij HTTPS/2 wordt het ook nog gecompressed). Het voordeel is wel dat je de claim voor authenticatie kan gebruiken en dat je de waarde zonder database call kan ophalen (na inloggen).
Als je zelf ook queries wil doen op de AspNetUsers tabel is de claims tabel wel erg lastig. Je kan dan beter data in de AspNetUsers tabel hebben zoals hier onder beschreven. Maar voor het bewaren van authorisatie gegevens of informatie die je alleen wil kunnen terughalen werkt het prima.
2) Data in de AspNetUsers tabel
Je kan ook de AspNetUsers tabel uitbreiden. Ik ga hier niet in op EF Migrations, omdat ik die nooit gebruik. Maar als je het wel gebruikt gaat de uitbreiding van de database min of meer automatisch.
Je begint met een class waarmee je de IdentityUser uitbreidt:
public class MyUser : IdentityUser { //expands on Identity user public bool IsManager { get; set; } public int DepartmentId {get; set; } }
Vervolgens gebruik je deze class overall waar je een IdentityUser specificeert, om te beginnen in ConfigureServices:
services.AddIdentity<MyUser , IdentityRole>();
De AspNetUser tabel moet nu in dit voorbeeld een kolom IsManager en een kolom DepartmentId met een geschikt type krijgen. Als je dit handmatig doet (zoals ik), zorg er dan voor dat er geen null waardes in de kolom komen. Specificeer een default waarde 0, en vul de kolom voor de bestaande gebruikers, anders krijg je een null reference error.
Vervolgens kan je deze waardes gebruiken zoals je de overige velden van de user gebruikt, alleen moet je overal waar je eerst UserManager<IndentityUser> had staan het veranderen naar de gebruikte class naam, in dit voorbeeld MyUser:
UserManager<MyUser>.
Nu kan je makkelijk je waardes aanpassen vanuit de code, bijvoorbeeld om de bool IsManager op false te zetten:
var user = await _userManager.GetUserAsync(User); if (demoted) { user.IsManager = false; await _userManager.UpdateAsync(user); }
In het bovenstaande voorbeeld hebben beide asynchrone methods onderliggende database operaties.
Het zou natuurlijk mooi zijn als je een of meer van je custom velden als claims kan gebruiken, net als eerder voor snelle toegang vanuit een cookie of in een authorization policy. Dat kan ook!
Gebruiken van AspNetUsers velden in claims en autorisatie.
Bij het inloggen haalt de SignInManager de claims en rollen op en zet die in de ClaimsPrincipal, die als cookie wordt bewaard. Ook de LastName en de Id van de user wordt in de ClaimsPrincipal als claims gezet. Intern wordt
IUserClaimsPrincipalFactory<TUser>
gebruikt om de ClaimsPrincipal te maken. Door een eigen implementatie te erven van deze factory kan je zelf claims toevoegen tijdens de login. Je kan hier standaard velden van User toevoegen (bijvoorbeeld ClaimTypes.Email ), maar ook je eigen velden:
public class AppClaimsPrincipalFactory : UserClaimsPrincipalFactory<MyUser, IdentityRole> { public AppClaimsPrincipalFactory( UserManager<ApplicationUser> userManager , RoleManager<IdentityRole> roleManager , IOptions<IdentityOptions> optionsAccessor) : base(userManager, roleManager, optionsAccessor) { } public async override Task<ClaimsPrincipal> CreateAsync(ApplicationUser user) { var principal = await base.CreateAsync(user); if (!string.IsNullOrWhiteSpace(user.FirstName)) { ((ClaimsIdentity)principal.Identity).AddClaims(new[] { new Claim(ClaimTypes.Email, user.Email) }); } ((ClaimsIdentity)principal.Identity).AddClaims(new[] { new Claim(MyClaimTypes.IsManager, user.IsManager), new Claim(MyClaimTypes.DepartmentId , user.DepartmentId ) } return principal; } }
Vervolgens moet je deze implementatie registreren in de ConfigureServices in startup.cs, na de Identity Service:
public void ConfigureServices(IServiceCollection services) { // [...] services.AddDbContext<SecurityDbContext>(options => options.UseSqlServer(Configuration.GetConnectionString("Security"))); services.AddIdentity<MyUser, IdentityRole>() .AddEntityFrameworkStores<SecurityDbContext>() .AddDefaultTokenProviders(); services.AddScoped<Microsoft.AspNetCore.Identity.IUserClaimsPrincipalFactory<MyUser>, AppClaimsPrincipalFactory>(); // [...] }
Nu worden alle claims die je in jouw implementatie hebt toegevoegd beschikbaar in de ClaimPrincipal in ieder Controller, zonder verdere database operatie:
var eMail = User.FindFirstValue(ClaimTypes.Email); var isManager = User.FindFirstValue(MyClaimTypes.IsManager); var departmentId = User.FindFirstValue(MyClaimTypes.DepartmentId);
Je kunt ieder van deze claims ook gebruiken in autorisatie policies, zoals eerder beschreven.