با سلام و تشکر؛ از توضیحاتتون برداشتم این بود که Asp.NET Identity با EntityFrameworkWithHierarchyId مشکل داره. برای رفع این مشکل راه حلی هم وجود داره؟ یا اگه بخواهیم از EntityFrameworkWithHierarchyId استفاده کنیم باید از امکان Asp.NET Identity چشم پوشی کنیم؟
پاسخ به بازخوردهای پروژهها
درخواست راهنمایی بیشتر
«مدیریت کاربران، نقشها و تعیین دسترسی ها؛»
برای درک مقدمات این مورد نیاز هست با ASP.NET Identity 2.x آشنا باشید و همچنین مطلب « اعمال تزریق وابستگیها به مثال رسمی ASP.NET Identity»
اشتراکها
استفاده از Identity در Razor page
فرمت کوکیهای ASP.NET Identity از پروژهی سورس باز Katana دریافت شدهاست و تولید آن پس از لاگین کاربر، شامل مراحل زیر میباشد:
1- با استفاده از کلاس ApplicationUser، شیء ClaimsPrincipal را تولید میکند.
2- به این ClaimsPrincipal اطلاعاتی مانند ApplicationUser.Id و SecurityStamp اضافه میشوند.
3- در ادامه، ClaimsPrincipal به OWIN و کلاس CookieAuthenticationHandler آن ارسال میشود.
4- کار کلاس CookieAuthenticationHandler، تولید و تنظیم اطلاعاتی مانند تاریخ صدور کوکی، تاریخ انقضای آن، نوع کوکی، مانند ماندگار بودن یا امن بودن (HTTPS) و امثال آن است. حاصل این مراحله، تولید یک AuthenticationTicket است.
5- در آخر، AuthenticationTicket و ClaimsPrincipal به کلاس SecureDataFormat، برای ابتدا، serialize شدن اشیاء، رمزنگاری و در نهایت تبدیل آنها به فرمت base64، ارسال میشوند.
جزئیات تکمیلی مرحلهی آخر آن نیز به این ترتیب است:
AuthenticationTicket با استفاده از کلاس TicketSerializer سریالایز میشود. پس از آن یک memory stream تشکیل شده و اطلاعات ClaimsIdentity و AuthenticationTicket سریالایز شده به آن ارسال میشوند. این memory stream با استفاده از الگوریتم GZip فشرده شده و برای پردازش بیشتر بازگشت داده میشود. مرحلهی بعد، رمزنگاری اطلاعات فشرده سازی شدهاست. برای این منظور از کلاس DpapiDataProtector دات نت استفاده میکنند. پس از رمزنگاری، استریم نهایی با فرمت base64 برای درج در HTTP Response آماده خواهد شد.
سؤال: چرا کوکیهای یک کاربر معین لاگین شدهی توسط ASP.NET Identity، در مرورگرهای مختلف متفاوت است؟
هرچند اطلاعاتی مانند ApplicationUser.Id و SecurityStamp برای یک کاربر، در مرورگرهای مختلف یکسان هستند، اما در مرحلهی چهارم، ذکر شد که AuthenticationTicket دارای اطلاعات بیشتری مانند زمان تولید کوکی نیز هست. بنابراین اطلاعات نهایی رمزنگاری شدهی در این حالت که در زمانهای مختلفی تولید شدهاند، یکسان نخواهند بود.
سؤال: در ساب دومینهای مختلف دومین مشخصی، چندین برنامهی مختلف نصب شدهاند. چگونه میتوان از یک سیستم لاگین ASP.NET Identity برای تمام آنها استفاده کرد؟
برای این منظور نیاز هست خاصیت CookieDomain را به صورت صریح مقدار دهی کرد. برای اینکار فایل Startup.Auth.cs را گشوده و CookieAuthenticationOptions را تنظیم کنید:
البته کار به همینجا ختم نمیشود. پس از آن نیاز است به ازای تمام دومینهای موجود، یک machine key مشخص تنظیم شود. از این جهت که در مرحلهی پنجم تولید کوکی، کلاس DpapiDataProtector دات نت، از machine key موجود، برای رمزنگاری اطلاعات استفاده میکند و اگر این machine key، به ازای برنامههای مختلف متفاوت باشد، کوکی تولید شده، قابل رمزگشایی و استفاده نخواهد بود.
برای اینکار به کنسول IIS مراجعه کرده و گزینهی machine key آنرا بیابید. در این قسمت بر روی generate keys کلیک کرده و اطلاعات تولیدی را باید به تمام web.configهای موجود کپی کنید:
سؤال: برنامههای مختلفی بر روی یک دومین نصب هستند، اما قصد نداریم از سیستم اعتبارسنجی یکپارچهای برای تمام آنها استفاده کنیم. اما اگر در یکی لاگین کنیم، بلافاصله لاگین در برنامهی دوم منقضی میشود، چرا؟
شبیه به همین مساله با Forms Authentication هم وجود دارد. برای رفع آن باید نام کوکیهای هر برنامه را منحصربفرد کنید و از نام پیش فرض کوکیها استفاده نکنید تا بر روی یکدیگر بازنویسی نشوند. برای اینکار خاصیت CookieName شیء CookieAuthenticationOptions را جداگانه مقدار دهی کنید:
سؤال: لاگین انجام شدهی در برنامهای که از ASP.NET Identity استفاده میکند، زود منقضی میشود؛ چرا؟
برای تنظیم صریح زمان انقضای کوکی ASP.NET Identity نیاز است خاصیت ExpireTimeSpan آنرا مقدار دهی کنید:
سؤال: کاربر سیستم ASP.NET Identity از سیستم خارج شدهاست (log off کرده) ولی هنوز میتوان از کوکی پیشین او برای اعتبارسنجی مجدد استفاده کرد. چطور میتوان این نقیصهی امنیتی را برطرف کرد؟
مشکل از اینجا است:
در مثال رسمی ASP.NET Identity یک چنین کدی برای خروج از سیستم ارائه شدهاست. نمونهی امنتر آن به صورت زیر است:
در اینجا علاوه بر عدم استفادهی از متد بدون پارامتر SignOut (با توجه به خاصیت AuthenticationType ذکر شدهی در CookieAuthenticationOptions)، کار به روز رسانی مجدد SecurityStamp کوکی نیز انجام شدهاست. با این تغییر، کوکی موجود بلا استفاده خواهد شد؛ چون دیگر قابل رمزگشایی نیست.
همچنین بهتر است مقدار validateInterval مربوط به SecurityStampValidator.OnValidateIdentity که به صورت پیش فرض 30 دقیقه است را به مقدار کمتری مانند 5 دقیقه تغییر دهید (تنظیمات OnValidateIdentity مربوط به CookieAuthenticationOptions فایل آغارین برنامه). کار این تنظیم، بررسی اعتبار کوکی، در بازههای زمانی مشخص شدهاست.
1- با استفاده از کلاس ApplicationUser، شیء ClaimsPrincipal را تولید میکند.
2- به این ClaimsPrincipal اطلاعاتی مانند ApplicationUser.Id و SecurityStamp اضافه میشوند.
3- در ادامه، ClaimsPrincipal به OWIN و کلاس CookieAuthenticationHandler آن ارسال میشود.
4- کار کلاس CookieAuthenticationHandler، تولید و تنظیم اطلاعاتی مانند تاریخ صدور کوکی، تاریخ انقضای آن، نوع کوکی، مانند ماندگار بودن یا امن بودن (HTTPS) و امثال آن است. حاصل این مراحله، تولید یک AuthenticationTicket است.
5- در آخر، AuthenticationTicket و ClaimsPrincipal به کلاس SecureDataFormat، برای ابتدا، serialize شدن اشیاء، رمزنگاری و در نهایت تبدیل آنها به فرمت base64، ارسال میشوند.
جزئیات تکمیلی مرحلهی آخر آن نیز به این ترتیب است:
AuthenticationTicket با استفاده از کلاس TicketSerializer سریالایز میشود. پس از آن یک memory stream تشکیل شده و اطلاعات ClaimsIdentity و AuthenticationTicket سریالایز شده به آن ارسال میشوند. این memory stream با استفاده از الگوریتم GZip فشرده شده و برای پردازش بیشتر بازگشت داده میشود. مرحلهی بعد، رمزنگاری اطلاعات فشرده سازی شدهاست. برای این منظور از کلاس DpapiDataProtector دات نت استفاده میکنند. پس از رمزنگاری، استریم نهایی با فرمت base64 برای درج در HTTP Response آماده خواهد شد.
سؤال: چرا کوکیهای یک کاربر معین لاگین شدهی توسط ASP.NET Identity، در مرورگرهای مختلف متفاوت است؟
هرچند اطلاعاتی مانند ApplicationUser.Id و SecurityStamp برای یک کاربر، در مرورگرهای مختلف یکسان هستند، اما در مرحلهی چهارم، ذکر شد که AuthenticationTicket دارای اطلاعات بیشتری مانند زمان تولید کوکی نیز هست. بنابراین اطلاعات نهایی رمزنگاری شدهی در این حالت که در زمانهای مختلفی تولید شدهاند، یکسان نخواهند بود.
سؤال: در ساب دومینهای مختلف دومین مشخصی، چندین برنامهی مختلف نصب شدهاند. چگونه میتوان از یک سیستم لاگین ASP.NET Identity برای تمام آنها استفاده کرد؟
برای این منظور نیاز هست خاصیت CookieDomain را به صورت صریح مقدار دهی کرد. برای اینکار فایل Startup.Auth.cs را گشوده و CookieAuthenticationOptions را تنظیم کنید:
var cookieAuthenticationOptions = new CookieAuthenticationOptions { AuthenticationType = DefaultAuthenticationTypes.ApplicationCookie, LoginPath = new PathString("/Account/Login"), CookieDomain = ".mydomain.com" };
برای اینکار به کنسول IIS مراجعه کرده و گزینهی machine key آنرا بیابید. در این قسمت بر روی generate keys کلیک کرده و اطلاعات تولیدی را باید به تمام web.configهای موجود کپی کنید:
<machineKey validationKey="DAD9E2B0F9..." decryptionKey="ADD1C39C02..." validation="SHA1" decryption="AES" />
سؤال: برنامههای مختلفی بر روی یک دومین نصب هستند، اما قصد نداریم از سیستم اعتبارسنجی یکپارچهای برای تمام آنها استفاده کنیم. اما اگر در یکی لاگین کنیم، بلافاصله لاگین در برنامهی دوم منقضی میشود، چرا؟
شبیه به همین مساله با Forms Authentication هم وجود دارد. برای رفع آن باید نام کوکیهای هر برنامه را منحصربفرد کنید و از نام پیش فرض کوکیها استفاده نکنید تا بر روی یکدیگر بازنویسی نشوند. برای اینکار خاصیت CookieName شیء CookieAuthenticationOptions را جداگانه مقدار دهی کنید:
CookieName = "my-very-own-cookie-name"
سؤال: لاگین انجام شدهی در برنامهای که از ASP.NET Identity استفاده میکند، زود منقضی میشود؛ چرا؟
برای تنظیم صریح زمان انقضای کوکی ASP.NET Identity نیاز است خاصیت ExpireTimeSpan آنرا مقدار دهی کنید:
app.UseCookieAuthentication(new CookieAuthenticationOptions { ExpireTimeSpan = TimeSpan.FromHours(24.0), });
سؤال: کاربر سیستم ASP.NET Identity از سیستم خارج شدهاست (log off کرده) ولی هنوز میتوان از کوکی پیشین او برای اعتبارسنجی مجدد استفاده کرد. چطور میتوان این نقیصهی امنیتی را برطرف کرد؟
مشکل از اینجا است:
public ActionResult LogOff() { AuthenticationManager.SignOut(); return RedirectToAction("Index", "Home"); }
[HttpPost] [ValidateAntiForgeryToken] public async Task<ActionResult> LogOff() { var user = await UserManager.FindByNameAsync(User.Identity.Name); AuthenticationManager.SignOut(DefaultAuthenticationTypes.ApplicationCookie); await UserManager.UpdateSecurityStampAsync(user.Id); return RedirectToAction("Login", "Account"); }
همچنین بهتر است مقدار validateInterval مربوط به SecurityStampValidator.OnValidateIdentity که به صورت پیش فرض 30 دقیقه است را به مقدار کمتری مانند 5 دقیقه تغییر دهید (تنظیمات OnValidateIdentity مربوط به CookieAuthenticationOptions فایل آغارین برنامه). کار این تنظیم، بررسی اعتبار کوکی، در بازههای زمانی مشخص شدهاست.
یکی از انواع روشهایی که در SQL Server و مشتقات آن برای نمایش رکوردها به صورت اتفاقی مورد استفاده قرار میگیرد، استفاده از کوئری زیر است:
سؤال: ترجمه و معادل کوئری فوق در Entity framework به چه صورتی است؟
پاسخ:
یک مثال کامل را در این زمینه در ادامه ملاحظه میکنید:
تنها نکته مهم آن سطر ذیل است که برای مرتب سازی اتفاقی استفاده شده است:
که معادل
در SQL Server است.
خروجی SQL تولیدی کوئری LINQ فوق را نیز در ادامه مشاهده میکنید:
SELECT * FROM table ORDER BY NEWID()
پاسخ:
یک مثال کامل را در این زمینه در ادامه ملاحظه میکنید:
using System; using System.Data.Entity; using System.Data.Entity.Migrations; using System.Linq; namespace Sample { public class User { public int Id { get; set; } public string Name { get; set; } public int Age { get; set; } } public class MyContext : DbContext { public DbSet<User> Users { get; set; } } public class Configuration : DbMigrationsConfiguration<MyContext> { public Configuration() { AutomaticMigrationsEnabled = true; AutomaticMigrationDataLossAllowed = true; } protected override void Seed(MyContext context) { context.Users.Add(new User { Name = "User 1", Age = 20 }); context.Users.Add(new User { Name = "User 2", Age = 25 }); context.Users.Add(new User { Name = "User 3", Age = 30 }); context.Users.Add(new User { Name = "User 4", Age = 35 }); context.Users.Add(new User { Name = "User 5", Age = 40 }); base.Seed(context); } } public static class Test { public static void RunTests() { Database.SetInitializer(new MigrateDatabaseToLatestVersion<MyContext, Configuration>()); using (var context = new MyContext()) { var randomListOfUsers = context.Users .Where(person => person.Age >= 25 && person.Age < 40) .OrderBy(person => Guid.NewGuid()) .ToList(); foreach (var person in randomListOfUsers) Console.WriteLine("{0}:{1}", person.Name, person.Age); } } } }
.OrderBy(person => Guid.NewGuid())
ORDER BY NEWID()
خروجی SQL تولیدی کوئری LINQ فوق را نیز در ادامه مشاهده میکنید:
SELECT [Project1].[Id] AS [Id], [Project1].[Name] AS [Name], [Project1].[Age] AS [Age] FROM ( SELECT NEWID() AS [C1], ------ Guid created here [Extent1].[Id] AS [Id], [Extent1].[Name] AS [Name], [Extent1].[Age] AS [Age] FROM [dbo].[Users] AS [Extent1] WHERE ([Extent1].[Age] >= 25) AND ([Extent1].[Age] < 40) ) AS [Project1] ORDER BY [Project1].[C1] ASC ------ Used for sorting here
در قسمت قبل، با useState Hook آشنا شدیم. همچنین چندین مثال را در مورد نحوهی تعریف تکی و یا چندتایی آن در یک کامپوننت تابعی، با انواع و اقسام دادههای مختلف، بررسی کردیم؛ اما بهتر است از کدام حالت استفاده شود؟ آیا بهتر است به ازای هر خاصیت state، یکبار useState Hook جدیدی را تعریف کنیم و یا بهتر است همانند کامپوننتهای کلاسی، یک شیء کامل را به همراه چندین خاصیت، به یک تک useState Hook معرفی کنیم؟
پیاده سازی یک فرم لاگین با استفاده از چندین useState Hook
در ابتدا، یک مثال کاربردیتر را به کمک useState Hookها پیاده سازی میکنیم. در اینجا هر المان فرم را به یک useState Hook مجزا، متصل کردهایم. کدهای کامل این کامپوننت را در ادامه مشاهده میکنید:
توضیحات:
- اگر دقت کرده باشید، اینبار این کامپوننت تابعی را به صورت متداول ()function Login تعریف کردهایم. مزیت یک چنین تعریفی، امکان export در محل آن میباشد:
و دیگر برخلاف حالت استفادهی از arrow functionها برای تعریف کامپوننتهای تابعی، نیازی نیست تا این export را جداگانه در این ماژول درج کرد.
به علاوه وجود واژهی default در اینجا سبب میشود که برای import آن، بتوان از هر نام دلخواهی استفاده کرد و در اینجا اجباری به استفادهی از نام Login وجود ندارد که نمونهی استفادهی از آن در فایل index.js، میتواند به صورت زیر باشد:
- همانطور که در قسمت قبل نیز بررسی کردیم، useState Hookها را با هر نوع دادهی دلخواهی میتوان مقدار دهی اولیه کرد؛ برای مثال با یک int و یا یک object. همچنین الزامی هم به تعریف فقط یک useState Hook وجود ندارد و هر قسمتی از state را میتوان توسط یک useState Hook مجزا، تعریف و مدیریت کرد.
- فرم لاگین تعریف شده، از یک فیلد نام کاربری و یک فیلد کلمهی عبور تشکیل شدهاست.
- اکنون میخواهیم اطلاعات دریافت شدهی از کاربر را در state کامپوننت جاری منعکس کنیم. به همین جهت، کار با import متد useState شروع میشود. سپس به ازای هر فیلد در فرم، یک state مجزا را تعریف میکنیم:
- اکنون برای به روز رسانی مقادیر درج شدهی در stateهای تعریف شده بر اساس اطلاعات وارد شدهی توسط کاربر، از رویداد onChange استفاده میکنیم؛ برای مثال:
در اینجا تابع مدیریت کنندهی رویداد onChange، به صورت inline تعریف شدهاست. پیشتر اگر با کامپوننتهای کلاسی میخواستیم اینکار را انجام دهیم، نیاز به clone شیء state، دسترسی به خاصیت متناظر با نام فیلد تعریف شدهی در آن به صورت پویا، به روز رسانی آن و در آخر به روز رسانی state با مقدار جدید شیء state میبود. اما در اینجا نیازی به دانستن نام المان و یا نام خاصیتی نیست.
- پس از به روز رسانی state، میخواهیم در حین submit فرم، این اطلاعات را برای مثال به صورت یک شیء، به سمت سرور ارسال کنیم. به همین جهت نیاز است رویداد onSubmit فرم را مدیریت کرد. در این متد ابتدا از post back معمول آن به سمت سرور جلوگیری میشود و سپس بر اساس متغیرهای تعریف شدهی در state، یک شیء را ایجاد کردهایم:
همچنین چون در پایین فرم نیز میخواهیم این اطلاعات را به صورت JSON نمایش دهیم:
یک state مجزا را هم برای این شیء تعریف:
و در handleSubmit، به روز رسانی کردهایم.
- دو سطر بعدی را که در انتهای handleSubmit مشاهده میکنید، روشی است برای خالی کردن المانهای فرم، پس از ارسال اطلاعات فرم، برای مثال به backend server. البته این حالت فقط برای حالتی نیاز است که فرم قرار نباشد به آدرس دیگری Redirect شود. برای خالی کردن المانهای فرم، المانهای آنرا باید تبدیل به controlled elements کرد که اینکار با مقدار دهی value آنها توسط value={username} صورت گرفتهاست. به این ترتیب محتوای این المانها با اطلاعاتی که در state داریم، قابل کنترل میشوند.
پیاده سازی فرم ثبت نام با استفاده از تنها یک useState Hook
مثال دوم این مطلب نیز در مورد مدیریت المانهای یک فرم توسط useState Hook است؛ با این تفاوت که در اینجا تنها یک شیء، کل state را تشکیل میدهد. کدهای کامل این مثال را در ادامه مشاهده میکنید:
توضیحات:
- فرم ثبت نام فوق از سه فیلد نام کاربری، ایمیل و کلمهی عبور تشکیل شدهاست.
- اینبار نحوهی تشکیل state مرتبط با این سه فیلد را بسیار شبیه به حالت مدیریت state در کامپوننتهای کلاسی، تعریف کردهایم؛ که تنها با یک تک شیء، انجام میشود و نام آنرا form در نظر گرفتهایم:
- اکنون باید راهی را بیابیم تا این خواص شیء form را بر اساس ورودیهای کاربر، به روز رسانی کنیم. به همین جهت رویداد onChange این ورودی را به متغیر handleChange که متد منتسب به آن، این تغییرات را ردیابی میکند، متصل میکنیم:
متد رویدادگردان منتسب به handleChange نیز به صورت زیر تعریف میشود:
این متد بر اساس name المانهای ورودی عمل میکند (در مثال اول این قسمت، نیازی به دانستن نام المانها نبود). زمانیکه یک شیء را به صورت [event.target.name]: event.target.value تعریف میکنیم، یعنی قرار است نام خاصیت این شیء را به صورت پویا تعریف کنیم و مقدار آن نیز از target.value شیء رویداد رسیده، تامین میشود. سپس این شیء جدید، با فراخوانی متد setForm، سبب به روز رسانی شیء form موجود در state میشود.
- علت وجود spread operator تعریف شدهی در اینجا یعنی form...، این است که در حالت استفادهی از useState، برخلاف حالت کار با کامپوننتهای کلاسی، خواص اضافه شدهی به state، به شیء نهایی به صورت خودکار اضافه نمیشوند و باید کار یکی سازی را توسط spread operator انجام داد. برای مثال فرض کنید که کاربر، فیلد نام کاربری را ابتدا ثبت میکند. بنابراین در این لحظه، شیء ارسالی به setForm، فقط دارای خاصیت username خواهد شد. اکنون اگر در ادامه، کاربر فیلد ایمیل را تکمیل کند، اینبار فقط خاصیت ایمیل در این شیء قرار خواهد گرفت (یا مقدار قبلی را به روز رسانی میکند) و از سایر خواص صرفنظر میشود؛ مگر اینکه توسط spread operator، سایر خواص پیشین موجود در شیء form را نیز در اینجا لحاظ کنیم، تا اطلاعاتی را از دست نداده باشیم.
بنابراین به صورت خلاصه در روش سنتی کار با کامپوننتهای کلاسی، فراخوانی متد this.setState کار merge خواص را انجام میدهد؛ اما در اینجا فقط کار replace صورت میگیرد و باید کار merge خواص یک شیء را به صورت دستی و توسط یک spread operator انجام دهیم. البته در قسمت قبل چون تمام خواص شیء تعریف شدهی در state را با هم به روز رسانی میکردیم:
نیازی به تعریف spread operator نبود؛ اما در مثال جاری، هربار فقط یک خاصیت به روز رسانی میشود.
- سایر فیلدهای فرم نیز به همین روش onChange={handleChange}، به متد رویدادگردان فوق متصل میشوند.
- در پایان برای مدیریت رخداد ارسال فرم، handleSubmit را به صورت زیر تعریف کردهایم:
در اینجا برخلاف مثال اول، دیگر نیازی به تشکیل دستی یک شیء جدید برای ارسال به سرور وجود ندارد و هم اکنون اطلاعات کل شیء form، در اختیار برنامه است.
- همچنین چون در پایین فرم نیز میخواهیم این اطلاعات را به صورت JSON نمایش دهیم:
یک state مجزا را هم برای این شیء تعریف:
و در handleSubmit، آنرا با فراخوانی متد setUser، به روز رسانی کردهایم.
- برای پاک کردن المانهای فرم، پس از submit آن، ابتدا نیاز است این المانها را تبدیل به controlled elements کرد که اینکار با مقدار دهی value آنها توسط برای مثال value={form.username} صورت گرفتهاست. به این ترتیب محتوای این المانها با اطلاعاتی که در state داریم، قابل کنترل میشوند. اکنون اگر setForm را با یک شیء خالی مقدار دهی کنیم، به صورت خودکار المانهای فرم را پاک میکند. برای اینکار بجای تعریف شیء موجود در state به صورت inline:
میتوان آنرا خارج از تابع کامپوننت قرار داد:
و سپس آنرا به عنوان مقدار اولیه، به صورت setForm(initialFormState)، فراخوانی کرد؛ تا سبب پاک شدن المانهای فرم شود.
مقایسهی روشهای مختلف مدیریت state توسط useState Hook
پیاده سازی یک فرم لاگین با استفاده از چندین useState Hook
در ابتدا، یک مثال کاربردیتر را به کمک useState Hookها پیاده سازی میکنیم. در اینجا هر المان فرم را به یک useState Hook مجزا، متصل کردهایم. کدهای کامل این کامپوننت را در ادامه مشاهده میکنید:
import React, { useState } from "react"; export default function Login() { const [username, setUsername] = useState(""); const [password, setPassword] = useState(""); const [user, setUser] = useState(null); const handleSubmit = event => { event.preventDefault(); const userData = { username, password }; setUser(userData); setUsername(""); setPassword(""); }; return ( <> <h2 className="mt-3">Login</h2> <form onSubmit={handleSubmit}> <div className="form-group"> <label htmlFor="username">Username</label> <input type="text" name="username" id="username" onChange={event => setUsername(event.target.value)} value={username} className="form-control" /> </div> <div className="form-group"> <label htmlFor="password">Password</label> <input type="password" name="password" id="password" onChange={event => setPassword(event.target.value)} value={password} className="form-control" /> </div> <button type="submit">Submit</button> </form> {user && JSON.stringify(user, null, 2)} </> ); }
- اگر دقت کرده باشید، اینبار این کامپوننت تابعی را به صورت متداول ()function Login تعریف کردهایم. مزیت یک چنین تعریفی، امکان export در محل آن میباشد:
export default function Login() {
به علاوه وجود واژهی default در اینجا سبب میشود که برای import آن، بتوان از هر نام دلخواهی استفاده کرد و در اینجا اجباری به استفادهی از نام Login وجود ندارد که نمونهی استفادهی از آن در فایل index.js، میتواند به صورت زیر باشد:
import App from "./components/part02/Login";
- فرم لاگین تعریف شده، از یک فیلد نام کاربری و یک فیلد کلمهی عبور تشکیل شدهاست.
- اکنون میخواهیم اطلاعات دریافت شدهی از کاربر را در state کامپوننت جاری منعکس کنیم. به همین جهت، کار با import متد useState شروع میشود. سپس به ازای هر فیلد در فرم، یک state مجزا را تعریف میکنیم:
const [username, setUsername] = useState(""); const [password, setPassword] = useState("");
<input type="text" name="username" id="username" onChange={event => setUsername(event.target.value)} value={username} className="form-control" />
- پس از به روز رسانی state، میخواهیم در حین submit فرم، این اطلاعات را برای مثال به صورت یک شیء، به سمت سرور ارسال کنیم. به همین جهت نیاز است رویداد onSubmit فرم را مدیریت کرد. در این متد ابتدا از post back معمول آن به سمت سرور جلوگیری میشود و سپس بر اساس متغیرهای تعریف شدهی در state، یک شیء را ایجاد کردهایم:
const handleSubmit = event => { event.preventDefault(); const userData = { username, password }; setUser(userData); setUsername(""); setPassword(""); };
{user && JSON.stringify(user, null, 2)}
const [user, setUser] = useState(null);
- دو سطر بعدی را که در انتهای handleSubmit مشاهده میکنید، روشی است برای خالی کردن المانهای فرم، پس از ارسال اطلاعات فرم، برای مثال به backend server. البته این حالت فقط برای حالتی نیاز است که فرم قرار نباشد به آدرس دیگری Redirect شود. برای خالی کردن المانهای فرم، المانهای آنرا باید تبدیل به controlled elements کرد که اینکار با مقدار دهی value آنها توسط value={username} صورت گرفتهاست. به این ترتیب محتوای این المانها با اطلاعاتی که در state داریم، قابل کنترل میشوند.
مثال دوم این مطلب نیز در مورد مدیریت المانهای یک فرم توسط useState Hook است؛ با این تفاوت که در اینجا تنها یک شیء، کل state را تشکیل میدهد. کدهای کامل این مثال را در ادامه مشاهده میکنید:
import React, { useState } from "react"; const initialFormState = { username: "", email: "", password: "" }; export default function Register() { const [form, setForm] = useState(initialFormState); const [user, setUser] = useState(null); const handleChange = event => { setForm({ ...form, [event.target.name]: event.target.value }); }; const handleSubmit = event => { event.preventDefault(); setUser(form); setForm(initialFormState); }; return ( <> <h2 className="mt-3">Register</h2> <form onSubmit={handleSubmit}> <div className="form-group"> <label htmlFor="username">Username</label> <input type="text" name="username" id="username" onChange={handleChange} value={form.username} className="form-control" /> </div> <div className="form-group"> <label htmlFor="email">Email</label> <input type="email" name="email" id="email" onChange={handleChange} value={form.email} className="form-control" /> </div> <div className="form-group"> <label htmlFor="password">Password</label> <input type="password" name="password" id="password" onChange={handleChange} value={form.password} className="form-control" /> </div> <button type="submit" className="btn btn-primary"> Submit </button> </form> {user && JSON.stringify(user, null, 2)} </> ); }
- فرم ثبت نام فوق از سه فیلد نام کاربری، ایمیل و کلمهی عبور تشکیل شدهاست.
- اینبار نحوهی تشکیل state مرتبط با این سه فیلد را بسیار شبیه به حالت مدیریت state در کامپوننتهای کلاسی، تعریف کردهایم؛ که تنها با یک تک شیء، انجام میشود و نام آنرا form در نظر گرفتهایم:
const [form, setForm] = useState({ username: "", email: "", password: ""});
<input type="text" name="username" id="username" onChange={handleChange} value={form.username} className="form-control" />
const handleChange = event => { setForm({ ...form, [event.target.name]: event.target.value }); };
- علت وجود spread operator تعریف شدهی در اینجا یعنی form...، این است که در حالت استفادهی از useState، برخلاف حالت کار با کامپوننتهای کلاسی، خواص اضافه شدهی به state، به شیء نهایی به صورت خودکار اضافه نمیشوند و باید کار یکی سازی را توسط spread operator انجام داد. برای مثال فرض کنید که کاربر، فیلد نام کاربری را ابتدا ثبت میکند. بنابراین در این لحظه، شیء ارسالی به setForm، فقط دارای خاصیت username خواهد شد. اکنون اگر در ادامه، کاربر فیلد ایمیل را تکمیل کند، اینبار فقط خاصیت ایمیل در این شیء قرار خواهد گرفت (یا مقدار قبلی را به روز رسانی میکند) و از سایر خواص صرفنظر میشود؛ مگر اینکه توسط spread operator، سایر خواص پیشین موجود در شیء form را نیز در اینجا لحاظ کنیم، تا اطلاعاتی را از دست نداده باشیم.
بنابراین به صورت خلاصه در روش سنتی کار با کامپوننتهای کلاسی، فراخوانی متد this.setState کار merge خواص را انجام میدهد؛ اما در اینجا فقط کار replace صورت میگیرد و باید کار merge خواص یک شیء را به صورت دستی و توسط یک spread operator انجام دهیم. البته در قسمت قبل چون تمام خواص شیء تعریف شدهی در state را با هم به روز رسانی میکردیم:
setMousePosition({ x: event.pageX, y: event.pageY });
- سایر فیلدهای فرم نیز به همین روش onChange={handleChange}، به متد رویدادگردان فوق متصل میشوند.
- در پایان برای مدیریت رخداد ارسال فرم، handleSubmit را به صورت زیر تعریف کردهایم:
const handleSubmit = event => { event.preventDefault(); setUser(form); setForm(initialFormState); };
- همچنین چون در پایین فرم نیز میخواهیم این اطلاعات را به صورت JSON نمایش دهیم:
{user && JSON.stringify(user, null, 2)}
const [user, setUser] = useState(null);
- برای پاک کردن المانهای فرم، پس از submit آن، ابتدا نیاز است این المانها را تبدیل به controlled elements کرد که اینکار با مقدار دهی value آنها توسط برای مثال value={form.username} صورت گرفتهاست. به این ترتیب محتوای این المانها با اطلاعاتی که در state داریم، قابل کنترل میشوند. اکنون اگر setForm را با یک شیء خالی مقدار دهی کنیم، به صورت خودکار المانهای فرم را پاک میکند. برای اینکار بجای تعریف شیء موجود در state به صورت inline:
const [form, setForm] = useState({ username: "", email: "", password: ""});
const initialFormState = { username: "", email: "", password: "" }; export default function Register() { const [form, setForm] = useState(initialFormState);
مقایسهی روشهای مختلف مدیریت state توسط useState Hook
همانطور که مشاهده کردید، با useState Hook، به انعطاف پذیری بیشتری برای مدیریت حالت، نسبت به روش سنتی کامپوننتهای کلاسی رسیدهایم. در حالت تعریف یک useState به ازای هر فیلد، روش تعریف رویدادگردانها و همچنین تبدیل المانها به المانهای کنترل شده، نسبت به روش تعریف تنها یک useState به ازای کل فرم، سادهتر و قابل درکتر است. اما زمانیکه نیاز به پاک کردن المانهای فرم باشد، روش کار کردن با یک تک شیء، سادهتر است. درکل بهتر است برای خواص غیرمرتبط state، به ازای هر کدام، یک useState را تعریف کرد و برای یک فرم، همان روش قرار دادن اطلاعات تمام المانها در یک شیء، برای کار با فرمهای طولانیتر، سریعتر و قابلیت مدیریت سادهتری را به همراه دارد.
کدهای کامل این قسمت را از اینجا میتوانید دریافت کنید: sample-30-part-02.zip
تا اینجا تمام قسمتهای این سری، برای اساس اطلاعات یک کلاس Config استاتیک تشکیل شدهی در حافظه ارائه شدند. این روش برای دمو و توضیح مفاهیم پایهی IdentityServer بسیار مفید است؛ اما برای دنیای واقعی خیر. بنابراین در ادامه میخواهیم این قسمت را با اطلاعات ذخیره شدهی در بانک اطلاعاتی تعویض کنیم. یک روش مدیریت آن، نصب ASP.NET Core Identity دقیقا داخل همان پروژهی IDP است. در این حالت کدهای ASP.NET Core Identity مایکروسافت، کار مدیریت کاربران IDP را انجام میدهند. روش دیگر اینکار را که در اینجا بررسی خواهیم کرد، تغییر کدهای Quick Start UI اضافه شدهی در «قسمت چهارم - نصب و راه اندازی IdentityServer»، جهت پذیرفتن مدیریت کاربران مبتنی بر بانک اطلاعاتی تهیه شدهی توسط خودمان است. مزیت آن آشنا شدن بیشتر با کدهای Quick Start UI و درک زیرساخت آن است.
تکمیل ساختار پروژهی IDP
تا اینجا برای IDP، یک پروژهی خالی وب را ایجاد و به مرور، آنرا تکمیل کردیم. اما اکنون نیاز است پشتیبانی از بانک اطلاعاتی را نیز به آن اضافه کنیم. برای این منظور چهار پروژهی Class library کمکی را نیز به Solution آن اضافه میکنیم:
- DNT.IDP.DomainClasses
در این پروژه، کلاسهای متناظر با موجودیتهای جداول مرتبط با اطلاعات کاربران قرار میگیرند.
- DNT.IDP.DataLayer
این پروژه Context برنامه و Migrations آنرا تشکیل میدهد. همچنین به همراه تنظیمات و Seed اولیهی اطلاعات بانک اطلاعاتی نیز میباشد.
رشتهی اتصالی آن نیز در فایل DNT.IDP\appsettings.json ذخیره شدهاست.
- DNT.IDP.Common
الگوریتم هش کردن اطلاعات، در این پروژهی مشترک بین چند پروژهی دیگر قرار گرفتهاست. از آن جهت هش کردن کلمات عبور، در دو پروژهی DataLayer و همچنین Services استفاده میکنیم.
- DNT.IDP.Services
کلاس سرویس کاربران که با استفاده از DataLayer با بانک اطلاعاتی ارتباط برقرار میکند، در این پروژه قرار گرفتهاست.
ساختار بانک اطلاعاتی کاربران IdentityServer
در اینجا ساختار بانک اطلاعاتی کاربران IdentityServer، بر اساس جداول کاربران و Claims آنها تشکیل میشود:
در اینجا SubjectId همان Id کاربر، در سطح IDP است. این خاصیت به صورت یک کلید خارجی در جداول UserClaims و UserLogins نیز بکار میرود.
ساختار Claims او نیز به صورت زیر تعریف میشود که با تعریف یک Claim استاندارد، سازگاری دارد:
همچنین کاربر میتوان تعدادی لاگین نیز داشته باشد:
هدف از آن، یکپارچه سازی سیستم، با IDPهای ثالث مانند گوگل، توئیتر و امثال آنها است.
در پروژهی DNT.IDP.DataLayer در پوشهی Configurations آن، کلاسهای UserConfiguration و UserClaimConfiguration را مشاهده میکنید که حاوی اطلاعات اولیهای برای تشکیل User 1 و User 2 به همراه Claims آنها هستند. این اطلاعات را دقیقا از فایل استاتیک Config که در قسمتهای قبل تکمیل کردیم، به این دو کلاس جدید IEntityTypeConfiguration منتقل کردهایم تا به این ترتیب متد GetUsers فایل استاتیک Config را با نمونهی دیتابیسی آن جایگزین کنیم.
سرویسی که از طریق Context برنامه با بانک اطلاعاتی ارتباط برقرار میکند، چنین ساختاری را دارد:
که توسط آن امکان دسترسی به یک کاربر، اطلاعات Claims او و افزودن رکوردهایی جدید وجود دارد.
تنظیمات نهایی این سرویسها و Context برنامه نیز در فایل DNT.IDP\Startup.cs جهت معرفی به سیستم تزریق وابستگیها، صورت گرفتهاند. همچنین در اینجا متد initializeDb را نیز مشاهده میکنید که با فراخوانی متد context.Database.Migrate، تمام کلاسهای Migrations پروژهی DataLayer را به صورت خودکار به بانک اطلاعاتی اعمال میکند.
غیرفعال کردن صفحهی Consent در Quick Start UI
در «قسمت چهارم - نصب و راه اندازی IdentityServer» فایلهای Quick Start UI را به پروژهی IDP اضافه کردیم. در ادامه میخواهیم قدم به قدم این پروژه را تغییر دهیم.
در صفحهی Consent در Quick Start UI، لیست scopes درخواستی برنامهی کلاینت ذکر شده و سپس کاربر انتخاب میکند که کدامیک از آنها، باید به برنامهی کلاینت ارائه شوند. این صفحه، برای سناریوی ما که تمام برنامههای کلاینت توسط ما توسعه یافتهاند، بیمعنا است و صرفا برای کلاینتهای ثالثی که قرار است از IDP ما استفاده کنند، معنا پیدا میکند. برای غیرفعال کردن آن کافی است به فایل استاتیک Config مراجعه کرده و خاصیت RequireConsent کلاینت مدنظر را به false تنظیم کرد.
تغییر نام پوشهی Quickstart و سپس اصلاح فضای نام پیشفرض کنترلرهای آن
در حال حاضر کدهای کنترلرهای Quick Start UI داخل پوشهی Quickstart برنامهی IDP قرار گرفتهاند. با توجه به اینکه قصد داریم این کدها را تغییر دهیم و همچنین این پوشه در اساس، همان پوشهی استاندارد Controllers است، ابتدا نام این پوشه را به Controllers تغییر داده و سپس در تمام کنترلرهای ذیل آن، فضای نام پیشفرض IdentityServer4.Quickstart.UI را نیز به فضای نام متناسبی با پوشه بندی پروژهی جاری تغییر میدهیم. برای مثال کنترلر Account واقع در پوشهی Account، اینبار دارای فضای نام DNT.IDP.Controllers.Account خواهد شد و به همین ترتیب برای مابقی کنترلها عمل میکنیم.
پس از این تغییرات، عبارات using موجود در Viewها را نیز باید تغییر دهید تا برنامه در زمان اجرا به مشکلی برنخورد. البته ASP.NET Core 2.1 در زمان کامپایل برنامه، تمام Viewهای آنرا نیز کامپایل میکند و اگر خطایی در آنها وجود داشته باشد، امکان بررسی و رفع آنها پیش از اجرای برنامه، میسر است.
و یا میتوان جهت سهولت کار، فایل DNT.IDP\Views\_ViewImports.cshtml را جهت معرفی این فضاهای نام جدید ویرایش کرد تا نیازی به تغییر Viewها نباشد:
تعامل با IdentityServer از طریق کدهای سفارشی
پس از تشکیل «ساختار بانک اطلاعاتی کاربران IdentityServer» و همچنین تهیه سرویسهای متناظری جهت کار با آن، اکنون نیاز است مطمئن شویم IdentityServer از این بانک اطلاعاتی برای دریافت اطلاعات کاربران خود استفاده میکند.
در حال حاضر، با استفاده از متد الحاقی AddTestUsers معرفی شدهی در فایل DNT.IDP\Startup.cs، اطلاعات کاربران درون حافظهای برنامه را از متد ()Config.GetUsers دریافت میکنیم.
بنابراین اولین قدم، بررسی ساختار متد AddTestUsers است. برای این منظور به مخزن کد IdentityServer4 مراجعه کرده و کدهای متد الحاقی AddTestUsers را بررسی میکنیم:
- ابتدا یک TestUserStore را به صورت Singleton ثبت کردهاست.
- سپس سرویس پروفایل کاربران را اضافه کردهاست. این سرویس با پیاده سازی اینترفیس IProfileService تهیه میشود. کار آن اتصال یک User Store سفارشی به سرویس کاربران و دریافت اطلاعات پروفایل آنها مانند Claims است.
- در آخر TestUserResourceOwnerPasswordValidator، کار اعتبارسنجی کلمهی عبور و نام کاربری را در صورت استفادهی از Flow ویژهای به نام ResourceOwner که استفادهی از آن توصیه نمیشود (ROBC Flow)، انجام میدهد.
برای جایگزین کردن AddTestUsers، کلاس جدید IdentityServerBuilderExtensions را در ریشهی پروژهی IDP با محتوای ذیل اضافه میکنیم:
در اینجا ابتدا IUsersService سفارشی برنامه معرفی شدهاست که User Store سفارشی برنامه است. البته چون UsersService ما با بانک اطلاعاتی کار میکند، نباید به صورت Singleton ثبت شود و باید در پایان هر درخواست به صورت خودکار Dispose گردد. به همین جهت طول عمر آن Scoped تعریف شدهاست. در کل ضرورتی به ذکر این سطر نیست؛ چون پیشتر کار ثبت IUsersService در کلاس Startup برنامه انجام شدهاست.
سپس یک ProfileService سفارشی را ثبت کردهایم. این سرویس، با پیاده سازی IProfileService به صورت زیر پیاده سازی میشود:
سرویس پروفایل، توسط سرویس کاربران برنامه که در ابتدای مطلب آنرا تهیه کردیم، امکان دسترسی به اطلاعات پروفایل کاربران را مانند Claims او، پیدا میکند.
در متدهای آن، ابتدا subjectId و یا همان Id منحصربفرد کاربر جاری سیستم، دریافت شده و سپس بر اساس آن میتوان از usersService، جهت دریافت اطلاعات مختلف کاربر، کوئری گرفت و نتیجه را در خواص context جاری، برای استفادههای بعدی، ذخیره کرد.
اکنون به کلاس src\IDP\DNT.IDP\Startup.cs مراجعه کرده و متد AddTestUsers را با AddCustomUserStore جایگزین میکنیم:
تا اینجا فقط این سرویسهای جدید را ثبت کردهایم، اما هنوز کار خاصی را انجام نمیدهند و باید از آنها در برنامه استفاده کرد.
اتصال IdentityServer به User Store سفارشی
در ادامه، سازندهی کنترلر DNT.IDP\Quickstart\Account\AccountController.cs را بررسی میکنیم:
- سرویس توکار IIdentityServerInteractionService، کار تعامل برنامه با IdentityServer4 را انجام میدهد.
- IClientStore پیاده سازی محل ذخیره سازی اطلاعات کلاینتها را ارائه میدهد که در حال حاضر توسط متد استاتیک Config در اختیار آن قرار میگیرد.
- IEventService رخدادهایی مانند لاگین موفقیت آمیز یک کاربر را گزارش میدهد.
- در آخر، TestUserStore تزریق شدهاست که میخواهیم آنرا با User Store سفارشی خودمان جایگزین کنیم. بنابراین در ابتدا TestUserStore را با UserStore سفارشی خودمان جایگزین میکنیم:
فعلا فیلد TestUserStore را نیز سطح کلاس جاری باقی نگه میداریم. از این جهت که قسمتهای لاگین خارجی سیستم (استفاده از گوگل، توئیتر و ...) هنوز از آن استفاده میکنند و آنرا در قسمتی دیگر تغییر خواهیم داد.
پس از معرفی فیلد usersService_، اکنون در قسمت زیر از آن استفاده میکنیم:
در اکشن متد لاگین، جهت بررسی صحت نام کاربری و کلمهی عبور و همچنین یافتن کاربر متناظر با آن:
تا همینجا برنامه را کامپایل کرده و اجرا کنید. پس از لاگین در آدرس https://localhost:5001/Gallery/IdentityInformation، هنوز اطلاعات User Claims کاربر وارد شدهی به سیستم نمایش داده میشوند که بیانگر صحت عملکرد CustomUserProfileService است.
افزودن امکان ثبت کاربران جدید به برنامهی IDP
پس از اتصال قسمت login برنامهی IDP به بانک اطلاعاتی، اکنون میخواهیم امکان ثبت کاربران را نیز به آن اضافه کنیم.
این قسمت شامل تغییرات ذیل است:
الف) اضافه شدن RegisterUserViewModel
این ViewModel که فیلدهای فرم ثبتنام را تشکیل میدهد، ابتدا با نام کاربری و کلمهی عبور شروع میشود:
سپس سایر خواصی که در اینجا اضافه میشوند:
در کنترلر UserRegistrationController، تبدیل به UserClaims شده و در جدول مخصوص آن ذخیره خواهند شد.
ب) افزودن UserRegistrationController
این کنترلر، RegisterUserViewModel را دریافت کرده و سپس بر اساس آن، شیء User ابتدای بحث را تشکیل میدهد. ابتدا نام کاربری و کلمهی عبور را در جدول کاربران ثبت میکند و سپس سایر خواص این ViewModel را در جدول UserClaims:
ج) افزودن RegisterUser.cshtml
این فایل، view متناظر با ViewModel فوق را ارائه میدهد که توسط آن، کاربری میتواند اطلاعات خود را ثبت کرده و وارد سیستم شود.
د) اصلاح فایل ViewImports.cshtml_ جهت تعریف فضای نام UserRegistration
در RegisterUser.cshtml از RegisterUserViewModel استفاده میشود. به همین جهت بهتر است فضای نام آنرا به ViewImports اضافه کرد.
ه) افزودن لینک ثبت نام به صفحهی لاگین در Login.cshtml
این لینک دقیقا در ذیل چکباکس Remember My Login اضافه شدهاست.
اکنون اگر برنامه را اجرا کنیم، ابتدا مشاهده میکنیم که صفحهی لاگین به همراه لینک ثبت نام ظاهر میشود:
و پس از کلیک بر روی آن، صفحهی ثبت کاربر جدید به صورت زیر نمایش داده خواهد شد:
برای آزمایش، کاربری را ثبت کنید. پس از ثبت اطلاعات، بلافاصله وارد سیستم خواهید شد. البته چون در اینجا subscriptionlevel به FreeUser تنظیم شدهاست، این کاربر یکسری از لینکهای برنامهی MVC Client را به علت نداشتن دسترسی، مشاهده نخواهد کرد.
کدهای کامل این قسمت را از اینجا میتوانید دریافت کنید.
برای اجرای برنامه:
- ابتدا به پوشهی 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 وارد کنید.
تکمیل ساختار پروژهی IDP
تا اینجا برای IDP، یک پروژهی خالی وب را ایجاد و به مرور، آنرا تکمیل کردیم. اما اکنون نیاز است پشتیبانی از بانک اطلاعاتی را نیز به آن اضافه کنیم. برای این منظور چهار پروژهی Class library کمکی را نیز به Solution آن اضافه میکنیم:
- DNT.IDP.DomainClasses
در این پروژه، کلاسهای متناظر با موجودیتهای جداول مرتبط با اطلاعات کاربران قرار میگیرند.
- DNT.IDP.DataLayer
این پروژه Context برنامه و Migrations آنرا تشکیل میدهد. همچنین به همراه تنظیمات و Seed اولیهی اطلاعات بانک اطلاعاتی نیز میباشد.
رشتهی اتصالی آن نیز در فایل DNT.IDP\appsettings.json ذخیره شدهاست.
- DNT.IDP.Common
الگوریتم هش کردن اطلاعات، در این پروژهی مشترک بین چند پروژهی دیگر قرار گرفتهاست. از آن جهت هش کردن کلمات عبور، در دو پروژهی DataLayer و همچنین Services استفاده میکنیم.
- DNT.IDP.Services
کلاس سرویس کاربران که با استفاده از DataLayer با بانک اطلاعاتی ارتباط برقرار میکند، در این پروژه قرار گرفتهاست.
ساختار بانک اطلاعاتی کاربران IdentityServer
در اینجا ساختار بانک اطلاعاتی کاربران IdentityServer، بر اساس جداول کاربران و Claims آنها تشکیل میشود:
namespace DNT.IDP.DomainClasses { public class User { [Key] [MaxLength(50)] public string SubjectId { get; set; } [MaxLength(100)] [Required] public string Username { get; set; } [MaxLength(100)] public string Password { get; set; } [Required] public bool IsActive { get; set; } public ICollection<UserClaim> UserClaims { get; set; } public ICollection<UserLogin> UserLogins { get; set; } } }
ساختار Claims او نیز به صورت زیر تعریف میشود که با تعریف یک Claim استاندارد، سازگاری دارد:
namespace DNT.IDP.DomainClasses { public class UserClaim { public int Id { get; set; } [MaxLength(50)] [Required] public string SubjectId { get; set; } public User User { get; set; } [Required] [MaxLength(250)] public string ClaimType { get; set; } [Required] [MaxLength(250)] public string ClaimValue { get; set; } } }
namespace DNT.IDP.DomainClasses { public class UserLogin { public int Id { get; set; } [MaxLength(50)] [Required] public string SubjectId { get; set; } public User User { get; set; } [Required] [MaxLength(250)] public string LoginProvider { get; set; } [Required] [MaxLength(250)] public string ProviderKey { get; set; } } }
در پروژهی DNT.IDP.DataLayer در پوشهی Configurations آن، کلاسهای UserConfiguration و UserClaimConfiguration را مشاهده میکنید که حاوی اطلاعات اولیهای برای تشکیل User 1 و User 2 به همراه Claims آنها هستند. این اطلاعات را دقیقا از فایل استاتیک Config که در قسمتهای قبل تکمیل کردیم، به این دو کلاس جدید IEntityTypeConfiguration منتقل کردهایم تا به این ترتیب متد GetUsers فایل استاتیک Config را با نمونهی دیتابیسی آن جایگزین کنیم.
سرویسی که از طریق Context برنامه با بانک اطلاعاتی ارتباط برقرار میکند، چنین ساختاری را دارد:
public interface IUsersService { Task<bool> AreUserCredentialsValidAsync(string username, string password); Task<User> GetUserByEmailAsync(string email); Task<User> GetUserByProviderAsync(string loginProvider, string providerKey); Task<User> GetUserBySubjectIdAsync(string subjectId); Task<User> GetUserByUsernameAsync(string username); Task<IEnumerable<UserClaim>> GetUserClaimsBySubjectIdAsync(string subjectId); Task<IEnumerable<UserLogin>> GetUserLoginsBySubjectIdAsync(string subjectId); Task<bool> IsUserActiveAsync(string subjectId); Task AddUserAsync(User user); Task AddUserLoginAsync(string subjectId, string loginProvider, string providerKey); Task AddUserClaimAsync(string subjectId, string claimType, string claimValue); }
تنظیمات نهایی این سرویسها و Context برنامه نیز در فایل DNT.IDP\Startup.cs جهت معرفی به سیستم تزریق وابستگیها، صورت گرفتهاند. همچنین در اینجا متد initializeDb را نیز مشاهده میکنید که با فراخوانی متد context.Database.Migrate، تمام کلاسهای Migrations پروژهی DataLayer را به صورت خودکار به بانک اطلاعاتی اعمال میکند.
غیرفعال کردن صفحهی Consent در Quick Start UI
در «قسمت چهارم - نصب و راه اندازی IdentityServer» فایلهای Quick Start UI را به پروژهی IDP اضافه کردیم. در ادامه میخواهیم قدم به قدم این پروژه را تغییر دهیم.
در صفحهی Consent در Quick Start UI، لیست scopes درخواستی برنامهی کلاینت ذکر شده و سپس کاربر انتخاب میکند که کدامیک از آنها، باید به برنامهی کلاینت ارائه شوند. این صفحه، برای سناریوی ما که تمام برنامههای کلاینت توسط ما توسعه یافتهاند، بیمعنا است و صرفا برای کلاینتهای ثالثی که قرار است از IDP ما استفاده کنند، معنا پیدا میکند. برای غیرفعال کردن آن کافی است به فایل استاتیک Config مراجعه کرده و خاصیت RequireConsent کلاینت مدنظر را به false تنظیم کرد.
تغییر نام پوشهی Quickstart و سپس اصلاح فضای نام پیشفرض کنترلرهای آن
در حال حاضر کدهای کنترلرهای Quick Start UI داخل پوشهی Quickstart برنامهی IDP قرار گرفتهاند. با توجه به اینکه قصد داریم این کدها را تغییر دهیم و همچنین این پوشه در اساس، همان پوشهی استاندارد Controllers است، ابتدا نام این پوشه را به Controllers تغییر داده و سپس در تمام کنترلرهای ذیل آن، فضای نام پیشفرض IdentityServer4.Quickstart.UI را نیز به فضای نام متناسبی با پوشه بندی پروژهی جاری تغییر میدهیم. برای مثال کنترلر Account واقع در پوشهی Account، اینبار دارای فضای نام DNT.IDP.Controllers.Account خواهد شد و به همین ترتیب برای مابقی کنترلها عمل میکنیم.
پس از این تغییرات، عبارات using موجود در Viewها را نیز باید تغییر دهید تا برنامه در زمان اجرا به مشکلی برنخورد. البته ASP.NET Core 2.1 در زمان کامپایل برنامه، تمام Viewهای آنرا نیز کامپایل میکند و اگر خطایی در آنها وجود داشته باشد، امکان بررسی و رفع آنها پیش از اجرای برنامه، میسر است.
و یا میتوان جهت سهولت کار، فایل DNT.IDP\Views\_ViewImports.cshtml را جهت معرفی این فضاهای نام جدید ویرایش کرد تا نیازی به تغییر Viewها نباشد:
@using DNT.IDP.Controllers.Account; @using DNT.IDP.Controllers.Consent; @using DNT.IDP.Controllers.Grants; @using DNT.IDP.Controllers.Home; @using DNT.IDP.Controllers.Diagnostics; @addTagHelper *, Microsoft.AspNetCore.Mvc.TagHelpers
تعامل با IdentityServer از طریق کدهای سفارشی
پس از تشکیل «ساختار بانک اطلاعاتی کاربران IdentityServer» و همچنین تهیه سرویسهای متناظری جهت کار با آن، اکنون نیاز است مطمئن شویم IdentityServer از این بانک اطلاعاتی برای دریافت اطلاعات کاربران خود استفاده میکند.
در حال حاضر، با استفاده از متد الحاقی AddTestUsers معرفی شدهی در فایل DNT.IDP\Startup.cs، اطلاعات کاربران درون حافظهای برنامه را از متد ()Config.GetUsers دریافت میکنیم.
بنابراین اولین قدم، بررسی ساختار متد AddTestUsers است. برای این منظور به مخزن کد IdentityServer4 مراجعه کرده و کدهای متد الحاقی AddTestUsers را بررسی میکنیم:
public static class IdentityServerBuilderExtensions { public static IIdentityServerBuilder AddTestUsers(this IIdentityServerBuilder builder, List<TestUser> users) { builder.Services.AddSingleton(new TestUserStore(users)); builder.AddProfileService<TestUserProfileService>(); builder.AddResourceOwnerValidator<TestUserResourceOwnerPasswordValidator>(); return builder; } }
- سپس سرویس پروفایل کاربران را اضافه کردهاست. این سرویس با پیاده سازی اینترفیس IProfileService تهیه میشود. کار آن اتصال یک User Store سفارشی به سرویس کاربران و دریافت اطلاعات پروفایل آنها مانند Claims است.
- در آخر TestUserResourceOwnerPasswordValidator، کار اعتبارسنجی کلمهی عبور و نام کاربری را در صورت استفادهی از Flow ویژهای به نام ResourceOwner که استفادهی از آن توصیه نمیشود (ROBC Flow)، انجام میدهد.
برای جایگزین کردن AddTestUsers، کلاس جدید IdentityServerBuilderExtensions را در ریشهی پروژهی IDP با محتوای ذیل اضافه میکنیم:
using DNT.IDP.Services; using Microsoft.Extensions.DependencyInjection; namespace DNT.IDP { public static class IdentityServerBuilderExtensions { public static IIdentityServerBuilder AddCustomUserStore(this IIdentityServerBuilder builder) { // builder.Services.AddScoped<IUsersService, UsersService>(); builder.AddProfileService<CustomUserProfileService>(); return builder; } } }
سپس یک ProfileService سفارشی را ثبت کردهایم. این سرویس، با پیاده سازی IProfileService به صورت زیر پیاده سازی میشود:
namespace DNT.IDP.Services { public class CustomUserProfileService : IProfileService { private readonly IUsersService _usersService; public CustomUserProfileService(IUsersService usersService) { _usersService = usersService; } public async Task GetProfileDataAsync(ProfileDataRequestContext context) { var subjectId = context.Subject.GetSubjectId(); var claimsForUser = await _usersService.GetUserClaimsBySubjectIdAsync(subjectId); context.IssuedClaims = claimsForUser.Select(c => new Claim(c.ClaimType, c.ClaimValue)).ToList(); } public async Task IsActiveAsync(IsActiveContext context) { var subjectId = context.Subject.GetSubjectId(); context.IsActive = await _usersService.IsUserActiveAsync(subjectId); } } }
در متدهای آن، ابتدا subjectId و یا همان Id منحصربفرد کاربر جاری سیستم، دریافت شده و سپس بر اساس آن میتوان از usersService، جهت دریافت اطلاعات مختلف کاربر، کوئری گرفت و نتیجه را در خواص context جاری، برای استفادههای بعدی، ذخیره کرد.
اکنون به کلاس src\IDP\DNT.IDP\Startup.cs مراجعه کرده و متد AddTestUsers را با AddCustomUserStore جایگزین میکنیم:
namespace DNT.IDP { public class Startup { public void ConfigureServices(IServiceCollection services) { services.AddIdentityServer() .AddDeveloperSigningCredential() .AddCustomUserStore() .AddInMemoryIdentityResources(Config.GetIdentityResources()) .AddInMemoryApiResources(Config.GetApiResources()) .AddInMemoryClients(Config.GetClients());
اتصال IdentityServer به User Store سفارشی
در ادامه، سازندهی کنترلر DNT.IDP\Quickstart\Account\AccountController.cs را بررسی میکنیم:
public AccountController( IIdentityServerInteractionService interaction, IClientStore clientStore, IAuthenticationSchemeProvider schemeProvider, IEventService events, TestUserStore users = null) { _users = users ?? new TestUserStore(TestUsers.Users); _interaction = interaction; _clientStore = clientStore; _schemeProvider = schemeProvider; _events = events; }
- IClientStore پیاده سازی محل ذخیره سازی اطلاعات کلاینتها را ارائه میدهد که در حال حاضر توسط متد استاتیک Config در اختیار آن قرار میگیرد.
- IEventService رخدادهایی مانند لاگین موفقیت آمیز یک کاربر را گزارش میدهد.
- در آخر، TestUserStore تزریق شدهاست که میخواهیم آنرا با User Store سفارشی خودمان جایگزین کنیم. بنابراین در ابتدا TestUserStore را با UserStore سفارشی خودمان جایگزین میکنیم:
private readonly TestUserStore _users; private readonly IUsersService _usersService; public AccountController( // ... IUsersService usersService) { _usersService = usersService; // ... }
پس از معرفی فیلد usersService_، اکنون در قسمت زیر از آن استفاده میکنیم:
در اکشن متد لاگین، جهت بررسی صحت نام کاربری و کلمهی عبور و همچنین یافتن کاربر متناظر با آن:
public async Task<IActionResult> Login(LoginInputModel model, string button) { //... if (ModelState.IsValid) { if (await _usersService.AreUserCredentialsValidAsync(model.Username, model.Password)) { var user = await _usersService.GetUserByUsernameAsync(model.Username);
افزودن امکان ثبت کاربران جدید به برنامهی IDP
پس از اتصال قسمت login برنامهی IDP به بانک اطلاعاتی، اکنون میخواهیم امکان ثبت کاربران را نیز به آن اضافه کنیم.
این قسمت شامل تغییرات ذیل است:
الف) اضافه شدن RegisterUserViewModel
این ViewModel که فیلدهای فرم ثبتنام را تشکیل میدهد، ابتدا با نام کاربری و کلمهی عبور شروع میشود:
public class RegisterUserViewModel { // credentials [MaxLength(100)] public string Username { get; set; } [MaxLength(100)] public string Password { get; set; }
public class RegisterUserViewModel { // ... // claims [Required] [MaxLength(100)] public string Firstname { get; set; } [Required] [MaxLength(100)] public string Lastname { get; set; } [Required] [MaxLength(150)] public string Email { get; set; } [Required] [MaxLength(200)] public string Address { get; set; } [Required] [MaxLength(2)] public string Country { get; set; }
ب) افزودن UserRegistrationController
این کنترلر، RegisterUserViewModel را دریافت کرده و سپس بر اساس آن، شیء User ابتدای بحث را تشکیل میدهد. ابتدا نام کاربری و کلمهی عبور را در جدول کاربران ثبت میکند و سپس سایر خواص این ViewModel را در جدول UserClaims:
varuserToCreate=newUser { Password=model.Password.GetSha256Hash(), Username=model.Username, IsActive=true }; userToCreate.UserClaims.Add(newUserClaim("country",model.Country)); userToCreate.UserClaims.Add(newUserClaim("address",model.Address)); userToCreate.UserClaims.Add(newUserClaim("given_name",model.Firstname)); userToCreate.UserClaims.Add(newUserClaim("family_name",model.Lastname)); userToCreate.UserClaims.Add(newUserClaim("email",model.Email)); userToCreate.UserClaims.Add(newUserClaim("subscriptionlevel","FreeUser"));
این فایل، view متناظر با ViewModel فوق را ارائه میدهد که توسط آن، کاربری میتواند اطلاعات خود را ثبت کرده و وارد سیستم شود.
د) اصلاح فایل ViewImports.cshtml_ جهت تعریف فضای نام UserRegistration
در RegisterUser.cshtml از RegisterUserViewModel استفاده میشود. به همین جهت بهتر است فضای نام آنرا به ViewImports اضافه کرد.
ه) افزودن لینک ثبت نام به صفحهی لاگین در Login.cshtml
این لینک دقیقا در ذیل چکباکس Remember My Login اضافه شدهاست.
اکنون اگر برنامه را اجرا کنیم، ابتدا مشاهده میکنیم که صفحهی لاگین به همراه لینک ثبت نام ظاهر میشود:
و پس از کلیک بر روی آن، صفحهی ثبت کاربر جدید به صورت زیر نمایش داده خواهد شد:
برای آزمایش، کاربری را ثبت کنید. پس از ثبت اطلاعات، بلافاصله وارد سیستم خواهید شد. البته چون در اینجا subscriptionlevel به FreeUser تنظیم شدهاست، این کاربر یکسری از لینکهای برنامهی MVC Client را به علت نداشتن دسترسی، مشاهده نخواهد کرد.
کدهای کامل این قسمت را از اینجا میتوانید دریافت کنید.
برای اجرای برنامه:
- ابتدا به پوشهی 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 وارد کنید.
با بررسی فیلد مورد نظر در خروجی html تولید شده، میتوانید صحت عملکرد برنامه را بررسی کنید.
مثال زیر در این زمینه میباشد که مدل آن در یک class library دیگر است (البته در اینجا به جای استفاده از نام اکشن و نام کنترلر از نام روت استفاده شده است)
حالت اول: مدل برنامه در حالتی که فقط فیلد مورد نظر باید بررسی شود (ایجاد کاربر):
namespace Project.Models { public class EmployeeCreateModel { [Required] [Display(Name = "آدرس ایمیل")] [EmailAddress(ErrorMessage = "لطفاً {0} معتبر وارد کنید.")] [Remote("UserExistByEmailValidation", HttpMethod = "POST", ErrorMessage = "ایمیل وارد شده هم اکنون توسط یکی از کاربران مورد استفاده است.")] public string Email { get; set; } ... } }
- حالت دوم: مدل برنامه در حالتی که به جز فیلد مورد نظر باید یک فیلد دیگر نیز مورد بررسی قرار گیرد (ویرایش کاربر):
namespace Project.Models { public class EmployeeEditModel { public int Id { get; set; } [Required] [Display(Name = "آدرس ایمیل")] [EmailAddress(ErrorMessage = "لطفاً {0} معتبر وارد کنید.")] [Remote("EmailExistForOtherUserValidation", AdditionalFields = "Id", HttpMethod = "POST", ErrorMessage = "ایمیل وارد شده هم اکنون توسط یکی از کاربران مورد استفاده است.")] public string Email { get; set; } .... } }
namespace Project.Web.Controllers { [RoutePrefix("UserValidation")] [Route("{Action}")] [OutputCache(Location = OutputCacheLocation.None, NoStore = true)] public partial class UserValidationController : Controller { readonly IUserService<User> _userService; readonly IUnitOfWork _uow; public UserValidationController(IUnitOfWork uow, IUserService<User> userService) { _userService = userService; _uow = uow; } [HttpPost] [Route("~/CheckEmail", Name = "UserExistByEmailValidation")] public virtual JsonResult CheckEmail(string email) { return Json(!_userService.UserExistsByEmail(email)); } [HttpPost] [Route("~/CheckEmailForOtherUser", Name = "EmailExistForOtherUserValidation")] public virtual JsonResult CheckEmailForOtherUser(string email, int id) { return Json(!_userService.EmailExistForOtherUser(email, id)); } } }
- حالت اول:
- حالت دوم (فیلد Id هم ارسال میگردد):
در صورتی که خروجی درست بود، باید scriptها را مورد بررسی قرار دهید که یکی از متدوالترین آنها
@section Scripts { @Scripts.Render("~/bundles/jqueryval") }
بالاخره گوگل کار تهیه API مخصوص ابزار Analytics خود را به پایان رساند و اکنون برنامه نویسها میتوانند همانند سایر سرویسهای گوگل از این ابزار گزارشگیری نمایند.
خلاصه کاربردی این API ، دو صفحه تعاریف پروتکل (+) و ریز مواردی (+) است که میتوان گزارشگیری نمود.
هنوز کتابخانه google-gdata جهت استفاده از این API به روز رسانی نشده است؛ بنابراین در این مقاله سعی خواهیم کرد نحوه کار با این API را از صفر بازنویسی کنیم.
مطابق صفحه تعاریف پروتکل، سه روش اعتبارسنجی جهت دریافت اطلاعات API معرفی شده است که در اینجا از روش ClientLogin که مرسومتر است استفاده خواهیم کرد.
مطابق مثالی که در آن صفحه قرار دارد، اطلاعاتی شبیه به اطلاعات زیر را باید ارسال و دریافت کنیم:
POST /accounts/ClientLogin HTTP/1.1
User-Agent: curl/7.15.1 (i486-pc-linux-gnu) libcurl/7.15.1
OpenSSL/0.9.8a zlib/1.2.3 libidn/0.5.18
Host: www.google.com
Accept: */*
Content-Length: 103
Content-Type: application/x-www-form-urlencoded
accountType=GOOGLE&Email=userName@google.com&Passwd=myPasswrd&source=curl-tester-1.0&service=analytics
HTTP/1.1 200 OK
Content-Type: text/plain
Cache-control: no-cache
Pragma: no-cache
Date: Mon, 02 Jun 2008 22:08:51 GMT
Content-Length: 497
SID=DQ...
LSID=DQAA...
Auth=DQAAAG8...
string getSecurityToken()
{
if (string.IsNullOrEmpty(Email))
throw new NullReferenceException("Email is required!");
if (string.IsNullOrEmpty(Password))
throw new NullReferenceException("Password is required!");
WebRequest request = WebRequest.Create("https://www.google.com/accounts/ClientLogin");
request.Method = "POST";
string postData = "accountType=GOOGLE&Email=" + Email + "&Passwd=" + Password + "&service=analytics&source=vahid-testapp-1.0";
byte[] byteArray = Encoding.ASCII.GetBytes(postData);
request.ContentType = "application/x-www-form-urlencoded";
request.ContentLength = byteArray.Length;
using (Stream dataSt = request.GetRequestStream())
{
dataSt.Write(byteArray, 0, byteArray.Length);
}
string auth = string.Empty;
using (WebResponse response = request.GetResponse())
{
using (Stream dataStream = response.GetResponseStream())
{
using (StreamReader reader = new StreamReader(dataStream))
{
string responseFromServer = reader.ReadToEnd().Trim();
string[] tokens = responseFromServer.Split('\n');
foreach (string token in tokens)
{
if (token.StartsWith("SID="))
continue;
if (token.StartsWith("LSID="))
continue;
if (token.StartsWith("Auth="))
{
auth = token.Substring(5);
}
else
{
throw new AuthenticationException("Error authenticating Google user " + Email);
}
}
}
}
}
return auth;
}
همانطور که ملاحظه میکنید به آدرس https://www.google.com/accounts/ClientLogin ، اطلاعات postData با متد POST ارسال شده (دقیقا مطابق توضیحات گوگل) و سپس از پاسخ دریافتی، مقدار نشانه Auth را جدا نموده و در ادامه عملیات استفاده خواهیم کرد. وجود این نشانه در پاسخ دریافتی به معنای موفقیت آمیز بودن اعتبار سنجی ما است و مقدار آن در طول کل عملیات باید نگهداری شده و مورد استفاده مجدد قرار گیرد.
سپس مطابق ادامه توضیحات API گوگل باید لیست پروفایلهایی را که ایجاد کردهایم پیدا نمائیم:
string getAvailableProfiles(string authToken)
{
return fetchPage("https://www.google.com/analytics/feeds/accounts/default", authToken);
}
متد fetchPage را از پیوست این مقاله میتوانید دریافت نمائید. خروجی یک فایل xml است که با انواع و اقسام روشهای موجود قابل آنالیز است، از کتابخانههای XML دات نت گرفته تا Linq to xml و یا روش serialization که من روش آخر را ترجیح میدهم.
مرحله بعد، ساخت URL زیر و دریافت مجدد اطلاعات مربوطه است:
string url = string.Format("https://www.google.com/analytics/feeds/data?ids={0}&metrics=ga:pageviews&start-date={1}&end-date={2}", id, from, to);
return fetchPage(url, auth);
فایلهای کلاسهای مورد استفاده را از اینجا دریافت نمائید.
مثالی در مورد نحوه استفاده از آن:
CGoogleAnalytics cga = new CGoogleAnalytics
{
Email = "username@gmail.com",
Password = "password",
From = DateTime.Now.Subtract(TimeSpan.FromDays(1)),
To = DateTime.Now.Subtract(TimeSpan.FromDays(1))
};
List<CGoogleAnalytics.SitePagePreviews> pagePreviews =
cga.GetTotalNumberOfPageViews();
foreach (var list in pagePreviews)
{
//string site = list.Site;
//int pw = list.PagePreviews;
}