Пример «claims-based» авторизации с «xml-based» конфигурацией политики доступа

Введение

Тема аутентификации и авторизации всегда будет актуальна для большинства web-приложений. Многие .NET разработчики уже успели познакомиться с Windows Identity Foundation (WIF), его подходами и возможностями для реализации так называемых «identity-aware» приложений. Для тех, кто не успел поработать с WIF, первое знакомство можно начать с изучения следующего раздела MSDN. В данной же статье я предлагаю более детально взглянуть на так называемый «claims-based» подход к авторизации пользователей путем изучения того, как это может выглядеть на примере.

Claims-Based Authorization

«Claims-Based» авторизация это подход, при котором решение авторизации о предоставлении или запрете доступа определенному пользователю базируется на произвольной логике, которая в качестве входных данных использует некий набор «claims» относящихся к этому пользователю. Проводя аналогию с «Role-Based» подходом, у некого администратора в его наборе «claims» будет только один элемент с типом «Role» и значением «Administrator», например. Более детально, о преимуществах и проблемах, которые решает этот подход можно прочесть на том же MSDN, также советую посмотреть лекцию Доминика Байера.

В целом, вышеупомянутый подход поощряет разработчиков к разделению бизнес логики приложения от логики авторизации и это действительно удобно. Так как же это выглядит на практике? К ней собственно и приступим.

Постановка задачи

Предположим, что нужно создать некий API сервис, который будет доступен нескольким клиентским приложениям. Функционал у клиентских приложений разный, пользователи также. Возможно, появятся еще и другие клиентские приложения, со своими пользователями и схемой взаимодействия с API, поэтому нам необходимо иметь гибкую систему авторизации для того, чтобы иметь возможность на любом этапе сконфигурировать политику доступа к API для того или иного приложения/пользователя. API в нашем случае будет построено с использованием ASP.NET Web API 2.0, клиентскими приложениями будут, например, Windows Phone приложение и Web-сайт.

Рассмотрим приложения их пользователей и функционал более детально:

Windows Phone клиент

Windows Phone

  1. Сам по себе может только регистрировать новых пользователей.
  2. Зарегистрированные пользователи могут:
    • просматривать свой профиль;
    • обновлять свой профиль;
    • производить смену своего пароля;

Web клиент

Web Site

  1. Сам по себе не имеет доступа к API.
  2. Зарегистрированные мобильным клиентом пользователи могут:
    • просматривать свой профиль;
    • обновлять свой профиль;
    • производить смену своего пароля;

  3. Администраторы системы могут:
    • всё то же, что и пользователи для своего аккаунта;
    • всё то же, что и пользователи для аккаунта любого пользователя;
    • просматривать список всех зарегистрированный пользователей;
    • создавать/удалять пользователей;

Итак, мы имеем представление о том, какой функционал должен предоставляться через API, каким клиентам и с какими правилами. Что ж, приступим к реализации!

Реализация

