Сообщений 1    Оценка 0        Оценить  
Система Orphus

Удобная авторизация на страницах ASP.NET

Автор: Черняев Константин
Опубликовано: 29.03.2013
Исправлено: 10.12.2016
Версия текста: 1.0
Постановка проблемы
Идея
Реализация
Класс PageBase
Класс PageBaseAnonym
Класс PageBaseLogined
Класс-родитель страницы в проекте сайта
Класс конкретной страницы
Использование
Результат
Исходники на GitHub

Ты никогда не решишь проблему, если будешь думать так же, как те, кто её создал.
Альберт Эйнштейн

Постановка проблемы

Практически в любом проекте Web-сайта имеется задача авторизовать пользователя, то есть реализовать права доступа как к какой-то отдельной информации или действиям на странице, так и к странице в целом. Конкретнее, практически всегда встает необходимость:

Простейшее решение этой задачи – в обработчике события страницы Page_Load (или Page_Init или даже Page_PreInit) проверить все условия, и в случае запрета выполнить одно из следующих действий:

Можно сделать проверки в одном из обработчиков мастер-страницы, но важно помнить, что у мастер-страницы нет события PreInit, ее первое «пользовательское» событие – Init. Поэтому побочный эффект от этого будет в том, что событие PreInit у страницы будет обработано независимо от успеха авторизации, поскольку произойдет перед Init мастер-страницы. Кроме того, условия авторизации могут быть разными на разных страницах, поэтому проверки лучше проводить в их собственных обработчиках. А устанавливать значения свойств контролов страницы из мастер-страницы тем более не стоит.

Такой подход сработает, и авторизация будет работать. Но проект будет иметь следующие недостатки:

Учитывая эти недостатки, возникает вопрос - как сделать так, чтобы:

Самое важное свойство предлагаемого решения – простота восприятия и поддержки проекта.

Идея

Идея предлагаемого подхода заключается в выделении в классе страницы следующих членов:

  1. Свойство, возвращающее пользовательские роли, которым данная страница в принципе может быть доступна. Назовем его GrantedRoles.
  2. Метод авторизации на основе проверки пользовательских условий. Имеются в виду все условия, кроме проверки на разрешенные роли из предыдущего пункта. Например, таким условием может быть «запрет пользователю-неадминистратору на открытие профилей пользователей, привязанных к компаниям таких-то типов, кроме своей компании» или «запрет пользователю на открытие результатов аукциона, к которому не был приглашен поставщик этого пользователя». Назовем его AuthDepended.
  3. Метод сокрытия частей страницы, которые не должны быть видны при определенных условиях. Например, предположим, что на странице компании выводится список ее работников. И тогда может возникнуть условие «для всех пользователей этой компании ниже такого-то статуса и всех пользователей других компаний скрыть список ее сотрудников». Этот же метод можно использовать, чтобы убрать запрещенную функциональность, например, чтобы скрыть кнопку, или перевести нужные элементы в режим readonly. Назовем его AuthVisual.

Объявление и использование этих трех членов класса страницы удобно реализовать в ее базовых классах, сделав их виртуальными и изначально с пустой реализацией. Программисту же конкретных классов страниц нужно будет определить эти члены, если потребуется.

Реализация

Описанную функциональность удобно реализовать следующим образом. В базовом классе страницы сделать все проверки авторизации и, в случае запрета доступа, вернуть ответ пользователю (показать причину запрета), а в самом классе страницы переопределять указанные выше виртуальные члены, на тех страницах, где это нужно:

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

PageBase – самый общий класс в цепочке. В реализации автора он не содержит в себе ничего, что бы напрямую относилось к авторизации, только общие методы для всех страниц сайта, например, такой для ObjectDataSource:

                protected
        void objectDataSource_ObjectCreating(object sender,
ObjectDataSourceEventArgs e)
{
    e.ObjectInstance = this;
}

Также, например, в нем реализованы методы-хелперы FindControlsRecursive (по Id контрола находит объект контрола в контейнере), ResponseAsFile (заменяет http-ответ на переданный файл), ResponseAsOnlyControl (заменяет http-ответ на html-текст только переданного контрола), ResponseAsExcelTable (заменяет http-ответ файлом Excel).

Класс PageBaseAnonym

От 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 предназначен для страниц, где требуется авторизация, и содержит в себе необходимое все для этого. Рассмотрим подробнее.

Разрешенные пользовательские роли

Так как класс 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-странице, может быть локализовано в двух методах и одном свойстве. Практически полностью отделяется бизнес-логика от авторизации, и код становится лаконичным и простым в понимании и поддержке.

Исходники на GitHub

Небольшой показательный пример проекта можно посмотреть здесь. В этом репозитарии проект с сайтом - My.Example.Web. Классы, описанные в статье, лежат тут.


Любой из материалов, опубликованных на этом сервере, не может быть воспроизведен в какой бы то ни было форме и какими бы то ни было средствами без письменного разрешения владельцев авторских прав.
    Сообщений 1    Оценка 0        Оценить