web-dev-qa-db-fra.com

Comment utiliser l'autorisation basée sur les ressources ASP.NET Core sans dupliquer le code if / else partout

J'ai une API dotnet core 2.2 avec certains contrôleurs et méthodes d'action qui doivent être autorisés en fonction d'une revendication utilisateur et de la ressource à laquelle vous accédez. Fondamentalement, chaque utilisateur peut avoir 0 ou plusieurs "rôles" pour chaque ressource. Tout cela se fait à l'aide des revendications d'identité ASP.NET.

Donc, je comprends que je dois utiliser autorisation basée sur les ressources . Mais les deux exemples sont pour la plupart identiques et nécessitent la logique impérative explicite if/else de chaque méthode d'action, ce que j'essaie d'éviter.

Je veux pouvoir faire quelque chose comme

[Authorize("Admin")] // or something similar
public async Task<IActionResult> GetSomething(int resourceId)
{
   var resource = await SomeRepository.Get(resourceId);

   return Json(resource);
}

Et ailleurs, définissez la logique d'autorisation comme une stratégie/filtre/exigence/que ce soit et avez accès à la fois aux revendications utilisateur actuelles et au paramètre resourceId reçu par le point de terminaison. Donc là, je peux voir si l'utilisateur a une réclamation qui indique qu'il a le rôle "Admin" pour ce resourceId spécifique.

7
emzero

Modifier: basé sur les commentaires pour le rendre dynamique

L'essentiel avec RBAC et les revendications dans .NET, est de créer votre ClaimsIdentity, puis de laisser le framework faire son travail. Vous trouverez ci-dessous un exemple de middleware qui examinera le paramètre de requête "utilisateur" et générera ensuite le ClaimsPrincipal basé sur un dictionnaire.

Pour éviter d'avoir à se connecter à un fournisseur d'identité, j'ai créé un middleware qui configure le ClaimsPrincipal:

// **THIS CLASS IS ONLY TO DEMONSTRATE HOW THE ROLES NEED TO BE SETUP **
public class CreateFakeIdentityMiddleware
{
    private readonly RequestDelegate _next;

    public CreateFakeIdentityMiddleware(RequestDelegate next)
    {
        _next = next;
    }

    private readonly Dictionary<string, string[]> _tenantRoles = new Dictionary<string, string[]>
    {
        ["tenant1"] = new string[] { "Admin", "Reader" },
        ["tenant2"] = new string[] { "Reader" },
    };

    public async Task InvokeAsync(HttpContext context)
    {
        // Assume this is the roles
        List<Claim> claims = new List<Claim>
        {
            new Claim(ClaimTypes.Name, "John"),
            new Claim(ClaimTypes.Email, "[email protected]")
        };

        foreach (KeyValuePair<string, string[]> tenantRole in _tenantRoles)
        {
            claims.AddRange(tenantRole.Value.Select(x => new Claim(ClaimTypes.Role, $"{tenantRole.Key}:{x}".ToLower())));
        }

        // Note: You need these for the AuthorizeAttribute.Roles    
        claims.AddRange(_tenantRoles.SelectMany(x => x.Value)
            .Select(x => new Claim(ClaimTypes.Role, x.ToLower())));

        context.User = new System.Security.Claims.ClaimsPrincipal(new ClaimsIdentity(claims,
            "Bearer"));

        await _next(context);
    }
}

Pour câbler cela, utilisez simplement la méthode d'extension seMiddleware pour IApplicationBuilder dans votre classe de démarrage.

app.UseMiddleware<RBACExampleMiddleware>();

Je crée un AuthorizationHandler qui recherchera le paramètre de requête "locataire" et réussira ou échouera en fonction des rôles.

public class SetTenantIdentityHandler : AuthorizationHandler<TenantRoleRequirement>
{
    public const string TENANT_KEY_QUERY_NAME = "tenant";

    private static readonly ConcurrentDictionary<string, string[]> _methodRoles = new ConcurrentDictionary<string, string[]>();

