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

в 8:38, , рубрики: .net, Веб-разработка, метки: ,

Пример «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 based» авторизации с «xml based» конфигурацией политики доступа

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

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

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

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

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

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

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

Заключение

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

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

Автор: sqrter

Источник

* - обязательные к заполнению поля


https://ajax.googleapis.com/ajax/libs/jquery/3.4.1/jquery.min.js