Home

Awesome

ASP.NET Core Authorization Lab

This is walk through for an ASP.NET Core Authorization Lab, now updated for ASP.NET Core 2.1 and VS2017. (If you're still using 1.x then the older version of the labs are available in the Core1x branch.)

This lab uses the Model-View-Controller template as that's what everyone has been using up until now and it's the most familiar starting point for the vast majority of people.

Official authorization documentation is at https://docs.asp.net/en/latest/security/authorization/index.html.

Tip: When you stop finish running the app at each stage always close the browser to clear the identity cookie.

Step 0: Preparation

Create a new, blank, ASP.NET project.

Add MVC to the app.

app.UseMvc(routes =>
{
     routes.MapRoute(
          name: "default",
          template: "{controller=Home}/{action=Index}/{id?}");
});

using Microsoft.AspNetCore.Mvc;

namespace AuthorizationLab.Controllers
{
    public class HomeController : Controller
    {
        public IActionResult Index()
        {
            return View();
        }
    }
}

Step 1: Setup authentication

services.AddAuthentication(CookieAuthenticationDefaults.AuthenticationScheme)
    .AddCookie(CookieAuthenticationDefaults.AuthenticationScheme,
        options => 
        {
                options.LoginPath = new PathString("/Account/Login/");
                options.AccessDeniedPath = new PathString("/Account/Forbidden/");
        });
using Microsoft.AspNetCore.Mvc;

namespace AuthorizationLab.Controllers
{
    public class AccountController : Controller
    {
        public IActionResult Login()
        {
            return View();
        }

        public IActionResult Forbidden()
        {
            return View();
        }
    }
}
public async Task<IActionResult> Login(string returnUrl = null)
{
    const string Issuer = "https://contoso.com";
    var claims = new List<Claim>();
    claims.Add(new Claim(ClaimTypes.Name, "barry", ClaimValueTypes.String, Issuer));
    var userIdentity = new ClaimsIdentity("SuperSecureLogin");
    userIdentity.AddClaims(claims);
    var userPrincipal = new ClaimsPrincipal(userIdentity);

    await HttpContext.SignInAsync(
        CookieAuthenticationDefaults.AuthenticationScheme,
        userPrincipal,
        new AuthenticationProperties
        {
            ExpiresUtc = DateTime.UtcNow.AddMinutes(20),
            IsPersistent = false,
            AllowRefresh = false
        });

    return RedirectToLocal(returnUrl);
}

private IActionResult RedirectToLocal(string returnUrl)
{
    if (Url.IsLocalUrl(returnUrl))
    {
        return Redirect(returnUrl);
    }
    else
    {
        return RedirectToAction("Index", "Home");
    }
}
@using System.Security.Claims;

@if (!User.Identities.Any(u => u.IsAuthenticated))
{
    <h1>Hello World</h1>
}
else
{
    <h1>Hello @User.Identities.First(
      u => u.IsAuthenticated && 
      u.HasClaim(c => c.Type == ClaimTypes.Name)).FindFirst(ClaimTypes.Name).Value</h1>
}

Remember to close the browser to clear the identity cookie before moving on to the next step.

Step 2: Authorize all the things

services.AddMvc(config =>
{
    var policy = new AuthorizationPolicyBuilder()
                     .RequireAuthenticatedUser()
                     .Build();
    config.Filters.Add(new AuthorizeFilter(policy));
});

Remember to close the browser to clear the identity cookie before moving on to the next step.

Step 3: Roles

[Authorize(Roles = "Administrator")]
claims.Add(new Claim(ClaimTypes.Role, "Administrator", ClaimValueTypes.String, Issuer));

Remember to close the browser to clear the identity cookie before moving on to the next step.

Step 4: Simple Policies

services.AddAuthorization(options =>
{
    options.AddPolicy("AdministratorOnly", policy => policy.RequireRole("Administrator"));
});
[Authorize(Policy = "AdministratorOnly")]
services.AddAuthorization(options =>
{
    options.AddPolicy("AdministratorOnly", policy => policy.RequireRole("Administrator"));
    options.AddPolicy("EmployeeId", policy => policy.RequireClaim("EmployeeId"));
});
claims.Add(new Claim("EmployeeId", string.Empty, ClaimValueTypes.String, Issuer));
[Authorize(Policy = "EmployeeId")]
options.AddPolicy("EmployeeId", policy => policy.RequireClaim("EmployeeId", "123", "456"));
claims.Add(new Claim("EmployeeId", "123", ClaimValueTypes.String, Issuer));

If a policy has multiple claim requirements all the claim requirements must be fulfilled for authorization to succeed.

Remember to close the browser to clear the identity cookie before moving on to the next step.

Step 5: Code Based Policies

Code based policies consist of a requirement, implementing IAuthorizationRequirement and a handler for the requirement, implementing AuthorizationHandler<T> where T is the requirement.

claims.Add(new Claim(ClaimTypes.DateOfBirth, "1970-06-08", ClaimValueTypes.Date));
using System;
using System.Security.Claims;
using System.Threading.Tasks;
using Microsoft.AspNetCore.Authorization;

namespace AuthorizationLab
{
    public class MinimumAgeRequirement : AuthorizationHandler<MinimumAgeRequirement>, IAuthorizationRequirement
    {
        int _minimumAge;

        public MinimumAgeRequirement(int minimumAge)
        {
            _minimumAge = minimumAge;
        }

        protected override Task HandleRequirementAsync(
            AuthorizationHandlerContext context, 
            MinimumAgeRequirement requirement)
        {
            if (!context.User.HasClaim(c => c.Type == ClaimTypes.DateOfBirth))
            {
                return Task.CompletedTask;
            }

            var dateOfBirth = Convert.ToDateTime(
                context.User.FindFirst(c => c.Type == ClaimTypes.DateOfBirth).Value);

            int calculatedAge = DateTime.Today.Year - dateOfBirth.Year;
            if (dateOfBirth > DateTime.Today.AddYears(-calculatedAge))
            {
                calculatedAge--;
            }

            if (calculatedAge >= _minimumAge)
            {
                context.Succeed(requirement);
            }

            return Task.CompletedTask;
        }
    }
}
options.AddPolicy("Over21Only", policy => policy.Requirements.Add(new MinimumAgeRequirement(21)));

Remember to close the browser to clear the identity cookie before moving on to the next step.

Step 6: Multiple handlers for a requirement

You may have noticed what a handler returns, nothing at all (Strictly we're returning Task.CompletedTask;, which is effectively nothing). Handlers inform the authorization service they have succeeded by calling context.Succeed(requirement);. You may be asking yourself if there is a context.Succeed() is there a context.Fail()? There is, but if your requirement isn't met you shouldn't touch the context at all. Now you may be asking why not? Well ...

Sometimes you may want multiple handlers for an Authorization Requirement, for example when there are multiple ways to fulfill a requirement. Microsoft's office doors open with your Microsoft badge, however on days you forget your badge you can go to reception and get a temporary pass and the receptionist will let you through the gates. Thus there are two ways to fulfill the single entry requirement. In the ASP.NET Core authorization model this would be implemented as two handlers for a single requirement.

using Microsoft.AspNetCore.Authorization;

namespace AuthorizationLab
{
    public class OfficeEntryRequirement : IAuthorizationRequirement
    {
    }
}
using Microsoft.AspNetCore.Authorization;

namespace AuthorizationLab
{
    public class HasBadgeHandler : AuthorizationHandler<OfficeEntryRequirement>
    {
        protected override Task HandleRequirementAsync(
          AuthorizationHandlerContext context, 
          OfficeEntryRequirement requirement)
        {
            if (!context.User.HasClaim(c => c.Type == "BadgeNumber" && 
                                            c.Issuer == "https://contoso.com"))
            {
                return Task.CompletedTask;
            }

            context.Succeed(requirement);

            return Task.CompletedTask;
        }
    }
}

That takes care of people who remembered their badges, issued by the right company (after all multiple companies have entry cards, so you want to check that the card is issued by the company you expect. The Claims class has an issuer property which details who issued the claim, so in our case it's who issued the badge).

But what about those who forget and have a temporary badge? You could just put it all in one handler, but handlers and requirements are meant to be reusable. You could use the HasBadgeHandler shown above for other things, not just office entry (for example the Microsoft code signing infrastructure needs the smart card that is our office badge to trigger jobs).

using System;
using Microsoft.AspNetCore.Authorization;

namespace AuthorizationLab
{
    public class HasTemporaryPassHandler : AuthorizationHandler<OfficeEntryRequirement>
    {
        protected override Task HandleRequirementAsync(
          AuthorizationHandlerContext context, 
          OfficeEntryRequirement requirement)
        {
            if (!context.User.HasClaim(c => c.Type == "TemporaryBadgeExpiry" &&
                                            c.Issuer == "https://contoso.com"))
            {
                return Task.CompletedTask;
            }

            var temporaryBadgeExpiry = 
                Convert.ToDateTime(context.User.FindFirst(
                                       c => c.Type == "TemporaryBadgeExpiry" &&
                                       c.Issuer == "https://contoso.com").Value);

            if (temporaryBadgeExpiry > DateTime.Now)
            {
                context.Succeed(requirement);
            }

            return Task.CompletedTask;
        }
    }
}

Note that neither handler calls context.Fail(). context.Fail() is there for occasions when authorization cannot continue, even if there's another handler, for example, "My Entire User Database is on fire." or "The user I'm looking at has just been blocked, but other back-end systems may not yet be updated."

options.AddPolicy("BuildingEntry", policy => policy.Requirements.Add(new OfficeEntryRequirement()));
claims.Add(new Claim("BadgeNumber", "123456", ClaimValueTypes.String, Issuer));
[Authorize(Policy = "BuildingEntry")]

Handlers are held in the ASP.NET DI container. In our previous sample we combined the requirement and the handler in one class, so the authorization system knew about it without having to manually register it in DI. Now we have separate handlers we need to register them in the DI container before they can be found.

services.AddSingleton<IAuthorizationHandler, HasBadgeHandler>();
services.AddSingleton<IAuthorizationHandler, HasTemporaryPassHandler>();
claims.Add(new Claim("TemporaryBadgeExpiry", 
                     DateTime.Now.AddDays(1).ToString(), 
                     ClaimValueTypes.String, 
                     Issuer));
claims.Add(new Claim("TemporaryBadgeExpiry", 
                     DateTime.Now.AddDays(-1).ToString(), 
                     ClaimValueTypes.String, 
                     Issuer));

Step 7: Resource Based Requirements

So far we've covered requirements that are based only on a user's identity. However often authorization requires the resource being accessed. For example a Document class may have an author and only authors can edit the document, whilst others can view it.

namespace AuthorizationLab
{
    public class Document
    {
        public int Id { get; set; }
        public string Author { get; set; }
    }
}
using System.Collections.Generic;

namespace AuthorizationLab
{
    public interface IDocumentRepository
    {
        IEnumerable<Document> Get();

        Document Get(int id);
    }
}

Create an implementation of the repository, with some test documents, DocumentRepository.cs

using System.Collections.Generic;
using System.Linq;

namespace AuthorizationLab
{
    public class DocumentRepository : IDocumentRepository
    {
        static List<Document> _documents = new List<Document> {
            new Document { Id = 1, Author = "barry" },
            new Document { Id = 2, Author = "someoneelse" }
        };

        public IEnumerable<Document> Get()
        {
            return _documents;
        }

        public Document Get(int id)
        {
            return (_documents.FirstOrDefault(d => d.Id == id));
        }
    }
}
services.AddSingleton<IDocumentRepository, DocumentRepository>();

Now we can create a suitable controller and views to display a list of documents and the document itself.

using Microsoft.AspNetCore.Mvc;

namespace AuthorizationLab.Controllers
{
    public class DocumentController : Controller
    {
        IDocumentRepository _documentRepository;

        public DocumentController(IDocumentRepository documentRepository)
        {
            _documentRepository = documentRepository;
        }

        public IActionResult Index()
        {
            return View(_documentRepository.Get());
        }

        public IActionResult Edit(int id)
        {
            var document = _documentRepository.Get(id);

            if (document == null)
            {
                return new NotFoundResult();
            }

            return View(document);
        }
    }
}
@using AuthorizationLab
@model IEnumerable<Document>

<h1>Document Library</h1>
@foreach (var document in Model)
{
    <p>
        @Html.ActionLink("Document #"+document.Id, "Edit",  new { id = document.Id })
    </p>
}
@using AuthorizationLab
@model Document

<h1>Document #@Model.Id</h1>
<h2>Author: @Model.Author</h2>

Now we need to define operations to authorize against. For a document this might be Read, Write, Edit and Delete. We provide a base class, OperationAuthorizationRequirement which you can use as a starting point, but it's optional.

using Microsoft.AspNetCore.Authorization;

namespace AuthorizationLab
{
    public class EditRequirement : IAuthorizationRequirement
    {
    }
}

Now, as before, we write a handler for the requirement, but this time we write a handler which takes a resource.

using System.Security.Claims;
using Microsoft.AspNetCore.Authorization;

namespace AuthorizationLab
{
    public class DocumentEditHandler : AuthorizationHandler<EditRequirement, Document>
    {
        protected override Task HandleRequirementAsync(
            AuthorizationHandlerContext context, 
            EditRequirement requirement, 
            Document resource)
        {
            if (resource.Author == context.User.FindFirst(ClaimTypes.Name).Value)
            {
                context.Succeed(requirement);
            }

            return Task.CompletedTask;
        }
    }
}
services.AddSingleton<IAuthorizationHandler, DocumentEditHandler>();

We cannot use resource handlers in attributes, because binding hasn't happened at that point and we need the resource. The resource only becomes available inside the action method. So we must call the authorization service directly.

using Microsoft.AspNetCore.Authorization;
using Microsoft.AspNetCore.Mvc;

namespace AuthorizationLab.Controllers
{
    public class DocumentController : Controller
    {
        IDocumentRepository _documentRepository;
        IAuthorizationService _authorizationService;

        public DocumentController(IDocumentRepository documentRepository, 
                                  IAuthorizationService authorizationService)
        {
            _documentRepository = documentRepository;
            _authorizationService = authorizationService;
        }

        public IActionResult Index()
        {
            return View(_documentRepository.Get());
        }

        public IActionResult Edit(int id)
        {
            var document = _documentRepository.Get(id);

            if (document == null)
            {
                return new NotFoundResult();
            }

            return View(document);
        }
    }
}

Finally we can call the service inside an action method.

using System.Threading.Tasks;
using Microsoft.AspNetCore.Authorization;
using Microsoft.AspNetCore.Mvc;

namespace AuthorizationLab.Controllers
{
    public class DocumentController : Controller
    {
        IDocumentRepository _documentRepository;
        IAuthorizationService _authorizationService;

        public DocumentController(IDocumentRepository documentRepository, 
                                  IAuthorizationService authorizationService)
        {
            _documentRepository = documentRepository;
            _authorizationService = authorizationService;
        }

        public IActionResult Index()
        {
            return View(_documentRepository.Get());
        }

        [Authorize]
        public async Task<IActionResult> Edit(int id)
        {
            var document = _documentRepository.Get(id);

            if (document == null)
            {
                return new NotFoundResult();
            }


            var authorizationResult = await _authorizationService.AuthorizeAsync(User, document, new EditRequirement());
            if (authorizationResult.Succeeded)
            {
                return View(document);
            }
            else
            {
                return new ForbidResult();
            }
        }
    }
}

Step 8: Authorizing in Views

For resource links and other UI elements you probably want to not show those links to users in the UI, so as to reduce temptation. You still want to keep authorization checks in the Controller - never rely solely on UI element removal as a security mechanism. ASP.NET Core allows DI within views, so you can use the same approach in Step 7 to hide documents in the document list the current user cannot access.

@using Microsoft.AspNetCore.Authorization
@using AuthorizationLab
@model IEnumerable<Document>
@inject IAuthorizationService AuthorizationService

<h1>Document Library</h1>
@foreach (var document in Model)
{
    <p>
        @Html.ActionLink("Document #"+document.Id, "Edit",  new { id = document.Id })
    </p>
}
@using Microsoft.AspNetCore.Authorization
@using AuthorizationLab

@model IEnumerable<Document>
@inject IAuthorizationService AuthorizationService

<h1>Document Library</h1>
@{ 
    var requirement = new EditRequirement();
    foreach (var document in Model)
    {
        var authorizationResult = await AuthorizationService.AuthorizeAsync(User, document, requirement);
        if (authorizationResult.Succeeded)
        {
        <p>@Html.ActionLink("Document #" + document.Id, "Edit", new { id = document.Id })</p>
        }
    }
}

Applying what you've learnt

Open the Workshop_Start folder.

This is a sample web site for inventory control. The site allows record label employees to update the details of albums.

There are 3 users, barryd, davidfowl and dedwards. barryd is an administrator for Paddy Productions. dewards is an administrator for ToneDeaf Records. davidfowl is an employee of ToneDeaf Records, but not an administrator. Administrators are part of the Administrator role.

A User repository has been provided for you and is injected into the Account controller. You should use the ValidateLogin() function to first check if the login is correct, then retrieve a suitable user principal using the Get() method.

The cookie authentication middleware is already configured, the scheme name is available from the Constants.MiddlewareScheme field.

Change the site to include the following functionality:

  1. Change the AccountController Login action to create a cookie for the user logging in using the already configured cookie middleware.
  2. Make the entire site require a login, excluding the Login action in the AccountController.
  3. Make the Edit action in the HomeController only available to logged in Administrators for any company.
  4. Make the Edit functionality only available to Administrators for the company that has issued the album.
  5. Change the Index action in the HomeController so it only lists all albums and but the edit link is only shown for administrators for the company that issued the album.

A sample solution is contained in the Workshop_Suggested_Solution folder.