Building an ASP.NET MVC sitemap provider with security trimming
Edit on GitHub
Warning!
A new version of the source code provided in this post is available here. Use this blog post as reference only.
Yes, it has been a while since my last post. A nice vacation to Austria, some work to catch up, ... All excuses, I know, but I'll make it up to you with a huge blog post!
If you have been using the ASP.NET MVC framework, you possibly have been searching for something like the classic ASP.NET sitemap. After you've played with it, you even found it useful! But not really flexible and easy to map to routes and controllers. Sounds familiar? Continue reading! Doesn't ring a bell? Well, continue reading, please!
Feel free to download the sample code.
UPDATE: A version for preview 5 can also be downloaded: MvcSitemapProvider.cs (19.46 kb)
The base concept of this class is based on someone else's version which supports dynamic nodes, populated by code instead of XML. Unfortunately, I forgot to write down the URL where I found it. So please, if you do find something like that, let me know so I can thank this person for the base concepts of his class...
The concept
What I would like, is having a web.sitemap file which looks like the following:
[code:c#]
<?xml version="1.0" encoding="utf-8" ?>
<siteMap>
<siteMapNode id="Root" url="~/Index.aspx">
<mvcSiteMapNode id="Home" title="Home" controller="Home" action="Index">
<mvcSiteMapNode id="About" title="About Us" controller="Home" action="About" />
</mvcSiteMapNode>
<mvcSiteMapNode id="Products" title="Products" controller="Products">
<mvcSiteMapNode id="Books" title="Books" controller="Products" action="List" category="Books" />
<mvcSiteMapNode id="DVD" title="DVD's" controller="Products" action="List" category="DVD"/>
</mvcSiteMapNode>
<mvcSiteMapNode id="Account" title="Account" controller="Account">
<mvcSiteMapNode id="Login" title="Login" controller="Account" action="Login" />
<mvcSiteMapNode id="Register" title="Account Creation" controller="Account" action="Register" />
<mvcSiteMapNode id="ChangePassword" title="Change Password" controller="Account" action="ChangePassword" />
<mvcSiteMapNode id="Logout" title="Logout" controller="Account" action="Logout" />
</mvcSiteMapNode>
</siteMapNode>
</siteMap>
[/code]
That's right: regular siteMapNodes, but also mvcSiteMapNodes! I want my ASP.NET menu control and sitemap path to use both node types for determining the current locattion on my website. And since the ASP.NET MVC framework uses routing and allows extra parameters to build up a URL, I thought of creating an mvcSiteMapNode.
Each mvcSiteMapNode is structured like this:
id | Id for the current node. Can only occur once! |
title | The title to show in menu's. |
description | Optional description. |
controller | The controller to map this node to. Will default to "Home" if it is not specified. |
action | The action on that controller to map this node to. Will default to "Index" if it is not specified. |
* | Well, any other attribute will be used as route data values. For example, if you add "category='Books'", it will correpond with new { category = "Books" } in your route definitions. |
paramid | Well, this one maps to new { id = ... }, since I already used id before... |
Implementing it
Two options for this one... Option one would be extending the existing XmlSiteMapProvider class, but that seemed like a no-go because... well... I wanted to take the hard way :-) Option two it is! And that's extending StaticSiteMapProvider.
This MvcSiteMapProvider class will have to do some things:
- Read the web.config settings
- Cache my sitemap nodes for a specified amount of time
- Do some mapping of the current HttpContext (which is not IHttpContext, unfortunately...) to the current route
- Security trimming! The provider should check my controllers for AuthorizeAttribute and follow the directions of that attribute.
If you want to check the full source code, feel free to download it. I'll not go trough it completely in this blog post, but just pick some interesting parts.
MvcSiteMapNode
First things first! If I want to use a custom sitemap node, I'll have to create one! Here's my overloaded version of the SiteMapNode class which now also contains a Controller and Action property:
[code:c#]
/// <summary>
/// MvcSiteMapNode
/// </summary>
public class MvcSiteMapNode : SiteMapNode
{
#region Properties
public string Id { get; set; }
public string Controller { get; set; }
public string Action { get; set; }
#endregion
#region Constructor
/// <summary>
/// Creates a new MvcSiteMapNode instance
/// </summary>
public MvcSiteMapNode(SiteMapProvider provider, string key)
: base(provider, key)
{
Id = key;
}
#endregion
}
[/code]
Reading the mvcSiteMapNode XML
That's actually a nice one! Here's the full snippet:
[code:c#]
/// <summary>
/// Maps an XMLElement from the XML file to a SiteMapNode.
/// </summary>
/// <param name="node">The element to map.</param>
/// <returns>A SiteMapNode which represents the XMLElement.</returns>
protected SiteMapNode GetMvcSiteMapNodeFromXMLElement(XElement node)
{
// Get the ID attribute, need this so we can get the key.
string id = GetAttributeValue(node.Attribute("id"));
// Create a new sitemapnode, setting the key and url
var smNode = new MvcSiteMapNode(this, id);
// Create a route data dictionary
IDictionary<string, object> routeValues = new Dictionary<string, object>();
// Add each attribute to our attributes collection on the sitemapnode
// and to a route data dictionary.
foreach (XAttribute attribute in node.Attributes())
{
string attributeName = attribute.Name.ToString();
string attributeValue = attribute.Value;
smNode[attributeName] = attributeValue;
if (attributeName != "title" && attributeName != "description"
&& attributeName != "resourceKey" && attributeName != "id"
&& attributeName != "paramid")
{
routeValues.Add(attributeName, attributeValue);
}
else if (attributeName == "paramid")
{
routeValues.Add("id", attributeValue);
}
}
// Set the other properties on the sitemapnode,
// these are for title and description, these come
// from the nodes attrbutes are we populated all attributes
// from the xml to the node.
smNode.Title = smNode["title"];
smNode.Description = smNode["description"];
smNode.ResourceKey = smNode["resourceKey"];
smNode.Controller = smNode["controller"];
smNode.Action = smNode["action"] ?? "Index";
// Verify route values
if (!routeValues.ContainsKey("controller")) routeValues.Add("controller", "Home");
if (!routeValues.ContainsKey("action")) routeValues.Add("action", "Index");
// Build URL
MvcHandler handler = HttpContext.Current.Handler as MvcHandler;
RouteData routeData = handler.RequestContext.RouteData;
smNode.Url = "~/" + routeData.Route.GetVirtualPath(handler.RequestContext, new RouteValueDictionary(routeValues)).VirtualPath;
return smNode;
}
[/code]
Interesting part to note are the last 4 lines of code. I'm using the application's route data to map controller, action and values to a virtual path, which will be used by all sitemap controls to link to a URL. Coolness! If I change my routes in Global.asax.cs, my menu will automatically be updated without having to change my web.sitemap file.
Security trimming
Some more code. I told you it would be a long post!
[code:c#]
/// <summary>
/// Determine if a node is accessible for a user
/// </summary>
/// <param name="context">Current HttpContext</param>
/// <param name="node">Sitemap node</param>
/// <returns>True/false if the node is accessible</returns>
public override bool IsAccessibleToUser(HttpContext context, SiteMapNode node)
{
// Is security trimming enabled?
if (!this.SecurityTrimmingEnabled)
return true;
// Is it a regular node? No need for more things to do!
MvcSiteMapNode mvcNode = node as MvcSiteMapNode;
if (mvcNode == null)
return base.IsAccessibleToUser(context, node);
// Find current handler
MvcHandler handler = context.Handler as MvcHandler;
if (handler != null)
{
// It's an MvcSiteMapNode, try to figure out the controller class
IController controller = ControllerBuilder.Current.GetControllerFactory().CreateController(handler.RequestContext, mvcNode.Controller);
// Find all AuthorizeAttributes on the controller class and action method
object[] controllerAttributes = controller.GetType().GetCustomAttributes(typeof(AuthorizeAttribute), true);
object[] actionAttributes = controller.GetType().GetMethod(mvcNode.Action).GetCustomAttributes(typeof(AuthorizeAttribute), true);
// Attributes found?
if (controllerAttributes.Length == 0 && actionAttributes.Length == 0)
return true;
// Find out current principal
IPrincipal principal = handler.RequestContext.HttpContext.User;
// Find out configuration
string roles = "";
string users = "";
if (controllerAttributes.Length > 0)
{
AuthorizeAttribute attribute = controllerAttributes[0] as AuthorizeAttribute;
roles += attribute.Roles;
users += attribute.Users;
}
if (actionAttributes.Length > 0)
{
AuthorizeAttribute attribute = actionAttributes[0] as AuthorizeAttribute;
roles += attribute.Roles;
users += attribute.Users;
}
// Still need security trimming?
if (string.IsNullOrEmpty(roles) && string.IsNullOrEmpty(users) && principal.Identity.IsAuthenticated)
return true;
// Determine if the current user is allowed to access the current node
string[] roleArray = roles.Split(',');
string[] usersArray = users.Split(',');
foreach (string role in roleArray)
{
if (role != "*" && !principal.IsInRole(role)) return false;
}
foreach (string user in usersArray)
{
if (user != "*" && (principal.Identity.Name == "" || principal.Identity.Name != user)) return false;
}
return true;
}
return false;
}
[/code]
Now read it again, it might be a bit confusing. What actually happens, is the following:
- Security trimming is not enabled? Well duh! Of cource you can access this node!
- If the curent node that a menu control or something similar tries to render is a regular sitemap node, simply use the base class to verify security
- If it is an MvcSiteMapNode that we're accessing, do some work...
- Find out the controller and action method that's being called
- Check for security attributes on the controller
- Check for security attributes on the action method
- Verify if the current IPrincipal complies with all previous stuff
- No access granted in the past few lines of code? return false!
I can now actually hide a sitemap node from unauthorized users by simply adding the [Authorize(...)] attribute to a controller action!
Using it
Feel free to download the sample code or check the live demo. It has been configured to use my custom sitemap provider by adding the following in web.config:
[code:xml]
<system.web>
<!-- ... -->
<siteMap defaultProvider="MvcSitemapProvider">
<providers>
<add name="MvcSitemapProvider"
type="MvcSitemapProviderDemo.Core.MvcSitemapProvider"
siteMapFile="~/Web.sitemap" securityTrimmingEnabled="true"
cacheDuration="10" />
</providers>
</siteMap>
<!-- ... -->
</system.web>
[/code]
In short: I've told ASP.NET to use my sitemap provider in favor of the standard sitemap provider. Don't you just love this provider model!
Known issues
- The root node should always link to url "~/Index.aspx"
- A controller + action + values combination can only occur once (but that's the case with regular sitemaps too)
Note: based on ASP.NET MVC preview 4 - A version for preview 5 can also be downloaded: MvcSiteMapProvider.cs (19.90 kb)
This is an imported post. It was imported from my old blog using an automated tool and may contain formatting errors and/or broken images.
59 responses