در
قسمت قبل، دسترسی به قسمتهایی از برنامهی کلاینت را توسط ویژگی Authorize و همچنین نقشهای مشخصی، محدود کردیم. در این مطلب میخواهیم اگر کاربری هنوز وارد سیستم نشدهاست و قصد مشاهدهی صفحات محافظت شده را دارد، به صورت خودکار به صفحهی لاگین هدایت شود و یا اگر کاربری که وارد سیستم شدهاست اما نقش مناسبی را جهت دسترسی به یک صفحه ندارد، بجای هدایت به صفحهی لاگین، پیام مناسبی را دریافت کند.
هدایت سراسری و خودکار کاربران اعتبارسنجی نشده به صفحهی لاگین
در برنامهی این سری، اگر کاربری که به سیستم وارد نشدهاست، بر روی دکمهی Book یک اتاق کلیک کند، فقط پیام «Not Authorized» را مشاهده خواهد کرد که تجربهی کاربری مطلوبی بهشمار نمیرود. بهتر است در یک چنین حالتی، کاربر را به صورت خودکار به صفحهی لاگین هدایت کرد و پس از لاگین موفق، مجددا او را به همین آدرس درخواستی پیش از نمایش صفحهی لاگین، هدایت کرد. برای مدیریت این مساله کامپوننت جدید RedirectToLogin را طراحی میکنیم که جایگزین پیام «Not Authorized» در کامپوننت ریشهای BlazorWasm.Client\App.razor خواهد شد. بنابراین ابتدا فایل جدید BlazorWasm.Client\Pages\Authentication\RedirectToLogin.razor را ایجاد میکنیم. چون این کامپوننت بدون مسیریابی خواهد بود و قرار است مستقیما داخل کامپوننت دیگری درج شود، نیاز است فضای نام آنرا نیز به فایل BlazorWasm.Client\_Imports.razor اضافه کرد:
@using BlazorWasm.Client.Pages.Authentication
پس از آن، محتوای این کامپوننت را به صورت زیر تکمیل میکنیم:
@using System.Security.Claims
@inject NavigationManager NavigationManager
if(AuthState is not null)
{
<div class="alert alert-danger">
<p>You [@AuthState.User.Identity.Name] do not have access to the requested page</p>
<div>
Your roles:
<ul>
@foreach (var claim in AuthState.User.Claims.Where(c => c.Type == ClaimTypes.Role))
{
<li>@claim.Value</li>
}
</ul>
</div>
</div>
}
@code
{
[CascadingParameter]
private Task<AuthenticationState> AuthenticationState {set; get;}
AuthenticationState AuthState;
protected override async Task OnInitializedAsync()
{
AuthState = await AuthenticationState;
if (!IsAuthenticated(AuthState))
{
var returnUrl = NavigationManager.ToBaseRelativePath(NavigationManager.Uri);
if (string.IsNullOrEmpty(returnUrl))
{
NavigationManager.NavigateTo("login");
}
else
{
NavigationManager.NavigateTo($"login?returnUrl={Uri.EscapeDataString(returnUrl)}");
}
}
}
private bool IsAuthenticated(AuthenticationState authState) =>
authState?.User?.Identity is not null && authState.User.Identity.IsAuthenticated;
}
توضیحات:
در اینجا روش کار کردن با AuthenticationState را از طریق کدنویسی ملاحظه میکنید. در زمان بارگذاری اولیهی این کامپوننت، بررسی میشود که آیا کاربر جاری، به سیستم وارد شدهاست یا خیر؟ اگر خیر، او را به سمت صفحهی لاگین هدایت میکنیم. اما اگر کاربر پیشتر به سیستم وارد شده باشد، متن شما دسترسی ندارید، به همراه لیست نقشهای او در صفحه ظاهر میشوند که برای دیباگ برنامه مفید است و دیگر به سمت صفحهی لاگین هدایت نمیشود.
در ادامه برای استفاده از این کامپوننت، به کامپوننت ریشهای BlazorWasm.Client\App.razor مراجعه کرده و قسمت NotAuthorized آنرا به صورت زیر، با معرفی کامپوننت RedirectToLogin، جایگزین میکنیم:
<NotAuthorized>
<RedirectToLogin></RedirectToLogin>
</NotAuthorized>
چون این کامپوننت اکنون در بالاترین سطح سلسله مراتب کامپوننتهای تعریف شده قرار دارد، به صورت سراسری به تمام صفحات و کامپوننتهای برنامه اعمال میشود.
چگونه دسترسی نقش ثابت Admin را به تمام صفحات محافظت شده برقرار کنیم؟
اگر خاطرتان باشد
در زمان ثبت کاربر ادمین Identity، تنها نقشی را که برای او ثبت کردیم، Admin بود که در تصویر فوق هم مشخص است؛ اما ویژگی Authorize استفاده شده جهت محافظت از کامپوننت (attribute [Authorize(Roles = ConstantRoles.Customer)]@)، تنها نیاز به نقش Customer را دارد. به همین جهت است که کاربر وارد شدهی به سیستم، هرچند از دیدگاه ما ادمین است، اما به این صفحه دسترسی ندارد. بنابراین اکنون این سؤال مطرح است که چگونه میتوان به صورت خودکار دسترسی نقش Admin را به تمام صفحات محافظت شدهی با نقشهای مختلف، برقرار کرد؟
برای رفع این مشکل همانطور که پیشتر نیز ذکر شد، نیاز است تمام نقشهای مدنظر را با یک کاما از هم جدا کرد و به خاصیت Roles ویژگی Authorize انتساب داد؛ و یا میتوان این عملیات را به صورت زیر نیز خلاصه کرد:
using System;
using BlazorServer.Common;
using Microsoft.AspNetCore.Authorization;
namespace BlazorWasm.Client.Utils
{
[AttributeUsage(AttributeTargets.Class | AttributeTargets.Method, AllowMultiple = true, Inherited = true)]
public class RolesAttribute : AuthorizeAttribute
{
public RolesAttribute(params string[] roles)
{
Roles = $"{ConstantRoles.Admin},{string.Join(",", roles)}";
}
}
}
در این حالت، AuthorizeAttribute سفارشی تهیه شده، همواره به همراه نقش ثابت ConstantRoles.Admin هم هست و همچنین دیگر نیازی نیست کار جمع زدن قسمتهای مختلف را با کاما انجام داد؛ چون string.Join نوشته شده همینکار را انجام میدهد.
پس از این تعریف میتوان در کامپوننتها، ویژگی Authorize نقش دار را با ویژگی جدید Roles، جایگزین کرد که همواره دسترسی کاربر Admin را نیز برقرار میکند:
@attribute [Roles(ConstantRoles.Customer, ConstantRoles.Employee)]
مدیریت سراسری خطاهای حاصل از درخواستهای HttpClient
تا اینجا نتایج حاصل از شکست اعتبارسنجی سمت کلاینت را به صورت سراسری مدیریت کردیم. اما برنامههای سمت کلاینت، به کمک HttpClient خود نیز میتوانند درخواستهایی را به سمت سرور ارسال کرده و در پاسخ، برای مثال not authorized و یا forbidden را دریافت کنند و یا حتی internal server error ای را در صورت بروز استثنایی در سمت سرور.
فرض کنید Web API Endpoint جدید زیر را تعریف کردهایم که نقش ادیتور را میپذیرد. این نقش، جزو نقشهای تعریف شدهی در برنامه و سیستم Identity ما نیست. بنابراین هر درخواستی که به سمت آن ارسال شود، برگشت خواهد خورد و پردازش نمیشود:
namespace BlazorWasm.WebApi.Controllers
{
[Route("api/[controller]")]
[Authorize(Roles = "Editor")]
public class MyProtectedEditorsApiController : Controller
{
[HttpGet]
public IActionResult Get()
{
return Ok(new ProtectedEditorsApiDTO
{
Id = 1,
Title = "Hello from My Protected Editors Controller!",
Username = this.User.Identity.Name
});
}
}
}
برای مدیریت سراسری یک چنین خطای سمت سروری در یک برنامهی Blazor WASM میتوان یک Http Interceptor نوشت:
namespace BlazorWasm.Client.Services
{
public class ClientHttpInterceptorService : DelegatingHandler
{
private readonly NavigationManager _navigationManager;
private readonly ILocalStorageService _localStorage;
private readonly IJSRuntime _jsRuntime;
public ClientHttpInterceptorService(
NavigationManager navigationManager,
ILocalStorageService localStorage,
IJSRuntime JsRuntime)
{
_navigationManager = navigationManager ?? throw new ArgumentNullException(nameof(navigationManager));
_localStorage = localStorage ?? throw new ArgumentNullException(nameof(localStorage));
_jsRuntime = JsRuntime ?? throw new ArgumentNullException(nameof(JsRuntime));
}
protected override async Task<HttpResponseMessage> SendAsync(HttpRequestMessage request, CancellationToken cancellationToken)
{
// How to add a JWT to all of the requests
var token = await _localStorage.GetItemAsync<string>(ConstantKeys.LocalToken);
if (token is not null)
{
request.Headers.Authorization = new AuthenticationHeaderValue("bearer", token);
}
var response = await base.SendAsync(request, cancellationToken);
if (!response.IsSuccessStatusCode)
{
await _jsRuntime.ToastrError($"Failed to call `{request.RequestUri}`. StatusCode: {response.StatusCode}.");
switch (response.StatusCode)
{
case HttpStatusCode.NotFound:
_navigationManager.NavigateTo("/404");
break;
case HttpStatusCode.Forbidden: // 403
case HttpStatusCode.Unauthorized: // 401
_navigationManager.NavigateTo("/unauthorized");
break;
default:
_navigationManager.NavigateTo("/500");
break;
}
}
return response;
}
}
}
توضیحات:
با ارثبری از کلاس پایهی DelegatingHandler میتوان متد SendAsync تمام درخواستهای ارسالی توسط برنامه را بازنویسی کرد و تحت نظر قرار داد. برای مثال در اینجا، پیش از فراخوانی await base.SendAsync کلاس پایه (یا همان درخواست اصلی که در قسمتی از برنامه صادر شدهاست)، یک توکن را به هدرهای درخواست، اضافه کردهایم و یا پس از این فراخوانی (که معادل فراخوانی اصل کد در حال اجرای برنامه است)، با بررسی StatusCode بازگشتی از سمت سرور، کاربر را به یکی از صفحات یافت نشد، خطایی رخ دادهاست و یا دسترسی ندارید، هدایت کردهایم. برای نمونه کامپوننت Unauthorized.razor را با محتوای زیر تعریف کردهایم:
@page "/unauthorized"
<div class="alert alert-danger mt-3">
<p>You don't have access to the requested resource.</p>
</div>
که سبب میشود زمانیکه StatusCode مساوی 401 و یا 403 را از سمت سرور دریافت کردیم، خطای فوق را به صورت خودکار به کاربر نمایش دهیم.
پس از تدارک این Interceptor سراسری، نوبت به معرفی آن به برنامهاست که ... در ابتدا نیاز به نصب بستهی نیوگت زیر را دارد:
dotnet add package Microsoft.Extensions.Http
این بستهی نیوگت، امکان دسترسی به متدهای الحاقی AddHttpClient و سپس AddHttpMessageHandler را میسر میکند که توسط متد AddHttpMessageHandler است که میتوان Interceptor سراسری را به سیستم معرفی کرد. بنابراین تعاریف قبلی و پیشفرض HttpClient را حذف کرده و با AddHttpClient جایگزین میکنیم:
namespace BlazorWasm.Client
{
public class Program
{
public static async Task Main(string[] args)
{
var builder = WebAssemblyHostBuilder.CreateDefault(args);
//...
// builder.Services.AddScoped(sp => new HttpClient { BaseAddress = new Uri(builder.HostEnvironment.BaseAddress) });
/*builder.Services.AddScoped(sp => new HttpClient
{
BaseAddress = new Uri(builder.Configuration.GetValue<string>("BaseAPIUrl"))
});*/
// dotnet add package Microsoft.Extensions.Http
builder.Services.AddHttpClient(
name: "ServerAPI",
configureClient: client =>
{
client.BaseAddress = new Uri(builder.Configuration.GetValue<string>("BaseAPIUrl"));
client.DefaultRequestHeaders.Add("User-Agent", "BlazorWasm.Client 1.0");
}
)
.AddHttpMessageHandler<ClientHttpInterceptorService>();
builder.Services.AddScoped<ClientHttpInterceptorService>();
builder.Services.AddScoped(sp => sp.GetRequiredService<IHttpClientFactory>().CreateClient("ServerAPI"));
//...
}
}
}
پس از این تنظیمات، در هر قسمتی از برنامه که با HttpClient تزریق شده کار میشود، تفاوتی نمیکند که چه نوع درخواستی به سمت سرور ارسال میشود، هر نوع درخواستی که باشد، تحت نظر قرار گرفته شده و بر اساس پاسخ دریافتی از سمت سرور، واکنش نشان داده خواهد شد. به این ترتیب دیگر نیازی نیست تا switch (response.StatusCode) را که در Interceptor تکمیل کردیم، در تمام قسمتهای برنامه که با HttpClient کار میکنند، تکرار کرد. همچنین مدیریت سراسری افزودن JWT به تمام درخواستها نیز به صورت خودکار انجام میشود.
کدهای کامل این مطلب را از اینجا میتوانید دریافت کنید: Blazor-5x-Part-33.zip