Введение
Тема аутентификации и авторизации всегда будет актуальна для большинства 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 клиент
- Сам по себе может только регистрировать новых пользователей.
- Зарегистрированные пользователи могут:
- просматривать свой профиль;
- обновлять свой профиль;
- производить смену своего пароля;
Web клиент
- Сам по себе не имеет доступа к API.
- Зарегистрированные мобильным клиентом пользователи могут:
- просматривать свой профиль;
- обновлять свой профиль;
- производить смену своего пароля;
- Администраторы системы могут:
- всё то же, что и пользователи для своего аккаунта;
- всё то же, что и пользователи для аккаунта любого пользователя;
- просматривать список всех зарегистрированный пользователей;
- создавать/удалять пользователей;
Итак, мы имеем представление о том, какой функционал должен предоставляться через 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 какую реализацию использовать, но как вы заметили, в конфигурации выше остались две детали:
- вместо набора xml-секций «policy» у нас пусто;
- присутствует 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, разобрали пример того, с чего можно начать при построении гибкой системы авторизации, а также немного «покодировали».
Спасибо за внимание.
Автор: sqrter