I'm in the same boat as you - I've always hated the RoleProviders. Yeah, they're great if you want to get things up and running for a small website, but they're not very realistic. The major downside I've always found is that they tie you directly to ASP.NET.
The way I went for a recent project was defining a couple of interfaces that are part of the service layer (NOTE: I simplified these quite a bit - but you could easily add to them):
public interface IAuthenticationService
{
bool Login(string username, string password);
void Logout(User user);
}
public interface IAuthorizationService
{
bool Authorize(User user, Roles requiredRoles);
}
Then your users could have a Roles
enum:
public enum Roles
{
Accounting = 1,
Scheduling = 2,
Prescriptions = 4
// What ever else you need to define here.
// Notice all powers of 2 so we can OR them to combine role permissions.
}
public class User
{
bool IsAdministrator { get; set; }
Roles Permissions { get; set; }
}
For your IAuthenticationService
, you could have a base implementation that does standard password checking and then you could have a FormsAuthenticationService
that does a little bit more such as setting the cookie etc. For your AuthorizationService
, you'd need something like this:
public class AuthorizationService : IAuthorizationService
{
public bool Authorize(User userSession, Roles requiredRoles)
{
if (userSession.IsAdministrator)
{
return true;
}
else
{
// Check if the roles enum has the specific role bit set.
return (requiredRoles & user.Roles) == requiredRoles;
}
}
}
On top of these base services, you could easily add services to reset passwords etc.
Since you're using MVC, you could do authorization at the action level using an ActionFilter
:
public class RequirePermissionFilter : IAuthorizationFilter
{
private readonly IAuthorizationService authorizationService;
private readonly Roles permissions;
public RequirePermissionFilter(IAuthorizationService authorizationService, Roles requiredRoles)
{
this.authorizationService = authorizationService;
this.permissions = requiredRoles;
this.isAdministrator = isAdministrator;
}
private IAuthorizationService CreateAuthorizationService(HttpContextBase httpContext)
{
return this.authorizationService ?? new FormsAuthorizationService(httpContext);
}
public void OnAuthorization(AuthorizationContext filterContext)
{
var authSvc = this.CreateAuthorizationService(filterContext.HttpContext);
// Get the current user... you could store in session or the HttpContext if you want too. It would be set inside the FormsAuthenticationService.
var userSession = (User)filterContext.HttpContext.Session["CurrentUser"];
var success = authSvc.Authorize(userSession, this.permissions);
if (success)
{
// Since authorization is performed at the action level, the authorization code runs
// after the output caching module. In the worst case this could allow an authorized user
// to cause the page to be cached, then an unauthorized user would later be served the
// cached page. We work around this by telling proxies not to cache the sensitive page,
// then we hook our custom authorization code into the caching mechanism so that we have
// the final say on whether or not a page should be served from the cache.
var cache = filterContext.HttpContext.Response.Cache;
cache.SetProxyMaxAge(new TimeSpan(0));
cache.AddValidationCallback((HttpContext context, object data, ref HttpValidationStatus validationStatus) =>
{
validationStatus = this.OnCacheAuthorization(new HttpContextWrapper(context));
}, null);
}
else
{
this.HandleUnauthorizedRequest(filterContext);
}
}
private void HandleUnauthorizedRequest(AuthorizationContext filterContext)
{
// Ajax requests will return status code 500 because we don't want to return the result of the
// redirect to the login page.
if (filterContext.RequestContext.HttpContext.Request.IsAjaxRequest())
{
filterContext.Result = new HttpStatusCodeResult(500);
}
else
{
filterContext.Result = new HttpUnauthorizedResult();
}
}
public HttpValidationStatus OnCacheAuthorization(HttpContextBase httpContext)
{
var authSvc = this.CreateAuthorizationService(httpContext);
var userSession = (User)httpContext.Session["CurrentUser"];
var success = authSvc.Authorize(userSession, this.permissions);
if (success)
{
return HttpValidationStatus.Valid;
}
else
{
return HttpValidationStatus.IgnoreThisRequest;
}
}
}
Which you can then decorate on your controller actions:
[RequirePermission(Roles.Accounting)]
public ViewResult Index()
{
// ...
}
The advantage of this approach is you can also use dependency injection and an IoC container to wire things up. Also, you can use it across multiple applications (not just your ASP.NET one). You would use your ORM to define the appropriate schema.
If you need more details around the FormsAuthorization/Authentication
services or where to go from here, let me know.
EDIT: To add "security trimming", you could do it with an HtmlHelper. This probably needs a little more... but you get the idea.
public static bool SecurityTrim<TModel>(this HtmlHelper<TModel> source, Roles requiredRoles)
{
var authorizationService = new FormsAuthorizationService();
var user = (User)HttpContext.Current.Session["CurrentUser"];
return authorizationService.Authorize(user, requiredRoles);
}
And then inside your view (using Razor syntax here):
@if(Html.SecurityTrim(Roles.Accounting))
{
<span>Only for accounting</span>
}
EDIT: The UserSession
would look something like this:
public class UserSession
{
public int UserId { get; set; }
public string UserName { get; set; }
public bool IsAdministrator { get; set; }
public Roles GetRoles()
{
// make the call to the database or whatever here.
// or just turn this into a property.
}
}
This way, we don't expose the password hash and all other details inside the session of the current user since they're really not needed for the user's session lifetime.