Introduction
You already know how role-based authorization works in ASP.NET Core.
[Authorize(Roles = "Administrator")]
public class SomeController : Controller
{
}
But what if you don't want hardcode roles in authorization attribute or create roles later and specify in which controller and action it has access without touching source code?
Using the Code
Let's begin our journey. Create ASP.NET Core Web Application project and change authentication to Individual User Accounts.
After creating project, the first thing we need is to find all controllers inside project. Add two new classes MvcControllerInfo
and MvcActionInfo
inside Models folder:
public class MvcControllerInfo
{
public string Id => $"{AreaName}:{Name}";
public string Name { get; set; }
public string DisplayName { get; set; }
public string AreaName { get; set; }
public IEnumerable<MvcActionInfo> Actions { get; set; }
}
public class MvcActionInfo
{
public string Id => $"{ControllerId}:{Name}";
public string Name { get; set; }
public string DisplayName { get; set; }
public string ControllerId { get; set; }
}
Add another class MvcControllerDiscovery
to Services folder to discover all controllers and actions:
public class MvcControllerDiscovery : IMvcControllerDiscovery
{
private List<MvcControllerInfo> _mvcControllers;
private readonly IActionDescriptorCollectionProvider _actionDescriptorCollectionProvider;
public MvcControllerDiscovery
(IActionDescriptorCollectionProvider actionDescriptorCollectionProvider)
{
_actionDescriptorCollectionProvider = actionDescriptorCollectionProvider;
}
public IEnumerable<MvcControllerInfo> GetControllers()
{
if (_mvcControllers != null)
return _mvcControllers;
_mvcControllers = new List<MvcControllerInfo>();
var items = _actionDescriptorCollectionProvider
.ActionDescriptors.Items
.Where(descriptor => descriptor.GetType() == typeof(ControllerActionDescriptor))
.Select(descriptor => (ControllerActionDescriptor)descriptor)
.GroupBy(descriptor => descriptor.ControllerTypeInfo.FullName)
.ToList();
foreach (var actionDescriptors in items)
{
if (!actionDescriptors.Any())
continue;
var actionDescriptor = actionDescriptors.First();
var controllerTypeInfo = actionDescriptor.ControllerTypeInfo;
var currentController = new MvcControllerInfo
{
AreaName = controllerTypeInfo.GetCustomAttribute<AreaAttribute>()?.RouteValue,
DisplayName =
controllerTypeInfo.GetCustomAttribute<DisplayNameAttribute>()?.DisplayName,
Name = actionDescriptor.ControllerName,
};
var actions = new List<MvcActionInfo>();
foreach (var descriptor in actionDescriptors.GroupBy
(a => a.ActionName).Select(g => g.First()))
{
var methodInfo = descriptor.MethodInfo;
actions.Add(new MvcActionInfo
{
ControllerId = currentController.Id,
Name = descriptor.ActionName,
DisplayName =
methodInfo.GetCustomAttribute<DisplayNameAttribute>()?.DisplayName,
});
}
currentController.Actions = actions;
_mvcControllers.Add(currentController);
}
return _mvcControllers;
}
IActionDescriptorCollectionProvider
provides the cached collection of ActionDescriptor
which each descriptor represents an action. Each MvcControllerInfo
contains its Actions.
Open Startup
class and inside Configure
method, register MvcControllerDiscovery
dependency.
services.AddSingleton<IMvcControllerDiscovery, MvcControllerDiscovery>();
It's time to add role controller to manage roles. In Controller folder, create RoleController
then add Create
action:
public class RoleController : Controller
{
private readonly IMvcControllerDiscovery _mvcControllerDiscovery;
public RoleController(IMvcControllerDiscovery mvcControllerDiscovery)
{
_mvcControllerDiscovery = mvcControllerDiscovery;
}
public ActionResult Create()
{
ViewData["Controllers"] = _mvcControllerDiscovery.GetControllers();
return View();
}
}
Go to Models folder and add RoleViewModel
class:
public class RoleViewModel
{
[Required]
[StringLength(256, ErrorMessage = "The {0} must be at least {2} characters long.")]
public string Name { get; set; }
public IEnumerable<MvcControllerInfo> SelectedControllers { get; set; }
}
public class RoleViewModel
{
[Required]
[StringLength(256, ErrorMessage = "The {0} must be at least {2} characters long.")]
public string Name { get; set; }
public IEnumerable<MvcControllerInfo> SelectedControllers { get; set; }
}
And in View folder, add another folder and name it Role, then add Create.cshtml view. I used jqeury.bonsai for showing controller and action hierarchy.
@model RoleViewModel
@{
ViewData["Title"] = "Create Role";
var controllers = (IEnumerable<MvcControllerInfo>)ViewData["Controllers"];
}
@section Header {
<link href="~/lib/jquery-bonsai/jquery.bonsai.css" rel="stylesheet" />
}
<h2>Create Role</h2>
<hr />
<div class="row">
<div class="col-md-6">
<form asp-action="Create" class="form-horizontal">
<div asp-validation-summary="ModelOnly" class="text-danger"></div>
<div class="form-group">
<label asp-for="Name" class="control-label col-md-2"></label>
<div class="col-md-10">
<input asp-for="Name" class="form-control" />
<span asp-validation-for="Name" class="text-danger"></span>
</div>
</div>
<div class="form-group">
<label class="col-md-3 control-label">Access List</label>
<div class="col-md-9">
<ol id="tree">
@foreach (var controller in controllers)
{
string name;
{
name = controller.DisplayName ?? controller.Name;
}
<li class="controller" data-value="@controller.Name">
<input type="hidden" class="area" value="@controller.AreaName" />
@name
@if (controller.Actions.Any())
{
<ul>
@foreach (var action in controller.Actions)
{
{
name = action.DisplayName ?? action.Name;
}
<li data-value="@action.Name">@name</li>
}
</ul>
}
</li>
}
</ol>
</div>
</div>
<div class="form-group">
<input type="submit" value="Create" class="btn btn-default" />
</div>
</form>
</div>
</div>
<div>
<a asp-action="Index">Back to List</a>
</div>
@section Scripts {
@{await Html.RenderPartialAsync("_ValidationScriptsPartial");}
<script src="~/lib/jquery-qubit/jquery.qubit.js"></script>
<script src="~/lib/jquery-bonsai/jquery.bonsai.js"></script>
<script>
$(function () {
$('#tree').bonsai({
expandAll: false,
checkboxes: true,
createInputs: 'checkbox'
});
$('form').submit(function () {
var i = 0, j = 0;
$('.controller > input[type="checkbox"]:checked,
.controller > input[type="checkbox"]:indeterminate').each(function () {
var controller = $(this);
if ($(controller).prop('indeterminate')) {
$(controller).prop("checked", true);
}
var controllerName = 'SelectedControllers[' + i + ']';
$(controller).prop('name', controllerName + '.Name');
var area = $(controller).next().next();
$(area).prop('name', controllerName + '.AreaName');
$('ul > li > input[type="checkbox"]:checked',
$(controller).parent()).each(function () {
var action = $(this);
var actionName = controllerName + '.Actions[' + j + '].Name';
$(action).prop('name', actionName);
j++;
});
j = 0;
i++;
});
return true;
});
});
</script>
}
It's time to save role, but before that, we need to make some changes.
- First, create new class
ApplicationRole
inside Models folder:
public class ApplicationRole : IdentityRole
{
public string Access { get; set; }
}
- Open
ApplicationDbContext
, change it to:
public class ApplicationDbContext : IdentityDbContext<ApplicationUser, ApplicationRole, string>
- Open
Startup
class and inside Configure
method, change services.AddIdentity...
to:
services.AddIdentity<ApplicationUser, ApplicationRole>()
.AddEntityFrameworkStores<ApplicationDbContext>()
.AddDefaultTokenProviders();
- Finally, add new EF migration, in nuget Package Manager Console, run
Add-Migration RoleAccessAdded
command and new migration will be added to Data->Migrations
folder.
Go back to the RoleController
and add post
method of Create action.
private readonly IMvcControllerDiscovery _mvcControllerDiscovery;
private readonly RoleManager<ApplicationRole> _roleManager;
public RoleController(IMvcControllerDiscovery mvcControllerDiscovery,
RoleManager<ApplicationRole> roleManager)
{
_mvcControllerDiscovery = mvcControllerDiscovery;
_roleManager = roleManager;
}
[HttpPost]
[ValidateAntiForgeryToken]
public async Task<ActionResult> Create(RoleViewModel viewModel)
{
if (!ModelState.IsValid)
{
ViewData["Controllers"] = _mvcControllerDiscovery.GetControllers();
return View(viewModel);
}
var role = new ApplicationRole { Name = viewModel.Name };
if (viewModel.SelectedControllers != null && viewModel.SelectedControllers.Any())
{
foreach (var controller in viewModel.SelectedControllers)
foreach (var action in controller.Actions)
action.ControllerId = controller.Id;
var accessJson = JsonConvert.SerializeObject(viewModel.SelectedControllers);
role.Access = accessJson;
}
var result = await _roleManager.CreateAsync(role);
if (result.Succeeded)
return RedirectToAction(nameof(Index));
foreach (var error in result.Errors)
ModelState.AddModelError("", error.Description);
ViewData["Controllers"] = _mvcControllerDiscovery.GetControllers();
return View(viewModel);
}
Selected controllers serialized into json and stored in role Access
property.
You can decorate controllers and actions with DisplayName
attribute to show user more meaningful name instead of controller and action name.
[DisplayName("Access Management")]
public class AccessController : Controller
{
[DisplayName("Access List")]
public async Task<ActionResult> Index()
}
Next step is assigning roles to users. Add new view model and name it UserRoleViewModel and new controller AccessController. AccessController
is straightforward and has nothing complicated.
After assigning roles to users, now we can check a user has permission to access a controller and action or not. Add new folder Filters, then add new class DynamicAuthorizationFilter
to the folder and DynamicAuthorizationFilter
inherits IAsyncAuthorizationFilter
.
public class DynamicAuthorizationFilter : IAsyncAuthorizationFilter
{
private readonly ApplicationDbContext _dbContext;
public DynamicAuthorizationFilter(ApplicationDbContext dbContext)
{
_dbContext = dbContext;
}
public async Task OnAuthorizationAsync(AuthorizationFilterContext context)
{
if (!IsProtectedAction(context))
return;
if (!IsUserAuthenticated(context))
{
context.Result = new UnauthorizedResult();
return;
}
var actionId = GetActionId(context);
var userName = context.HttpContext.User.Identity.Name;
var roles = await (
from user in _dbContext.Users
join userRole in _dbContext.UserRoles on user.Id equals userRole.UserId
join role in _dbContext.Roles on userRole.RoleId equals role.Id
where user.UserName == userName
select role
).ToListAsync();
foreach (var role in roles)
{
var accessList =
JsonConvert.DeserializeObject<IEnumerable<MvcControllerInfo>>(role.Access);
if (accessList.SelectMany(c => c.Actions).Any(a => a.Id == actionId))
return;
}
context.Result = new ForbidResult();
}
private bool IsProtectedAction(AuthorizationFilterContext context)
{
if (context.Filters.Any(item => item is IAllowAnonymousFilter))
return false;
var controllerActionDescriptor = (ControllerActionDescriptor)context.ActionDescriptor;
var controllerTypeInfo = controllerActionDescriptor.ControllerTypeInfo;
var actionMethodInfo = controllerActionDescriptor.MethodInfo;
var authorizeAttribute = controllerTypeInfo.GetCustomAttribute<AuthorizeAttribute>();
if (authorizeAttribute != null)
return true;
authorizeAttribute = actionMethodInfo.GetCustomAttribute<AuthorizeAttribute>();
if (authorizeAttribute != null)
return true;
return false;
}
private bool IsUserAuthenticated(AuthorizationFilterContext context)
{
return context.HttpContext.User.Identity.IsAuthenticated;
}
private string GetActionId(AuthorizationFilterContext context)
{
var controllerActionDescriptor = (ControllerActionDescriptor)context.ActionDescriptor;
var area = controllerActionDescriptor.ControllerTypeInfo.
GetCustomAttribute<AreaAttribute>()?.RouteValue;
var controller = controllerActionDescriptor.ControllerName;
var action = controllerActionDescriptor.ActionName;
return $"{area}:{controller}:{action}";
}
}
IsProtectedAction
checks if requested controller and action has Authorize
attribute or not and if controller has Authorize
attribute, action has AllowAnonymous
attribute or not because we don't want check access on unprotected controllers and actions. IsUserAuthenticated
checks user whether is authenticated or not and if user is not authenticated, UnauthorizedResult
will be returned. - Then, we fetch user roles and check if those roles have access to requested controller or not and if user has no access,
ForbidResult
will be returned.
Now, we need to register this filter globally in Startup
class and modify services.AddMvc()
to this:
services.AddMvc(options => options.Filters.Add(typeof(DynamicAuthorizationFilter)));
That's it! Now we are able to create role and assign roles to user and check user access on each request.
And finally, we need a custom TageHelper
to check whether user has access to view links or not.
[HtmlTargetElement("secure-content")]
public class SecureContentTagHelper : TagHelper
{
private readonly ApplicationDbContext _dbContext;
public SecureContentTagHelper(ApplicationDbContext dbContext)
{
_dbContext = dbContext;
}
[HtmlAttributeName("asp-area")]
public string Area { get; set; }
[HtmlAttributeName("asp-controller")]
public string Controller { get; set; }
[HtmlAttributeName("asp-action")]
public string Action { get; set; }
[ViewContext, HtmlAttributeNotBound]
public ViewContext ViewContext { get; set; }
public override async Task ProcessAsync(TagHelperContext context, TagHelperOutput output)
{
output.TagName = null;
var user = ViewContext.HttpContext.User;
if (!user.Identity.IsAuthenticated)
{
output.SuppressOutput();
return;
}
var roles = await (
from usr in _dbContext.Users
join userRole in _dbContext.UserRoles on usr.Id equals userRole.UserId
join role in _dbContext.Roles on userRole.RoleId equals role.Id
where usr.UserName == user.Identity.Name
select role
).ToListAsync();
var actionId = $"{Area}:{Controller}:{Action}";
foreach (var role in roles)
{
var accessList =
JsonConvert.DeserializeObject<IEnumerable<MvcControllerInfo>>(role.Access);
if (accessList.SelectMany(c => c.Actions).Any(a => a.Id == actionId))
return;
}
output.SuppressOutput();
}
}
In each view, wrap anchor tag inside secure-content
tag:
<ul class="nav navbar-nav">
<li><a asp-area="" asp-controller="Home" asp-action="Index">Home</a></li>
<li><a asp-area="" asp-controller="Home" asp-action="About">About</a></li>
<li><a asp-area="" asp-controller="Home" asp-action="Contact">Contact</a></li>
<secure-content asp-area="" asp-controller="Role" asp-action="Index">
<li><a asp-area="" asp-controller="Role" asp-action="Index">Role</a></li>
</secure-content>
<secure-content asp-area="" asp-controller="Access" asp-action="Index">
<li><a asp-area="" asp-controller="Access" asp-action="Index">Access</a></li>
</secure-content>
</ul
You can find source and demo on github.
Points of Interest
This is a simple solution if you don't want hardcode roles, users and policies in authorization attribute and create roles later and assign and edit access of roles, then assign roles to users.