Сообщений 1 Оценка 0 Оценить |
Постановка проблемы Идея Реализация Класс PageBase ИспользованиеКласс PageBaseAnonym Класс PageBaseLogined Класс-родитель страницы в проекте сайта Класс конкретной страницы Результат Исходники на GitHub |
Ты никогда не решишь проблему, если будешь думать так же, как те, кто её создал. Альберт Эйнштейн
Практически в любом проекте Web-сайта имеется задача авторизовать пользователя, то есть реализовать права доступа как к какой-то отдельной информации или действиям на странице, так и к странице в целом. Конкретнее, практически всегда встает необходимость:
Простейшее решение этой задачи – в обработчике события страницы Page_Load (или Page_Init или даже Page_PreInit) проверить все условия, и в случае запрета выполнить одно из следующих действий:
Можно сделать проверки в одном из обработчиков мастер-страницы, но важно помнить, что у мастер-страницы нет события PreInit, ее первое «пользовательское» событие – Init. Поэтому побочный эффект от этого будет в том, что событие PreInit у страницы будет обработано независимо от успеха авторизации, поскольку произойдет перед Init мастер-страницы. Кроме того, условия авторизации могут быть разными на разных страницах, поэтому проверки лучше проводить в их собственных обработчиках. А устанавливать значения свойств контролов страницы из мастер-страницы тем более не стоит. |
Такой подход сработает, и авторизация будет работать. Но проект будет иметь следующие недостатки:
Учитывая эти недостатки, возникает вопрос - как сделать так, чтобы:
Самое важное свойство предлагаемого решения – простота восприятия и поддержки проекта.
Идея предлагаемого подхода заключается в выделении в классе страницы следующих членов:
Объявление и использование этих трех членов класса страницы удобно реализовать в ее базовых классах, сделав их виртуальными и изначально с пустой реализацией. Программисту же конкретных классов страниц нужно будет определить эти члены, если потребуется.
Описанную функциональность удобно реализовать следующим образом. В базовом классе страницы сделать все проверки авторизации и, в случае запрета доступа, вернуть ответ пользователю (показать причину запрета), а в самом классе страницы переопределять указанные выше виртуальные члены, на тех страницах, где это нужно:
1. Свойство, возвращающее массив идентификаторов ролей, которым в принципе может быть разрешен доступ к странице:
protected virtual int[] GrantedRoles { get { return EmptyRoles; } } staticreadonlyint[] EmptyRoles = newint[0]; |
Как видно, его базовая реализация возвращает пустой массив, что означает отсутствие проверки по роли пользователя (а не отсутствие разрешенных ролей).
2. Метод авторизации на основе проверки пользовательских условий:
protected virtual void AuthDepended(){} |
В случае запрета авторизации следует генерировать исключение PageDeniedException.
3. Метод авторизации частей страницы (можно его назвать «визуальная авторизация»):
protected virtual void AuthVisual(){} |
Его задача – установка видимости визуальных элементов. В случае запрета доступа к компоненту следует делать его невидимым (control.Visible=false) или, в случае запрета редактирования, выключенным (control.Enabled=false).
Эти члены наследуются от базового класса страницы и не обязательны к определению.
Их использование показано на диаграмме последовательности:
Проверяется авторизация на этапе PreInit страницы (так как это самое первое «пользовательское» событие в жизненном цикле страницы).
Место визуальной авторизации на схеме – на этапе PreRenderComplete.
Теоретически, самое подходящее место визуальной авторизации - на этапе PreRenderComplete, чтобы изменения в PreRender были менее приоритетны, чем авторизационные. Но этому мешает ограничение ASP.NET, не все позволяющее сделать невидимым на столь поздем этапе. Поэтому для общности самым разумным оказывается LoadComplete. |
Каждая страница сайта, помимо использования мастер-страницы, наследуется от общего класса с цепочкой наследования:
public abstract class PageBaseLogined<TParameters> : PageBaseAnonym<TParameters> where TParameters : PageParametersBase publicabstractclass PageBaseAnonym<TParameters> : PageBase where TParameters : PageParametersBase publicabstractpartialclass PageBase : Page |
ПРИМЕЧАНИЕ Очень удобно все эти классы положить в проект в общем репозитарии (назовем его My.Common), и его как субмодуль включать во все репозитарии компании, содержащие сайты ASP.NET. Таким образом исключается копирование множества кода. |
Тип-параметр TParameters нужен для реализации GET-параметров страницы (см. статью в RDSN #3 2012 «Удобная реализация GET-параметров страницы в ASP.NET»), и в авторизации роли не играет. Хотя авторизация и может зависеть от параметров, тогда в методах AuthDepended и AuthVisual можно будет использовать свойство Ps, инкапсулирующее переданные странице параметры в типизированном виде.
Как следует из названий, страницы, на которых требуется авторизация, нужно наследовать от класса PageBaseLogined, а на которых не требуется – от PageBaseAnonym.
Остановимся подробно на каждом классе из цепочки, начиная с самого общего.
PageBase – самый общий класс в цепочке. В реализации автора он не содержит в себе ничего, что бы напрямую относилось к авторизации, только общие методы для всех страниц сайта, например, такой для ObjectDataSource:
protected void objectDataSource_ObjectCreating(object sender, ObjectDataSourceEventArgs e) { e.ObjectInstance = this; } |
Также, например, в нем реализованы методы-хелперы FindControlsRecursive (по Id контрола находит объект контрола в контейнере), ResponseAsFile (заменяет http-ответ на переданный файл), ResponseAsOnlyControl (заменяет http-ответ на html-текст только переданного контрола), ResponseAsExcelTable (заменяет http-ответ файлом Excel).
От PageBase наследуется класс PageBaseAnonym. В реализации автора, в простейшем случае, он содержит только поддержку QueryString-параметров страницы, и метод SetResponseAsMyException(MyException ex), выводящий сообщение переданного бизнес-исключения и останавливающий обработку запроса страницы (Response.End):
protected void SetResponseAsMyException(MyException ex) { Response.Write(GetResponseAsUCException(ex)); Response.End(); } protected virtual string GetResponseAsMyException(MyException ex) { return ex.Message + "<br/><br/><a href='/'>Перейти на главную<a>" + " <a href='" + Request.UrlReferrer + "'>Назад<a>"; } |
Страницы, на которых не требуется авторизация, нужно наследовать от PageBaseAnonym.
Класс PageBaseLogined предназначен для страниц, где требуется авторизация, и содержит в себе необходимое все для этого. Рассмотрим подробнее.
Так как класс PageBaseLogined находится в общей для всех проектов сайтов сборке, роли могут быть представлены в нем только по идентификатору (типа int) – в каждом конкретном сайте роли свои, и общее у них только то, что номера – целые числа.
Собственно свойство, возвращающее массив ролей, которым разрешено посещать эту страницу объявлено так:
protected virtual int[] GrantedRoles { get { return EmptyRoles; } } staticreadonlyint[] EmptyRoles = newint[0]; |
Константное поле EmptyRoles, возвращающееся по умолчанию, означает отсутствие проверки (а не отсутствие разрешенных ролей, что означало бы запрет доступа всем).
Кроме того, объявлено свойство:
protected abstract IEnumerable<int> MyRoles { get; } |
возвращающее набор ролей текущего пользователя. Точнее, должное возвращать, поскольку оно абстрактное и реализуется в базовом классе страницы конкретного проекта. Оно абстрактно, потому что в коде общего проекта нет возможности получить аутентифицированного пользователя и, соответственно, его роли – класс пользователя не является общим, он свой для каждого сайта.
ПРИМЕЧАНИЕ Подразумевается, что роль с номером 1 всегда означает Суперадминистратор. Для нее никакие проверки авторизации не производятся. |
Функция «принадлежит ли текущий пользователь одной из данных ролей» реализована таким образом:
public bool AmI(paramsint[] roles) { return MyRoles.Intersect(roles).Any(); } |
Для улучшения производительности есть перегрузка с сигнатурой
bool AmI(int roleId) |
Собственно авторизация доступа к странице, как на основе роли, так и контекстно-зависимая, реализуется на этапе PreInit таким образом:
protected override void Page_PreInit() { try { bool notAmi1 = !AmI(1); if (notAmi1) AuthByRoles(); CheckParsAndCreatePs(); if (notAmi1) AuthDepended(); } catch (PageDeniedException ex) { SetResponseAsMyException(ex); } } |
Метод CheckParsAndCreatePs() ответственен за реализацию GET-параметров страницы, которая описана в отдельной статье (RSDN #3 2012). Если эта реализация не используется, этот вызов можно просто удалить. |
Как видно из кода, роль 1 (Суперадминистратор) особенна тем, что для нее не проводятся проверки. Суть проверки сводится к тому, что в случае запрета доступа бросается исключение PageDeniedException, сообщение которого отображается пользователю вместо страницы, поэтому оно должно содержать причину запрета в user-friendly форме.
Рассмотрим участвующие в коде методы.
Метод AuthByRoles проводит проверку на пересечение с разрешенными ролями GrantedRoles:
void AuthByRoles() { int[] grantedRoles = GrantedRoles; if (grantedRoles != null && grantedRoles.Length != 0 && !AmI(grantedRoles)) throw new PageDeniedException(string.Format("Недостаточно прав для этой страницы.<br/>(Разрешенные роли: {0}, ваши роли: {1})", string.Join(",",grantedRoles), string.Join(",",MyRoles))); } |
Метод AuthDepended реализуется, если необходимо, в классе каждой конкретной страницы и проверяет права доступа, зависящие от контекста (например, могут быть условия вида «страницу пользователя можно смотреть, только если вы с ним принадлежите общему для обоих поставщику», «смотреть результаты аукциона можно только если ваш поставщик был к нему допущен» etc). Объявление выглядит так:
protected virtual void AuthDepended() {} |
Третий этап авторизации – сокрытие запрещенных визуальных элементов. За это отвечает метод
protected virtual void AuthVisual() {} |
Реализовывать его нужно на каждой конкретной странице в случае необходимости. Вызывается он на этапе Page_LoadComplete:
protected void Page_LoadComplete() { if (!AmI(1)) AuthVisual(); } |
Как уже было указано, не удается это сделать на этапе Page_PreRenderComplete, этому мешает ограничение ASP.NET, не все позволяющее сделать невидимым на столь позднем этапе. |
Приведем примеры бизнес-условий, при которых нужно использовать метод: «пользователю-неадмину нельзя редактировать другого пользователя, все элементы сделать readonly», «пользователю не из отдела контроля нельзя показывать причину недопуска к аукциону».
Все описанные классы хранятся в проекте, общем для всех сайтов. Его рекомендуется вынести в отдельный git-репозитарий и включать как субмодуль в репозитарии с конкретными проектами. Непосредственно же в проекте веб-сайта рекомендуется создать дополнительную «прослойку» между описанными базовыми классами и конкретными классами страниц:
public partial class PageBase[Имя проекта]<TParameters> : PageBaseLogined<TParameters> where TParameters : PageParametersBase |
Нужен этот класс для общей для всех страниц данного сайта реализации следующих свойств:
1. DTO-объект текущего пользователя:
public UserDTO LoginedUser |
Во избежание частых запросов к БД объект текущего пользователя хранится в HttpContext.Current.Cache, а этот кеш обновляется с помощью зависимости SqlCacheDependency, оперативно реагируя на изменения БД.
На самом деле это свойство было бы очень удобно определить в классе PageBaseLogined. Но обратим внимание, что в проекте этого класса UserDTO недоступен. Поэтому свойство LoginedUser нужно вынести в проект сайта. |
Удобная реализация такова:
public static UserDTO LoginedUser { get { UserDTO u = HttpContext.Current.Cache[HttpContext.Current.Session .SessionID + "LoginedUser"] as UserDTO; string login = HttpContext.Current.User.Identity.Name; if (u == null || string.Compare(login, u.Login, StringComparison.InvariantCultureIgnoreCase) != 0) { u = Db.FindUserDTO(login); if (u == null) { try { FormsAuthentication.SignOut(); } catch (HttpException ex) { Logger.ErrorException("FormsAuthentication.SignOut()", ex); } Logger.Error("No user with login=[{0}], so signed out", login); HttpContext.Current.Response.Redirect(HttpContext.Current.Request.RawUrl); HttpContext.Current.Response.Flush(); HttpContext.Current.ApplicationInstance.CompleteRequest(); HttpContext.Current.Response.End(); throw new DislogoutException(string.Format( "No user with login=[{0}], so signed out", login)); } HttpContext.Current.Cache.Insert(HttpContext.Current.Session .SessionID + "LoginedUser", u, BLCache.GetUserAggregateCacheDependency(), DateTime.Today.AddDays(1), TimeSpan.Zero, CacheItemPriority.Normal, null); } return u; } } |
Следует обратить особое внимание на сравнение логина из кеша и со страницы (string.Compare(login, u.Login, StringComparison.InvariantCultureIgnoreCase)) – его регистронезависимость обязательно нужна, если в процедуре логина поиск пользователя не зависит от регистра символов введенного логина, что вполне естественно при обычном collation SQL-сервера (Cyrillic_General_CI_AS или любом другом с суффиксом CI, означающем case insensitive).
2. Реализация описанного выше унаследованного абстрактного свойства MyRoles:
protected override IEnumerable<int> MyRoles { get { return LoginedUser.Roles.Select(r => r.UserRoleId); } } |
В классе страницы, если ее унаследовать от PageBase[Имя проекта], доступны:
Правильное определение этих методов гарантирует корректную авторизацию.
Если авторизация на странице не нужна, наследовать ее нужно от PageBaseAnonym. В таком случае странице не нужен ни один из перечисленных членов.
Вся описанная инфраструктура позволяет в классе конкретной страницы написать, например, так:
protected override int[] GrantedRoles { get { returnnew[] {2, 3, 5, 6}; } } protectedoverridevoid AuthDepended() { AuthByTenderType(Ps.CurrentTender.TenderTypeId); if (!AmI(2, 3) && LoginedUser.SupplierId == null) PageDeniedException.Throw("У вас нет привязки к поставщику"); } protectedoverridevoid AuthVisual() { areaAddon.Visible = LoginedUser.EnabledAddons.Any(x => x.Enabled); } |
В этом случае к странице будут допущены пользователи только указанных ролей (идетификаторы ролей 2,3,5,6), причем только если пройдет проверка AuthByTenderType и SupplierId==null для ролей 2 и 3. А указанный компонент страницы (areaAddon) будет невидимым при указанном условии. Как уже было указано, все эти проверки не исполняются для Суперадминистратора (роль 1). Свойство Ps.CurrentTender в данном примере возвращает объект, соответствующий переданному странице QueryString-параметру. То есть это пример контекстно-зависимой авторизации.
Стоит заметить, что может остаться нужность в некоторых местах кода страницы вставить еще проверки для визуальной авторизации. Например, это шаблоны элементов с привязкой к данным (databinded controls – GridView, Repeater, FormsView etc), или любой другой шаблонный контрол (templated control).
Если возникнет необходимость провести визуальную авторизацию котролов мастер-страницы, то нужно «вручную» присвоить значения их свойствам Visible и Enabled. Делать для этого специальный метод и усложнять инфраструктуру авторизации избыточно, потому что мастер-страниц не должно быть много при хорошей архитектуре ASP.NET-приложения.
С помощью описанных классов и методов достигается, что все, что связано с авторизацией доступа на ASP.NET-странице, может быть локализовано в двух методах и одном свойстве. Практически полностью отделяется бизнес-логика от авторизации, и код становится лаконичным и простым в понимании и поддержке.
Небольшой показательный пример проекта можно посмотреть здесь. В этом репозитарии проект с сайтом - My.Example.Web. Классы, описанные в статье, лежат тут.
Сообщений 1 Оценка 0 Оценить |