.NET Deep Dive — Advanced Policy-Based Controller Action Security

by • Tue Aug 08 2023 00:00:00 GMT+0000 (Coordinated Universal Time)

.NET Deep Dive — Advanced Policy-Based Controller Action Security
Back to home
csharp

.NET Deep Dive — Advanced Policy-Based Controller Action Security


Authorization is a crucial aspect of building secure web applications. In complex software systems with varying user roles and permissions, implementing authorization logic can quickly become tedious and unmaintainable. ASP.NET Core provides a powerful policy-based mechanism to elegantly handle authorization for MVC controllers and Razor pages. Let’s take a look !

Securing Pages and Controller

Let’s look at a basic example. Say we are building an e-commerce platform and need to restrict access to certain pages to logged in users only. We can easily do this by applying the [Authorize] attribute to our Razor Page model:

[Authorize]
public class VipHousekeepingModel 
    : PageModel 
{
}

The AuthorizeAttribute will automatically redirect unauthenticated users to the login page. This saves us from having to manually check if a user is authenticated in page handler methods with an annoying if/else block.

We can also specify specific authorization requirements, like roles:

[Authorize(Roles = "Admin,PowerUser")]
public IActionResult AdminPortal() { }

But hardcoding roles everywhere has maintenance and separation of concerns issues. This is where policy-based authorization comes in. Don’t forget guys and girls, magic values are against clean code.

Introducing Policy-Based Authorization

The policy-based authorization system allows us to centrally define authorization requirements in a startp class:

public void ConfigureServices(IServiceCollection services)
{
  services.AddAuthorization(options =>
  {
    options.AddPolicy("AdminOnly", policy => 
    {
      policy.RequireRole("Admin");
    });
  });
}

We can now consume these policies via attributes:

[Authorize(Policy = "AdminOnly")]
public IActionResult AdminDashboard() { }

This keeps our controller code clean. Adding requirements like role checks, age restrictions etc. can be done in the central policy definitions.

Combining Multiple Requirements

Requirements can be combined using the RequireAssertion policy builder method:

options.AddPolicy("AdminOrManager", policy => 
{
  policy.RequireAssertion(context => 
     context.User.IsInRole("Admin") ||
     context.User.IsInRole("Manager"))
});

We can compose policies in this way to model complex authorization concerns elegantly.

Implementing Custom Requirements

Expand the capabilities of ASP.NET authorization by creating custom requirements. This is achieved through the IAuthorizationRequirement interface.

Suppose we wish to restrict access to a page only for authenticated users with a mobile phone set. How do we proceed?

Drawing a parallel to the CQRS pattern, begin by creating a class derived from IAuthorizationRequirement. Following this, create its corresponding handler derived from AuthorizationHandler<T>, where T is our custom requirement class.

public sealed record LoggedVerifiedMemberRequirement 
    : IAuthorizationRequirement
{}
public class LoggedVerifiedMemberRequirementHandler
    : AuthorizationHandler<LoggedVerifiedMemberRequirement>
{
    private readonly ILogger<LoggedVerifiedMemberRequirementHandler> _logger;

    public LoggedVerifiedMemberRequirementHandler(ILogger<LoggedVerifiedMemberRequirementHandler> logger)
    {
        _logger = logger;
    }

    protected override Task HandleRequirementAsync(AuthorizationHandlerContext context,
        LoggedVerifiedMemberRequirement requirement)
    {
        _logger.LogInformation("Evaluating access for {UserName}", context.User.Identity.Name);

        if (!IsUserAuthenticated(context))
        {
            _logger.LogInformation("User not authenticated");
            return Task.CompletedTask;
        }

        if (!HasValidMobileClaim(context))
        {
            _logger.LogInformation("User has no mobile claim");
            return Task.CompletedTask;
        }

        _logger.LogInformation("Access granted");
        context.Succeed(requirement);
        return Task.CompletedTask;
    }


    private bool IsUserAuthenticated(AuthorizationHandlerContext context)
    {
        return context.User.Identity.IsAuthenticated;
    }

    private bool HasValidMobileClaim(AuthorizationHandlerContext context)
    {
        return context.User.HasClaim(claim => claim.Type == ClaimTypes.MobilePhone);
    }
}

Next, integrate the custom policy requirements into your Dependency Injection container.

builder.Services.AddAuthorization(options =>
{
    options.AddPolicy("LoggedVerifiedMember", policy =>
    {
        policy.Requirements.Add(new LoggedVerifiedMemberRequirement());
    });
});

Now, you can incorporate your newly crafted custom policy into your pages.

[Authorize(Policy = "LoggedVerifiedMember")]
public class HousekeepingModel 
    : PageModel
{
    public void OnGet()
    {
        
    }
}

Access denied: A Closer Look

You do not have access to this resource.

By default, the ASP.NET Identity’s IdentityUser will have a null value for the phone number. To grant access, you must set it in your profile via the application at /Identity/Account/Manage.

However, there’s a catch! ASP.NET Identity doesn’t natively treat MobilePhone as a user claim. You’ll need to manually add it. Here’s a sample implementation that facilitates this update:

Create a temporary page to handle the claim update:

public class UpdateClaim 
    : PageModel
{
    private readonly UserManager<IdentityUser> _userManager;
    private readonly SignInManager<IdentityUser> _signInManager;

    public UpdateClaim(UserManager<IdentityUser> userManager, SignInManager<IdentityUser> signInManager)
    {
        _userManager = userManager;
        _signInManager = signInManager;
    }

    public async Task OnGetAsync()
    {
        var user = await _userManager.GetUserAsync(User);
        var claim = User.Claims.FirstOrDefault(c => c.Type == ClaimTypes.MobilePhone);
        
        if (claim != null && claim.Value != user.PhoneNumber)
        {
            await _userManager.RemoveClaimAsync(user, claim);
            await _userManager.AddClaimAsync(user, new Claim(ClaimTypes.MobilePhone, user.PhoneNumber));
            
            await _signInManager.RefreshSignInAsync(user);
        }
        else if (claim == null && !string.IsNullOrEmpty(user.PhoneNumber))
        {
            await _userManager.AddClaimAsync(user, new Claim(ClaimTypes.MobilePhone, user.PhoneNumber));
            
            await _signInManager.RefreshSignInAsync(user);
        }
    }
}

After visiting this page, return to the Housekeeping page. Voila! You’re now a VIP.

Congratulation !

Conclusion

The policy-based approach makes complex authorization intuitive, scalable and maintainable. Centralizing requirements allows clean and expressive authorization.

Refer to the docs for advanced scenarios.

ecrin digital Looking for a full-stack developer?

Explore Ecrin Digital, a full-stack development agency that can help you with your next project. Quality is their top priority, and they are committed to delivering the best possible product.

Discover Ecrin Digital.

Need a backend expert?