Utilisateurs, rôles et policies
Lagoon recommande d'utiliser les policies pour exprimer les autorisations. Une policy est associée à un ou plusieurs rôles, mais elle doit représenter une capacité fonctionnelle précise plutôt qu'un rôle technique.
Principes de conception
- Une policy est la méthode recommandée pour sécuriser les accès.
- Une policy est associée à un ou plusieurs rôles.
- Une policy doit être aussi précise que possible.
- Les rôles et policies sont définis dans le projet Shared, car ils sont utilisés par le serveur et par le client.
- Un utilisateur peut être associé à un ou plusieurs rôles.
Exemple de design :
| Type | Exemples |
|---|---|
| Rôles | Administrateur, Référent, UtilisateurDesirade, UtilisateurExterne |
| Policies | AddClient, SeeClient, CreateTicket |
| Associations | AddClient : Administrateur + Référent ; SeeClient : Administrateur + Référent + UtilisateurDesirade ; CreateTicket : tous les rôles cités |
Définir les rôles
Dans l'application Demo, les rôles sont déclarés dans une enum du projet Shared.
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
}
Les libellés visibles passent par des clés de traduction comme #RoleAdmin et #RoleUser.
Définir les policies
Les policies applicatives sont aussi définies dans le projet Shared. L'exemple Demo hérite des policies prédéfinies Lagoon et associe les policies aux rôles avec 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)
{
// Enregistre les policies associées aux rôles applicatifs.
options.AddRequireRolePolicies(GetRequireRolePolicies());
}
private static RequireRolePolicies<Roles, P> GetRequireRolePolicies()
{
return new()
{
// Le profil User donne accès à la lecture de contenu.
{ Roles.User, P.ContentReader },
// Le profil Admin inclut le profil User et les policies d'administration.
{ Roles.Admin, Roles.User, P.ContentEditor, P.EulaEditor, P.LogReader, P.SettingsAdministrator, P.UserAdministrator }
};
}
}
Enregistrer les policies côté serveur et côté client
Les policies doivent être enregistrées dans OnConfigureAuthorization des deux côtés.
protected override void OnConfigureAuthorization(AuthorizationOptions options)
{
// Enregistre les policies partagées par l'application.
Policies.ConfigureAuthorization(options);
}
Sécuriser un contrôleur
Pour restreindre tout un contrôleur, appliquez Authorize sur la classe.
[ApiController]
[Route("api/[controller]")]
[Authorize(Policy = Policies.ContentEditor)]
public class AuthenticationController : LgControllerBase
{
}
Pour restreindre une méthode précise, appliquez Authorize sur l'action.
[ApiController]
[Route("api/[controller]")]
[Authorize]
public class AuthenticationController : LgControllerBase
{
[HttpPost("CreateUser")]
[Authorize(Policy = Policies.UserAdministrator)]
public Task<IActionResult> CreateUserAsync(User user)
{
// Création utilisateur côté serveur uniquement.
return Task.FromResult<IActionResult>(Ok());
}
}
Sécuriser une page cliente
LgPage est authentifiée par défaut. Ajoutez AllowAnonymous pour ouvrir une page, ou Authorize avec une policy pour la restreindre.
[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 est prioritaire sur Authorize, même via héritage. Les propriétés Roles et AuthenticationSchemes de l'attribut Authorize ne sont pas supportées par Lagoon et déclenchent une exception à l'exécution.
Afficher une portion d'interface selon une policy
Utilisez AuthorizeView pour conditionner une portion d'écran ou de menu. N'utilisez pas de propriété PolicyVisible.
<LgMenuConfiguration>
<Menu>
<LgMenuItem Text="#Administration" AutoHide="true">
<AuthorizeView Policy="@Policies.UserAdministrator">
<Authorized>
<LgMenuItem Text="#Users" Uri="admin/users" />
</Authorized>
</AuthorizeView>
</LgMenuItem>
</Menu>
</LgMenuConfiguration>
Policy fondée sur un claim personnalisé
Une policy peut aussi exiger un claim de l'utilisateur plutôt qu'un rôle, par exemple son service ou son site. Le claim est ajouté à chaque connexion avec context.AddCustomClaim(type, valeur) dans OnSignIn ou OnSignInAsync ; appelez-la une fois par valeur si le claim en a plusieurs.
Par défaut, un claim personnalisé n'est émis que dans le jeton d'identité : le client le voit, mais le serveur ne le reçoit pas dans le jeton d'accès, si bien qu'une policy serveur fondée sur ce claim refuse toujours l'accès. Déclarez son type dans AuthenticationOptions.AccessTokenClaims pour qu'il soit aussi émis dans le jeton d'accès.
protected override void OnConfigureAuthentification(AuthenticationOptions options)
{
// Claims émis dans le jeton d'accès (en plus des rôles).
options.AccessTokenClaims = new[] { Policies.DepartmentClaim };
}
protected override Task OnSignInAsync(AuthenticationContext context, ApplicationUser user)
{
// Une valeur par service de l'utilisateur.
foreach (string department in GetDepartments(context.IdentitySource.UserLogin))
{
context.AddCustomClaim(Policies.DepartmentClaim, department);
}
return Task.CompletedTask;
}
La policy est définie dans le projet Shared avec RequireClaim, puis enregistrée côté serveur et côté client comme les autres 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 fondée sur le claim "department".
options.AddPolicy(SalesReader, policy => policy.RequireClaim(DepartmentClaim, "Sales"));
}
Elle s'utilise ensuite comme une policy fondée sur les rôles : [Authorize(Policy = Policies.SalesReader)] sur un contrôleur ou une page, AuthorizeView Policy="@Policies.SalesReader" dans l'interface.
Le contenu du jeton d'accès dépend de AccessTokenClaims :
AccessTokenClaims | Claims émis dans le jeton d'accès |
|---|---|
null (par défaut) | les claims role, le claim displayname et les claims dont le type se termine par id |
| renseignée | les claims role, le claim displayname et uniquement les claims dont le type est déclaré dans la liste |
Un claim déclaré dans AccessTokenClaims :
- est émis dans le jeton d'accès et dans le jeton d'identité, un claim par valeur ;
- est émis à nouveau à chaque renouvellement du jeton d'accès ;
- est aussi renvoyé par l'endpoint
/connect/userinfo; - est comparé sans tenir compte de la casse :
"Department"déclare aussi le claimdepartment.
Quand la liste est renseignée, les claims dont le type se termine par id ne sont plus émis dans le jeton d'accès s'ils n'y figurent pas. Le claim displayname y reste dans tous les cas : le suivi des modifications (LgApplicationTrackedChangeDbContext) y lit le nom de l'auteur.
La liste ne change pas le jeton d'identité : tous les claims de l'utilisateur y sont émis et restent disponibles pour l'application cliente. Le security stamp d'ASP.NET Core Identity n'est jamais émis, même s'il est déclaré.
Chaque claim déclaré grossit le jeton d'accès, donc l'en-tête Authorization: Bearer envoyé avec chaque appel d'API. Ne déclarez que les claims utilisés côté serveur. Une information seulement affichée par le client n'a pas besoin d'être déclarée : elle est déjà dans le jeton d'identité.
CustomClaims est obsolèteLa propriété AuthenticationOptions.CustomClaims est obsolète et sans effet. Un claim ajouté avec context.AddCustomClaim(...) dans OnSignIn ou OnSignInAsync est disponible pour l'application cliente sans être déclaré ; pour l'émettre aussi dans le jeton d'accès, déclarez-le dans AccessTokenClaims.
La valeur d'un claim est fixée à la connexion. Si elle change dans la source (annuaire, base…), elle n'est prise en compte qu'à la connexion suivante. Avec des utilisateurs stockés en base, la connexion enregistre les claims ajoutés avec AddCustomClaim dans AspNetUserClaims : voir Claims stockés en base.
Claims stockés en base
Avec des utilisateurs stockés en base, chaque connexion enregistre les claims ajoutés avec context.AddCustomClaim(...) dans la table AspNetUserClaims. L'option AuthenticationOptions.PreserveStoredClaims choisit ce que deviennent les claims déjà stockés :
PreserveStoredClaims | Effet de la connexion sur AspNetUserClaims |
|---|---|
false (par défaut) | les claims stockés sont remplacés par ceux ajoutés à la connexion : un claim que la connexion n'ajoute pas est supprimé, même s'il a été enregistré autrement (écran d'administration, seeder…). Si la connexion n'ajoute aucun claim, tous les claims de l'utilisateur sont supprimés. |
true | seuls les claims stockés dont le type est ajouté à la connexion sont remplacés ; les claims des autres types sont conservés. |
Activez PreserveStoredClaims quand l'application enregistre des claims en dehors de la connexion, par exemple avec UserManager.AddClaimAsync :
protected override void OnConfigureAuthentification(AuthenticationOptions options)
{
// Les claims enregistrés par l'écran d'administration ne sont pas supprimés à la connexion.
options.PreserveStoredClaims = true;
}
Avec PreserveStoredClaims = true, une connexion qui ajoute Site=Lyon à un utilisateur stocké avec Department=Sales et Site=Paris laisse Department=Sales et Site=Lyon : l'ancienne valeur du type Site est remplacée, le type Department n'est pas touché. Les claims stockés sont émis dans les jetons comme ceux ajoutés à la connexion.
L'option est sans effet quand les utilisateurs ne sont pas stockés (UserStorage = UserStorageMode.None, voir Utilisateurs sans stockage).
Enregistrer les rôles en base
Les rôles de l'application doivent exister en base de données. Dans les applications Lagoon, cet enregistrement est généralement fait dans les seeders appelés pendant OnUpdateDatabaseAsync.
protected override async Task OnUpdateDatabaseAsync(IServiceProvider scopedServiceProvider, ApplicationDbContext db, CancellationToken cancellationToken)
{
// Initialise les données de référence, dont les rôles de l'application.
UserManager<ApplicationUser> userManager = scopedServiceProvider.GetRequiredService<UserManager<ApplicationUser>>();
await DataSeeder.SeedAsync(db, userManager, cancellationToken);
}
Gérer les utilisateurs côté serveur
La création, modification et suppression d'utilisateurs se fait côté serveur avec 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 évite un accès base si seul l'identifiant est nécessaire.
Guid userId = ContextUserId;
// Charge l'utilisateur complet si des informations supplémentaires sont nécessaires.
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 manipule la base et doit rester côté serveur. Ne créez pas et ne modifiez pas d'utilisateurs côté client.
Ajouter des propri étés à l'utilisateur
L'objet ApplicationUser peut être personnalisé pour ajouter des champs applicatifs.
public class ApplicationUser : IdentityUser
{
/// <summary>
/// Firstname.
/// </summary>
public string FirstName { get; set; }
/// <summary>
/// Lastname.
/// </summary>
public string LastName { get; set; }
}
Afficher le nom de l'utilisateur dans l'interface
Pour afficher le prénom et le nom de l'utilisateur dans l'application cliente, ajoutez à la connexion un claim displayname construit à partir des propriétés de l'utilisateur.
protected override Task OnSignInAsync(AuthenticationContext context, ApplicationUser user)
{
context.AddCustomClaim("displayname", string.Join(' ', user.FirstName, user.LastName));
return Task.CompletedTask;
}
Comme tout claim de l'utilisateur, displayname est émis dans le jeton d'identité : il est disponible côté client sans déclaration dans AccessTokenClaims. Côté client, GetUser() (hérité de LgComponentBase) renvoie l'utilisateur connecté et l'extension GetDisplayName() lit la valeur du claim displayname (à défaut, elle compose le nom à partir des claims de prénom et de nom).
<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>
Voir aussi LgAuthorizeView et Configuration du menu.