$.ajax({ headers: { 'Authorization': 'Bearer ' + jwtToken },
کوکیها به اندازهی خود اینترنت قدیمی هستند و تا سالها، تنها گزینهی شخصی سازی تجربهی کاربری در وب و انتقال آن از یک صفحه به صفحهی دیگری بهشمار میآمدند؛ به این نوع کوکیها، First-party cookies هم میگویند و توسط خود سایت ارائه دهندهی محتوا و برنامهها، تنظیم میشوند. در مقابل آن، third-party cookies یا کوکیهای ثالث هم وجود دارند که از طریق دومین دیگری بجز دومین اصلی برنامه، ارائه و تنظیم میشوند؛ به همین جهت به آنها Cross-site cookies هم میگویند. یکی از اهداف کوکیهای ثالث، ردیابی فعالیت کاربران در بین سایتهای مختلف است و این روزها به علت سوء استفادههای زیادی که از آنها میشوند، یکی از وسایل به اشتراکگذاری و جمع آوری اطلاعات خصوصی کاربران شدهاند.
البته کوکیهای ثالث، کاربردهای مفیدی هم دارند؛ مانند امکان پیاده سازی لاگین و اعتبارسنجی یکپارچهی بین چندین برنامه که به آن SSO یا Single Sign On هم گفته میشود؛ نمونهی آن، استفاده از Identity server و یا OpenId dict در دنیای داتنت است.
اما ... در کل به علت مشکلات یاد شده، اکثر مرورگرها تصمیم به عدم پذیرش و پردازش آنها گرفتهاند. برای مثال مرورگر Safari سالها است که اینگونه کوکیها را بلاک میکند و یا مرورگر فایرفاکس، کوکیهای ثالث ردیابها را بلاک میکند و ... مرورگر کروم نیز تصمیم گرفتهاست، تا پایان سال 2024، به این جمع محلق شود. هرچند اخیرا اعلام کردهاند، بجای اینکه مرورگر کروم، راسا این کار را انجام دهد، قرار است امکان انتخاب این گزینه به خود کاربر واگذار شود (چون ... خود گوگل از این نوع کوکیها منتفع است!). البته این تصمیم، بهنظر W3C خوش نیامده و اعلام کردهاند که این نوع کوکیها باید بروند!
روش آزمایش برنامهها برای بررسی تاثیر ممنوعیت کوکیهای ثالث
برای اینکه پیش از موعد، امکان آزمایش برنامهی خود را داشته باشید و بتوانید تاثیر ممنوعیت بکارگیری کوکیهای ثالث را بررسی کنید، در مرورگر کروم به آدرس زیر مراجعه کرده:
chrome://flags/#test-third-party-cookie-phaseout
و سپس این گزینه را فعال کنید و یا روش دوم فعالسازی آن، اجرای مرورگر کروم با سوئیچ test-third-party-cookie-phaseout-- از طریق خط فرمان است.
کدام برنامهها با ممنوعیت کوکیهای ثالث مشکل پیدا میکنند؟
اگر برنامهی SSO شما، مبتنی بر اعتبارسنجی یکپارچهی از نوع implicit flow است، حتما مشکل پیدا میکنید. در این حالت بهتر است به نوع امنتر authorization flow + PKCE مهاجرت کنید.
علت مسدود شدن نوع اعتبارسنجی و احراز هویت implicit flow که در برنامههای تک صفحهای وب (SPA/single-page apps) مرسوم است، شباهت بسیار زیاد آن به کاری است که ردیابهای اینترنتی انجام میدهند. در اینحالت، سایتهای ثالث، یک iframe مخفی را در پشت صحنه، درون سایت جاری باز کرده و توسط آن شروع به استفادهی از امکانات مرتبط با کوکیها میکنند. این الگو دقیقا همان کاری است که توسط implicit flow هم انجام میشود. بنابراین مرورگری که کوکیهای ثالث از این دست را مسدود میکند، قابلیت اعتبارسنجی یکپارچهی برنامههای SPA را هم غیرفعال خواهد کرد.
نسخه net core 2.2. Preview 3.، روز چهارشنبه 25 مهر، مطابق با 17 اکتبر منتشر شد. این نسخه شامل ویژگیهای جدیدی از جمله موارد زیر میباشد:
- تغییرات عمده در API
- Authorization Server
- بهبود کارآیی و سرعت
- پشتیبانی از Spatial Data برای SQL Server و SQLite
- پشتیبانی از Cosmos DB
- جایگزینی Bootstrap 4, Angular 6
مهمترین ویژگی مربوط به EF Core برای گروهی از برنامه نویسان، پشتیبانی از فیلدهای جغرافیایی یا همان Spatial Data میباشد.
نسخهی net core 2.2 RTM.، قبل از انتهای سال میلادی جاری منتشر خواهد شد و پس از آن آغاز به کار بر روی net core 3. خواهد بود. برای نسخهی net Core 3.، هدف بهبود برنامههای مبتنی بر Desktop و سازگاری آن با فرمت جدید csproj، اعلام شدهاست.
برای استفاده از ویژگیهای net core 2.2. باید نسخهی visual studio 2017 preview، یعنی ورژن Version 15.9.0 به بعد را نصب کنید. اگر صرفا dotnet core 2.2 را نصب کنید و از ویژوال استودیوی جاری قصد استفاده را داشته باشید، به شما پیغام میدهد که این نسخه قابلیت استفاده از دات نت 2.2 به بعد را ندارد.
برای تست و استفاده از net core 2.2 preview 3. مراحل زیر را طی کنید:
1- ابتدا آخرین انتشار ویژوال استودیو 2017، یعنی نسخهی پیشنمایش preview را از اینجا دریافت و نصب کنید. نسخه Enterprise را انتخاب کنید تا installer دانلود شود وشروع به نصب کند. حجم این نسخه، از نسخهی اصلی کمتر میباشد.
پس از نصب، این نسخه را میتوانید در کنار نسخهی اصلی، همزمان بر روی سیستم داشته باشید.
- برای نصب کلی بر روی سیستم، اینجا کلیک کنید و نسخهی SDK Installer مناسب دستگاه خود را دانلود کنید (64 یا 32 بیتی).
- برای استفادهی از EF Core آن در پروژه جاری میتوانید آنرا از اینجا دانلود و یا بوسیلهی دستور زیر نصب کنید:
Install-Package Microsoft.EntityFrameworkCore -Version 2.2.0-preview3-35497
3- پس از نصب، ویژوال استودیوی preview را از همان مسیر visual studio، اجرا کنید. یک پروژهی جدید از نوع وب را ایجاد کنید و فریمورک را بر روی بالاترین مقدار آن قرار دهید؛ مثل تصویر زیر:
بعد از ایجاد پروژه، میتوانید در solution Explorer، ارجاعات جدید را مشاهده کنید:
نمایش منتظر بمانید در حین بارگذاری اولیهی کامپوننت
کامپوننتهایی که قرار است اطلاعات را از یک Web API دریافت کنند، مدتی باید منتظر بمانند تا عملیات رفت و برگشت به سرور، تکمیل شود. در این بین میتوان یک loading را به کاربر نمایش داد:
@page "/hotel/rooms" @if (Rooms is not null && Rooms.Any()) { } else { <div style="position:fixed;top:50%;left:50%;margin-top:-50px;margin-left:-100px;"> <img src="images/loader.gif" /> </div> } @code { IEnumerable<HotelRoomDTO> Rooms = new List<HotelRoomDTO>(); // ... }
- هر زمانیکه کار روال رویدادگردان OnInitializedAsync به پایان برسد (که شامل اجرای متد LoadRooms نیز هست)، سبب فراخوانی خودکار StateHasChanged میشود. این فراخوانی، UI را مجددا رندر میکند. به همین جهت است که پس از پایان کار، محتوای if، رندر خواهد شد.
- از این loading سفارشی که در میانهی صفحه نمایش داده میشود، میتوان در فایل wwwroot\index.html نیز بجای loading پیشفرض آن استفاده کرد:
<body> <div id="app"> <div style=" position: fixed; top: 50%; left: 50%; margin-top: -50px; margin-left: -100px; " > <img src="images/ajax-loader.gif" /> </div> </div>
افزودن خواصی جدید به HotelRoomDTO
میخواهیم به کاربر امکان تغییر تعداد روزهای اقامت را بدهیم. این انتخاب باید در لیست اتاقهای نمایش داده شده، با تغییر تعداد روزهای اقامت (TotalDays) و هزینهی جدید متناظر با آن (TotalAmount)، منعکس شود. به همین جهت این خواص را به HotelRoomDTO، اضافه میکنیم:
namespace BlazorServer.Models { public class HotelRoomDTO { // ... public int TotalDays { get; set; } public decimal TotalAmount { get; set; } } }
@code { HomeVM HomeModel = new HomeVM(); // ... private async Task LoadRoomsAsync() { Rooms = await HotelRoomService.GetHotelRoomsAsync(HomeModel.StartDate, HomeModel.EndDate); foreach (var room in Rooms) { room.TotalAmount = room.RegularRate * HomeModel.NoOfNights; room.TotalDays = HomeModel.NoOfNights; } } }
افزودن امکان تغییر تعداد روزهای اقامت در همان صفحهی نمایش لیست اتاقها
همانطور که در تصویر فوق هم مشاهده میکنید، میخواهیم در این صفحه نیز کاربر بتواند زمان شروع اقامت و مدت مدنظر را تغییر دهد. به همین جهت، HomeModel ای را که در قسمت قبل از Local Storage دریافت کردیم، به فرم زیر متصل میکنیم تا اجزای آن در این فرم، نمایش داده شده و قابل تغییر شوند:
@if (Rooms is not null && Rooms.Any()) { <EditForm Model="HomeModel" OnValidSubmit="SaveBookingInfo" class="bg-light"> <div class="pt-3 pb-2 px-5 mx-1 mx-md-0 bg-secondary"> <DataAnnotationsValidator /> <div class="row px-3 mx-3"> <div class="col-6 col-md-4"> <div class="form-group"> <label class="text-warning">Check in Date</label> <InputDate @bind-Value="HomeModel.StartDate" class="form-control" /> </div> </div> <div class="col-6 col-md-4"> <div class="form-group"> <label class="text-warning">Check Out Date</label> <input @bind="HomeModel.EndDate" disabled="disabled" readonly="readonly" type="date" class="form-control" /> </div> </div> <div class=" col-4 col-md-2"> <div class="form-group"> <label class="text-warning">No. of nights</label> <select class="form-control" @bind="HomeModel.NoOfNights"> <option value="Select" selected disabled="disabled">(Select No. Of Nights)</option> @for (var i = 1; i <= 10; i++) { <option value="@i">@i</option> } </select> </div> </div> <div class="col-8 col-md-2"> <div class="form-group" style="margin-top: 1.9rem !important;"> @if (IsProcessing) { <button class="btn btn-success btn-block form-control"> <i class="fa fa-spin fa-spinner"></i>Processing... </button> } else { <input type="submit" value="Update" class="btn btn-success btn-block form-control" /> } </div> </div> </div> </div> </EditForm>
@code { bool IsProcessing; // ... private async Task SaveBookingInfo() { IsProcessing = true; HomeModel.EndDate = HomeModel.StartDate.AddDays(HomeModel.NoOfNights); await LocalStorage.SetItemAsync(ConstantKeys.LocalInitialBooking, HomeModel); await LoadRoomsAsync(); IsProcessing = false; } }
سؤال: زمانیکه IsProcessing به true تنظیم میشود که هنوز کار متد رویدادگردان SaveBookingInfo به پایان نرسیدهاست و فراخوانی خودکار StateHasChanged در پایان متدهای رویدادگردان صورت میگیرد. پس چطور است که سبب رندر مجدد UI و تغییر برچسب دکمهی Update میشود؟
پاسخ به این سؤال را در قسمت 6 این سری با بررسی چرخهی حیات کامپوننتها، مشاهده کردیم:
«البته متدهای رویدادگردان async، دوبار سبب فراخوانی ضمنی StateHasChanged میشوند؛ یکبار زمانیکه قسمت sync متد به پایان میرسد (در این مثال یعنی تا قبل از اولین await نوشته شده) و یکبار هم زمانیکه کار فراخوانی کلی متد به پایان خواهد رسید»
نمایش لیست اتاقها
نمایش لیست اتاقها مطابق تصویر فوق، دو قسمت اصلی را دارد:
الف) نمایش لیست تصاویر منتسب به یک اتاق، توسط کامپوننت carousel بوت استرپ
@foreach (var room in Rooms) { <div class="row p-2 my-3 " style="border-radius:20px; border: 1px solid gray"> <div class="col-12 col-lg-3 col-md-4"> <div id="carouselExampleIndicators_@room.Id" class="carousel slide mb-4 m-md-3 m-0 pt-3 pt-md-0" data-ride="carousel"> <ol class="carousel-indicators"> @{ int imageIndex = 0; int innerImageIndex = 0; } @foreach (var image in room.HotelRoomImages) { if (imageIndex == 0) { <li data-target="#carouselExampleIndicators_@room.Id" data-slide-to="@imageIndex" class="active"></li> } else { <li data-target="#carouselExampleIndicators_@room.Id" data-slide-to="@imageIndex"></li> } imageIndex++; } </ol> <div class="carousel-inner"> @foreach (var image in room.HotelRoomImages) { var imageUrl = $"{ImagesBaseAddress}/{image.RoomImageUrl}"; if (innerImageIndex == 0) { <div class="carousel-item active"> <img class="d-block w-100" style="border-radius:20px;" src="@imageUrl" alt="First slide"> </div> } else { <div class="carousel-item"> <img class="d-block w-100" style="border-radius:20px;" src="@imageUrl" alt="First slide"> </div> } innerImageIndex++; } </div> <a class="carousel-control-prev" href="#carouselExampleIndicators_@room.Id" role="button" data-slide="prev"> <span class="carousel-control-prev-icon" aria-hidden="true"></span> <span class="sr-only">Previous</span> </a> <a class="carousel-control-next" href="#carouselExampleIndicators_@room.Id" role="button" data-slide="next"> <span class="carousel-control-next-icon" aria-hidden="true"></span> <span class="sr-only">Next</span> </a> </div> </div> }
- سپس در حلقهای که برای نمایش لیست اتاقها تهیه کردهایم، قسمتهای مختلف carousel را تکمیل میکنیم که در اینجا نیاز به ایندکس تصاویر، لیست تصاویر و یک Id منحصربفرد برای این carousel خاص را دارد تا بتوان چندین وهله از آنرا در صفحه قرار داد که این id را بر اساس Id اتاق مشخص کردهایم.
دو نکته:
- در این مثال برای تعریف لینک به تصاویر، کد زیر را مشاهده میکنید:
var imageUrl = $"{ImagesBaseAddress}/{image.RoomImageUrl}";
@code { string ImagesBaseAddress = "https://localhost:5006";
- کامپوننت carousel برای اجرا، نیاز به فایل lib/bootstrap/dist/js/bootstrap.bundle.min.js را نیز دارد. به همین جهت مدخل اسکریپت آنرا باید به فایل wwwroot\index.html اضافه کرد.
ب) نمایش جزئیات نام و هزینهی اتاق
قسمت دوم حلقهی foreach نمایش لیست اتاقها، جهت نمایش جزئیات هر اتاق تعریف شدهاست:
@foreach (var room in Rooms) { <div class="col-12 col-lg-9 col-md-8"> <div class="row pt-3"> <div class="col-12 col-lg-8"> <p class="card-title text-warning" style="font-size:xx-large">@room.Name</p> <p class="card-text"> @((MarkupString)room.Details) </p> </div> <div class="col-12 col-lg-4"> <div class="row pb-3 pt-2"> <div class="col-12 col-lg-11 offset-lg-1"> <a href="@($"hotel/room-details/{room.Id}")" class="btn btn-success btn-block">Book</a> </div> </div> <div class="row "> <div class="col-12 pb-5"> <span class="float-right"> <span class="float-right">Occupancy : @room.Occupancy adults </span><br /> <span class="float-right pt-1">Room Size : @room.SqFt sqft</span><br /> <h4 class="text-warning font-weight-bold pt-4"> <span style="border-bottom:1px solid #ff6a00"> @room.TotalAmount.ToString("#,#.00;(#,#.00#)") </span> </h4> <span class="float-right">Cost for @room.TotalDays nights</span> </span> </div> </div> </div> </div> </div> </div> }
- هر اتاق نمایش داده شده، لینکی را به صفحهی خاص خودش نیز دارد که آنرا در قسمت بعدی تکمیل میکنیم.
- در اینجا TotalAmount و TotalDays محاسباتی و قابل تغییر بر اساس انتخاب کاربر نیز درج شدهاند.
یک تمرین: در برنامهی Blazor Server، سرویسی را جهت درج مشخصات امکانات رفاهی هتل تهیه کردیم. این امکانات رفاهی را از طریق Web API برنامه دریافت و سپس در برنامهی سمت کلاینت نمایش دهید.
بنابراین تکمیل این تمرین شامل تهیهی موارد زیر است که کدنویسی آن، با دو قسمت اخیر این سری دقیقا یکی است و نکتهی جدیدی را به همراه ندارد (و کدهای کامل آن را از انتهای بحث میتوانید دریافت کنید):
- تهیهی HotelAmenityController در پروژهی Web API که به کمک IAmenityService، لیست امکانات رفاهی را بازگشت میدهد.
- تهیهی ClientHotelAmenityService در پروژهی WASM که همانند ClientHotelRoomService قسمت قبل ، از Web API، لیست HotelAmenityDTOها را دریافت میکند.
- ثبت سرویس جدید ClientHotelAmenityService در Program.cs.
- در آخر حلقهای را بر روی لیست HotelAmenityDTO دریافتی از ClientHotelRoomService در کامپوننت Index.razor تشکیل داده و آنها را نمایش میدهیم.
کدهای کامل این مطلب را از اینجا میتوانید دریافت کنید: Blazor-5x-Part-28.zip
اگر در نگارش 2.2، فایل کلاس آغازین برنامهی شما چنین شکلی را دارد:
public void Configure(IApplicationBuilder app) { ... app.UseStaticFiles(); app.UseAuthentication(); app.UseSignalR(hubs => { hubs.MapHub<ChatHub>("/chat"); }); app.UseMvc(routes => { routes.MapRoute("default", "{controller=Home}/{action=Index}/{id?}"); }); }
public void Configure(IApplicationBuilder app) { ... app.UseStaticFiles(); app.UseRouting(); app.UseAuthentication(); app.UseAuthorization(); app.UseCors(); app.UseEndpoints(endpoints => { endpoints.MapHub<ChatHub>("/chat"); endpoints.MapControllerRoute("default", "{controller=Home}/{action=Index}/{id?}"); }); }
- متد UseRouting اضافه شدهاست.
- UseStaticFiles (در صورت نیاز) باید قبل از UseRouting فراخوانی شود.
- اگر برنامهی شما به مباحث اعتبارسنجی پرداخته و در کنترلرهای آن از فیلتر Authorize استفاده میشود، دو تغییر مهم فراخوانی UseAuthentication و سپس UseAuthorization (که این مورد جدید هست) را پس از فراخوانی UseRouting باید انجام دهید. در غیر اینصورت در حین مرور این صفحات و قسمتهای محافظت شده، برنامه با خطای زیر متوقف خواهد شد:
Endpoint xyz contains authorization metadata, but a middleware was not found that supports authorization. Configure your application startup by adding app.UseAuthorization() inside the call to Configure(..) in the application startup code.
- متدهای UseMvc و یا UseSignalR قبلی، به متد UseEndpoints تبدیل شدهاند.
Blazor 5x - قسمت 25 - تهیه API مخصوص Blazor WASM - بخش 2 - تامین پایهی اعتبارسنجی و احراز هویت
- «معرفی JSON Web Token»
توسعهی IdentityUser
در قسمتهای 21 تا 23، روش نصب و یکپارچگی ASP.NET Core Identity را با یک برنامهی Blazor Server بررسی کردیم. در پروژهی Web API جاری هم از قصد داریم از ASP.NET Core Identity استفاده کنیم؛ البته بدون نصب UI پیشفرض آن. به همین جهت فقط از ApplicationDbContext آن برنامه که از IdentityDbContext مشتق شده و همچنین قسمتی از تنظیمات سرویسهای ابتدایی آن که در قسمت قبل بررسی کردیم، در اینجا استفاده خواهیم کرد.
IdentityUser پیشفرض که معرف موجودیت کاربران یک سیستم مبتنی بر ASP.NET Core Identity است، برای ثبت نام یک کاربر، فقط به ایمیل و کلمهی عبور او نیاز دارد که نمونهای از آنرا در حین معرفی «ثبت کاربر ادمین Identity» بررسی کردیم. اکنون میخواهیم این موجودیت پیشفرض را توسعه داده و برای مثال نام کاربر را نیز به آن اضافه کنیم. برای اینکار فایل جدید BlazorServer\BlazorServer.Entities\ApplicationUser .cs را به پروژهی Entities با محتوای زیر اضافه میکنیم:
using Microsoft.AspNetCore.Identity; namespace BlazorServer.Entities { public class ApplicationUser : IdentityUser { public string Name { get; set; } } }
اکنون که یک ApplicationUser سفارشی را ایجاد کردیم، نیازی نیست تا DbSet خاص آنرا به ApplicationDbContext برنامه اضافه کنیم. برای معرفی آن به برنامه ابتدا باید به فایل BlazorServer\BlazorServer.DataAccess\ApplicationDbContext.cs مراجعه کرده و نوع IdentityUser را به IdentityDbContext، از طریق آرگومان جنریکی که میپذیرد، معرفی کنیم:
public class ApplicationDbContext : IdentityDbContext<ApplicationUser>
پس از این تغییر، در فایل BlazorWasm\BlazorWasm.WebApi\Startup.cs نیز باید ApplicationUser را به عنوان نوع جدید کاربران، معرفی کرد:
namespace BlazorWasm.WebApi { public class Startup { // ... public void ConfigureServices(IServiceCollection services) { services.AddIdentity<ApplicationUser, IdentityRole>() .AddEntityFrameworkStores<ApplicationDbContext>() .AddDefaultTokenProviders(); // ...
پس از این تغییرات، باید از طریق خط فرمان به پوشهی BlazorServer.DataAccess وارد شد و دستورات زیر را جهت ایجاد و اعمال Migrations متناظر با تغییرات فوق، اجرا کرد. چون در این دستورات اینبار پروژهی آغازین، به پروژهی Web API اشاره میکند، باید بستهی نیوگت Microsoft.EntityFrameworkCore.Design را نیز به پروژهی آغازین اضافه کرد، تا بتوان آنها را با موفقیت به پایان رساند:
dotnet tool update --global dotnet-ef --version 5.0.4 dotnet build dotnet ef migrations --startup-project ../../BlazorWasm/BlazorWasm.WebApi/ add AddNameToAppUser --context ApplicationDbContext dotnet ef --startup-project ../../BlazorWasm/BlazorWasm.WebApi/ database update --context ApplicationDbContext
ایجاد مدلهای ثبت نام
در ادامه میخواهیم کنترلری را ایجاد کنیم که کار ثبت نام و لاگین را مدیریت میکند. برای این منظور باید بتوان از کاربر، اطلاعاتی مانند نام کاربری و کلمهی عبور او را دریافت کرد و پس از پایان عملیات نیز نتیجهی آنرا بازگشت داد. به همین جهت دو مدل زیر را جهت مدیریت قسمت ثبت نام، به پروژهی BlazorServer.Models اضافه میکنیم:
using System.ComponentModel.DataAnnotations; namespace BlazorServer.Models { public class UserRequestDTO { [Required(ErrorMessage = "Name is required")] public string Name { get; set; } [Required(ErrorMessage = "Email is required")] [RegularExpression("^[a-zA-Z0-9_.-]+@[a-zA-Z0-9-]+.[a-zA-Z0-9-.]+$", ErrorMessage = "Invalid email address")] public string Email { get; set; } public string PhoneNo { get; set; } [Required(ErrorMessage = "Password is required.")] [DataType(DataType.Password)] public string Password { get; set; } [Required(ErrorMessage = "Confirm password is required")] [DataType(DataType.Password)] [Compare("Password", ErrorMessage = "Password and confirm password is not matched")] public string ConfirmPassword { get; set; } } }
public class RegistrationResponseDTO { public bool IsRegistrationSuccessful { get; set; } public IEnumerable<string> Errors { get; set; } }
ایجاد و تکمیل کنترلر Account، جهت ثبت نام کاربران
در ادامه نیاز داریم تا جهت ارائهی امکانات اعتبارسنجی و احراز هویت کاربران، کنترلر جدید Account را به پروژهی Web API اضافه کنیم:
using System; using BlazorServer.Entities; using BlazorServer.Models; using Microsoft.AspNetCore.Authorization; using Microsoft.AspNetCore.Identity; using Microsoft.AspNetCore.Mvc; using System.Threading.Tasks; using System.Linq; using BlazorServer.Common; namespace BlazorWasm.WebApi.Controllers { [Route("api/[controller]/[action]")] [ApiController] [Authorize] public class AccountController : ControllerBase { private readonly SignInManager<ApplicationUser> _signInManager; private readonly UserManager<ApplicationUser> _userManager; private readonly RoleManager<IdentityRole> _roleManager; public AccountController(SignInManager<ApplicationUser> signInManager, UserManager<ApplicationUser> userManager, RoleManager<IdentityRole> roleManager) { _roleManager = roleManager ?? throw new ArgumentNullException(nameof(roleManager)); _userManager = userManager ?? throw new ArgumentNullException(nameof(userManager)); _signInManager = signInManager ?? throw new ArgumentNullException(nameof(signInManager)); } [HttpPost] [AllowAnonymous] public async Task<IActionResult> SignUp([FromBody] UserRequestDTO userRequestDTO) { var user = new ApplicationUser { UserName = userRequestDTO.Email, Email = userRequestDTO.Email, Name = userRequestDTO.Name, PhoneNumber = userRequestDTO.PhoneNo, EmailConfirmed = true }; var result = await _userManager.CreateAsync(user, userRequestDTO.Password); if (!result.Succeeded) { var errors = result.Errors.Select(e => e.Description); return BadRequest(new RegistationResponseDTO { Errors = errors, IsRegistrationSuccessful = false }); } var roleResult = await _userManager.AddToRoleAsync(user, ConstantRoles.Customer); if (!roleResult.Succeeded) { var errors = result.Errors.Select(e => e.Description); return BadRequest(new RegistationResponseDTO { Errors = errors, IsRegistrationSuccessful = false }); } return StatusCode(201); // Created } } }
- در تعریف ابتدایی این کنترلر، ویژگیهای زیر ذکر شدهاند:
[Route("api/[controller]/[action]")] [ApiController] [Authorize]
تا اینجا اگر برنامه را اجرا کنیم، میتوان با استفاده از Swagger UI، آنرا آزمایش کرد:
که با اجرای آن، برای نمونه به خروجی زیر میرسیم:
که عنوان میکند کلمهی عبور باید حداقل دارای یک عدد و یک حرف بزرگ باشد. پس از اصلاح آن، status-code=201 را دریافت خواهیم کرد.
و اگر سعی کنیم همین کاربر را مجددا ثبت نام کنیم، با خطای زیر مواجه خواهیم شد:
ایجاد مدلهای ورود به سیستم
در پروژهی Web API، از UI پیشفرض ASP.NET Core Identity استفاده نمیکنیم. به همین جهت نیاز است مدلهای قسمت لاگین را به صورت زیر تعریف کنیم:
using System.ComponentModel.DataAnnotations; namespace BlazorServer.Models { public class AuthenticationDTO { [Required(ErrorMessage = "UserName is required")] [RegularExpression("^[a-zA-Z0-9_.-]+@[a-zA-Z0-9-]+.[a-zA-Z0-9-.]+$", ErrorMessage = "Invalid email address")] public string UserName { get; set; } [Required(ErrorMessage = "Password is required.")] [DataType(DataType.Password)] public string Password { get; set; } } }
using System.Collections.Generic; namespace BlazorServer.Models { public class AuthenticationResponseDTO { public bool IsAuthSuccessful { get; set; } public string ErrorMessage { get; set; } public string Token { get; set; } public UserDTO UserDTO { get; set; } } public class UserDTO { public string Id { get; set; } public string Name { get; set; } public string Email { get; set; } public string PhoneNo { get; set; } } }
ایجاد مدل مشخصات تولید JSON Web Token
پس از لاگین موفق، نیاز است یک JWT را تولید کرد و در اختیار کلاینت قرار داد. مشخصات ابتدایی تولید این توکن، توسط مدل زیر تعریف میشود:
namespace BlazorServer.Models { public class BearerTokensOptions { public string Key { set; get; } public string Issuer { set; get; } public string Audience { set; get; } public int AccessTokenExpirationMinutes { set; get; } } }
{ "BearerTokens": { "Key": "This is my shared key, not so secret, secret!", "Issuer": "https://localhost:5001/", "Audience": "Any", "AccessTokenExpirationMinutes": 20 } }
namespace BlazorWasm.WebApi { public class Startup { // ... public void ConfigureServices(IServiceCollection services) { services.AddOptions<BearerTokensOptions>().Bind(Configuration.GetSection("BearerTokens")); // ...
ایجاد سرویسی برای تولید JSON Web Token
سرویس زیر به کمک سرویس توکار UserManager مخصوص Identity و مشخصات ابتدایی توکنی که معرفی کردیم، کار تولید یک JWT را انجام میدهد:
using System; using System.Collections.Generic; using System.IdentityModel.Tokens.Jwt; using System.Security.Claims; using System.Text; using System.Threading.Tasks; using BlazorServer.Entities; using BlazorServer.Models; using Microsoft.AspNetCore.Identity; using Microsoft.Extensions.Options; using Microsoft.IdentityModel.Tokens; namespace BlazorServer.Services { public interface ITokenFactoryService { Task<string> CreateJwtTokensAsync(ApplicationUser user); } public class TokenFactoryService : ITokenFactoryService { private readonly UserManager<ApplicationUser> _userManager; private readonly BearerTokensOptions _configuration; public TokenFactoryService( UserManager<ApplicationUser> userManager, IOptionsSnapshot<BearerTokensOptions> bearerTokensOptions) { _userManager = userManager ?? throw new ArgumentNullException(nameof(userManager)); if (bearerTokensOptions is null) { throw new ArgumentNullException(nameof(bearerTokensOptions)); } _configuration = bearerTokensOptions.Value; } public async Task<string> CreateJwtTokensAsync(ApplicationUser user) { var signingCredentials = new SigningCredentials( new SymmetricSecurityKey(Encoding.UTF8.GetBytes(_configuration.Key)), SecurityAlgorithms.HmacSha256); var claims = await getClaimsAsync(user); var now = DateTime.UtcNow; var tokenOptions = new JwtSecurityToken( issuer: _configuration.Issuer, audience: _configuration.Audience, claims: claims, notBefore: now, expires: now.AddMinutes(_configuration.AccessTokenExpirationMinutes), signingCredentials: signingCredentials); return new JwtSecurityTokenHandler().WriteToken(tokenOptions); } private async Task<List<Claim>> getClaimsAsync(ApplicationUser user) { string issuer = _configuration.Issuer; var claims = new List<Claim> { // Issuer new Claim(JwtRegisteredClaimNames.Iss, issuer, ClaimValueTypes.String, issuer), // Issued at new Claim(JwtRegisteredClaimNames.Iat, DateTimeOffset.UtcNow.ToUnixTimeSeconds().ToString(), ClaimValueTypes.Integer64, issuer), new Claim(ClaimTypes.Name, user.Email, ClaimValueTypes.String, issuer), new Claim(ClaimTypes.Email, user.Email, ClaimValueTypes.String, issuer), new Claim("Id", user.Id, ClaimValueTypes.String, issuer), new Claim("DisplayName", user.Name, ClaimValueTypes.String, issuer), }; var roles = await _userManager.GetRolesAsync(user); foreach (var role in roles) { claims.Add(new Claim(ClaimTypes.Role, role, ClaimValueTypes.String, issuer)); } return claims; } } }
در آخر، این سرویس را به صورت زیر به لیست سرویسهای ثبت شدهی پروژهی Web API، اضافه میکنیم:
namespace BlazorWasm.WebApi { public class Startup { // ... public void ConfigureServices(IServiceCollection services) { services.AddScoped<ITokenFactoryService, TokenFactoryService>(); // ...
تکمیل کنترلر Account جهت لاگین کاربران
پس از ثبت نام کاربران، اکنون میخواهیم امکان لاگین آنها را نیز فراهم کنیم:
namespace BlazorWasm.WebApi.Controllers { [Route("api/[controller]/[action]")] [ApiController] [Authorize] public class AccountController : ControllerBase { private readonly SignInManager<ApplicationUser> _signInManager; private readonly UserManager<ApplicationUser> _userManager; private readonly ITokenFactoryService _tokenFactoryService; public AccountController( SignInManager<ApplicationUser> signInManager, UserManager<ApplicationUser> userManager, ITokenFactoryService tokenFactoryService) { _userManager = userManager ?? throw new ArgumentNullException(nameof(userManager)); _signInManager = signInManager ?? throw new ArgumentNullException(nameof(signInManager)); _tokenFactoryService = tokenFactoryService; } [HttpPost] [AllowAnonymous] public async Task<IActionResult> SignIn([FromBody] AuthenticationDTO authenticationDTO) { var result = await _signInManager.PasswordSignInAsync( authenticationDTO.UserName, authenticationDTO.Password, isPersistent: false, lockoutOnFailure: false); if (!result.Succeeded) { return Unauthorized(new AuthenticationResponseDTO { IsAuthSuccessful = false, ErrorMessage = "Invalid Authentication" }); } var user = await _userManager.FindByNameAsync(authenticationDTO.UserName); if (user == null) { return Unauthorized(new AuthenticationResponseDTO { IsAuthSuccessful = false, ErrorMessage = "Invalid Authentication" }); } var token = await _tokenFactoryService.CreateJwtTokensAsync(user); return Ok(new AuthenticationResponseDTO { IsAuthSuccessful = true, Token = token, UserDTO = new UserDTO { Name = user.Name, Id = user.Id, Email = user.Email, PhoneNo = user.PhoneNumber } }); } } }
تا اینجا اگر برنامه را اجرا کنیم، میتوان در قسمت ورود به سیستم، برای نمونه مشخصات کاربر ادمین را وارد کرد:
و پس از اجرای درخواست، به خروجی زیر میرسیم:
که در اینجا JWT تولید شدهی به همراه قسمتی از مشخصات کاربر، در خروجی نهایی مشخص است. میتوان محتوای این توکن را در سایت jwt.io مورد بررسی قرار داد که به این خروجی میرسیم و حاوی claims تعریف شدهاست:
{ "iss": "https://localhost:5001/", "iat": 1616396383, "http://schemas.xmlsoap.org/ws/2005/05/identity/claims/name": "vahid@dntips.ir", "http://schemas.xmlsoap.org/ws/2005/05/identity/claims/emailaddress": "vahid@dntips.ir", "Id": "582855fb-e95b-45ab-b349-5e9f7de40c0c", "DisplayName": "vahid@dntips.ir", "http://schemas.microsoft.com/ws/2008/06/identity/claims/role": "Admin", "nbf": 1616396383, "exp": 1616397583, "aud": "Any" }
تنظیم Web API برای پذیرش و پردازش JWT ها
تا اینجا پس از لاگین، یک JWT را در اختیار کلاینت قرار میدهیم. اما اگر کلاینت این JWT را به سمت سرور ارسال کند، اتفاق خاصی رخ نخواهد داد و توسط آن، شیء User قابل دسترسی در یک اکشن متد، به صورت خودکار تشکیل نمیشود. برای رفع این مشکل، ابتدا بستهی جدید نیوگت Microsoft.AspNetCore.Authentication.JwtBearer را به پروژهی Web API اضافه میکنیم، سپس به کلاس آغازین پروژهی Web API مراجعه کرده و آنرا به صورت زیر تکمیل میکنیم:
namespace BlazorWasm.WebApi { public class Startup { // ... public void ConfigureServices(IServiceCollection services) { var bearerTokensSection = Configuration.GetSection("BearerTokens"); services.AddOptions<BearerTokensOptions>().Bind(bearerTokensSection); // ... var apiSettings = bearerTokensSection.Get<BearerTokensOptions>(); var key = Encoding.UTF8.GetBytes(apiSettings.Key); services.AddAuthentication(opt => { opt.DefaultAuthenticateScheme = JwtBearerDefaults.AuthenticationScheme; opt.DefaultChallengeScheme = JwtBearerDefaults.AuthenticationScheme; opt.DefaultScheme = JwtBearerDefaults.AuthenticationScheme; }) .AddJwtBearer(cfg => { cfg.RequireHttpsMetadata = false; cfg.SaveToken = true; cfg.TokenValidationParameters = new TokenValidationParameters { ValidateIssuerSigningKey = true, IssuerSigningKey = new SymmetricSecurityKey(key), ValidateAudience = true, ValidateIssuer = true, ValidAudience = apiSettings.Audience, ValidIssuer = apiSettings.Issuer, ClockSkew = TimeSpan.Zero, ValidateLifetime = true }; }); // ...
افزودن JWT به تنظیمات Swagger
هر کدام از اکشن متدهای کنترلرهای Web API برنامه که مزین به فیلتر Authorize باشد، در Swagger UI با یک قفل نمایش داده میشود. در این حالت میتوان این UI را به نحو زیر سفارشی سازی کرد تا بتواند JWT را دریافت و به سمت سرور ارسال کند:
namespace BlazorWasm.WebApi { public class Startup { // ... public void ConfigureServices(IServiceCollection services) { // ... services.AddSwaggerGen(c => { c.SwaggerDoc("v1", new OpenApiInfo { Title = "BlazorWasm.WebApi", Version = "v1" }); c.AddSecurityDefinition("Bearer", new OpenApiSecurityScheme { In = ParameterLocation.Header, Description = "Please enter the token in the field", Name = "Authorization", Type = SecuritySchemeType.ApiKey }); c.AddSecurityRequirement(new OpenApiSecurityRequirement { { new OpenApiSecurityScheme { Reference = new OpenApiReference { Type = ReferenceType.SecurityScheme, Id = "Bearer" } }, new string[] { } } }); }); } // ...
کدهای کامل این مطلب را از اینجا میتوانید دریافت کنید: Blazor-5x-Part-25.zip
آشنایی و بررسی ابزار Glimpse
بعد از آپلود سایت ما میتوانیم دسترسی به تنظیمات خاص glimpse را تنها به کاربران عضو محدود کنیم:
<location path="Glimpse.axd" > <system.web> <authorization> <allow users="Administrator" /> <deny users="*" /> </authorization> </system.web> </location>
یا میتوانیم آنرا غیرفعال کنیم :
<glimpse defaultRuntimePolicy="Off" xdt:Transform="SetAttributes"> </glimpse>
همچنین میتوانیم با پیاده سازی اینترفیس IRuntimePolicy سیاستهای مختلف نمایش تبهای glimpse را تعیین کنیم :
using Glimpse.AspNet.Extensions; using Glimpse.Core.Extensibility; namespace Test { public class GlimpseSecurityPolicy:IRuntimePolicy { public RuntimePolicy Execute(IRuntimePolicyContext policyContext) { // You can perform a check like the one below to control Glimpse's permissions within your application. // More information about RuntimePolicies can be found at http://getglimpse.com/Help/Custom-Runtime-Policy var httpContext = policyContext.GetHttpContext(); if (!httpContext.User.IsInRole("Administrator ")) { return RuntimePolicy.Off; } return RuntimePolicy.On; } public RuntimeEvent ExecuteOn { get { return RuntimeEvent.EndRequest; } } } }
زمانیکه glimpse را از طریق Nuget نصب میکنید کلاس فوق به صورت اتوماتیک به پروژه اضافه میشود با این تفاوت که به صورت کامنت شده است تنها کاری شما باید انجام بدید کدهای فوق را از حالت کامنت خارج کنید و Role مربوطه را جایگزین کنید.
نکته : کلاس فوق نیاز به رجیستر شدن ندارد و تشخیص آن توسط Glimpse به صورت خودکار انجام میشود.