Users, roles and policies
Lagoon recommends using policies to express authorizations. A policy is associated with one or more roles, but it should represent a precise functional capability rather than a technical role.
Design principles
- A policy is the recommended method to secure access.
- A policy is associated with one or more roles.
- A policy should be as precise as possible.
- Roles and policies are defined in the Shared project because they are used by both the server and client.
- A user can be associated with one or more roles.
Design example:
| Type | Examples |
|---|---|
| Roles | Administrator, Referent, UtilisateurDesirade, UtilisateurExterne |
| Policies | AddClient, SeeClient, CreateTicket |
| Associations | AddClient: Administrator + Referent; SeeClient: Administrator + Referent + UtilisateurDesirade; CreateTicket: all listed roles |
Define roles
In the Demo application, roles are declared in an enum in the Shared project.
namespace Demo.Shared;
/// <summary>
/// Roles of the application.
/// </summary>
public enum Roles
{
/// <summary>
/// Administrator role.
/// </summary>
[Display(Name = "#RoleAdmin")]
Admin,
/// <summary>
/// User role.
/// </summary>
[Display(Name = "#RoleUser")]
User
}
Visible labels use translation keys like #RoleAdmin and #RoleUser.
Define policies
Application policies are also defined in the Shared project. The Demo example inherits predefined Lagoon policies and associates policies with roles using AddRequireRolePolicies.
using P = Demo.Shared.Policies;
namespace Demo.Shared;
/// <summary>
/// Policies of the application.
/// </summary>
public class Policies : Lagoon.Shared.Policies
{
/// <summary>
/// Allows you to see ...
/// </summary>
public const string ContentReader = "content.r";
/// <summary>
/// Allows you to modify ...
/// </summary>
public const string ContentEditor = "content.rw";
/// <summary>
/// Allows you to add, modify or delete users from the application.
/// </summary>
public const string UserAdministrator = "user.adm";
/// <summary>
/// Policies initialization.
/// </summary>
/// <param name="options">AuthorizationOption to configure.</param>
public static void ConfigureAuthorization(AuthorizationOptions options)
{
// Registers policies associated with application roles.
options.AddRequireRolePolicies(GetRequireRolePolicies());
}
private static RequireRolePolicies<Roles, P> GetRequireRolePolicies()
{
return new()
{
// The User profile provides access to read content.
{ Roles.User, P.ContentReader },
// The Admin profile includes the User profile and administration policies.
{ Roles.Admin, Roles.User, P.ContentEditor, P.EulaEditor, P.LogReader, P.SettingsAdministrator, P.UserAdministrator }
};
}
}
Register policies on server and client sides
Policies must be registered in OnConfigureAuthorization on both sides.
protected override void OnConfigureAuthorization(AuthorizationOptions options)
{
// Registers policies shared by the application.
Policies.ConfigureAuthorization(options);
}
Secure a controller
To restrict an entire controller, apply Authorize on the class.
[ApiController]
[Route("api/[controller]")]
[Authorize(Policy = Policies.ContentEditor)]
public class AuthenticationController : LgControllerBase
{
}
To restrict a specific method, apply Authorize on the action.
[ApiController]
[Route("api/[controller]")]
[Authorize]
public class AuthenticationController : LgControllerBase
{
[HttpPost("CreateUser")]
[Authorize(Policy = Policies.UserAdministrator)]
public Task<IActionResult> CreateUserAsync(User user)
{
// User creation on server side only.
return Task.FromResult<IActionResult>(Ok());
}
}
Secure a client page
LgPage is authenticated by default. Add AllowAnonymous to open a page, or Authorize with a policy to restrict it.
[AllowAnonymous]
[Route(ROUTE)]
public partial class MyPage : LgPage
{
private const string ROUTE = "my-page";
}
[Authorize(Policy = Policies.UserAdministrator)]
[Route(ROUTE)]
public partial class AdministrationPage : LgPage
{
private const string ROUTE = "administration";
}
AllowAnonymous takes precedence over Authorize, even through inheritance. The Roles and AuthenticationSchemes properties of the Authorize attribute are not supported by Lagoon and trigger a runtime exception.
Display a portion of the interface according to a policy
Use AuthorizeView to condition a portion of the screen or menu. Do not use the PolicyVisible property.
<LgMenuConfiguration>
<Menu>
<LgMenuItem Text="#Administration" AutoHide="true">
<AuthorizeView Policy="@Policies.UserAdministrator">
<Authorized>
<LgMenuItem Text="#Users" Uri="admin/users" />
</Authorized>
</AuthorizeView>
</LgMenuItem>
</Menu>
</LgMenuConfiguration>
Policy based on a custom claim
A policy can also require a claim from the user rather than a role, for example their department or site. The claim is added on each login with context.AddCustomClaim(type, value) in OnSignIn or OnSignInAsync; call it once per value if the claim has multiple values.
By default, a custom claim is issued only in the identity token: the client sees it, but the server does not receive it in the access token, so a server policy based on this claim always denies access. Declare its type in AuthenticationOptions.AccessTokenClaims so it is also issued in the access token.
protected override void OnConfigureAuthentification(AuthenticationOptions options)
{
// Claims issued in the access token (in addition to roles).
options.AccessTokenClaims = new[] { Policies.DepartmentClaim };
}
protected override Task OnSignInAsync(AuthenticationContext context, ApplicationUser user)
{
// One value per user department.
foreach (string department in GetDepartments(context.IdentitySource.UserLogin))
{
context.AddCustomClaim(Policies.DepartmentClaim, department);
}
return Task.CompletedTask;
}
The policy is defined in the Shared project with RequireClaim, then registered on the server and client sides like other policies.
/// <summary>
/// Claim type holding the departments of the user.
/// </summary>
public const string DepartmentClaim = "department";
/// <summary>
/// Allows you to see the sales data.
/// </summary>
public const string SalesReader = "sales.r";
public static void ConfigureAuthorization(AuthorizationOptions options)
{
options.AddRequireRolePolicies(GetRequireRolePolicies());
// Policy based on the "department" claim.
options.AddPolicy(SalesReader, policy => policy.RequireClaim(DepartmentClaim, "Sales"));
}
It is then used like a role-based policy: [Authorize(Policy = Policies.SalesReader)] on a controller or page, AuthorizeView Policy="@Policies.SalesReader" in the interface.
The access token content depends on AccessTokenClaims:
AccessTokenClaims | Claims issued in the access token |
|---|---|
null (default) | role claims, the displayname claim, and claims whose type ends with id |
| populated | role claims, the displayname claim, and only claims whose type is declared in the list |
A claim declared in AccessTokenClaims:
- is issued in the access token and in the identity token, one claim per value;
- is issued again each time the access token is renewed;
- is also returned by the
/connect/userinfoendpoint; - is compared case-insensitively:
"Department"also declares thedepartmentclaim.
When the list is populated, claims whose type ends with id are no longer issued in the access token if they are not in the list. The displayname claim remains in all cases: change tracking (LgApplicationTrackedChangeDbContext) reads the author's name from it.
The list does not change the identity token: all user claims are issued and remain available to the client application. The ASP.NET Core Identity security stamp is never issued, even if declared.
Each declared claim increases the access token size, so the Authorization: Bearer header sent with every API call. Only declare claims used on the server side. Information only displayed by the client does not need to be declared: it is already in the identity token.
CustomClaims is obsoleteThe AuthenticationOptions.CustomClaims property is obsolete and has no effect. A claim added with context.AddCustomClaim(...) in OnSignIn or OnSignInAsync is available to the client application without being declared; to also issue it in the access token, declare it in AccessTokenClaims.
A claim's value is fixed at login. If it changes in the source (directory, database, etc.), it is taken into account only at the next login. With users stored in the database, login records the claims added with AddCustomClaim in AspNetUserClaims: see Stored claims.
Stored claims
With users stored in the database, each login records the claims added with context.AddCustomClaim(...) in the AspNetUserClaims table. The AuthenticationOptions.PreserveStoredClaims option determines what happens to already-stored claims:
PreserveStoredClaims | Effect of login on AspNetUserClaims |
|---|---|
false (default) | stored claims are replaced by those added at login: a claim not added by login is deleted, even if it was recorded otherwise (administration screen, seeder, etc.). If login adds no claims, all user claims are deleted. |
true | only stored claims whose type is added at login are replaced; claims of other types are preserved. |
Enable PreserveStoredClaims when the application records claims outside of login, for example with UserManager.AddClaimAsync:
protected override void OnConfigureAuthentification(AuthenticationOptions options)
{
// Claims recorded by the administration screen are not deleted at login.
options.PreserveStoredClaims = true;
}
With PreserveStoredClaims = true, a login that adds Site=Lyon to a user stored with Department=Sales and Site=Paris leaves Department=Sales and Site=Lyon: the old value of type Site is replaced, the Department type is not affected. Stored claims are issued in tokens like those added at login.
The option has no effect when users are not stored (UserStorage = UserStorageMode.None, see Stateless users).
Register roles in the database
The application roles must exist in the database. In Lagoon applications, this registration is typically done in seeders called during OnUpdateDatabaseAsync.
protected override async Task OnUpdateDatabaseAsync(IServiceProvider scopedServiceProvider, ApplicationDbContext db, CancellationToken cancellationToken)
{
// Initializes reference data, including application roles.
UserManager<ApplicationUser> userManager = scopedServiceProvider.GetRequiredService<UserManager<ApplicationUser>>();
await DataSeeder.SeedAsync(db, userManager, cancellationToken);
}
Manage users on the server side
Creating, modifying, and deleting users is done on the server side with UserManager<ApplicationUser>.
[ApiController]
[Route("api/[controller]")]
[Authorize(Policy = Policies.UserAdministrator)]
public class AdminUsersController : LgControllerBase
{
private readonly UserManager<ApplicationUser> _userManager;
public AdminUsersController(UserManager<ApplicationUser> userManager)
{
_userManager = userManager;
}
[HttpGet("ConnectedUser")]
public async Task<string> GetConnectedUserAsync()
{
// ContextUserId avoids a database access if only the identifier is needed.
Guid userId = ContextUserId;
// Loads the full user if additional information is needed.
ApplicationUser user = await _userManager.GetUserAsync(HttpContext.User);
return $"Ok - {user?.UserName} {user?.Email}";
}
[HttpPost("Create")]
public async Task<IActionResult> CreateAsync(User user)
{
ApplicationUser newUser = new()
{
UserName = user.Login,
Email = user.Login,
EmailConfirmed = true,
FirstName = "adminFirstName",
LastName = "adminLastName"
};
IdentityResult result = await _userManager.CreateAsync(newUser, user.Password);
if (result.Succeeded)
{
await _userManager.AddToRoleAsync(newUser, Roles.Admin.ToString());
await _userManager.AddToRoleAsync(newUser, Roles.User.ToString());
return Ok();
}
foreach (IdentityError error in result.Errors)
{
ModelState.AddModelError("Password", error.Description);
}
EnsureModelStateIsValid();
return Ok();
}
}
UserManager manipulates the database and must remain on the server side. Do not create or modify users on the client side.
Add properties to the user
The ApplicationUser object can be customized to add application-specific fields.
public class ApplicationUser : IdentityUser
{
/// <summary>
/// Firstname.
/// </summary>
public string FirstName { get; set; }
/// <summary>
/// Lastname.
/// </summary>
public string LastName { get; set; }
}
Display user name in the interface
To display the user's first and last name in the client application, add a displayname claim to the login constructed from the user's properties.
protected override Task OnSignInAsync(AuthenticationContext context, ApplicationUser user)
{
context.AddCustomClaim("displayname", string.Join(' ', user.FirstName, user.LastName));
return Task.CompletedTask;
}
Like any user claim, displayname is issued in the identity token: it is available on the client side without declaration in AccessTokenClaims. On the client side, GetUser() (inherited from LgComponentBase) returns the connected user, and the GetDisplayName() extension reads the displayname claim value (failing that, it constructs the name from first and last name claims).
<LgAuthorizeView>
<Authorized>
<LgMenuItem Text="User" IconName="@IconNames.All.PersonFill">
<LgMenuItem Text="@GetUser().Claims.GetDisplayName()" IconName="@IconNames.All.PersonSquare" />
<LgConnectMenuItem />
</LgMenuItem>
</Authorized>
<NotAuthorized>
<LgConnectMenuItem />
</NotAuthorized>
</LgAuthorizeView>
See also LgAuthorizeView and Menu configuration.