Нестандартная система разграничения доступа в приложении ASP. Net MVC
В одном из проектов, написанном на ASP. Net MVC 3 потребовалась авторизация пользователей.
Проект предусматривал несколько фиксированных ролей пользователей и авторизацию на уровне контроллеров и действий.
Казалось бы, что проще, используем существующее решение в виде атрибута [Authorize(Roles = «Role1»)], проставленного у контроллера или действия.
Так и было сделано, но оказалось что это оооочень неудобно в применении:
- Атрибуты разбросаны по всему проекту, приходилось держать в голове кто что и где может сделать.
- В случае добавления новой роли приходилось открывать все (!) файлы с контроллерами и добавлять соответствующие записи к атрибутам.
- Тоже самое в случае переименования роли (хотя тут позже был найден выход путем вынесения названия ролей в static const string).
Было решено применить иной подход:
- оставить авторизацию на уровне контроллер/действие
- вынести назначение прав в одно место (файл), что бы все было перед глазами
- сохранить или улучшить управление правами путем внедрения понятий «все действия в контроллере», «любая роль» и т.д.
Перейдем сразу к решению
Основа решения — добавление фильтра «Безопасность» (класс MySecurityFilter) к ряду уже зарегистрированных (пример использования кода в конце статьи).
Вот исходный код этого фильтра:
public class MySecurityFilter : FilterAttribute, IAuthorizationFilter
{
public void OnAuthorization(AuthorizationContext filterContext)
{
var role = filterContext.HttpContext.User.GetRole();
var action = (string)filterContext.RouteData.Values["action"];
var controllerType = filterContext.Controller.GetType();
if (!MySecurity.Instance.IsGrantAction(role, controllerType, action))
{
filterContext.Result = new HttpUnauthorizedResult();
}
}
}
Думаю особых вопросов здесь не должно возникнуть. Мы наследуемся от интерфейса IAuthorizationFilter и обрабатываем событие OnAuthorization:
- Получаем контроллер и экшн
- Запрашиваем у инстанс MySecurity разрешено ли нам действие
- Если запрещено задаем filterContext.Result = new HttpUnauthorizedResult();
Посмотрим, что внутри у MySecurity:
{
private static readonly MySecurity InstanceField = new MySecurity();
public static MySecurity Instance{get { return InstanceField;}}
private readonly Dictionary<string, List<string>> dict;
public MySecurity()
{
dict = new Dictionary<string, List<string>>();
}
public PermissionTableBuilder Table()
{
return new PermissionTableBuilder(this);
}
// методы класса, описание ниже
// ...
}
Несколько замечаний по данному классу:
1. Словарь содержит таблицу прав доступа. Ключ — название роли. Значение — список пермишшнов, в формате «ControllerName+Action»
Стоит отметить, что для ролей допустимо указание "*" и "?" (любой авторизованный и просто любой пользователь).
Для Action допустимо указание "*" — любой экшн в контроллере.
2. PermissionTableBuilder — специальный класс для упрощения построения этой таблицы
Методы класса MySecurity
Добавляем права:
public class MySecurity
public void AddPerm(string role, string perm)
{
if (!dict.ContainsKey(role))
{
dict[role] = new List<string>();
}
dict[role].Add(perm);
}
Проверяем права доступа:
public bool IsGrant(string role, string perm)
{
switch (role)
{
case "?":
return Any(perm);
case "*":
return Any(perm) || All(perm);
default:
return Any(perm) || All(perm) || Role(role, perm);
}
}
// проверяем права доступа для любого пользователя
private bool Any(string perm)
{
return Role("?", perm);
}
// проверяем права доступа для любого авторозованного, т.е. с ролью пользователя
private bool All(string perm)
{
return Role("*", perm);
}
// проверяем права доступа для конкретной роли
private bool Role(string role, string perm)
{
if (dict.ContainsKey(role))
{
return dict[role].Contains(perm);
}
return false;
}
Дополнительные методы для упрощения проверки прав доступа:
public bool IsGrantAction(string role, Type controllerType, string action)
{
return IsGrantAction(role, controllerType.Name, action);
}
public bool IsGrantAction(string role, string controllerName, string action)
{
// проверим сначала нет ли Permission, в котором разрешены любые действия
if (IsGrant(role, MySecurityHelper.GetPermission(controllerName, "*")))
{
return true;
}
return IsGrant(role, MySecurityHelper.GetPermission(controllerName, action));
}
Класс PermissionTableBuilder, служащий для упрощения построения таблицы прав доступа.
Его функционал может показаться непонятным, поэтому стоит взглянуть на пример его использования в конце статьи.
public class PermissionTableBuilder
{
private readonly MySecurity table;
private readonly List<string > roles;
public PermissionTableBuilder(MySecurity table)
{
this.table = table;
roles = new List<string>();
}
public PermissionTableBuilder WithRole(string role)
{
roles.Add(role);
return this;
}
public PermissionTableBuilder AddPerm<TController>(params string [] actions)
{
ProcessPerm(actions, x=> MySecurityHelper.GetPermission(typeof (TController), x));
return this;
}
public PermissionTableBuilder AddPerm(params string[] actions)
{
ProcessPerm(actions, x=>x);
return this;
}
private void ProcessPerm(string[] actions, Func<string, string> getPermNameFunc)
{
if (roles.Count == 0)
{
throw new InvalidOperationException("Call withRole first");
}
foreach (var action in actions)
{
foreach (var role in roles)
{
string perm = getPermNameFunc(action);
table.AddPerm(role, perm);
}
}
}
}
Тройка вспомогательных методов, оформленных отдельно:
public static class MySecurityHelper
{
public static string GetPermission(Type controllerType, string actionName)
{
return String.Format("{0}+{1}", controllerType.Name, actionName);
}
public static string GetPermission(string controllerName, string actionName)
{
if (controllerName.EndsWith("Controller"))
{
return String.Format("{0}+{1}", controllerName, actionName);
}
return String.Format("{0}Controller+{1}", controllerName, actionName);
}
public static string GetRole(this IPrincipal user)
{
var principal = user as RolePrincipal;
if (principal == null)
{
return "?";
}
return principal.GetRoles().SingleOrDefault() ?? "?";
}
}
А теперь смотрим на пример использования всей системы целиком:
Global.asax:
public static void RegisterGlobalFilters(GlobalFilterCollection filters)
{
// прочие фильтры
// ...
filters.Add(new MySecurityFilter());
}
protected void Application_Start()
{
// ...
PermissionListCreator.Create(MySecurity.Instance);
// ...
}
Вспомогательный класс PermissionListCreator.cs, содержащий код, создающий таблицу прав:
public static class PermissionListCreator
{
public static void Create(MySecurity security)
{
// Открытые для всех пользователей страницы
security.Table().WithRole("?").AddPerm<HomeController>("*");
security.Table().WithRole("?").AddPerm<LogOnController>("*");
security.Table().WithRole("?").AddPerm<ErrorController>("*");
// Личный кабинет
security.Table().WithRole("*").AddPerm<CabinetController>("*");
// Управление пользователями
security.Table().
WithRole(MyRoles.Director).AddPerm<UsersController>("*");
security.Table().
WithRole(MyRoles.Admin).AddPerm<UsersController>("Add", "Edit");
// просмотр лога
security.Table().
WithRole(MyRoles.Admin).
WithRole(MyRoles.Director).
WithRole(MyRoles.Commander).
WithRole(MyRoles.Developer).AddPerm<LogsController>("*");
// и так далее, со всеми контроллерами
}
- разрешили доступ всем пользователям к домашней странице проекта, форме авторизации, и сообщениям об ошибках.
- разрешили всем авторизованным пользователям доступ к личному кабинету
- разрешили администратору добавлять и изменять пользователей(но не удалять), а директору и удалять
- разрешили 4-м ролям просматривать лог
Обратите внимание, что все явно не разрешенные действия — запрещены!
На мой взгляд, получилось достаточно простое в использование и надежное решение. Правда сфера его применения ограничена, но ничто не мешает использовать его с любой другой системой разграничения доступа.