    protected override Task HandleRequirementAsync(AuthorizationHandlerContext context, TenantRoleRequirement requirement)
    {
        if (HasRoleInTenant(context))
        {
            context.Succeed(requirement);
        }
        return Task.CompletedTask;
    }

    private bool HasRoleInTenant(AuthorizationHandlerContext context)
    {
        if (context.Resource is AuthorizationFilterContext authorizationFilterContext)
        {
            if (authorizationFilterContext.HttpContext
                .Request
                .Query
                .TryGetValue(TENANT_KEY_QUERY_NAME, out StringValues tenant)
                && !string.IsNullOrWhiteSpace(tenant))
            {
                if (TryGetRoles(authorizationFilterContext, tenant.ToString().ToLower(), out string[] roles))
                {
                    if (context.User.HasClaim(x => roles.Any(r => x.Value == r)))
                    {
                        return true;
                    }
                }
            }
        }

        return false;
    }

    private bool TryGetRoles(AuthorizationFilterContext authorizationFilterContext,
        string tenantId,
        out string[] roles)
    {
        string actionId = authorizationFilterContext.ActionDescriptor.Id;
        roles = null;

        if (!_methodRoles.TryGetValue(actionId, out roles))
        {
            roles = authorizationFilterContext.Filters
                .Where(x => x.GetType() == typeof(AuthorizeFilter))
                .Select(x => x as AuthorizeFilter)
                .Where(x => x != null)
                .Select(x => x.Policy)
                .SelectMany(x => x.Requirements)
                .Where(x => x.GetType() == typeof(RolesAuthorizationRequirement))
                .Select(x => x as RolesAuthorizationRequirement)
                .SelectMany(x => x.AllowedRoles)
                .ToArray();

            _methodRoles.TryAdd(actionId, roles);
        }

        roles = roles?.Select(x => $"{tenantId}:{x}".ToLower())
            .ToArray();

        return roles != null;
    }
}

Le TenantRoleRequirement est une classe très simple:

public class TenantRoleRequirement : IAuthorizationRequirement { }

Ensuite, vous câblez tout dans le fichier startup.cs comme ceci:

services.AddTransient<IAuthorizationHandler, SetTenantIdentityHandler>();

// Although this isn't used to generate the identity, it is needed
services.AddAuthentication(JwtBearerDefaults.AuthenticationScheme)
.AddJwtBearer(options =>
{
    options.Audience = "https://localhost:5000/";
    options.Authority = "https://localhost:5000/identity/";
});

services.AddAuthorization(authConfig =>
{
    authConfig.AddPolicy(Policies.HasRoleInTenant, policyBuilder => {
        policyBuilder.RequireAuthenticatedUser();
        policyBuilder.AddRequirements(new TenantRoleRequirement());
    });
});

La méthode ressemble à ceci:

// TOOD: Move roles to a constants/globals
[Authorize(Policy = Policies.HasRoleInTenant, Roles = "admin")]
[HttpGet]
public ActionResult<IEnumerable<string>> Get()
{
    return new string[] { "value1", "value2" };
}

Voici les scénarios de test:

  1. Positif: https: // localhost: 44337/api/values? Tenant = tenant1

  2. Négatif: https: // localhost: 44337/api/values? Tenant = tenant2

  3. Négatif: https: // localhost: 44337/api/values

L'essentiel avec cette approche est que je ne retourne jamais réellement un 403. Le code configure l'identité puis laisse le framework gérer le résultat. Cela garantit que l'authentification est distincte de l'autorisation.

~ Vive

3
Rogala

Modifié sur la base des commentaires

Selon ma compréhension, vous souhaitez accéder à l'utilisateur actuel (toutes les informations qui s'y rapportent), au (x) rôle (s) que vous souhaitez spécifier pour un contrôleur (ou une action) et aux paramètres reçus par le point final. N'ont pas été essayés pour l'API Web, mais pour MVC core asp.net, vous pouvez y parvenir en utilisant AuthorizationHandler dans une autorisation basée sur une stratégie et combiner avec un service injecté spécialement créé pour déterminer l'accès aux rôles-ressources.