Начнем с определения интерфейса будущего API сервиса:

    public interface IUsersApiController     {         // List all users.         IEnumerable<User> GetAllUsers();          // Lookup single user.         User GetUserById(int id);          // Create user.         HttpResponseMessage Post(RegisterModel user);          // Restore user's password.         HttpResponseMessage RestorePassword(string email);          // Update user.         HttpResponseMessage Put(int id, UpdateUserModel value);          // Delete user.         HttpResponseMessage Delete(string email);     } 

Непосредственную реализацию API оставим за скобками данной статьи, по крайней мере, для примера сойдет и вариант вроде этого:

    public class UsersController : ApiController     {         //...         public HttpResponseMessage Post([FromBody]RegisterModel user)         {             if (ModelState.IsValid)             {                 return Request.CreateResponse(HttpStatusCode.OK, "Created!");             }             else             {                 return Request.CreateErrorResponse(HttpStatusCode.BadRequest, ModelState);             }         }         //...     } 

Следующим шагом создадим наследника класса ClaimsAuthorizationManager и переопределим некоторые его методы. ClaimsAuthorizationManager — это именно тот компонент WIF, который позволяет в одном месте перехватывать входящие запросы и выполнять произвольную логику, которая исходя из набора «claims» текущего пользователя* решает о предоставлении или запрете доступа.

* — о том, где этот набор формируется поговорим чуть позже.

Не уходя далеко, мы можем позаимствовать его реализацию из MSDN по этой ссылке. Как видим из секции «Examples» переопределены следующие методы:

    /// <summary>      /// Overloads  the base class method to load the custom policies from the config file      /// </summary>      /// <param name="nodelist">XmlNodeList containing the policy information read from the config file</param>     public override void LoadCustomConfiguration(XmlNodeList nodelist)     {...}      /// <summary>      /// Checks if the principal specified in the authorization context is authorized      /// to perform action specified in the authorization context on the specified resource      /// </summary>      /// <param name="pec">Authorization context</param>     /// <returns>true if authorized, false otherwise</returns>     public override bool CheckAccess(AuthorizationContext pec)     {...} 

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

   ...    <policy resource="http://localhost:28491/Developers.aspx" action="GET">      <or>        <claim claimType="http://schemas.microsoft.com/ws/2008/06/identity/claims/role" claimValue="developer" />        <claim claimType="http://schemas.xmlsoap.org/claims/Group" claimValue="Administrator" />      </or>    </policy>    <policy resource="http://localhost:28491/Administrators.aspx" action="GET">      <and>        <claim claimType="http://schemas.xmlsoap.org/claims/Group" claimValue="Administrator" />        <claim claimType="http://schemas.xmlsoap.org/ws/2005/05/identity/claims/country" claimValue="USA" />      </and>    </policy>    <policy resource="http://localhost:28491/Default.aspx" action="GET">    </policy>    ... 

Политика доступа здесь — это набор секций «policy», каждая из которых идентифицируется такими атрибутами как «resource» и «action». Внутри каждой такой секции перечислены «claims» которые необходимы для доступа к ресурсу. В случае WebApi «resource» — это имя контроллера, «action» — имя action-метода. Более того, есть возможность строить правила доступа с использованием логических условий*.

* — и всё бы замечательно если бы в текущей реализации была возможность конфигурировать больше 2-x элементов «claim» внутри блоков «and» или «or».

Пока используем всё «as-is», за исключением названия наследника, его изменим на XmlBasedAuthorizationManager. Если попробовать сбилдить проект, то окажется что нам не хватает класса PolicyReader, его можно взять из полных исходных кодов MSDN-примера.

После того, как новая реализация готова, сконфигурируем WebAPI приложение для использования ее в качестве менеджера авторизации. Для этого:

1. Зарегистрируем конфигурационные секции обязательные для работы WIF:

  <configSections>     <section name="system.identityModel" type="System.IdentityModel.Configuration.SystemIdentityModelSection, System.IdentityModel, Version=4.0.0.0, Culture=neutral, PublicKeyToken=B77A5C561934E089" />     <section name="system.identityModel.services" type="System.IdentityModel.Services.Configuration.SystemIdentityModelServicesSection, System.IdentityModel.Services, Version=4.0.0.0, Culture=neutral, PublicKeyToken=B77A5C561934E089" />     <!-- Others sections-->   </configSections> 

2. Укажем какую реализацию следует использовать в качестве менеджера авторизации:

  <system.identityModel>     <identityConfiguration>       <claimsAuthorizationManager type="YourProject.WebApi.Security.XmlBasedAuthorizationManager, YourProject.WebApi, Version=1.0.0.0, Culture=neutral">         <!-- Policies -->       </claimsAuthorizationManager>       <claimsAuthenticationManager type="YourProject.WebApi.Security.AuthenticationManager, YourProject.WebApi, Version=1.0.0.0, Culture=neutral" />     </identityConfiguration>   </system.identityModel> 

Отлично, мы указали WIF какую реализацию использовать, но как вы заметили, в конфигурации выше остались две детали:

  1. вместо набора xml-секций «policy» у нас пусто;
  2. присутствует xml-элемент "claimsAuthenticationManager", о котором я не упоминал ранее.

Рассмотрим эти пункты по порядку.

1. Конфигурация политики доступа

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

   <policy resource="Users" action="GetAllUsers">      <and>        <claim claimType="http://schemas.xmlsoap.org/ws/2005/05/identity/claims/system" claimValue="WebApplication" />        <claim claimType="http://schemas.microsoft.com/ws/2008/06/identity/claims/role" claimValue="Admin" />      </and>    </policy>    <policy resource="Users" action="Post">        <claim claimType="http://schemas.xmlsoap.org/ws/2005/05/identity/claims/system" claimValue="WPhoneApplication" />    </policy>    <policy resource="Users" action="RestorePassword">      <claim claimType="http://schemas.xmlsoap.org/ws/2005/05/identity/claims/system" claimValue="WPhoneApplication" />    </policy>    <policy resource="Users" action="GetUserById">      <or>        <and>          <claim claimType="http://schemas.xmlsoap.org/ws/2005/05/identity/claims/system" claimValue="WebApplication" />          <claim claimType="http://schemas.microsoft.com/ws/2008/06/identity/claims/role" claimValue="Admin" />        </and>        <and>          <claim claimType="http://schemas.microsoft.com/ws/2008/06/identity/claims/role" claimValue="User" />          <!-- Как указать вместо {0} идентификатор пользователя который отправил "request" ? -->          <!-- <claim claimType="UserId" claimValue="{0}" /> -->        </and>      </or>    </policy>    <policy resource="Users" action="Put">      <or>        <and>          <claim claimType="http://schemas.xmlsoap.org/ws/2005/05/identity/claims/system" claimValue="WebApplication" />          <claim claimType="http://schemas.microsoft.com/ws/2008/06/identity/claims/role" claimValue="Admin" />        </and>        <and>          <claim claimType="http://schemas.microsoft.com/ws/2008/06/identity/claims/role" claimValue="User" />          <!-- Как указать вместо {0} идентификатор пользователя который отправил "request" ? -->          <!-- <claim claimType="UserId" claimValue="{0}" /> -->        </and>      </or>    </policy> 

Видим, что некоторые policy-секции проще, некоторые сложнее, некоторые повторяются. Рассмотрим по частям, начиная с простого варианта — политика доступа для получения списка пользователей:

   <policy resource="Users" action="GetAllUsers">      <and>        <claim claimType="http://schemas.xmlsoap.org/ws/2005/05/identity/claims/system" claimValue="WebApplication" />        <claim claimType="http://schemas.microsoft.com/ws/2008/06/identity/claims/role" claimValue="Admin" />      </and>    </policy> 

Все предельно очевидно: доступ к данному ресурсу есть у тех пользователей, набор «claims» которых содержит оба «сlaim» — элемента.

Теперь более сложный вариант — получение информации о пользователе по идентификатору:

   <policy resource="Users" action="GetUserById">      <or>        <and>          <claim claimType="http://schemas.xmlsoap.org/ws/2005/05/identity/claims/system" claimValue="WebApplication" />          <claim claimType="http://schemas.microsoft.com/ws/2008/06/identity/claims/role" claimValue="Admin" />        </and>        <and>          <claim claimType="http://schemas.microsoft.com/ws/2008/06/identity/claims/role" claimValue="User" />          <!-- Как указать вместо {0} идентификатор пользователя который отправил "request" ? -->          <!-- <claim claimType="UserId" claimValue="{0}" /> -->        </and>      </or>    </policy> 

Возвращаясь к требованиям, к данному ресурсу могут иметь доступ только администраторы веб приложения, а также пользователи при условии, что каждый пользователь может получать данные только по своему аккаунту. Как видим, первое требование мы без труда устанавливаем в первом <and>..</and> блоке. Но как же быть с пользователями?

К сожалению, текущая реализация, которую Мы доблестно скопировали, не позволяет сейчас конфигурировать это условие. К тому же, как я уже упоминал выше, она также не позволяет использовать внутри логических "and/or" блоков вложенные элементы. Если уж быть предельно честным, то эта реализация жестко устанавливает количество «claim» элементов равное двум внутри "and/or" блоков.

Что касается условия «каждый отдельный пользователь может получать данные только по своему аккаунту», то я планирую предложить свой вариант решения в следующей статье. Предлагаю пока смириться с тем, что все пользователи могут просматривать информацию друг о друге, как выходит из составленной конфигурации. Особенно пока реализация метода GetUserById выглядит как throw new NotImplementedException().

А вот чтобы текущая конфигурация работала исправно мы немного изменим реализацию класса PolicyReader:

   /// <summary>     /// Read the Or Node     /// </summary>     /// <param name="rdr">XmlDictionaryReader of the policy Xml</param>     /// <param name="subject">ClaimsPrincipal subject</param>     /// <returns>A LINQ expression created from the Or node</returns>     private Expression<Func<ClaimsPrincipal, bool>> ReadOr(XmlDictionaryReader rdr, ParameterExpression subject)    {        Expression defaultExpr = Expression.Invoke((Expression<Func<bool>>)(() => false));        while (rdr.Read())        {            if (rdr.NodeType != XmlNodeType.EndElement && rdr.Name != "or")            {                defaultExpr = Expression.OrElse(defaultExpr, Expression.Invoke(ReadNode(rdr, subject), subject));            }            else                break;        }        rdr.ReadEndElement();        Expression<Func<ClaimsPrincipal, bool>> resultExpr               = Expression.Lambda<Func<ClaimsPrincipal, bool>>(defaultExpr, subject);        return resultExpr;    }     /// <summary>     /// Read the And Node     /// </summary>     /// <param name="rdr">XmlDictionaryReader of the policy Xml</param>     /// <param name="subject">ClaimsPrincipal subject</param>     /// <returns>A LINQ expression created from the And node</returns>     private Expression<Func<ClaimsPrincipal, bool>> ReadAnd(XmlDictionaryReader rdr, ParameterExpression subject)    {        Expression defaultExpr = Expression.Invoke((Expression<Func<bool>>)(() => true));        while (rdr.Read())        {            if (rdr.NodeType != XmlNodeType.EndElement && rdr.Name != "and")            {                defaultExpr = Expression.AndAlso(defaultExpr, Expression.Invoke(ReadNode(rdr, subject), subject));            }            else                break;        }        rdr.ReadEndElement();        Expression<Func<ClaimsPrincipal, bool>> resultExpr              = Expression.Lambda<Func<ClaimsPrincipal, bool>>(defaultExpr, subject);        return resultExpr;    } 

Что ж, мы сконфигурировали политику доступа к ресурсам нашего API, создали реализацию менеджера авторизации, который умеет работать с нашей конфигурацией. Теперь можно перейти к аутентификации — этапу, который предшествует авторизации.

2. Аутентификация и ClaimsAuthenticationManager

Еще до того как принимать решение имеет ли пользователь доступ к ресурсу, сперва нужно произвести аутентификацию, и если она успешна — наполнить набор «claims» пользователя.

Для аутентификации будем использовать Basic Authentication и, например, ее реализацию в Thinktecture.IdentityModel.45. Для этого в NuGet-консоли выполним команду:

Install-Package Thinktecture.IdentityModel

Код класса WebApiConfig изменим, чтобы он был приблизительно следующим:

   public static class WebApiConfig    {        public static void Register(HttpConfiguration config)        {            var authentication = CreateAuthenticationConfiguration();            config.MessageHandlers.Add(new AuthenticationHandler(authentication));             config.MapHttpAttributeRoutes();            config.Routes.MapHttpRoute(                name: "DefaultApi",                routeTemplate: "api/{controller}/{id}",                defaults: new { id = RouteParameter.Optional }            );             config.EnableSystemDiagnosticsTracing();             config.Filters.Add(new ClaimsAuthorizeAttribute());        }         private static AuthenticationConfiguration CreateAuthenticationConfiguration()        {            var authentication = new AuthenticationConfiguration            {                ClaimsAuthenticationManager = new AuthenticationManager(),                RequireSsl = false //only for testing            };             #region Basic Authentication            authentication.AddBasicAuthentication((username, password) =>                {                    var webSecurityService = ServiceLocator.Current.GetInstance<IWebSecurityService>();                    return webSecurityService.Login(username, password);                });            #endregion             return authentication;        }    } 

Здесь отмечу только то, что для проверки credentials пришедших из запроса у меня используется некий IWebSecurityService. Вы можете использовать здесь свою логику, например: return username == password;

Теперь при каждом запросе к любому ресурсу будет производиться проверка аутентификации, но еще нам нужно трансформировать базовый набор «claims» текущего пользователя. Этим занимается ClaimsAuthenticationManager, а точнее наш наследник этого класса, который мы уже зарегистрировали:

   public class AuthenticationManager : ClaimsAuthenticationManager    {        public override ClaimsPrincipal Authenticate(string resourceName, ClaimsPrincipal incomingPrincipal)        {            if (!incomingPrincipal.Identity.IsAuthenticated)            {                return base.Authenticate(resourceName, incomingPrincipal);            }             var claimsService = ServiceLocator.Current.GetInstance<IUsersClaimsService>();            var claims = claimsService.GetUserClaims(incomingPrincipal.Identity.Name);            foreach (var userClaim in claims)            {                incomingPrincipal.Identities.First().AddClaim(new Claim(userClaim.Type, userClaim.Value));            }            return incomingPrincipal;        }    } 

Как видим, если пользователь прошел аутентификацию — происходит получение его набора «claims», скажем из БД, посредством использования вновь созданного экземпляра IUsersClaimsService. После «трансформации» экземпляр ClaimsPrincipal возвращается дальше в конвеер для последующего использования, например, авторизацией.

Проверка результата

Пришло время проверить работоспособность нашего решения. Для этого нам естественно понадобятся пользователи с теми или иными «claims». Не будем долго фантазировать над тем откуда их взять и немного видоизменим AuthenticationManager в целях тестирования. Вместо использования IUsersClaimsService вставим следующий код:

   public class AuthenticationManager : ClaimsAuthenticationManager    {        public override ClaimsPrincipal Authenticate(string resourceName, ClaimsPrincipal incomingPrincipal)        {            ...            if (incomingPrincipal.Identity.Name.ToLower().Contains("user"))            {                incomingPrincipal.Identities.First().AddClaim(new Claim(ClaimTypes.Role, "User"));            }            return incomingPrincipal;        }    } 

Отлично, теперь все пользователи, логин которых содержит слово «user» будут содержать нужный «claim».
Запустим проект и перейдем по ссылке localhost:[port]/api/users

Вводим заветные логин и пароль, наша незамысловатая авторизация проверить их на равенство, а менеджер авторизации трансформирует набор «claims»:

Продолжим выполнение и убедимся, что простой смертный не может просматривать список всех пользователей:

Теперь давайте вспомним о том, что на этапе конфигурирования политики доступа нам пришлось на некоторое время разрешить всем пользователям просматривать информацию друг о друге, этим и воспользуемся. Попробуем узнать о пользователе с Id=100, зайдя по ссылке ~/api/users/100:

И вот мы наблюдаем, что некая реализация, появившаяся в кулуарах, возвращает информацию о любом пользователе 🙂

Заключение

Итак мы познакомились с некоторыми возможностями WIF, разобрали пример того, с чего можно начать при построении гибкой системы авторизации, а также немного «покодировали».

Спасибо за внимание.

ссылка на оригинал статьи http://habrahabr.ru/post/198424/

Добавить комментарий

Ваш адрес email не будет опубликован. Обязательные поля помечены *