بررسی Claims Transformations
میخواهیم Claims بازگشت داده شدهی توسط IDP را به یکسری Claims که کار کردن با آنها در برنامهی MVC Client سادهتر است، تبدیل کنیم.
زمانیکه اطلاعات Claim، توسط میانافزار oidc دریافت میشود، ابتدا بررسی میشود که آیا دیکشنری نگاشتها وجود دارد یا خیر؟ اگر بله، کار نگاشتها از یک claim type به claim type دیگر انجام میشود.
برای مثال لیست claims اصلی بازگشت داده شدهی توسط IDP، پس از تبدیلات و نگاشتهای آن در برنامهی کلاینت، یک چنین شکلی را پیدا میکند:
Claim type: sid - Claim value: f3940d6e58cbb576669ee49c90e22cb1 Claim type: http://schemas.xmlsoap.org/ws/2005/05/identity/claims/nameidentifier - Claim value: d860efca-22d9-47fd-8249-791ba61b07c7 Claim type: http://schemas.microsoft.com/identity/claims/identityprovider - Claim value: local Claim type: http://schemas.microsoft.com/claims/authnmethodsreferences - Claim value: pwd Claim type: given_name - Claim value: Vahid Claim type: family_name - Claim value: N
namespace ImageGallery.MvcClient.WebApp { public class Startup { public Startup(IConfiguration configuration) { Configuration = configuration; JwtSecurityTokenHandler.DefaultInboundClaimTypeMap.Clear(); }
در ادامه اگر مجددا لیست claims را پس از logout و login، بررسی کنیم، به این صورت تبدیل شدهاست:
• Claim type: sid - Claim value: 91f5a09da5cdbbe18762526da1b996fb • Claim type: sub - Claim value: d860efca-22d9-47fd-8249-791ba61b07c7 • Claim type: idp - Claim value: local • Claim type: given_name - Claim value: Vahid • Claim type: family_name - Claim value: N
کار با مجموعهی User Claims
تا اینجا لیست this.User.Claims، به همراه تعدادی Claims است که به آنها نیازی نداریم؛ مانند sid که بیانگر session id در سمت IDP است و یا idp به معنای identity provider میباشد. حذف آنها حجم کوکی نگهداری کنندهی آنها را کاهش میدهد. همچنین میخواهیم تعدادی دیگر را نیز به آنها اضافه کنیم.
علاوه بر اینها میانافزار oidc، یکسری از claims دریافتی را راسا فیلتر و حذف میکند؛ مانند زمان منقضی شدن توکن و امثال آن که در عمل واقعا به تعدادی از آنها نیازی نداریم. اما میتوان این سطح تصمیم گیری فیلتر claims رسیده را نیز کنترل کرد. در تنظیمات متد AddOpenIdConnect، خاصیت options.ClaimActions نیز وجود دارد که توسط آن میتوان بر روی حذف و یا افزوده شدن Claims، کنترل بیشتری را اعمال کرد:
namespace ImageGallery.MvcClient.WebApp { public void ConfigureServices(IServiceCollection services) { // ... .AddOpenIdConnect("oidc", options => { // ... options.ClaimActions.Remove("amr"); options.ClaimActions.DeleteClaim("sid"); options.ClaimActions.DeleteClaim("idp"); }); }
اکنون اگر پس از logout و login، لیست this.User.Claims را بررسی کنیم، دیگر خبری از sid و idp در آن نیست. همچنین claim از نوع amr نیز به صورت پیشفرض حذف نشدهاست:
• Claim type: sub - Claim value: d860efca-22d9-47fd-8249-791ba61b07c7 • Claim type: amr - Claim value: pwd • Claim type: given_name - Claim value: Vahid • Claim type: family_name - Claim value: N
افزودن Claim جدید آدرس کاربر
برای افزودن Claim جدید آدرس کاربر، به کلاس src\IDP\DNT.IDP\Config.cs مراجعه کرده و آنرا به صورت زیر تکمیل میکنیم:
namespace DNT.IDP { public static class Config { // identity-related resources (scopes) public static IEnumerable<IdentityResource> GetIdentityResources() { return new List<IdentityResource> { new IdentityResources.OpenId(), new IdentityResources.Profile(), new IdentityResources.Address() }; }
همین مورد را به لیست AllowedScopes متد GetClients نیز اضافه میکنیم:
AllowedScopes = { IdentityServerConstants.StandardScopes.OpenId, IdentityServerConstants.StandardScopes.Profile, IdentityServerConstants.StandardScopes.Address },
namespace DNT.IDP { public static class Config { public static List<TestUser> GetUsers() { return new List<TestUser> { new TestUser { // ... Claims = new List<Claim> { // ... new Claim("address", "Main Road 1") } }, new TestUser { // ... Claims = new List<Claim> { // ... new Claim("address", "Big Street 2") } } }; }
پس از آن به کلاس ImageGallery.MvcClient.WebApp\Startup.cs مراجعه میکنیم تا درخواست این claim را به لیست scopes میانافزار oidc اضافه کنیم:
namespace ImageGallery.MvcClient.WebApp { public class Startup { public void ConfigureServices(IServiceCollection services) { // ... .AddOpenIdConnect("oidc", options => { // ... options.Scope.Add("address"); // … options.ClaimActions.DeleteClaim("address"); }); }
یک نکته: فراخوانی DeleteClaim بر روی address غیر ضروری است و میشود این سطر را حذف کرد. از این جهت که اگر به سورس OpenID Connect Options مایکروسافت مراجعه کنیم، مشاهده خواهیم کرد که میانافزار اعتبارسنجی استاندارد ASP.NET Core، تنها تعداد معدودی از claims را نگاشت میکند. به این معنا که هر claim ای که در token وجود داشته باشد، اما اینجا نگاشت نشده باشد، در claims نهایی حضور نخواهند داشت و address claim یکی از اینها نیست. بنابراین در لیست نهایی this.User.Claims حضور نخواهد داشت؛ مگر اینکه مطابق همین سورس، با استفاده از متد options.ClaimActions.MapUniqueJsonKey، یک نگاشت جدید را برای آن تهیه کنیم و البته چون نمیخواهیم آدرس در لیست claims وجود داشته باشد، این نگاشت را تعریف نخواهیم کرد.
دریافت اطلاعات بیشتری از کاربران از طریق UserInfo Endpoint
همانطور که در قسمت قبل با بررسی «تنظیمات بازگشت Claims کاربر به برنامهی کلاینت» عنوان شد، میانافزار oidc با UserInfo Endpoint کار میکند که تمام عملیات آن خودکار است. در اینجا امکان کار با آن از طریق برنامه نویسی مستقیم نیز جهت دریافت اطلاعات بیشتری از کاربران، وجود دارد. برای مثال شاید به دلایل امنیتی نخواهیم آدرس کاربر را در لیست Claims او قرار دهیم. این مورد سبب کوچکتر شدن کوکی متناظر با این اطلاعات و همچنین دسترسی به اطلاعات به روزتری از کاربر میشود.
درخواستی که به سمت UserInfo Endpoint ارسال میشود، باید یک چنین فرمتی را داشته باشد:
GET idphostaddress/connect/userinfo Authorization: Bearer R9aty5OPlk
اکنون برای دریافت دستی اطلاعات آدرس از IDP و UserInfo Endpoint آن، ابتدا نیاز است بستهی نیوگت IdentityModel را به پروژهی Mvc Client اضافه کنیم:
dotnet add package IdentityModel
namespace ImageGallery.MvcClient.WebApp.Controllers { [Authorize] public class GalleryController : Controller { public async Task<IActionResult> OrderFrame() { var discoveryClient = new DiscoveryClient(_configuration["IDPBaseAddress"]); var metaDataResponse = await discoveryClient.GetAsync(); var userInfoClient = new UserInfoClient(metaDataResponse.UserInfoEndpoint); var accessToken = await HttpContext.GetTokenAsync(OpenIdConnectParameterNames.AccessToken); var response = await userInfoClient.GetAsync(accessToken); if (response.IsError) { throw new Exception("Problem accessing the UserInfo endpoint.", response.Exception); } var address = response.Claims.FirstOrDefault(c => c.Type == "address")?.Value; return View(new OrderFrameViewModel(address)); }
OrderFrameViewModel ارسالی به View، یک چنین شکلی را دارد:
namespace ImageGallery.MvcClient.ViewModels { public class OrderFrameViewModel { public string Address { get; } = string.Empty; public OrderFrameViewModel(string address) { Address = address; } } }
@model ImageGallery.MvcClient.ViewModels.OrderFrameViewModel <div class="container"> <div class="h3 bottomMarginDefault">Order a framed version of your favorite picture.</div> <div class="text bottomMarginSmall">We've got this address on record for you:</div> <div class="text text-info bottomMarginSmall">@Model.Address</div> <div class="text">If this isn't correct, please contact us.</div> </div>
سپس به Shared\_Layout.cshtml مراجعه کرده و لینکی را به این اکشن متد و View، اضافه میکنیم:
<li><a asp-area="" asp-controller="Gallery" asp-action="OrderFrame">Order a framed picture</a></li>
اکنون اگر برنامه را اجرا کنیم، پس از login، یک چنین خروجی قابل مشاهده است:
همانطور که ملاحظه میکنید، آدرس شخص به صورت مستقیم از UserInfo Endpoint دریافت و نمایش داده شدهاست.
بررسی Authorization مبتنی بر نقشها
تا اینجا مرحلهی Authentication را که مشخص میکند کاربر وار شدهی به سیستم کیست، بررسی کردیم که اطلاعات آن از Identity token دریافتی از IDP استخراج میشود. مرحلهی پس از ورود به سیستم، مشخص کردن سطوح دسترسی کاربر و تعیین این مورد است که کاربر مجاز به انجام چه کارهایی میباشد. به این مرحله Authorization میگویند و روشهای مختلفی برای مدیریت آن وجود دارد:
الف) RBAC و یا Role-based Authorization و یا تعیین سطوح دسترسی بر اساس نقشهای کاربر
در این حالت claim ویژهی role، از IDP دریافت شده و توسط آن یکسری سطوح دسترسی کاربر مشخص میشوند. برای مثال کاربر وارد شدهی به سیستم میتواند تصویری را اضافه کند و یا آیا مجاز است نگارش قاب شدهی تصویری را درخواست دهد؟
ب) ABAC و یا Attribute based access control روش دیگر مدیریت سطوح دسترسی است و عموما آنرا نسبت به حالت الف ترجیح میدهند که آنرا در قسمتهای بعدی بررسی خواهیم کرد.
در اینجا روش «تعیین سطوح دسترسی بر اساس نقشهای کاربر» را بررسی میکنیم. برای این منظور به تنظیمات IDP در فایل src\IDP\DNT.IDP\Config.cs مراجعه کرده و claims جدیدی را تعریف میکنیم:
namespace DNT.IDP { public static class Config { public static List<TestUser> GetUsers() { return new List<TestUser> { new TestUser { //... Claims = new List<Claim> { //... new Claim("role", "PayingUser") } }, new TestUser { //... Claims = new List<Claim> { //... new Claim("role", "FreeUser") } } }; }
سپس باید برای این claim جدید یک scope جدید را نیز به قسمت GetIdentityResources اضافه کنیم تا توسط client قابل دریافت شود:
namespace DNT.IDP { public static class Config { public static IEnumerable<IdentityResource> GetIdentityResources() { return new List<IdentityResource> { // ... new IdentityResource( name: "roles", displayName: "Your role(s)", claimTypes: new List<string>() { "role" }) }; }
همچنین باید به کلاینت مجوز درخواست این scope را نیز بدهیم. به همین جهت آنرا به AllowedScopes مشخصات Client نیز اضافه میکنیم:
namespace DNT.IDP { public static class Config { public static IEnumerable<Client> GetClients() { return new List<Client> { new Client { // ... AllowedScopes = { // ... "roles" } // ... } }; }
در ادامه قرار است تنها کاربری که دارای نقش PayingUser است، امکان دسترسی به سفارش نگارش قاب شدهی تصاویر را داشته باشد. به همین جهت به کلاس ImageGallery.MvcClient.WebApp\Startup.cs برنامهی کلاینت مراجعه کرده و درخواست scope نقشهای کاربر را به متد تنظیمات AddOpenIdConnect اضافه میکنیم:
options.Scope.Add("roles");
برای آزمایش آن، یکبار از برنامه خارج شده و مجددا به آن وارد شوید. اینبار در صفحهی consent، از کاربر مجوز دسترسی به نقشهای او نیز سؤال پرسیده میشود:
اما اگر به لیست موجود در this.User.Claims در برنامهی کلاینت مراجعه کنیم، نقش او را مشاهده نخواهیم کرد و به این لیست اضافه نشدهاست:
• Claim type: sub - Claim value: d860efca-22d9-47fd-8249-791ba61b07c7 • Claim type: amr - Claim value: pwd • Claim type: given_name - Claim value: Vahid • Claim type: family_name - Claim value: N
همانطور که در نکتهای پیشتر نیز ذکر شد، چون role جزو لیست نگاشتهای OpenID Connect Options مایکروسافت نیست، آنرا به صورت خودکار به لیست claims اضافه نمیکند؛ دقیقا مانند آدرسی که بررسی کردیم. برای رفع این مشکل و افزودن نگاشت آن در متد تنظیمات AddOpenIdConnect، میتوان از متد MapUniqueJsonKey به صورت زیر استفاده کرد:
options.ClaimActions.MapUniqueJsonKey(claimType: "role", jsonKey: "role");
• Claim type: sub - Claim value: d860efca-22d9-47fd-8249-791ba61b07c7 • Claim type: amr - Claim value: pwd • Claim type: given_name - Claim value: Vahid • Claim type: family_name - Claim value: N • Claim type: role - Claim value: PayingUser
استفاده از نقش تعریف شده جهت محدود کردن دسترسی به سفارش تصاویر قاب شده
در ادامه قصد داریم لینک درخواست تصاویر قاب شده را فقط برای کاربرانی که دارای نقش PayingUser هستند، نمایش دهیم. به همین جهت به فایل Views\Shared\_Layout.cshtml مراجعه کرده و آنرا به صورت زیر تغییر میدهیم:
@if(User.IsInRole("PayingUser")) { <li><a asp-area="" asp-controller="Gallery" asp-action="OrderFrame">Order a framed picture</a></li> }
options.TokenValidationParameters = new TokenValidationParameters { NameClaimType = JwtClaimTypes.GivenName, RoleClaimType = JwtClaimTypes.Role, };
اکنون برای آزمایش آن یکبار از سیستم خارج شده و مجددا به آن وارد شوید. پس از آن لینک درخواست نگارش قاب شدهی تصاویر، برای کاربر User 1 نمایان خواهد بود و نه برای User 2 که FreeUser است.
البته هرچند تا این لحظه لینک نمایش View متناظر با اکشن متد OrderFrame را امن کردهایم، اما هنوز خود این اکشن متد به صورت مستقیم با وارد کردن آدرس https://localhost:5001/Gallery/OrderFrame در مرورگر قابل دسترسی است. برای رفع این مشکل به کنترلر گالری مراجعه کرده و دسترسی به اکشن متد OrderFrame را توسط فیلتر Authorize و با مقدار دهی خاصیت Roles آن محدود میکنیم:
namespace ImageGallery.MvcClient.WebApp.Controllers { [Authorize] public class GalleryController : Controller { [Authorize(Roles = "PayingUser")] public async Task<IActionResult> OrderFrame() {
برای آزمایش آن، توسط مشخصات کاربر User 2 به سیستم وارد شده و آدرس https://localhost:5001/Gallery/OrderFrame را مستقیما در مرورگر وارد کنید. در این حالت یک چنین تصویری نمایان خواهد شد:
همانطور که مشاهده میکنید، علاوه بر عدم دسترسی به این اکشن متد، به صفحهی Account/AccessDenied که هنوز در برنامهی کلاینت تعریف نشدهاست، هدایت شدهایم. به همین جهت خطای 404 و یا یافت نشد، نمایش داده شدهاست.
برای تغییر مقدار پیشفرض صفحهی عدم دسترسی، ابتدا Controllers\AuthorizationController.cs را با این محتوا ایجاد میکنیم:
using Microsoft.AspNetCore.Mvc; public class AuthorizationController : Controller { public IActionResult AccessDenied() { return View(); } }
<div class="container"> <div class="h3">Woops, looks like you're not authorized to view this page.</div> <div>Would you prefer to <a asp-controller="Gallery" asp-action="Logout">log in as someone else</a>?</div> </div>
اکنون نیاز است تا این آدرس جدید را به کلاس ImageGallery.MvcClient.WebApp\Startup.cs معرفی کنیم.
namespace ImageGallery.MvcClient.WebApp { public class Startup { public void ConfigureServices(IServiceCollection services) { services.AddAuthentication(options => { // ... }).AddCookie("Cookies", options => { options.AccessDeniedPath = "/Authorization/AccessDenied"; }) // ...
برای آزمایش آن، یکبار از برنامه خارج شده و مجددا با اکانت User 2 به آن وارد شوید و آدرس https://localhost:5001/Gallery/OrderFrame را مستقیما در مرورگر وارد کنید. اینبار تصویر زیر که همان آدرس جدید تنظیم شدهاست نمایش داده خواهد شد:
کدهای کامل این قسمت را از اینجا میتوانید دریافت کنید.
برای اجرای برنامه:
- ابتدا به پوشهی src\WebApi\ImageGallery.WebApi.WebApp وارد شده و dotnet_run.bat آنرا اجرا کنید تا WebAPI برنامه راه اندازی شود.
- سپس به پوشهی src\IDP\DNT.IDP مراجعه کرده و و dotnet_run.bat آنرا اجرا کنید تا برنامهی IDP راه اندازی شود.
- در آخر به پوشهی src\MvcClient\ImageGallery.MvcClient.WebApp وارد شده و dotnet_run.bat آنرا اجرا کنید تا MVC Client راه اندازی شود.
اکنون که هر سه برنامه در حال اجرا هستند، مرورگر را گشوده و مسیر https://localhost:5001 را درخواست کنید. در صفحهی login نام کاربری را User 1 و کلمهی عبور آنرا password وارد کنید.