« ... حالا بعد از اعمال روش ارائه شده در این مطلب (ذخیرهسازی token و refresh token در دیتابیس) چطور میتوانیم کاربرانی که از توکن قبلی استفاده میکنند را مجبور به Sign out کنیم؟ ...»همان قسمت «تهیه یک اعتبارسنج توکن سفارشی» مطلب جاری هست که از نتیجهی «پیاده سازی Logout» استفاده میکند. یا حتی میتوانید در قسمت logout یک SerialNumber را هم تغییر دهید که به صورت یک Claim سفارشی در توکن قبلی وجود داشته باشد. عدم انطباق این مقادیر را در این اعتبارسنج سفارشی بررسی کنید.
<?xml version="1.0"?> <Configuration xmlns:xsd="http://www.w3.org/2001/XMLSchema" xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"> <AllowAnonymousPush>false</AllowAnonymousPush> <Repositories>D:\GitRepo</Repositories> <AllowUserRepositoryCreation>true</AllowUserRepositoryCreation> <AllowAnonymousRegistration>false</AllowAnonymousRegistration> <DefaultLanguage>en-US</DefaultLanguage> <IsCommitAuthorAvatarVisible>true</IsCommitAuthorAvatarVisible> </Configuration>
قابلیت جالبی که در اینجا به نظر من خیلی مهم بود، استخراج تاریخچهی کامل ساب ورژن توسط گیت و انتقال همه آنها به مخزن گیت است که تنها با یک خط فرمان انجام پذیر است. برای اینکار مخرنی را در گیت ساخته و آدرس .git آن را برای اجرای فرمان نگه میداریم:
البته نصب گیت برای ویندوز برای صدور فرمان انتقال به گیت الزامی است که میتوانید از این آدرس آنرا دانلود و نصب کنید.
پس از آن در 2 مرحله مخرن ساب ورژن را به گیت انتقال میدهیم:
1- استخراج آن در یک مخزن لوکال
2- افزودن به سرور گیت (که راه اندازی شده)
برای استخراج مخزنی از ساب ورژن به یک مخزن لوکال گیت، یک فولدر خالی را ایجاد میکنیم. سپس با خط فرمان به آن وارد میشویم و بعد فرمان زیر را اجرا میکنیم:
در ادامه نام کاربری و کلمهی عبور را وارد میکنیم. البته به صورت پیش فرض، نام کاربری جاری ویندوز را در نظر میگیرد و بعد نام کاربری و کلمهی عبور سرویس ساب ورژن را میپرسد و حالا گیت کارش را شروع میکند:
پس از اتمام کار با توجه به مقالهی «مراحل ارسال یک پروژهی Visual Studio به GitHub» برای کار با گیت در ویژوال استودیو، میتوان به کار با گیت بصورت ریموت ادامه دهید.
و اما نکتهی آخر: من برای استفاده از این سرور مجبور بودم که نام localhost را با نام mehdi-pc جابجا کنم تا بتوانم از طریق یک کامپیوتر دیگر با سورس کنترل کار کنم و طی جستجوهایی که در اینترنت کردم، این کار بصورت کامند و فرمانهای شبه لینوکسی انجام پذیر بود. ولی راهی را همچون این مقاله «مشکل در جابجایی پروژههای svn» پیدا کردم که بنظرم آنرا مرتبط با موضوع میدانم و گفتن آن را خالی از لطف نمیبینم.
فایل config در واقع فایل کانفیگ داخل مخزن لوکال است؛ یعنی داخل فولدر .git و بصورت متنی ذخیره شده است:
طبق انتظار قسمتی از فایل که در زیر آمده، مربوط به مشخصات اتصال به سرور ریموت میباشد:
[remote "origin"] url = http://mehdi-pc:8551/NewsService.git fetch = +refs/heads/*:refs/remotes/origin/*
البته باید بسیار با دقت این تغییر را ایجاد کنید و مطمئن باشید که آدرس را بطور صحیح و به یک مخزن درست گیت تغییر میدهید.
Blazor 5x - قسمت 19 - کار با فرمها - بخش 7 - نکات ویژهی کار با EF-Core در برنامههای Blazor Server
طول عمر سرویسها، در برنامههای Blazor Server متفاوت هستند
هنگامیکه با یک ASP.NET Core Web API متداول کار میکنیم، درخواستهای HTTP رسیده، از میانافزارهای موجود رد شده و پردازش میشوند. اما هنگامیکه با Blazor Server کار میکنیم، به علت وجود یک اتصال دائم SignalR که عموما از نوع Web socket است، دیگر درخواست HTTP وجود ندارد. تمام رفت و برگشتهای برنامه به سرور و پاسخهای دریافتی، از طریق Web socket منتقل میشوند و نه درخواستها و پاسخهای متداول HTTP.
این روش پردازشی، اولین تاثیری را که بر روی رفتار یک برنامه میگذارد، تغییر طول عمر سرویسهای آن است. برای مثال در برنامههای Web API، طول عمر درخواستها، از نوع Scoped هستند و با شروع پردازش یک درخواست، سرویسهای مورد نیاز وهله سازی شده و در پایان درخواست، رها میشوند.
این مساله در حین کار با EF-Core نیز بسیار مهم است؛ از این جهت که در برنامههای Web API نیز EF-Core و DbContext آن، به صورت سرویسهایی با طول عمر Scoped تعریف میشوند. برای مثال زمانیکه یک چنین تعریفی را در برنامه داریم:
services.AddDbContext<ApplicationDbContext>(options => options.UseSqlServer(connectionString));
public static IServiceCollection AddDbContext<TContext>( [NotNullAttribute] this IServiceCollection serviceCollection, [CanBeNullAttribute] Action<DbContextOptionsBuilder> optionsAction = null, ServiceLifetime contextLifetime = ServiceLifetime.Scoped, ServiceLifetime optionsLifetime = ServiceLifetime.Scoped) where TContext : DbContext;
اما زمانیکه مانند یک برنامهی مبتنی بر Blazor Server، دیگر HTTP Requests متداولی را نداریم، چطور؟ در این حالت زمانیکه یک اتصال SignalR برقرار شد، وهلهای از DbContext که در اختیار برنامهی Blazor Server قرار میگیرد، تا زمانیکه کاربر این اتصال را به نحوی قطع نکرده (مانند بستن کامل مرورگر و یا ریفرش صفحه)، ثابت باقی خواهد ماند. یعنی به ازای هر اتصال SignalR، طول عمر ServiceLifetime.Scoped پیشفرض تعریف شده، همانند یک وهلهی با طول عمر Singleton عمل میکند. در این حالت تمام صفحات و کامپوننتهای یک برنامهی Blazor Server، از یک تک وهلهی مشخص DbContext که در ابتدای کار دریافت کردهاند، کار میکنند و از آنجائیکه DbContext به صورت thread-safe کار نمیکند، این تک وهله مشکلات زیادی را ایجاد خواهد کرد که یک نمونه از آنرا در عمل، در پایان قسمت قبل مشاهده کردید:
«اگر برنامه را اجرا کرده و سعی در حذف یک ردیف کنیم، به خطای زیر میرسیم و یا حتی اگر کاربر شروع کند به کلیک کردن سریع در قسمتهای مختلف برنامه، باز هم این خطا مشاهده میشود:
An exception occurred while iterating over the results of a query for context type 'BlazorServer.DataAccess.ApplicationDbContext'. System.InvalidOperationException: A second operation was started on this context before a previous operation completed. This is usually caused by different threads concurrently using the same instance of DbContext. For more information on how to avoid threading issues with DbContext, see https://go.microsoft.com/fwlink/?linkid=2097913.
هر درخواست Web API نیز بر روی یک ترد جداگانه اجرا میشود؛ اما چون ابتدا و انتهای درخواستها مشخص است، طول عمر Scoped، در ابتدای درخواست شروع شده و در پایان آن رها سازی میشود. به همین جهت استثنائی را که در اینجا مشاهده میکنید، در برنامههای Web API شاید هیچگاه مشاهده نشود.
معرفی DbContextFactory در EF Core 5x
همواره باید طول عمر DbContext را تا جای ممکن، کوتاه نگه داشت. مشکل فعلی ما، Singleton رفتار کردن DbContextها (داشتن طول عمر طولانی) در برنامههای Blazor Server هستند. یک چنین رفتاری را شاید در برنامههای دسکتاپ هم پیشتر مشاهده کرده باشید. برای مثال در برنامههای دسکتاپ WPF، تا زمانیکه یک فرم باز است، Context ایجاد شدهی در آن هم برقرار است و Dispose نمیشود. در یک چنین حالتهایی، عموما Context را در زمان نیاز، ایجاد کرده و پس از پایان آن کار کوتاه، Context را رها میکنند. به همین جهت نیاز به DbContext Factory ای وجود دارد که بتواند یک چنین پیاده سازیهایی را میسر کند و خوشبختانه از زمان EF Core 5x، یک چنین امکانی خصوصا برای برنامههای Blazor Server تحت عنوان DbContextFactory ارائه شدهاست که به عنوان راه حل استاندارد دسترسی به DbContext در اینگونه برنامهها مورد استفاده قرار میگیرد.
برای کار با DbContextFactory، اینبار در فایل BlazorServer.App\Startup.cs، بجای استفاده از services.AddDbContext، از متد AddDbContextFactory استفاده میشود:
public void ConfigureServices(IServiceCollection services) { var connectionString = Configuration.GetConnectionString("DefaultConnection"); //services.AddDbContext<ApplicationDbContext>(options => options.UseSqlServer(connectionString)); services.AddDbContextFactory<ApplicationDbContext>(options => options.UseSqlServer(connectionString));
روش اول کار با DbContextFactory در کامپوننتهای Blazor Server : وهله سازی از نو، به ازای هر متد
در این روش پس از ثبت AddDbContextFactory در فایل Startup برنامه مانند مثال فوق، ابتدا سرویس IDbContextFactory که به ApplicationDbContext اشاره میکند به ابتدای کامپوننت تزریق میشود:
@inject IDbContextFactory<ApplicationDbContext> DbFactory
private async Task DeleteImageAsync() { using var context = DbFactory.CreateDbContext(); var image = await context.HotelRoomImages.FindAsync(1); // ... }
روش دوم کار با DbContextFactory در کامپوننتهای Blazor Server : یکبار وهله سازی Context به ازای هر کامپوننت
در این روش میتوان طول عمر Context را معادل طول عمر کامپوننت تعریف کرد که مزیت استفادهی از Change tracking موجود در EF-Core را به همراه خواهد داشت. در این حالت کامپوننتهای Blazor Server، شبیه به فرمهای برنامههای دسکتاپ عمل میکنند:
@implements IDisposable @inject IDbContextFactory<ApplicationDbContext> DbFactory @code { private ApplicationDbContext Context; protected override async Task OnInitializedAsync() { Context = DbFactory.CreateDbContext(); await base.OnInitializedAsync(); } private async Task DeleteImageAsync() { var image = await Context.HotelRoomImages.FindAsync(1); // ... } public void Dispose() { Context.Dispose(); } }
- اما بجای اینکه به ازای هر متد، کار فراخوانی DbFactory.CreateDbContext صورت گیرد، یکبار در آغاز کار کامپوننت و در روال رویدادگردان OnInitializedAsync، کار وهله سازی Context کامپوننت انجام شده و از این تک Context در تمام متدهای کامپوننت استفاده خواهد شد.
- در این حالت کار Dispose خودکار این Context به متد Dispose نهایی کل کامپوننت واگذار شدهاست. برای اینکه این متد فراخوانی شود، نیاز است در ابتدای تعاریف کامپوننت، از دایرکتیو implements IDisposable@ استفاده کرد.
سؤال: اگر سرویسی از ApplicationDbContext تزریق شدهی در سازندهی خود استفاده میکند، چکار باید کرد؟
برای نمونه سرویسهای از پیش تعریف شدهی ASP.NET Core Identity، در سازندهی خود از ApplicationDbContext استفاده میکنند و نه از IDbContextFactory. در این حالت برای تامین ApplicationDbContextهای تزریق شده، فقط کافی است از روش زیر استفاده کنیم:
services.AddScoped<ApplicationDbContext>(serviceProvider => serviceProvider.GetRequiredService<IDbContextFactory<ApplicationDbContext>>().CreateDbContext());
سؤال: روش پیاده سازی سرویسهای یک برنامه Blazor Server به چه صورتی باید تغییر کند؟
تا اینجا روشهایی که برای استفاده از IDbContextFactory معرفی شدند (که روشهای رسمی و توصیه شدهی اینکار نیز هستند)، فرض را بر این گذاشتهاند که ما قرار است تمام منطق تجاری کار با بانک اطلاعاتی را داخل همان متدهای کامپوننتها انجام دهیم (این روش برنامه نویسی، بسیار مورد علاقهی مایکروسافت است و در تمام مثالهای رسمی آن به صورت ضمنی توصیه میشود!). اما اگر همانند مثالی که تاکنون در این سری بررسی کردیم، نخواهیم اینکار را انجام دهیم و علاقمند باشیم تا این منطق تجاری را به سرویسهای مجزایی، با مسئولیتهای مشخصی انتقال دهیم، روش استفادهی از IDbContextFactory چگونه خواهد بود؟
در این حالت از ترکیب روش دوم مطرح شدهی استفاده از IDbContextFactory که به همراه مزیت دسترسی کامل به Change Tracking توکار EF-Core و پیاده سازی الگوی واحد کار است و وهله سازی خودکار ApplicationDbContext که معرفی شد، استفاده خواهیم کرد؛ به این صورت:
الف) تمام سرویسهای EF-Core یک برنامهی Blazor Server باید اینترفیس IDisposable را پیاده سازی کنند.
این مورد برای سرویسهای پروژههای Web API، ضروری نیست؛ چون طول عمر Context آنها توسط خود IoC Container مدیریت میشود؛ اما در برنامههای Blazor Server، مطابق توضیحاتی که ارائه شد، خودمان باید این طول عمر را مدیریت کنیم.
بنابراین به پروژهی سرویسهای برنامه مراجعه کرده و هر سرویسی که ApplicationDbContext تزریق شدهای را در سازندهی خود میپذیرد، یافته و تعریف اینترفیس آنرا به صورت زیر تغییر میدهیم:
public interface IHotelRoomService : IDisposable { // ... } public interface IHotelRoomImageService : IDisposable { // ... }
public class HotelRoomService : IHotelRoomService { private bool _isDisposed; // ... public void Dispose() { Dispose(disposing: true); GC.SuppressFinalize(this); } protected virtual void Dispose(bool disposing) { if (!_isDisposed) { try { if (disposing) { _dbContext.Dispose(); } } finally { _isDisposed = true; } } } }
ب) Dispose دستی تمام سرویسها، در کامپوننتهای مرتبط
در ادامه تمام کامپوننتهایی را که از سرویسهای فوق استفاده میکنند یافته و ابتدا دایرکتیو implements IDisposable@ را به ابتدای آنها اضافه میکنیم. سپس متد Dispose آنها را جهت فراخوانی متد Dispose سرویسهای فوق، تکمیل خواهیم کرد:
بنابراین ابتدا به فایل BlazorServer\BlazorServer.App\Pages\HotelRoom\HotelRoomUpsert.razor مراجعه کرده و تغییرات زیر را اعمال میکنیم:
@page "/hotel-room/create" @page "/hotel-room/edit/{Id:int}" @implements IDisposable // ... @code { // ... public void Dispose() { HotelRoomImageService.Dispose(); HotelRoomService.Dispose(); } }
@page "/hotel-room" @implements IDisposable // ... @code { // ... public void Dispose() { HotelRoomService.Dispose(); } }
مشکل! اینبار خطای dispose شدن context را دریافت میکنیم!
System.ObjectDisposedException: Cannot access a disposed context instance. A common cause of this error is disposing a context instance that was resolved from dependency injection and then later trying to use the same context instance elsewhere in your application. This may occur if you are calling 'Dispose' on the context instance, or wrapping it in a using statement. If you are using dependency injection, you should let the dependency injection container take care of disposing context instances. Object name: 'ApplicationDbContext'.
مشکلی که در اینجا رخ داده این است که سرویسهایی را داریم با طول عمر به ظاهر Scoped که یکی از وابستگیهای آنها را به صورت دستی Dispose کردهایم. چون طول عمر Scoped در اینجا وجود ندارد و طول عمرها در اصل Singleton هستند، هربار که سرویس مدنظر مجددا درخواست شود، همان وهلهی ابتدایی که اکنون یکی از وابستگیهای آن Dispose شده، در اختیار برنامه قرار میگیرد.
پس از این تغییرات، اولین باری که برنامه را اجرا میکنیم، لیست اتاقها به خوبی نمایش داده میشوند و مشکلی نیست. بعد در همین حال و در همین صفحه، اگر بر روی دکمهی افزودن یک اتاق جدید کلیک کنیم، اتفاقی که رخ میدهد، فراخوانی متد Dispose کامپوننت لیست اتاقها است (بر روی آن یک break-point قرار دهید). بنابراین متد Dispose یک کامپوننت، با هدایت به یک مسیر دیگر، به صورت خودکار فراخوانی میشود. در این حالت Context برنامه Dispose شده و در کامپوننت ثبت یک اتاق جدید دیگر، در دسترس نخواهد بود؛ چون IHotelRoomService مورد استفاده مجددا وهله سازی نمیشود و از همان وهلهای که بار اول ایجاد شده، استفاده خواهد شد.
بنابراین سؤال اینجا است که چگونه میتوان سیستم تزریق وابستگیها را وادار کرد تا تمام سرویسهای تزریق شدهی به سازندههای سرویسهای HotelRoomService و HotelRoomImageService را مجددا وهله سازی کند و سعی نکند از همان وهلههای قبلی استفاده کند؟
پاسخ: یک روش این است که IHotelRoomImageService را خودمان به ازای هر کامپوننت به صورت دستی در روال رویدادگردان OnInitializedAsync وهله سازی کرده و DbFactory.CreateDbContext جدیدی را مستقیما به سازندهی آن ارسال کنیم. در این حالت مطمئن خواهیم شد که این وهله، جای دیگری به اشتراک گذاشته نمیشود:
@code { private IHotelRoomImageService HotelRoomImageService; protected override async Task OnInitializedAsync() { HotelRoomImageService = new HotelRoomImageService(DbFactory.CreateDbContext(), mapper); await base.OnInitializedAsync(); } private async Task DeleteImageAsync() { await HotelRoomImageService.DeleteAsync(1); // ... } public void Dispose() { HotelRoomImageService.Dispose(); } }
وادار کردن Blazor Server به وهله سازی مجدد سرویسهای کامپوننتها
بنابراین مشکل ما Singleton رفتار کردن سرویسها، در برنامههای Blazor است. برای مثال در برنامههای Blazor Server، تا زمانیکه اتصال SignalR برنامه برقرار است (مرورگر بسته نشده، برگهی جاری بسته نشده و یا کاربر صفحه را ریفرش نکرده)، هیچ سرویسی دوباره وهله سازی نمیشود.
برای رفع این مشکل، امکان Scoped رفتار کردن سرویسهای یک کامپوننت نیز در نظر گرفته شدهاند. برای نمونه کدهای کامپوننت HotelRoomList.razor را به صورت زیر تغییر میدهیم:
@page "/hotel-room" @*@implements IDisposable*@ @*@inject IHotelRoomService HotelRoomService*@ @inherits OwningComponentBase<IHotelRoomService>
چند نکته:
- فقط یکبار به ازای هر کامپوننت میتوان از دایرکتیو inherits استفاده کرد.
- زمانیکه طول عمر سرویسی را توسط OwningComponentBase مدیریت میکنیم، در حقیقت یک کلاس پایه را برای آن کامپوننت درنظر گرفتهایم که به همراه یک خاصیت عمومی ویژه، به نام Service و از نوع سرویس مدنظر ما است. در این حالت یا میتوان از خاصیت Service به صورت مستقیم استفاده کرد و یا میتوان به صورت زیر، همان کدهای قبلی را داشت و هربار که نیازی به HotelRoomService بود، آنرا به خاصیت عمومی Service هدایت کرد:
@code { private IHotelRoomService HotelRoomService => Service;
@page "/preferences" @using Microsoft.Extensions.DependencyInjection @inherits OwningComponentBase @code { private IHotelRoomService HotelRoomService { get; set; } private IHotelRoomImageService HotelRoomImageService { get; set; } protected override void OnInitialized() { HotelRoomService = ScopedServices.GetRequiredService<IHotelRoomService>(); HotelRoomImageService = ScopedServices.GetRequiredService<IHotelRoomImageService>(); } }
خلاصهی بحث جاری در مورد روش مدیریت DbContext برنامههای Blazor Server:
- بجای services.AddDbContext متداول، باید از AddDbContextFactory استفاده کرد:
services.AddDbContextFactory<ApplicationDbContext>(options => options.UseSqlServer(connectionString)); services.AddScoped<ApplicationDbContext>(serviceProvider => serviceProvider.GetRequiredService<IDbContextFactory<ApplicationDbContext>>().CreateDbContext());
- کامپوننتهای برنامه، سرویسهایی را که باید Scoped عمل کنند، دیگر نباید از طریق تزریق مستقیم آنها دریافت کنند؛ چون در این حالت همواره به همان وهلهای که در ابتدای کار ایجاد شده، میرسیم:
@inject IHotelRoomService HotelRoomService
@inherits OwningComponentBase<IHotelRoomService>
کدهای کامل این مطلب را از اینجا میتوانید دریافت کنید: Blazor-5x-Part-19.zip
تعریف موجودیتهای مورد نیاز جهت طراحی یک سیستم اعتبارسنجی
در اینجا کنترل کامل سیستم در اختیار ما است و در این حالت میتوان طراحی تمام قسمتها را از ابتدا و مطابق میل خود انجام داد. برای مثال سیستم اعتبارسنجی سادهی ما، شامل جدول کاربران و نقشهای آنها خواهد بود و این دو با هم رابطهی many-to-many دارند. به همین جهت جدول UserRole نیز در اینجا پیش بینی شدهاست.
جدول کاربران
public class User { public User() { UserRoles = new HashSet<UserRole>(); } public int Id { get; set; } public string Username { get; set; } public string Password { get; set; } public string DisplayName { get; set; } public bool IsActive { get; set; } public DateTimeOffset? LastLoggedIn { get; set; } /// <summary> /// every time the user changes his Password, /// or an admin changes his Roles or stat/IsActive, /// create a new `SerialNumber` GUID and store it in the DB. /// </summary> public string SerialNumber { get; set; } public virtual ICollection<UserRole> UserRoles { get; set; } }
جدول نقشهای کاربران
public class Role { public Role() { UserRoles = new HashSet<UserRole>(); } public int Id { get; set; } public string Name { get; set; } public virtual ICollection<UserRole> UserRoles { get; set; } }
public static class CustomRoles { public const string Admin = nameof(Admin); public const string User = nameof(User); }
جدول ارتباط نقشها با کاربران و برعکس
public class UserRole { public int UserId { get; set; } public int RoleId { get; set; } public virtual User User { get; set; } public virtual Role Role { get; set; } }
تعریف Context برنامه و فعالسازی Migrations در EF Core 2.0
DbContext برنامه را به صورت ذیل در یک اسمبلی دیگر اضافه خواهیم کرد:
public interface IUnitOfWork : IDisposable { DbSet<TEntity> Set<TEntity>() where TEntity : class; int SaveChanges(bool acceptAllChangesOnSuccess); int SaveChanges(); Task<int> SaveChangesAsync(bool acceptAllChangesOnSuccess, CancellationToken cancellationToken = new CancellationToken()); Task<int> SaveChangesAsync(CancellationToken cancellationToken = new CancellationToken()); } public class ApplicationDbContext : DbContext, IUnitOfWork { public ApplicationDbContext(DbContextOptions options) : base(options) { } public virtual DbSet<User> Users { set; get; } public virtual DbSet<Role> Roles { set; get; } public virtual DbSet<UserRole> UserRoles { get; set; } protected override void OnModelCreating(ModelBuilder builder) { // it should be placed here, otherwise it will rewrite the following settings! base.OnModelCreating(builder); // Custom application mappings builder.Entity<User>(entity => { entity.Property(e => e.Username).HasMaxLength(450).IsRequired(); entity.HasIndex(e => e.Username).IsUnique(); entity.Property(e => e.Password).IsRequired(); entity.Property(e => e.SerialNumber).HasMaxLength(450); }); builder.Entity<Role>(entity => { entity.Property(e => e.Name).HasMaxLength(450).IsRequired(); entity.HasIndex(e => e.Name).IsUnique(); }); builder.Entity<UserRole>(entity => { entity.HasKey(e => new { e.UserId, e.RoleId }); entity.HasIndex(e => e.UserId); entity.HasIndex(e => e.RoleId); entity.Property(e => e.UserId); entity.Property(e => e.RoleId); entity.HasOne(d => d.Role).WithMany(p => p.UserRoles).HasForeignKey(d => d.RoleId); entity.HasOne(d => d.User).WithMany(p => p.UserRoles).HasForeignKey(d => d.UserId); }); } }
سازندهی کلاس به همراه پارامتر DbContextOptions است تا بتوان آنرا در آغاز برنامه تغییر داد.
فعالسازی مهاجرتها در EF Core 2.0
EF Core 2.0 برخلاف نگارشهای قبلی آن به دنبال کلاسی مشتق شدهی از IDesignTimeDbContextFactory میگردد تا بتواند نحوهی وهله سازی ApplicationDbContext را دریافت کند. در اینجا چون DbContext تعریف شده دارای یک سازندهی با پارامتر است، EF Core 2.0 نمیداند که چگونه باید آنرا در حین ساخت مهاجرتها و اعمال آنها، وهله سازی کند. کار کلاس ApplicationDbContextFactory ذیل دقیقا مشخص سازی همین مساله است:
/// <summary> /// Only used by EF Tooling /// </summary> public class ApplicationDbContextFactory : IDesignTimeDbContextFactory<ApplicationDbContext> { public ApplicationDbContext CreateDbContext(string[] args) { var basePath = Directory.GetCurrentDirectory(); Console.WriteLine($"Using `{basePath}` as the BasePath"); var configuration = new ConfigurationBuilder() .SetBasePath(basePath) .AddJsonFile("appsettings.json") .Build(); var builder = new DbContextOptionsBuilder<ApplicationDbContext>(); var connectionString = configuration.GetConnectionString("DefaultConnection"); builder.UseSqlServer(connectionString); return new ApplicationDbContext(builder.Options); } }
{ "ConnectionStrings": { "DefaultConnection": "Data Source=(LocalDB)\\MSSQLLocalDB;Initial Catalog=ASPNETCore2CookieAuthenticationDB;Integrated Security=True;MultipleActiveResultSets=True;" }, "LoginCookieExpirationDays": 30 }
کار یافتن این کلاس در حین تدارک و اعمال مهاجرتها توسط EF Core 2.0 خودکار بوده و باید محل قرارگیری آن دقیقا در اسمبلی باشد که DbContext برنامه در آن تعریف شدهاست.
تدارک لایه سرویسهای برنامه
پس از مشخص شدن ساختار موجودیتها و همچنین Context برنامه، اکنون میتوان لایه سرویس برنامه را به صورت ذیل تکمیل کرد:
سرویس کاربران
public interface IUsersService { Task<string> GetSerialNumberAsync(int userId); Task<User> FindUserAsync(string username, string password); Task<User> FindUserAsync(int userId); Task UpdateUserLastActivityDateAsync(int userId); }
پیاده سازی کامل این سرویس را در اینجا میتوانید مشاهده کنید.
سرویس نقشهای کاربران
public interface IRolesService { Task<List<Role>> FindUserRolesAsync(int userId); Task<bool> IsUserInRole(int userId, string roleName); Task<List<User>> FindUsersInRoleAsync(string roleName); }
پیاده سازی کامل این سرویس را در اینجا میتوانید مشاهده کنید.
سرویس آغاز بانک اطلاعاتی
public interface IDbInitializerService { void Initialize(); void SeedData(); }
پیاده سازی کامل این سرویس را در اینجا میتوانید مشاهده کنید.
سرویس اعتبارسنجی کوکیهای کاربران
یکی از قابلیتهای میانافزار اعتبارسنجی ASP.NET Core 2.0، رخدادی است که در آن اطلاعات کوکی دریافتی از کاربر، رمزگشایی شده و در اختیار برنامه جهت تعیین اعتبار قرار میگیرد:
public interface ICookieValidatorService { Task ValidateAsync(CookieValidatePrincipalContext context); } public class CookieValidatorService : ICookieValidatorService { private readonly IUsersService _usersService; public CookieValidatorService(IUsersService usersService) { _usersService = usersService; _usersService.CheckArgumentIsNull(nameof(usersService)); } public async Task ValidateAsync(CookieValidatePrincipalContext context) { var userPrincipal = context.Principal; var claimsIdentity = context.Principal.Identity as ClaimsIdentity; if (claimsIdentity?.Claims == null || !claimsIdentity.Claims.Any()) { // this is not our issued cookie await handleUnauthorizedRequest(context); return; } var serialNumberClaim = claimsIdentity.FindFirst(ClaimTypes.SerialNumber); if (serialNumberClaim == null) { // this is not our issued cookie await handleUnauthorizedRequest(context); return; } var userIdString = claimsIdentity.FindFirst(ClaimTypes.UserData).Value; if (!int.TryParse(userIdString, out int userId)) { // this is not our issued cookie await handleUnauthorizedRequest(context); return; } var user = await _usersService.FindUserAsync(userId).ConfigureAwait(false); if (user == null || user.SerialNumber != serialNumberClaim.Value || !user.IsActive) { // user has changed his/her password/roles/stat/IsActive await handleUnauthorizedRequest(context); } await _usersService.UpdateUserLastActivityDateAsync(userId).ConfigureAwait(false); } private Task handleUnauthorizedRequest(CookieValidatePrincipalContext context) { context.RejectPrincipal(); return context.HttpContext.SignOutAsync(CookieAuthenticationDefaults.AuthenticationScheme); } }
- آیا کوکی دریافت شده دارای اطلاعات HttpContext.User است؟
- آیا این کوکی به همراه اطلاعات فیلد SerialNumber است؟
- آیا این کوکی به همراه Id کاربر است؟
- آیا کاربری که بر اساس این Id یافت میشود غیرفعال شدهاست؟
- آیا کاربری که بر اساس این Id یافت میشود دارای SerialNumber یکسانی با نمونهی موجود در بانک اطلاعاتی است؟
اگر خیر، این اعتبارسنجی رد شده و بلافاصله کوکی کاربر نیز معدوم خواهد شد.
تنظیمات ابتدایی میانافزار اعتبارسنجی کاربران در ASP.NET Core 2.0
تنظیمات کامل ابتدایی میانافزار اعتبارسنجی کاربران در ASP.NET Core 2.0 را در فایل Startup.cs میتوانید مشاهده کنید.
ابتدا سرویسهای برنامه معرفی شدهاند:
public void ConfigureServices(IServiceCollection services) { services.AddScoped<IUnitOfWork, ApplicationDbContext>(); services.AddScoped<IUsersService, UsersService>(); services.AddScoped<IRolesService, RolesService>(); services.AddScoped<ISecurityService, SecurityService>(); services.AddScoped<ICookieValidatorService, CookieValidatorService>(); services.AddScoped<IDbInitializerService, DbInitializerService>();
سپس تنظیمات مرتبط با ترزیق وابستگیهای ApplicationDbContext برنامه انجام شدهاست. در اینجا رشتهی اتصالی، از فایل appsettings.json خوانده شده و سپس در اختیار متد UseSqlServer قرار میگیرد:
services.AddDbContext<ApplicationDbContext>(options => { options.UseSqlServer( Configuration.GetConnectionString("DefaultConnection"), serverDbContextOptionsBuilder => { var minutes = (int)TimeSpan.FromMinutes(3).TotalSeconds; serverDbContextOptionsBuilder.CommandTimeout(minutes); serverDbContextOptionsBuilder.EnableRetryOnFailure(); }); });
در ادامه تعدادی Policy مبتنی بر نقشهای ثابت سیستم را تعریف کردهایم. این کار اختیاری است اما روش توصیه شدهی در ASP.NET Core، کار با Policyها است تا کار مستقیم با نقشها. Policyها انعطاف پذیری بیشتری را نسبت به نقشها ارائه میدهند و در اینجا به سادگی میتوان چندین نقش و یا حتی Claim را با هم ترکیب کرد و به صورت یک Policy ارائه داد:
// Only needed for custom roles. services.AddAuthorization(options => { options.AddPolicy(CustomRoles.Admin, policy => policy.RequireRole(CustomRoles.Admin)); options.AddPolicy(CustomRoles.User, policy => policy.RequireRole(CustomRoles.User)); });
قسمت اصلی تنظیمات میان افزار اعتبارسنجی مبتنی بر کوکیها در اینجا قید شدهاست:
// Needed for cookie auth. services .AddAuthentication(options => { options.DefaultChallengeScheme = CookieAuthenticationDefaults.AuthenticationScheme; options.DefaultSignInScheme = CookieAuthenticationDefaults.AuthenticationScheme; options.DefaultAuthenticateScheme = CookieAuthenticationDefaults.AuthenticationScheme; }) .AddCookie(options => { options.SlidingExpiration = false; options.LoginPath = "/api/account/login"; options.LogoutPath = "/api/account/logout"; //options.AccessDeniedPath = new PathString("/Home/Forbidden/"); options.Cookie.Name = ".my.app1.cookie"; options.Cookie.HttpOnly = true; options.Cookie.SecurePolicy = CookieSecurePolicy.SameAsRequest; options.Cookie.SameSite = SameSiteMode.Lax; options.Events = new CookieAuthenticationEvents { OnValidatePrincipal = context => { var cookieValidatorService = context.HttpContext.RequestServices.GetRequiredService<ICookieValidatorService>(); return cookieValidatorService.ValidateAsync(context); } }; });
کار نهایی تنظیمات میان افزار اعتبارسنجی در متد Configure با فراخوانی UseAuthentication صورت میگیرد. اینجا است که میان افزار، به برنامه معرفی خواهد شد:
public void Configure(IApplicationBuilder app, IHostingEnvironment env) { app.UseAuthentication();
همچنین پس از آن، کار اجرای سرویس آغاز بانک اطلاعاتی نیز انجام شدهاست تا نقشها و کاربر Admin را به سیستم اضافه کند:
var scopeFactory = app.ApplicationServices.GetRequiredService<IServiceScopeFactory>(); using (var scope = scopeFactory.CreateScope()) { var dbInitializer = scope.ServiceProvider.GetService<IDbInitializerService>(); dbInitializer.Initialize(); dbInitializer.SeedData(); }
پیاده سازی ورود و خروج به سیستم
پس از این مقدمات به مرحلهی آخر پیاده سازی این سیستم اعتبارسنجی میرسیم.
پیاده سازی Login
در اینجا از سرویس کاربران استفاده شده و بر اساس نام کاربری و کلمهی عبور ارسالی به سمت سرور، این کاربر یافت خواهد شد.
در صورت وجود این کاربر، مرحلهی نهایی کار، فراخوانی متد الحاقی HttpContext.SignInAsync است:
[AllowAnonymous] [HttpPost("[action]")] public async Task<IActionResult> Login([FromBody] User loginUser) { if (loginUser == null) { return BadRequest("user is not set."); } var user = await _usersService.FindUserAsync(loginUser.Username, loginUser.Password).ConfigureAwait(false); if (user == null || !user.IsActive) { await HttpContext.SignOutAsync(CookieAuthenticationDefaults.AuthenticationScheme); return Unauthorized(); } var loginCookieExpirationDays = _configuration.GetValue<int>("LoginCookieExpirationDays", defaultValue: 30); var cookieClaims = await createCookieClaimsAsync(user).ConfigureAwait(false); await HttpContext.SignInAsync( CookieAuthenticationDefaults.AuthenticationScheme, cookieClaims, new AuthenticationProperties { IsPersistent = true, // "Remember Me" IssuedUtc = DateTimeOffset.UtcNow, ExpiresUtc = DateTimeOffset.UtcNow.AddDays(loginCookieExpirationDays) }); await _usersService.UpdateUserLastActivityDateAsync(user.Id).ConfigureAwait(false); return Ok(); }
private async Task<ClaimsPrincipal> createCookieClaimsAsync(User user) { var identity = new ClaimsIdentity(CookieAuthenticationDefaults.AuthenticationScheme); identity.AddClaim(new Claim(ClaimTypes.NameIdentifier, user.Id.ToString())); identity.AddClaim(new Claim(ClaimTypes.Name, user.Username)); identity.AddClaim(new Claim("DisplayName", user.DisplayName)); // to invalidate the cookie identity.AddClaim(new Claim(ClaimTypes.SerialNumber, user.SerialNumber)); // custom data identity.AddClaim(new Claim(ClaimTypes.UserData, user.Id.ToString())); // add roles var roles = await _rolesService.FindUserRolesAsync(user.Id).ConfigureAwait(false); foreach (var role in roles) { identity.AddClaim(new Claim(ClaimTypes.Role, role.Name)); } return new ClaimsPrincipal(identity); }
[Route("api/[controller]")] [Authorize(Policy = CustomRoles.Admin)] public class MyProtectedAdminApiController : Controller
پیاده سازی Logout
متد الحاقی HttpContext.SignOutAsync کار Logout کاربر را تکمیل میکند.
[AllowAnonymous] [HttpGet("[action]"), HttpPost("[action]")] public async Task<bool> Logout() { await HttpContext.SignOutAsync(CookieAuthenticationDefaults.AuthenticationScheme); return true; }
آزمایش نهایی برنامه
در فایل index.html ، نمونهای از متدهای لاگین، خروج و فراخوانی اکشن متدهای محافظت شده را مشاهده میکنید. این روش برای برنامههای تک صفحهای وب یا SPA نیز میتواند مفید باشد و به همین نحو کار میکنند.
کدهای کامل این مطلب را از اینجا میتوانید دریافت کنید.
بررسی اجزای Hybrid Flow
در قسمت سوم در حین «انتخاب OpenID Connect Flow مناسب برای یک برنامهی کلاینت از نوع ASP.NET Core» به این نتیجه رسیدیم که Flow مناسب یک برنامهی Mvc Client از نوع Hybrid است. در اینجا هر Flow، شروع به ارسال درخواستی به سمت Authorization Endpoint میکند؛ با یک چنین قالبی:
https://idpHostAddress/connect/authorize? client_id=imagegalleryclient &redirect_uri=https://clientapphostaddress/signin-oidcoidc &scope=openid profile &response_type=code id_token &response_mode=form_post &nonce=63626...n2eNMxA0
- سپس client_id جهت تعیین برنامهای که درخواست را ارسال میکند، ذکر شدهاست؛ از این جهت که یک IDP جهت کار با چندین نوع کلاینت مختلف طراحی شدهاست.
- redirect_uri همان Redirect Endpoint است که در سطح برنامهی کلاینت تنظیم میشود.
- در مورد scope در قسمت قبل در حین راه اندازی IdentityServer توضیح دادیم. در اینجا برنامهی کلاینت، درخواست scopeهای openid و profile را دادهاست. به این معنا که نیاز دارد تا Id کاربر وارد شدهی به سیستم و همچنین Claims منتسب به او را در اختیار داشته باشد.
- response_type نیز به code id_token تنظیم شدهاست. توسط response_type، نوع Flow مورد استفاده مشخص میشود. ذکر code به معنای بکارگیری Authorization code flow است. ذکر id_token و یا id_token token هر دو به معنای استفادهی از implicit flow است. اما برای مشخص سازی Hybrid flow یکی از سه مقدار code id_token و یا code token و یا code id_token token با هم ذکر میشوند:
- در اینجا response_mode مشخص میکند که اطلاعات بازگشتی از سمت IDP که توسط response_type مشخص شدهاند، با چه قالبی به سمت کلاینت بازگشت داده شوند که میتواند از طریق Form POST و یا URI باشد.
در Hybrid flow با response_type از نوع code id_token، ابتدا کلاینت یک درخواست Authentication را به Authorization Endpoint ارسال میکند (با همان قالب URL فوق). سپس در سطح IDP، کاربر برای مثال با ارائهی کلمهی عبور و نام کاربری، تعیین اعتبار میشود. همچنین در اینجا IDP ممکن است رضایت کاربر را از دسترسی به اطلاعات پروفایل او نیز سؤال بپرسد (تحت عنوان مفهوم Consent). سپس IDP توسط یک Redirection و یا Form POST، اطلاعات authorization code و identity token را به سمت برنامهی کلاینت ارسال میکند. این همان اطلاعات مرتبط با response_type ای است که درخواست کردهایم. سپس برنامهی کلاینت این اطلاعات را تعیین اعتبار کرده و در صورت موفقیت آمیز بودن این عملیات، اکنون درخواست تولید توکن هویت را به token endpoint ارسال میکند. برای این منظور کلاینت سه مشخصهی authorization code ،client-id و client-secret را به سمت token endpoint ارسال میکند. در پاسخ یک identity token را دریافت میکنیم. در اینجا مجددا این توکن تعیین اعتبار شده و سپس Id کاربر را از آن استخراج میکند که در برنامهی کلاینت قابل استفاده خواهد بود. این مراحل را در تصویر زیر میتوانید ملاحظه کنید.
البته اگر دقت کرده باشید، یک identity token در همان ابتدای کار از Authorization Endpoint دریافت میشود. اما چرا از آن استفاده نمیکنیم؟ علت اینجا است که token endpoint نیاز به اعتبارسنجی client را نیز دارد. به این ترتیب یک لایهی امنیتی دیگر نیز در اینجا بکار گرفته میشود. همچنین access token و refresh token نیز از همین token endpoint قابل دریافت هستند.
تنظیم IdentityServer جهت انجام عملیات ورود به سیستم بر اساس جزئیات Hybrid Flow
برای افزودن قسمت لاگین به برنامهی MVC قسمت دوم، نیاز است تغییراتی را در برنامهی کلاینت و همچنین IDP اعمال کنیم. برای این منظور کلاس Config پروژهی IDP را که در قسمت قبل ایجاد کردیم، به صورت زیر تکمیل میکنیم:
namespace DNT.IDP { public static class Config { public static IEnumerable<Client> GetClients() { return new List<Client> { new Client { ClientName = "Image Gallery", ClientId = "imagegalleryclient", AllowedGrantTypes = GrantTypes.Hybrid, RedirectUris = new List<string> { "https://localhost:5001/signin-oidc" }, PostLogoutRedirectUris = new List<string> { "https://localhost:5001/signout-callback-oidc" }, AllowedScopes = { IdentityServerConstants.StandardScopes.OpenId, IdentityServerConstants.StandardScopes.Profile }, ClientSecrets = { new Secret("secret".Sha256()) } } }; } } }
- ابتدا نام کلاینت را مشخص میکنیم. این نام و عنوان، در صفحهی لاگین و Consent (رضایت دسترسی به اطلاعات پروفایل کاربر)، ظاهر میشود.
- همچنین نیاز است یک Id دلخواه را نیز برای آن مشخص کنیم؛ مانند imagegalleryclient در اینجا.
- AllowedGrantTypes را نیز به Hybrid Flow تنظیم کردهایم. علت آنرا در قسمت سوم این سری بررسی کردیم.
- با توجه به اینکه Hybrid Flow از Redirectها استفاده میکند و اطلاعات نهایی را به کلاینت از طریق Redirection ارسال میکند، به همین جهت آدرس RedirectUris را به آدرس برنامهی Mvc Client تنظیم کردهایم (که در اینجا بر روی پورت 5001 کار میکند). قسمت signin-oidc آنرا در ادامه تکمیل خواهیم کرد.
- در قسمت AllowedScopes، لیست scopeهای مجاز قابل دسترسی توسط این کلاینت مشخص شدهاند که شامل دسترسی به ID کاربر و Claims آن است.
- به ClientSecrets نیز جهت client authenticating نیاز داریم.
تنظیم برنامهی MVC Client جهت انجام عملیات ورود به سیستم بر اساس جزئیات Hybrid Flow
برای افزودن قسمت لاگین به سیستم، کلاس آغازین پروژهی MVC Client را به نحو زیر تکمیل میکنیم:
namespace ImageGallery.MvcClient.WebApp { public class Startup { public void ConfigureServices(IServiceCollection services) { services.AddAuthentication(options => { options.DefaultScheme = "Cookies"; options.DefaultChallengeScheme = "oidc"; }).AddCookie("Cookies") .AddOpenIdConnect("oidc", options => { options.SignInScheme = "Cookies"; options.Authority = "https://localhost:6001"; options.ClientId = "imagegalleryclient"; options.ResponseType = "code id_token"; //options.CallbackPath = new PathString("...") //options.SignedOutCallbackPath = new PathString("...") options.Scope.Add("openid"); options.Scope.Add("profile"); options.SaveTokens = true; options.ClientSecret = "secret"; options.GetClaimsFromUserInfoEndpoint = true; });
- ابتدا با فراخوانی AddAuthentication، کار تنظیمات میانافزار استاندارد Authentication برنامههای ASP.NET Core انجام میشود. در اینجا DefaultScheme آن به Cookies تنظیم شدهاست تا عملیات Sign-in و Sign-out سمت کلاینت را میسر کند. سپس DefaultChallengeScheme به oidc تنظیم شدهاست. این مقدار با Scheme ای که در ادامه آنرا تنظیم خواهیم کرد، تطابق دارد.
- سپس متد AddCookie فراخوانی شدهاست که authentication-Scheme را به عنوان پارامتر قبول میکند. به این ترتیب cookie based authentication در برنامه میسر میشود. پس از اعتبارسنجی توکن هویت دریافتی و تبدیل آن به Claims Identity، در یک کوکی رمزنگاری شده برای استفادههای بعدی ذخیره میشود.
- در آخر تنظیمات پروتکل OpenID Connect را ملاحظه میکنید. به این ترتیب مراحل اعتبارسنجی توسط این پروتکل در اینجا که Hybrid flow است، پشتیبانی خواهد شد. اینجا است که کار درخواست Authorization، دریافت و اعتبارسنجی توکن هویت صورت میگیرد. اولین پارامتر آن authentication-Scheme است که به oidc تنظیم شدهاست. به این ترتیب اگر قسمتی از برنامه نیاز به Authentication داشته باشد، OpenID Connect به صورت پیشفرض مورد استفاده قرار میگیرد. به همین جهت DefaultChallengeScheme را نیز به oidc تنظیم کردیم. در اینجا SignInScheme به Cookies تنظیم شدهاست که با DefaultScheme اعتبارسنجی تطابق دارد. به این ترتیب نتیجهی موفقیت آمیز عملیات اعتبارسنجی در یک کوکی رمزنگاری شده ذخیره خواهد شد. مقدار خاصیت Authority به آدرس IDP تنظیم میشود که بر روی پورت 6001 قرار دارد. تنظیم این مسیر سبب خواهد شد تا این میانافزار سمت کلاینت، به discovery endpoint دسترسی یافته و بتواند مقادیر سایر endpoints برنامهی IDP را به صورت خودکار دریافت و استفاده کند. سپس ClientId تنظیم شدهاست که باید با مقدار تنظیم شدهی آن در سمت IDP یکی باشد و همچنین مقدار ClientSecret در اینجا نیز باید با ClientSecrets سمت IDP یکی باشد. ResponseType تنظیم شدهی در اینجا با AllowedGrantTypes سمت IDP تطابق دارد که از نوع Hybrid است. سپس دو scope درخواستی توسط این برنامهی کلاینت که openid و profile هستند در اینجا اضافه شدهاند. به این ترتیب میتوان به مقادیر Id کاربر و claims او دسترسی داشت. مقدار CallbackPath در اینجا به RedirectUris سمت IDP اشاره میکند که مقدار پیشفرض آن همان signin-oidc است. با تنظیم SaveTokens به true امکان استفادهی مجدد از آنها را میسر میکند.
پس از تکمیل قسمت ConfigureServices و انجام تنظیمات میانافزار اعتبارسنجی، نیاز است این میانافزار را نیز به برنامه افزود که توسط متد UseAuthentication انجام میشود:
namespace ImageGallery.MvcClient.WebApp { public class Startup { public void Configure(IApplicationBuilder app, IHostingEnvironment env) { app.UseAuthentication();
پس از این تنظیمات، با اعمال ویژگی Authorize، دسترسی به کنترلر گالری برنامهی MVC Client را صرفا محدود به کاربران وارد شدهی به سیستم میکنیم:
namespace ImageGallery.MvcClient.WebApp.Controllers { [Authorize] public class GalleryController : Controller { // .... public async Task WriteOutIdentityInformation() { var identityToken = await HttpContext.GetTokenAsync(OpenIdConnectParameterNames.IdToken); Debug.WriteLine($"Identity token: {identityToken}"); foreach (var claim in User.Claims) { Debug.WriteLine($"Claim type: {claim.Type} - Claim value: {claim.Value}"); } }
فراخوانی متد GetTokenAsync با پارامتر IdToken، همان Identity token دریافتی از IDP را بازگشت میدهد. این توکن با تنظیم SaveTokens به true در تنظیمات AddOpenIdConnect که پیشتر انجام دادیم، قابل استخراج از کوکی اعتبارسنجی برنامه شدهاست.
این متد را در ابتدای اکشن متد Index فراخوانی میکنیم:
public async Task<IActionResult> Index() { await WriteOutIdentityInformation(); // ....
اجرای برنامه جهت آزمایش تنظیمات انجام شده
برای اجرای برنامه:
- ابتدا به پوشهی 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 را درخواست کنید:
در این حالت چون فیلتر Authorize به کل اکشن متدهای کنترلر گالری اعمال شده، میانافزار Authentication که در فایل آغازین برنامهی کلاینت MVC تنظیم شدهاست، وارد عمل شده و کاربر را به صفحهی لاگین سمت IDP هدایت میکند (شماره پورت آن 6001 است). لاگ این اعمال را هم در برگهی network مرورگر میتواند مشاهده کنید.
در اینجا نام کاربری و کلمهی عبور اولین کاربر تعریف شدهی در فایل Config.cs برنامهی IDP را که User 1 و password است، وارد میکنیم. پس از آن صفحهی Consent ظاهر میشود:
در اینجا از کاربر سؤال میپرسد که آیا به برنامهی کلاینت اجازه میدهید تا به Id و اطلاعات پروفایل و یا همان Claims شما دسترسی پیدا کند؟
فعلا گزینهی remember my design را انتخاب نکنید تا همواره بتوان این صفحه را در دفعات بعدی نیز مشاهده کرد. سپس بر روی گزینهی Yes, Allow کلیک کنید.
اکنون به صورت خودکار به سمت برنامهی MVC Client هدایت شده و میتوانیم اطلاعات صفحهی اول سایت را کاملا مشاهده کنیم (چون کاربر اعتبارسنجی شدهاست، از فیلتر Authorize رد خواهد شد).
همچنین در اینجا اطلاعات زیادی نیز جهت دیباگ برنامه لاگ میشوند که در آینده جهت عیب یابی آن میتوانند بسیار مفید باشند:
با دنبال کردن این لاگ میتوانید مراحل Hybrid Flow را مرحله به مرحله با مشاهدهی ریز جزئیات آن بررسی کنید. این مراحل به صورت خودکار توسط میانافزار Authentication انجام میشوند و در نهایت اطلاعات توکنهای دریافتی به صورت خودکار در اختیار برنامه برای استفاده قرار میگیرند. یعنی هم اکنون کوکی رمزنگاری شدهی اطلاعات اعتبارسنجی کاربر در دسترس است و به اطلاعات آن میتوان توسط شیء this.User، در اکشن متدهای برنامهی MVC، دسترسی داشت.
تنظیم برنامهی MVC Client جهت انجام عملیات خروج از سیستم
ابتدا نیاز است یک لینک خروج از سیستم را به برنامهی کلاینت اضافه کنیم. برای این منظور به فایل Views\Shared\_Layout.cshtml مراجعه کرده و لینک logout را در صورت IsAuthenticated بودن کاربر جاری وارد شدهی به سیستم، نمایش میدهیم:
<div class="navbar-collapse collapse"> <ul class="nav navbar-nav"> <li><a asp-area="" asp-controller="Gallery" asp-action="Index">Home</a></li> <li><a asp-area="" asp-controller="Gallery" asp-action="AddImage">Add an image</a></li> @if (User.Identity.IsAuthenticated) { <li><a asp-area="" asp-controller="Gallery" asp-action="Logout">Logout</a></li> } </ul> </div>
شیء this.User، هم در اکشن متدها و هم در Viewهای برنامه، جهت دسترسی به اطلاعات کاربر اعتبارسنجی شده، در دسترس است.
این لینک به اکشن متد Logout، در کنترلر گالری اشاره میکند که آنرا به صورت زیر تکمیل خواهیم کرد:
namespace ImageGallery.MvcClient.WebApp.Controllers { [Authorize] public class GalleryController : Controller { public async Task Logout() { // Clears the local cookie ("Cookies" must match the name of the scheme) await HttpContext.SignOutAsync("Cookies"); await HttpContext.SignOutAsync("oidc"); }
سپس نیاز است از برنامهی IDP نیز logout شویم. به همین جهت سطر دوم SignOutAsync با پارامتر oidc را مشاهده میکنید. بدون وجود این سطر، کاربر فقط از برنامهی کلاینت logout میشود؛ اما اگر به IDP مجددا هدایت شود، مشاهده خواهد کرد که در آن سمت، هنوز نام کاربری او توسط IDP شناسایی میشود.
بهبود تجربهی کاربری Logout
پس از logout، بدون انجام یکسری از تنظیمات، کاربر مجددا به برنامهی کلاینت به صورت خودکار هدایت نخواهد شد و در همان سمت IDP متوقف میشد. برای بهبود این وضعیت و بازگشت مجدد به برنامهی کلاینت، اینکار را یا توسط مقدار دهی خاصیت SignedOutCallbackPath مربوط به متد AddOpenIdConnect میتوان انجام داد و یا بهتر است مقدار پیشفرض آنرا به تنظیمات IDP نسبت داد که پیشتر در تنظیمات متد GetClients آنرا ذکر کرده بودیم:
PostLogoutRedirectUris = new List<string> { "https://localhost:5001/signout-callback-oidc" },
البته هنوز یک مرحلهی انتخاب و کلیک بر روی لینک بازگشت وجود دارد. برای حذف آن و خودکار کردن Redirect نهایی آن، میتوان کدهای IdentityServer4.Quickstart.UI را که در قسمت قبل به برنامهی IDP اضافه کردیم، اندکی تغییر دهیم. برای این منظور فایل src\IDP\DNT.IDP\Quickstart\Account\AccountOptions.cs را گشوده و سپس فیلد AutomaticRedirectAfterSignOut را که false است، به true تغییر دهید.
تنظیمات بازگشت Claims کاربر به برنامهی کلاینت
به صورت پیشفرض، Identity Server اطلاعات Claims کاربر را ارسال نمیکند و Identity token صرفا به همراه اطلاعات Id کاربر است. برای تنظیم آن میتوان در سمت تنظیمات IDP، در متد GetClients، زمانیکه new Client صورت میگیرد، خاصیت AlwaysIncludeUserClaimsInIdToken هر کلاینت را به true تنظیم کرد؛ اما ایده خوبی نیست. Identity token از طریق Authorization endpoint دریافت میشود. در اینجا اگر این اطلاعات از طریق URI دریافت شود و Claims به Identity token افزوده شوند، به مشکل بیش از حد طولانی شدن URL نهایی خواهیم رسید و ممکن است از طرف وب سرور یک چنین درخواستی برگشت بخورد. به همین جهت به صورت پیشفرض اطلاعات Claims به Identity token اضافه نمیشوند.
در اینجا برای دریافت Claims، یک endpoint دیگر در IDP به نام UserInfo endpoint درنظر گرفته شدهاست. در این حالت برنامهی کلاینت، مقدار Access token دریافتی را که به همراه اطلاعات scopes متناظر با Claims است، به سمت UserInfo endpoint ارسال میکند. باید دقت داشت زمانیکه Identity token دوم از Token endpoint دریافت میشود (تصویر ابتدای بحث)، به همراه آن یک Access token نیز صادر و ارسال میگردد. اینجا است که میانافزار oidc، این توکن دسترسی را به سمت UserInfo endpoint ارسال میکند تا user claims را دریافت کند:
در تنظیمات سمت کلاینت AddOpenIdConnect، درخواست openid و profile، یعنی درخواست Id کاربر و Claims آن وجود دارند:
options.Scope.Add("openid"); options.Scope.Add("profile");
options.GetClaimsFromUserInfoEndpoint = true;
کدهای کامل این قسمت را از اینجا میتوانید دریافت کنید.
برای اجرای برنامه:
- ابتدا به پوشهی 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 وارد کنید.
بررسی کارآیی کوئریها در SQL Server - قسمت هشتم - بررسی عملگرهای Merge Join و Sort در یک Query Plan
بررسی عملگر merge join
ابتدا در management studio از منوی Query، گزینهی Include actual execution plan را انتخاب میکنیم. سپس کوئریهای زیر را اجرا میکنیم:
USE [WideWorldImporters]; GO SET STATISTICS IO ON; GO SELECT [p].[PurchaseOrderID], [pl].[PurchaseOrderLineID] FROM [Purchasing].[PurchaseOrders] [p] JOIN [Purchasing].[PurchaseOrderLines] [pl] ON [p].[PurchaseOrderID] = [pl].[PurchaseOrderID]; GO
در اینجا یک merge join انجام شده، چون اطلاعات رسیدهی به آن، از پیش مرتب شدهاست. از این جهت که جدول PurchaseOrders دارای یک clustered index تعریف شدهی بر روی PurchaseOrderID است:
ALTER TABLE [Purchasing].[PurchaseOrders] ADD CONSTRAINT [PK_Purchasing_PurchaseOrders] PRIMARY KEY CLUSTERED ( [PurchaseOrderID] ASC )
CREATE NONCLUSTERED INDEX [FK_Purchasing_PurchaseOrderLines_PurchaseOrderID] ON [Purchasing].[PurchaseOrderLines] ( [PurchaseOrderID] ASC )
اما بهینه سازی کوئریهای SQL Server، همیشه در یک چنین شرایطی، از merge join استفاده نمیکند. برای مثال کوئری زیر نیز دقیقا از لحاظ تعریف ایندکس بر روی OrderID، وضعیت مشابهی با کوئری قبلی دارد:
SELECT [o].[OrderID], [ol].[OrderLineID] FROM [Sales].[Orders] [o] JOIN [Sales].[OrderLines] [ol] ON [o].[OrderID] = [ol].[OrderID]; GO
اگر به میزان ضخامت پیکانهای این پلن، با پلن قبلی دقت کنید، مشاهده میکنید که ضخامت آنها در اینجا افزایش یافتهاست. این افزایش ضخامت پیکانها، بیانگر افزایش میزان اطلاعات ارسالی به قسمتهای مختلف است (حدود 231 هزار ردیف) به همراه اسکن بالایی بر روی ایندکس [FK_Sales_Orders_SalespersonPersonID] است (بر روی PersonID بجای OrderID) و دومی بر روی [NCCX_Sales_OrderLines]. چون ایندکس OrderID سنگین است و تعداد ردیف زیادی را شامل میشود، بهینه ساز ترجیح دادهاست تا از ایندکس دیگری استفاده کند که I/O کمتری را به همراه دارد. در اینحالت دیگر merger join میسر نبوده و از hash match استفاده کردهاست.
اگر OrderID انتخاب شده را از جدول OrderLines تهیه کنیم، چه اتفاقی رخ میدهد؟ (در کوئری قبلی، OrderID از جدول Orders انتخاب شده بود)
SELECT [ol].[OrderID], [ol].[OrderLineID] FROM [Sales].[Orders] [o] JOIN [Sales].[OrderLines] [ol] ON [o].[OrderID] = [ol].[OrderID];
یک بازنویسی ساده و دریافت دو ستون از یک جدول سبب شدهاست تا بهینه سازی کوئری، join تشکیل شده را غیرضروری دانسته و مستقیم عمل کند.
اهمیت مرتب شده بودن اطلاعات در تشکیل Joinهای بهینه
کوئری زیر را در نظر بگیرید که در آن یک select * را داریم (که یک ضد الگو است):
SELECT * FROM [Sales].[Orders] [o] JOIN [Sales].[OrderLines] [ol] ON [o].[OrderID] = [ol].[OrderID]; GO
جدول OrderLines دارای یک non-clustered index، فقط بر روی ستون OrderID است؛ اما با select * نوشته شده، تمام ستونهای آنرا درخواست کردهایم (و نه فقط OrderID را)؛ به همین جهت اطلاعات آن پیش از ارسال به merge join باید توسط عملگر sort مرتب شود و همانطور که مشاهده میکنید، هزینهی این عملگر در این پلن، 82 درصد کل است.
تاثیر order by بر روی کوئری پلن تشکیل شده
دو کوئری زیر را در نظر بگیرید که تفاوت دومی با اولی، در داشتن یک ORDER BY است:
SELECT TOP 1000 * FROM [Sales].[OrderLines]; GO SELECT TOP 1000 * FROM [Sales].[OrderLines] ORDER BY [Description]; GO
اولی، تمام clustered index را اسکن نمیکند و جائیکه 1000 ردیف را از آن بازگشت میدهد، متوقف میشود.
اما در دومی چون نیاز به مرتب سازی اطلاعات بر اساس یک ستون بودهاست، عملگر sort مشاهده میشود. اسکن آن نیز بر روی کل اطلاعات است (پیکان مرتبط با آن، نسبت به پلن قبلی ضخیمتر است) و سپس آنها را مرتب میکند.
برای بهبود این وضعیت، تعداد ستونهای بازگشت داده شده را محدود کرده و سپس بر اساس آنها، ایندکس صحیحی را طراحی میکنیم:
بنابراین اینبار بجای select *، تعداد مشخصی از ستونها را بازگشت میدهیم:
SELECT [CustomerID], [OrderDate], [ExpectedDeliveryDate] FROM [Sales].[Orders] ORDER BY [CustomerID]; GO
CREATE NONCLUSTERED INDEX [IX_Sales_Orders_CustomerID_Dates] ON [Sales].[Orders]( [CustomerID] ASC ) INCLUDE ( [OrderDate], [ExpectedDeliveryDate] ) ON [USERDATA]; GO
declare @t table(id int, name nvarchar(max), active bit) insert @t values (1, 'Group 1', 1), (2, 'Group 2', 0)
select '[' + STUFF(( select ',{"id":' + cast(id as varchar(max)) + ',"name":"' + name + '"' + ',"active":' + cast(active as varchar(max)) +'}' from @t t1 for xml path(''), type ).value('.', 'varchar(max)'), 1, 1, '') + ']'
[{"id":1,"name":"Group 1","active":1},{"id":2,"name":"Group 2","active":0}]
declare @group table(id int, name nvarchar(max), active bit) insert @group values (1, 'Group 1', 1), (2, 'Group 2', 0) declare @member table(id int, groupid int,name nvarchar(max)) insert @member values (1, 1,'Ali'), (2, 1,'Mojtaba'),(3,2,'Hamid') select '[' + STUFF(( select ',{"id":' + cast(g.id as varchar(max)) + ',"name":"' + g.name + '"' + ',"members": { "children": [' + (select + STUFF(( select ',{"id":' + cast(m.id as varchar(max)) + ',"name":"' + m.name + '"}' from @member m where m.groupid = g.id for xml path(''), type ).value('.', 'varchar(max)'), 1, 1, '') + ']}' + ',"active":' + cast(g.active as varchar(max)) +'}') from @group g for xml path(''), type ).value('.', 'varchar(max)'), 1, 1, '') + ']'
[{"id":1,"name":"Group 1","members": { "children": [{"id":1,"name":"Ali"},{"id":2,"name":"Mojtaba"}]} ,"active":1}, {"id":2,"name":"Group 2","members": { "children": [{"id":3,"name":"Hamid"}]} ,"active":0}]