Pour ce faire, configurez d'abord la politique dans Startup.ConfigureServices:

services.AddAuthorization(options =>
{
    options.AddPolicy("UserResource", policy => policy.Requirements.Add( new UserResourceRequirement() ));
});
services.AddScoped<IAuthorizationHandler, UserResourceHandler>();
services.AddScoped<IRoleResourceService, RoleResourceService>();

créez ensuite le UserResourceHandler:

public class UserResourceHandler : AuthorizationHandler<UserResourceRequirement>
{
    readonly IRoleResourceService _roleResourceService;

    public UserResourceHandler (IRoleResourceService r)
    {
        _roleResourceService = r;
    }

    protected override async Task HandleRequirementAsync(AuthorizationHandlerContext authHandlerContext, UserResourceRequirement requirement)
    {
        if (context.Resource is AuthorizationFilterContext filterContext)
        {
            var area = (filterContext.RouteData.Values["area"] as string)?.ToLower();
            var controller = (filterContext.RouteData.Values["controller"] as string)?.ToLower();
            var action = (filterContext.RouteData.Values["action"] as string)?.ToLower();
            var id = (filterContext.RouteData.Values["id"] as string)?.ToLower();
            if (_roleResourceService.IsAuthorize(area, controller, action, id))
            {
                context.Succeed(requirement);
            }               
        }            
    }
}

L'accès aux paramètres reçus par le noeud final est réalisé en transtypant context.Resource En AuthorizationFilterContext, afin que nous puissions y accéder RouteData. Quant à UserResourceRequirement, nous pouvons le laisser vide.

public class UserResourceRequirement : IAuthorizationRequirement { }

Quant au IRoleResourceService, c'est une classe de service simple pour qu'on puisse y injecter n'importe quoi. Ce service est le substitut de l'association d'un rôle à une action dans le code afin que nous n'ayons pas besoin de le spécifier dans l'attribut de l'action. De cette façon, nous pouvons avoir la liberté de choisir l'implémentation, ex: à partir de la base de données, du fichier de configuration ou codée en dur.

L'accès à l'utilisateur dans RoleResourceService est réalisé en injectant IHttpContextAccessor. Veuillez noter que pour rendre IHttpContextAccessor injectable, ajoutez services.AddHttpContextAccessor() dans le corps de la méthode Startup.ConfigurationServices.

Voici un exemple pour obtenir les informations du fichier de configuration:

public class RoleResourceService : IRoleResourceService
{
    readonly IConfiguration _config;
    readonly IHttpContextAccessor _accessor;
    readonly UserManager<AppUser> _userManager;

    public class RoleResourceService(IConfiguration c, IHttpContextAccessor a, UserManager<AppUser> u) 
    {
        _config = c;
        _accessor = a;
        _userManager = u;
    }

    public bool IsAuthorize(string area, string controller, string action, string id)
    {
        var roleConfig = _config.GetValue<string>($"RoleSetting:{area}:{controller}:{action}"); //assuming we have the setting in appsettings.json
        var appUser = await _userManager.GetUserAsync(_accessor.HttpContext.User);
        var userRoles = await _userManager.GetRolesAsync(appUser);
        // all of needed data are available now, do the logic of authorization
        return result;
    } 
}

Obtenir le paramètre à partir de la base de données est sûrement un peu plus complexe, mais cela peut être fait car nous pouvons injecter AppDbContext. Pour l'approche codée en dur, il existe de nombreuses façons de le faire.

Une fois tout terminé, utilisez la stratégie sur une action:

[Authorize(Policy = "UserResource")] //dont need Role name because of the RoleResourceService
public ActionResult<IActionResult> GetSomething(int resourceId)
{
    //existing code
}

En fait, nous pouvons utiliser la politique "UserResource" pour toute action que nous voulons appliquer.

2
Riza