ASP.NET Core Identity 1.1 چگونه پیامهای خطای خود را تامین میکند؟
نگارش 1.1 این فریم ورک به همراه یک فایل Resources.resx است که تمام پیامهای خطاهای ارائه شدهی توسط متدهای مختلف آنرا به همراه دارد. این فایل توسط کلاس IdentityErrorDescriber به نحو ذیل استفاده میشود:
public class IdentityErrorDescriber { public virtual IdentityError DefaultError() { return new IdentityError { Code = nameof(DefaultError), Description = Resources.DefaultError }; }
سپس کلاس IdentityErrorDescriber به سیستم تزریق وابستگیهای آن اضافه شده و هرجائیکه نیاز به نمایش پیامی را داشته، از آن استفاده میکند.
بنابراین همانطور که ملاحظه میکنید کلاس Resources آن ثابت است و قابل تغییر نیست. به همین جهت اگر معادل فارسی این فایل را تهیه کنیم، توسط این فریم ورک به صورت خودکار استفاده نخواهد شد.
فارسی سازی IdentityErrorDescriber
بهترین راه فارسی سازی کلاس IdentityErrorDescriber، ارث بری از آن و بازنویسی متدهای virtual آن است که اینکار در کلاس CustomIdentityErrorDescriber انجام شدهاست:
public class CustomIdentityErrorDescriber : IdentityErrorDescriber { public override IdentityError DefaultError() { return new IdentityError { Code = nameof(DefaultError), Description = "خطایی رخ دادهاست." }; }
services.AddScoped<IdentityErrorDescriber, CustomIdentityErrorDescriber>(); services.AddIdentity<User, Role>(identityOptions => { }).AddUserStore<ApplicationUserStore>() // the rest of the setting … .AddErrorDescriber<CustomIdentityErrorDescriber>() // the rest of the setting …
به این ترتیب این فریم ورک هرزمانیکه نیاز به وهلهای از نوع IdentityErrorDescriber را داشته باشد، از وهلهی فارسی سازی شدهی ما استفاده میکند.
مشکل! هنوز پس از جایگزینی سرویس IdentityServicesRegistry اصلی، تعدادی از خطاها فارسی نیستند!
اگر به کلاس PasswordValidator آن مراجعه کنید، در سازندهی کلاس یک چنین تعریفی را میتوان مشاهده کرد:
public class PasswordValidator<TUser> : IPasswordValidator<TUser> where TUser : class { public PasswordValidator(IdentityErrorDescriber errors = null) { Describer = errors ?? new IdentityErrorDescriber(); }
public class CustomPasswordValidator : PasswordValidator<User> { private readonly IUsedPasswordsService _usedPasswordsService; private readonly ISet<string> _passwordsBanList; public CustomPasswordValidator( IdentityErrorDescriber errors,// How to use CustomIdentityErrorDescriber IOptionsSnapshot<SiteSettings> configurationRoot, IUsedPasswordsService usedPasswordsService) : base(errors) public class CustomUserValidator : UserValidator<User> { private readonly ISet<string> _emailsBanList; public CustomUserValidator( IdentityErrorDescriber errors,// How to use CustomIdentityErrorDescriber IOptionsSnapshot<SiteSettings> configurationRoot ) : base(errors)
یک نکته: اگر کلاسهای زیر را سفارشی سازی کردید، تمامشان از حالت ()errors ?? new IdentityErrorDescriber در سازندهی کلاس خود استفاده میکنند. بنابراین ذکر مجدد و بازنویسی سازندهی آنها را فراموش نکنید (در حد ذکر مجدد سازندهی کلاس پایه کفایت میکند و مابقی آن توسط سیستم تزریق وابستگیها مدیریت خواهد شد):
- PasswordValidator
- RoleManager
- RoleStore
- UserStore
- UserValidator
- RoleValidator
کدهای کامل این سری را در مخزن کد DNT Identity میتوانید ملاحظه کنید.
تبدیل یک View به رشته و بازگشت آن به همراه نتایج JSON حاصل از یک عملیات Ajax ایی در ASP.NET MVC
ممکن است بخواهیم در پاسخ یک تقاضای Ajax ایی، اگر عملیات در سمت سرور با موفقیت انجام شد، خروجی یک Controller action را به کاربر نهایی نشان دهیم. در
چنین سناریویی لازم است که بتوانیم خروجی یک action را
بصورت رشته برگردانیم. در این مقاله به این مسئله خواهیم پرداخت .
فرض کنید در یک سیستم وبلاگ ساده قصد داریم امکان کامنت گذاشتن بصورت Ajax را پیاده سازی کنیم. یک ایده عملی و کارآ
این است: بعد از اینکه کاربر متن کامنت را وارد کرد و دکمهی ارسال کامنت را زد،
تقاضا به سمت سرور ارسال شود و اگر سرور پیغام موفقیت را صادر کرد، متن نوشته شده
توسط کاربر را به کمک کدهای JavaScript و در همان سمت کلاینت بصورت یک
کادر کامنت جدید به محتوای صفحه اضافه کنیم. بنده در اینجا برای اینکه بتوانم اصل
موضوع مورد بحث را توضیح دهم، از یک سناریوی جایگزین استفاده میکنم؛ کاربر موقعیکه دکمه ارسال را زد، تقاضا به سرور ارسال میشود. سرور بعد از انجام عملیات، تحت یک
شی JSON هم نتیجهی انجام عملیات و هم
محتوای HTML نمایش کامنت جدید در صفحه را به سمت کلاینت
ارسال خواهد کرد و کلاینت در صورت موفقیت آمیز بودن عملیات، آن محتوا را به صفحه
اضافه میکند.
با توجه به توضیحات داده شده، ابتدا یک شیء نیاز داریم تا بتوانیم توسط آن نتیجهی عملیات Ajax ایی را بصورت JSON به سمت کلاینت ارسال کنیم:
public class MyJsonResult { public bool success { set; get; } public bool HasWarning { set; get; } public string WarningMessage { set; get; } public int errorcode { set; get; }
public string message {set; get; } public object data { set; get; } }
سپس به متدی نیاز داریم که کار تبدیل نتیجهی action را به رشته، انجام دهد:
public static string RenderViewToString(ControllerContext context, string viewPath, object model = null, bool partial = false) { ViewEngineResult viewEngineResult = null; if (partial) viewEngineResult = ViewEngines.Engines.FindPartialView(context, viewPath); else viewEngineResult = ViewEngines.Engines.FindView(context, viewPath, null); if (viewEngineResult == null) throw new FileNotFoundException("View cannot be found."); var view = viewEngineResult.View; context.Controller.ViewData.Model = model; string result = null; using(var sw = new StringWriter()) { var ctx = new ViewContext(context, view, context.Controller.ViewData, context.Controller.TempData, sw); view.Render(ctx, sw); result = sw.ToString(); } return result; }
فرض کنیم در سمت Controller هم از کدی شبیه به این استفاده میکنیم:
public JsonResult AddComment(CommentViewModel model) { MyJsonResult result = new MyJsonResult() { success = false; }; if (!ModelState.IsValid) { result.success = false; result.message = "لطفاً اطلاعات فرم را کامل وارد کنید"; return Json(result); } try { Comment theComment = model.toCommentModel(); //EF service factory Factory.CommentService.Create(theComment); Factory.SaveChanges(); result.data = Tools.RenderViewToString(this.ControllerContext, "/views/posts/_AComment", model, true); result.success = true; } catch (Exception ex) { result.success = false; result.message = "اشکال زمان اجرا"; } return Json(result); }
و در سمت کلاینت برای ارسال Form به صورت Ajax ایی خواهیم داشت:
@using (Ajax.BeginForm("AddComment", "posts", new AjaxOptions() { HttpMethod = "Post", OnSuccess = "AddCommentSuccess", LoadingElementId = "AddCommentLoading" }, new { id = "frmAddComment", @class = "form-horizontal" })) { @Html.HiddenFor(m => m.PostId) <label for="fname">@Texts.ContactName</label> <input type="text" id="fname" name="FullName" class="form-control" placeholder="@Texts.ContactName "> <label for="email">@Texts.Email</label> <input type="email" id="InputEmail" name="email" class="form-control" placeholder="@Texts.Email"> <br><textarea name="C_Content" cols="60" rows="10" class="form-control"></textarea><br> <input type="submit" value="@Texts.SubmitComments" name="" class="btn btn-primary"> <div class="loading-mask" style="display:none">@Texts.LoadingMessage</div> }
باید توجه شود Texts در اینجا یک Resource هست که به منظور نگهداری کلمات استفاده شده در سایت، برای زبانهای مختلف استفاده میشود (رجوع شود به مفهوم بومی سازی در Asp.net) .
و در قسمت script ها داریم:
<script type="text/javascript"> function AddCommentSuccess(jsData) { if (jsData && jsData.message) alert(jsData.message); if (jsData && jsData.success) { document.getElementById("frmAddComment").reset(); //افزودن کامنت جدید ساخته شده توسط کاربر به لیست کامنتهای صفحه $("#divAllComments").html(jsData.data + $("#divAllComments").html()); } } </script>
روش دیگری که این روزها در اکثر فریمهای دات نتی مرسوم شده است، استفاده از Data Annotations جهت انتساب یک سری متادیتا به خاصیتهای تعریف شده کلاسها است. برای مثال ASP.NET MVC از این قابلیت زیاد استفاده میکند (در تولید پویای کد، یا اعتبار سنجیهای سمت سرور و کاربر).
به همین جهت برای سازگاری بیشتر PdfReport با مدلهای اینگونه فریم ورکها، اکثر ویژگیها و Data Annotations متداول را نیز میتوان در PdfReport بکار برد. همچنین تعدادی ویژگی سفارشی نیز تعریف شده است، که در ادامه به بررسی آنها خواهیم پرداخت.
آشنایی با مدلهای بکار رفته در مثال جاری:
using System.ComponentModel; namespace PdfReportSamples.Models { public enum JobTitle { [Description("Grunt")] Grunt, [Description("Programmer")] Programmer, [Description("Analyst Programmer")] AnalystProgrammer, [Description("Project Manager")] ProjectManager, [Description("Chief Information Officer")] ChiefInformationOfficer, } }
using System; using System.Collections.Generic; using System.ComponentModel; using System.ComponentModel.DataAnnotations; using PdfReportSamples.Models; using PdfRpt.Aggregates.Numbers; using PdfRpt.ColumnsItemsTemplates; using PdfRpt.Core.Contracts; using PdfRpt.Core.Helper; using PdfRpt.DataAnnotations; namespace PdfReportSamples.DataAnnotations { public class Person { [IsVisible(false)] public int Id { get; set; } [DisplayName("User name")] //Note: If you don't specify the ColumnItemsTemplate, a new TextBlockField() will be used automatically. [ColumnItemsTemplate(typeof(TextBlockField))] public string Name { get; set; } [DisplayName("Job title")] public JobTitle JobTitle { set; get; } [DisplayName("Date of birth")] [DisplayFormat(DataFormatString = "{0:MM/dd/yyyy}")] public DateTime DateOfBirth { get; set; } [DisplayName("Date of death")] [DisplayFormat(NullDisplayText = "-", DataFormatString = "{0:MM/dd/yyyy}")] public DateTime? DateOfDeath { get; set; } [DisplayFormat(DataFormatString = "{0:n0}")] [CustomAggregateFunction(typeof(Sum))] public int Salary { get; set; } [IsCalculatedField(true)] [DisplayName("Calculated Field")] [DisplayFormat(DataFormatString = "{0:n0}")] [AggregateFunction(AggregateFunction.Sum)] public string CalculatedField { get; set; } [CalculatedFieldFormula("CalculatedField")] public static Func<IList<CellData>, object> CalculatedFieldFormula = list => { if (list == null) return string.Empty; var salary = (int)list.GetValueOf<Person>(x => x.Salary); return salary * 0.8; };//Note: It's a static field, not a property. } }
- اگر قصد ندارید خاصیتی در این بین، در گزارشات ظاهر شود، از ویژگی IsVisible با مقدار false استفاده کنید.
- از ویژگی DisplayName جهت تعیین برچسبهای سرستونها استفاده خواهد شد.
- ذکر ویژگی ColumnItemsTemplate اختیاری است و اگر عنوان نشود به صورت خودکار از TextBlockField استفاده خواهد شد. اما اگر نیاز به استفاده از قالبهای ستونهای سفارشی و یا حتی قالبهای پیش فرض دیگری که متنی نیستند، وجود دارد، میتوانید از ویژگی ColumnItemsTemplate به همراه نوع کلاس مورد نظر استفاده نمائید. کلاسهای پیش فرض قالبهای ستونها در PdfReport در پوشه Lib\ColumnsItemsTemplates سورس آن قرار دارند.
- برای تعیین نحوه فرمت اطلاعات در اینجا میتوان از ویژگی DisplayFormat استفاده کرد. این ویژگی در اسمبلی System.ComponentModel.DataAnnotations.dll دات نت تعریف شده است؛ که در اینجا نمونهای از استفاده از آنرا برای تعیین نحوه نمایش تاریخ، ملاحظه میکنید. توسط این ویژگی حتی میتوان مشخص ساخت (توسط پارامتر NullDisplayText) که اگر اطلاعاتی null بود، بجای آن چه عبارتی نمایش داده شود.
- اگر علاقمند به اعمال تابعی تجمعی به ستونی خاص هستید، از ویژگی CustomAggregateFunction استفاده کنید. پارامتر آن نوع کلاس تابع مورد نظر است. یک سری تابع تجمعی پیش فرض در فضای نام PdfRpt.Aggregates.Numbers قرار دارند. البته امکان تهیه انواع سفارشی آنها نیز پیش بینی شده است که در قسمتهای بعد به آن خواهیم پرداخت.
- امکان تعریف خواص محاسباتی نیز پیش بینی شده است. برای این منظور دو کار را باید انجام داد:
الف) ویژگی IsCalculatedField را با مقدار true بر روی خاصیت مورد نظر اعمال کنید.
ب) هم نام خاصیت محاسباتی افزوده شده به کلاس جاری، ویژگی CalculatedFieldFormula را بر روی یک فیلد استاتیک عمومی در آن کلاس به نحوی که ملاحظه میکنید (مطابق امضای فیلد CalculatedFieldFormula فوق)، تعریف نمائید. (علت این است که نمیتوان توسط ویژگیها از delegates استفاده کرد و این محدودیت ذاتی وجود دارد)
در ادامه کدهای منبع داده فرضی مثال جاری ذکر شده است:
using System; using System.Collections.Generic; using PdfReportSamples.Models; namespace PdfReportSamples.DataAnnotations { public static class PersonnelDataSource { public static IList<Person> CreatePersonnelList() { return new List<Person> { new Person { Id = 1, Name = "Edward", DateOfBirth = new DateTime(1900, 1, 1), DateOfDeath = new DateTime(1990, 10, 15), JobTitle = JobTitle.ChiefInformationOfficer, Salary = 5000 }, new Person { Id = 2, Name = "Margaret", DateOfBirth = new DateTime(1950, 2, 9), DateOfDeath = null, JobTitle = JobTitle.AnalystProgrammer, Salary = 4000 }, new Person { Id = 3, Name = "Grant", DateOfBirth = new DateTime(1975, 6, 13), DateOfDeath = null, JobTitle = JobTitle.Programmer, Salary = 3500 } }; } } }
using System; using PdfRpt.Core.Contracts; using PdfRpt.FluentInterface; namespace PdfReportSamples.DataAnnotations { public class DataAnnotationsPdfReport { public IPdfReportData CreatePdfReport() { return new PdfReport().DocumentPreferences(doc => { doc.RunDirection(PdfRunDirection.LeftToRight); doc.Orientation(PageOrientation.Portrait); doc.PageSize(PdfPageSize.A4); doc.DocumentMetadata(new DocumentMetadata { Author = "Vahid", Application = "PdfRpt", Keywords = "Test", Subject = "Test Rpt", Title = "Test" }); }) .DefaultFonts(fonts => { fonts.Path(Environment.GetEnvironmentVariable("SystemRoot") + "\\fonts\\tahoma.ttf", Environment.GetEnvironmentVariable("SystemRoot") + "\\fonts\\verdana.ttf"); }) .PagesFooter(footer => { footer.DefaultFooter(printDate: DateTime.Now.ToString("MM/dd/yyyy")); }) .PagesHeader(header => { header.DefaultHeader(defaultHeader => { defaultHeader.ImagePath(AppPath.ApplicationPath + "\\Images\\01.png"); defaultHeader.Message("new rpt."); defaultHeader.RunDirection(PdfRunDirection.LeftToRight); }); }) .MainTableTemplate(template => { template.BasicTemplate(BasicTemplate.ClassicTemplate); }) .MainTablePreferences(table => { table.ColumnsWidthsType(TableColumnWidthType.FitToContent); }) .MainTableDataSource(dataSource => { dataSource.StronglyTypedList(PersonnelDataSource.CreatePersonnelList()); }) .MainTableEvents(events => { events.DataSourceIsEmpty(message: "There is no data available to display."); }) .MainTableSummarySettings(summary => { summary.OverallSummarySettings("Total"); summary.PageSummarySettings("Page Summary"); summary.PreviousPageSummarySettings("Pervious Page Summary"); }) .MainTableAdHocColumnsConventions(adHocColumns => { adHocColumns.ShowRowNumberColumn(true); adHocColumns.RowNumberColumnCaption("#"); }) .Export(export => { export.ToExcel(); export.ToXml(); }) .Generate(data => data.AsPdfFile(AppPath.ApplicationPath + "\\Pdf\\DataAnnotationsSampleRpt.pdf")); } } }
پیاده سازیهای زیادی را در مورد JSON Web Token با ASP.NET Web API، با کمی جستجو میتوانید پیدا کنید. اما مشکلی که تمام آنها دارند، شامل این موارد هستند:
- چون توکنهای JWT، خودشمول هستند (در پیشنیاز بحث مطرح شدهاست)، تا زمانیکه این توکن منقضی نشود، کاربر با همان سطح دسترسی قبلی میتواند به سیستم، بدون هیچگونه مانعی لاگین کند. در این حالت اگر این کاربر غیرفعال شود، کلمهی عبور او تغییر کند و یا سطح دسترسیهای او کاهش یابند ... مهم نیست! باز هم میتواند با همان توکن قبلی لاگین کند.
- در روش JSON Web Token، عملیات Logout سمت سرور بیمعنا است. یعنی اگر برنامهای در سمت کاربر، قسمت logout را تدارک دیده باشد، چون در سمت سرور این توکنها جایی ذخیره نمیشوند، عملا این logout بیمفهوم است و مجددا میتوان از همان توکن قبلی، برای لاگین به سرور استفاده کرد. چون این توکن شامل تمام اطلاعات لازم برای لاگین است و همچنین جایی هم در سرور ثبت نشدهاست که این توکن در اثر logout، باید غیرمعتبر شود.
- با یک توکن از مکانهای مختلفی میتوان دسترسی لازم را جهت استفادهی از قسمتهای محافظت شدهی برنامه یافت (در صورت دسترسی، چندین نفر میتوانند از آن استفاده کنند).
به همین جهت راه حلی عمومی برای ذخیره سازی توکنهای صادر شده از سمت سرور، در بانک اطلاعاتی تدارک دیده شد که در ادامه به بررسی آن خواهیم پرداخت و این روشی است که میتواند به عنوان پایه مباحث Authentication و Authorization برنامههای تک صفحهای وب استفاده شود. البته سمت کلاینت این راه حل با jQuery پیاده سازی شدهاست (عمومی است؛ برای طرح مفاهیم پایه) و سمت سرور آن به عمد از هیچ نوع بانک اطلاعات و یا ORM خاصی استفاده نمیکند. سرویسهای آن برای بکارگیری انواع و اقسام روشهای ذخیره سازی اطلاعات قابل تغییر هستند و الزامی نیست که حتما از EF استفاده کنید یا از ASP.NET Identity یا هر روش خاص دیگری.
نگاهی به برنامه
در اینجا تمام قابلیتهای این پروژه را مشاهده میکنید.
- امکان لاگین
- امکان دسترسی به یک کنترلر مزین شدهی با فلیتر Authorize
- امکان دسترسی به یک کنترلر مزین شدهی با فلیتر Authorize جهت کاربری با نقش Admin
- پیاده سازی مفهوم ویژهای به نام refresh token که نیاز به لاگین مجدد را پس از منقضی شدن زمان توکن اولیهی لاگین، برطرف میکند.
- پیاده سازی logout
بستههای پیشنیاز برنامه
پروژهای که در اینجا بررسی شدهاست، یک پروژهی خالی ASP.NET Web API 2.x است و برای شروع به کار با JSON Web Tokenها، تنها نیاز به نصب 4 بستهی زیر را دارد:
PM> Install-Package Microsoft.Owin.Host.SystemWeb PM> Install-Package Microsoft.Owin.Security.Jwt PM> Install-Package structuremap PM> Install-Package structuremap.web
از structuremap هم برای تنظیمات تزریق وابستگیهای برنامه استفاده شدهاست. به این صورت قسمت تنظیمات اولیهی JWT ثابت باقی خواهد ماند و صرفا نیاز خواهید داشت تا کمی قسمت سرویسهای برنامه را بر اساس بانک اطلاعاتی و روش ذخیره سازی خودتان سفارشی سازی کنید.
دریافت کدهای کامل برنامه
کدهای کامل این برنامه را از اینجا میتوانید دریافت کنید. در ادامه صرفا قسمتهای مهم این کدها را بررسی خواهیم کرد.
بررسی کلاس AppJwtConfiguration
کلاس AppJwtConfiguration، جهت نظم بخشیدن به تعاریف ابتدایی توکنهای برنامه در فایل web.config، ایجاد شدهاست. اگر به فایل web.config برنامه مراجعه کنید، یک چنین تعریفی را مشاهده خواهید کرد:
<appJwtConfiguration tokenPath="/login" expirationMinutes="2" refreshTokenExpirationMinutes="60" jwtKey="This is my shared key, not so secret, secret!" jwtIssuer="http://localhost/" jwtAudience="Any" />
<configSections> <section name="appJwtConfiguration" type="JwtWithWebAPI.JsonWebTokenConfig.AppJwtConfiguration" /> </configSections>
- در این تنظیمات، دو زمان منقضی شدن را مشاهده میکنید؛ یکی مرتبط است به access tokenها و دیگری مرتبط است به refresh tokenها که در مورد اینها، در ادامه بیشتر توضیح داده خواهد شد.
- jwtKey، یک کلید قوی است که از آن برای امضاء کردن توکنها در سمت سرور استفاده میشود.
- تنظیمات Issuer و Audience هم در اینجا اختیاری هستند.
یک نکته
جهت سهولت کار تزریق وابستگیها، برای کلاس AppJwtConfiguration، اینترفیس IAppJwtConfiguration نیز تدارک دیده شدهاست و در تمام تنظیمات ابتدایی JWT، از این اینترفیس بجای استفادهی مستقیم از کلاس AppJwtConfiguration استفاده شدهاست.
بررسی کلاس OwinStartup
شروع به کار تنظیمات JWT و ورود آنها به چرخهی حیات Owin از کلاس OwinStartup آغاز میشود. در اینجا علت استفادهی از SmObjectFactory.Container.GetInstance انجام تزریق وابستگیهای لازم جهت کار با دو کلاس AppOAuthOptions و AppJwtOptions است.
- در کلاس AppOAuthOptions تنظیماتی مانند نحوهی تهیهی access token و همچنین refresh token ذکر میشوند.
- در کلاس AppJwtOptions تنظیمات فایل وب کانفیگ، مانند کلید مورد استفادهی جهت امضای توکنهای صادر شده، ذکر میشوند.
حداقلهای بانک اطلاعاتی مورد نیاز جهت ذخیره سازی وضعیت کاربران و توکنهای آنها
همانطور که در ابتدای بحث عنوان شد، میخواهیم اگر سطوح دسترسی کاربر تغییر کرد و یا اگر کاربر logout کرد، توکن فعلی او صرفنظر از زمان انقضای آن، بلافاصله غیرقابل استفاده شود. به همین جهت نیاز است حداقل دو جدول زیر را در بانک اطلاعاتی تدارک ببینید:
الف) کلاس User
در کلاس User، بر مبنای اطلاعات خاصیت Roles آن است که ویژگی Authorize با ذکر نقش مثلا Admin کار میکند. بنابراین حداقل نقشی را که برای کاربران، در ابتدای کار نیاز است مشخص کنید، نقش user است.
همچنین خاصیت اضافهتری به نام SerialNumber نیز در اینجا درنظر گرفته شدهاست. این مورد را باید به صورت دستی مدیریت کنید. اگر کاربری کلمهی عبورش را تغییر داد، اگر مدیری نقشی را به او انتساب داد یا از او گرفت و یا اگر کاربری غیرفعال شد، مقدار خاصیت و فیلد SerialNumber را با یک Guid جدید به روز رسانی کنید. این Guid در برنامه با Guid موجود در توکن مقایسه شده و بلافاصله سبب عدم دسترسی او خواهد شد (در صورت عدم تطابق).
ب) کلاس UserToken
در کلاس UserToken کار نگهداری ریز اطلاعات توکنهای صادر شده صورت میگیرد. توکنهای صادر شده دارای access token و refresh token هستند؛ به همراه زمان انقضای آنها. به این ترتیب زمانیکه کاربری درخواستی را به سرور ارسال میکند، ابتدا token او را دریافت کرده و سپس بررسی میکنیم که آیا اصلا چنین توکنی در بانک اطلاعاتی ما وجود خارجی دارد یا خیر؟ آیا توسط ما صادر شدهاست یا خیر؟ اگر خیر، بلافاصله دسترسی او قطع خواهد شد. برای مثال عملیات logout را طوری طراحی میکنیم که تمام توکنهای یک شخص را در بانک اطلاعاتی حذف کند. به این ترتیب توکن قبلی او دیگر قابلیت استفادهی مجدد را نخواهد داشت.
مدیریت بانک اطلاعاتی و کلاسهای سرویس برنامه
در لایه سرویس برنامه، شما سه سرویس را مشاهده خواهید کرد که قابلیت جایگزین شدن با کدهای یک ORM را دارند (نوع آن ORM مهم نیست):
الف) سرویس TokenStoreService
public interface ITokenStoreService { void CreateUserToken(UserToken userToken); bool IsValidToken(string accessToken, int userId); void DeleteExpiredTokens(); UserToken FindToken(string refreshTokenIdHash); void DeleteToken(string refreshTokenIdHash); void InvalidateUserTokens(int userId); void UpdateUserToken(int userId, string accessTokenHash); }
پیاده سازی این کلاس بسیار شبیه است به پیاده سازی ORMهای موجود و فقط یک SaveChanges را کم دارد.
یک نکته:
در سرویس ذخیره سازی توکنها، یک چنین متدی قابل مشاهده است:
public void CreateUserToken(UserToken userToken) { InvalidateUserTokens(userToken.OwnerUserId); _tokens.Add(userToken); }
ب) سرویس UsersService
public interface IUsersService { string GetSerialNumber(int userId); IEnumerable<string> GetUserRoles(int userId); User FindUser(string username, string password); User FindUser(int userId); void UpdateUserLastActivityDate(int userId); }
همچنین متدهای دیگری برای یافتن یک کاربر بر اساس نام کاربری و کلمهی عبور او (جهت مدیریت مرحلهی لاگین)، یافتن کاربر بر اساس Id او (جهت استخراج اطلاعات کاربر) و همچنین یک متد اختیاری نیز برای به روز رسانی فیلد آخرین تاریخ فعالیت کاربر در اینجا پیش بینی شدهاند.
ج) سرویس SecurityService
public interface ISecurityService { string GetSha256Hash(string input); }
پیاده سازی قسمت لاگین و صدور access token
در کلاس AppOAuthProvider کار پیاده سازی قسمت لاگین برنامه انجام شدهاست. این کلاسی است که توسط کلاس AppOAuthOptions به OwinStartup معرفی میشود. قسمتهای مهم کلاس AppOAuthProvider به شرح زیر هستند:
برای درک عملکرد این کلاس، در ابتدای متدهای مختلف آن، یک break point قرار دهید. برنامه را اجرا کرده و سپس بر روی دکمهی login کلیک کنید. به این ترتیب جریان کاری این کلاس را بهتر میتوانید درک کنید. کار آن با فراخوانی متد ValidateClientAuthentication شروع میشود. چون با یک برنامهی وب در حال کار هستیم، ClientId آنرا نال درنظر میگیریم و برای ما مهم نیست. اگر کلاینت ویندوزی خاصی را تدارک دیدید، این کلاینت میتواند ClientId ویژهای را به سمت سرور ارسال کند که در اینجا مدنظر ما نیست.
مهمترین قسمت این کلاس، متد GrantResourceOwnerCredentials است که پس از ValidateClientAuthentication بلافاصله فراخوانی میشود. اگر به کدهای آن دقت کنید، خود owin دارای خاصیتهای user name و password نیز هست.
این اطلاعات را به نحو ذیل از کلاینت خود دریافت میکند. اگر به فایل index.html مراجعه کنید، یک چنین تعریفی را برای متد login میتوانید مشاهده کنید:
function doLogin() { $.ajax({ url: "/login", // web.config --> appConfiguration -> tokenPath data: { username: "Vahid", password: "1234", grant_type: "password" }, type: 'POST', // POST `form encoded` data contentType: 'application/x-www-form-urlencoded'
در متد GrantResourceOwnerCredentials کار بررسی نام کاربری و کلمهی عبور کاربر صورت گرفته و در صورت یافت شدن کاربر (صحیح بودن اطلاعات)، نقشهای او نیز به عنوان Claim جدید به توکن اضافه میشوند.
در اینجا یک Claim سفارشی هم اضافه شدهاست:
identity.AddClaim(new Claim(ClaimTypes.UserData, user.UserId.ToString()));
در انتهای این کلاس، از متد TokenEndpointResponse جهت دسترسی به access token نهایی صادر شدهی برای کاربر، استفاده کردهایم. هش این access token را در بانک اطلاعاتی ذخیره میکنیم (جستجوی هشها سریعتر هستند از جستجوی یک رشتهی طولانی؛ به علاوه در صورت دسترسی به بانک اطلاعاتی، اطلاعات هشها برای مهاجم قابل استفاده نیست).
اگر بخواهیم اطلاعات ارسالی به کاربر را پس از لاگین، نمایش دهیم، به شکل زیر خواهیم رسید:
در اینجا access_token همان JSON Web Token صادر شدهاست که برنامهی کلاینت از آن برای اعتبارسنجی استفاده خواهد کرد.
بنابراین خلاصهی مراحل لاگین در اینجا به ترتیب ذیل است:
- فراخوانی متد ValidateClientAuthenticationدر کلاس AppOAuthProvider . طبق معمول چون ClientID نداریم، این مرحله را قبول میکنیم.
- فراخوانی متد GrantResourceOwnerCredentials در کلاس AppOAuthProvider . در اینجا کار اصلی لاگین به همراه تنظیم Claims کاربر انجام میشود. برای مثال نقشهای او به توکن صادر شده اضافه میشوند.
- فراخوانی متد Protect در کلاس AppJwtWriterFormat که کار امضای دیجیتال access token را انجام میدهد.
- فراخوانی متد CreateAsync در کلاس RefreshTokenProvider. کار این متد صدور توکن ویژهای به نام refresh است. این توکن را در بانک اطلاعاتی ذخیره خواهیم کرد. در اینجا چیزی که به سمت کلاینت ارسال میشود صرفا یک guid است و نه اصل refresh token.
- فرخوانی متد TokenEndpointResponse در کلاس AppOAuthProvider . از این متد جهت یافتن access token نهایی تولید شده و ثبت هش آن در بانک اطلاعاتی استفاده میکنیم.
پیاده سازی قسمت صدور Refresh token
در تصویر فوق، خاصیت refresh_token را هم در شیء JSON ارسالی به سمت کاربر مشاهده میکنید. هدف از refresh_token، تمدید یک توکن است؛ بدون ارسال کلمهی عبور و نام کاربری به سرور. در اینجا access token صادر شده، مطابق تنظیم expirationMinutes در فایل وب کانفیگ، منقضی خواهد شد. اما طول عمر refresh token را بیشتر از طول عمر access token در نظر میگیریم. بنابراین طول عمر یک access token کوتاه است. زمانیکه access token منقضی شد، نیازی نیست تا حتما کاربر را به صفحهی لاگین هدایت کنیم. میتوانیم refresh_token را به سمت سرور ارسال کرده و به این ترتیب درخواست صدور یک access token جدید را ارائه دهیم. این روش هم سریعتر است (کاربر متوجه این retry نخواهد شد) و هم امنتر است چون نیازی به ارسال کلمهی عبور و نام کاربری به سمت سرور وجود ندارند.
سمت کاربر، برای درخواست صدور یک access token جدید بر اساس refresh token صادر شدهی در زمان لاگین، به صورت زیر عمل میشود:
function doRefreshToken() { // obtaining new tokens using the refresh_token should happen only if the id_token has expired. // it is a bad practice to call the endpoint to get a new token every time you do an API call. $.ajax({ url: "/login", // web.config --> appConfiguration -> tokenPath data: { grant_type: "refresh_token", refresh_token: refreshToken }, type: 'POST', // POST `form encoded` data contentType: 'application/x-www-form-urlencoded'
- فرخوانی متد ValidateClientAuthentication در کلاس AppOAuthProvider . طبق معمول چون ClientID نداریم، این مرحله را قبول میکنیم.
- فراخوانی متد ReceiveAsync در کلاس RefreshTokenProvider. در قسمت توضیح مراحل لاگین، عنوان شد که پس از فراخوانی متد GrantResourceOwnerCredentials جهت لاگین، متد CreateAsync در کلاس RefreshTokenProvider فراخوانی میشود. اکنون در متد ReceiveAsync این refresh token ذخیره شدهی در بانک اطلاعاتی را یافته (بر اساس Guid ارسالی از طرف کلاینت) و سپس Deserialize میکنیم. به این ترتیب است که کار درخواست یک access token جدید بر مبنای refresh token موجود آغاز میشود.
- فراخوانی GrantRefreshToken در کلاس AppOAuthProvider . در اینجا اگر نیاز به تنظیم Claim اضافهتری وجود داشت، میتوان اینکار را انجام داد.
- فراخوانی متد Protect در کلاس AppJwtWriterFormat که کار امضای دیجیتال access token جدید را انجام میدهد.
- فراخوانی CreateAsync در کلاس RefreshTokenProvider . پس از اینکه context.DeserializeTicket در متد ReceiveAsync بر مبنای refresh token قبلی انجام شد، مجددا کار تولید یک توکن جدید در متد CreateAsync شروع میشود و زمان انقضاءها تنظیم خواهند شد.
- فراخوانی TokenEndpointResponse در کلاس AppOAuthProvider . مجددا از این متد برای دسترسی به access token جدید و ذخیرهی هش آن در بانک اطلاعاتی استفاده میکنیم.
پیاده سازی فیلتر سفارشی JwtAuthorizeAttribute
در ابتدای بحث عنوان کردیم که اگر مشخصات کاربر تغییر کردند یا کاربر logout کرد، امکان غیرفعال کردن یک توکن را نداریم و این توکن تا زمان انقضای آن معتبر است. این نقیصه را با طراحی یک AuthorizeAttribute سفارشی جدید به نام JwtAuthorizeAttribute برطرف میکنیم. نکات مهم این فیلتر به شرح زیر هستند:
- در اینجا در ابتدا بررسی میشود که آیا درخواست رسیدهی به سرور، حاوی access token هست یا خیر؟ اگر خیر، کار همینجا به پایان میرسد و دسترسی کاربر قطع خواهد شد.
- سپس بررسی میکنیم که آیا درخواست رسیده پس از مدیریت توسط Owin، دارای Claims است یا خیر؟ اگر خیر، یعنی این توکن توسط ما صادر نشدهاست.
- در ادامه شماره سریال موجود در access token را استخراج کرده و آنرا با نمونهی موجود در دیتابیس مقایسه میکنیم. اگر این دو یکی نبودند، دسترسی کاربر قطع میشود.
- همچنین در آخر بررسی میکنیم که آیا هش این توکن رسیده، در بانک اطلاعاتی ما موجود است یا خیر؟ اگر خیر باز هم یعنی این توکن توسط ما صادر نشدهاست.
بنابراین به ازای هر درخواست به سرور، دو بار بررسی بانک اطلاعاتی را خواهیم داشت:
- یکبار بررسی جدول کاربران جهت واکشی مقدار فیلد شماره سریال مربوط به کاربر.
- یکبار هم جهت بررسی جدول توکنها برای اطمینان از صدور توکن رسیده توسط برنامهی ما.
و نکتهی مهم اینجا است که از این پس بجای فیلتر معمولی Authorize، از فیلتر جدید JwtAuthorize در برنامه استفاده خواهیم کرد:
[JwtAuthorize(Roles = "Admin")] public class MyProtectedAdminApiController : ApiController
نحوهی ارسال درخواستهای Ajax ایی به همراه توکن صادر شده
تا اینجا کار صدور توکنهای برنامه به پایان میرسد. برای استفادهی از این توکنها در سمت کاربر، به فایل index.html دقت کنید. در متد doLogin، پس از موفقیت عملیات دو متغیر جدید مقدار دهی میشوند:
var jwtToken; var refreshToken; function doLogin() { $.ajax({ // same as before }).then(function (response) { jwtToken = response.access_token; refreshToken = response.refresh_token; }
function doCallApi() { $.ajax({ headers: { 'Authorization': 'Bearer ' + jwtToken }, url: "/api/MyProtectedApi", type: 'GET' }).then(function (response) {
پیاده سازی logout سمت سرور و کلاینت
پیاده سازی سمت سرور logout را در کنترلر UserController مشاهده میکنید. در اینجا در اکشن متد Logout، کار حذف توکنهای کاربر از بانک اطلاعاتی انجام میشود. به این ترتیب دیگر مهم نیست که توکن او هنوز منقضی شدهاست یا خیر. چون هش آن دیگر در جدول توکنها وجود ندارد، از فیلتر JwtAuthorizeAttribute رد نخواهد شد.
سمت کلاینت آن نیز در فایل index.html ذکر شدهاست:
function doLogout() { $.ajax({ headers: { 'Authorization': 'Bearer ' + jwtToken }, url: "/api/user/logout", type: 'GET'
بررسی تنظیمات IoC Container برنامه
تنظیمات IoC Container برنامه را در پوشهی IoCConfig میتوانید ملاحظه کنید. از کلاس SmWebApiControllerActivator برای فعال سازی تزریق وابستگیها در کنترلرهای Web API استفاده میشود و از کلاس SmWebApiFilterProvider برای فعال سازی تزریق وابستگیها در فیلتر سفارشی که ایجاد کردیم، کمک گرفته خواهد شد.
هر دوی این تنظیمات نیز در کلاس WebApiConfig ثبت و معرفی شدهاند.
به علاوه در کلاس SmObjectFactory، کار معرفی وهلههای مورد استفاده و تنظیم طول عمر آنها انجام شدهاست. برای مثال طول عمر IOAuthAuthorizationServerProvider از نوع Singleton است؛ چون تنها یک وهله از AppOAuthProvider در طول عمر برنامه توسط Owin استفاده میشود و Owin هربار آنرا وهله سازی نمیکند. همین مساله سبب شدهاست که معرفی وابستگیها در سازندهی کلاس AppOAuthProvider کمی با حالات متداول، متفاوت باشند:
public AppOAuthProvider( Func<IUsersService> usersService, Func<ITokenStoreService> tokenStoreService, ISecurityService securityService, IAppJwtConfiguration configuration)
در اینجا سرویس IAppJwtConfiguration با Func معرفی نشدهاست؛ چون طول عمر تنظیمات خوانده شدهی از Web.config نیز Singleton هستند و معرفی آن به همین نحو صحیح است.
اگر علاقمند بودید که بررسی کنید یک سرویس چندبار وهله سازی میشود، یک سازندهی خالی را به آن اضافه کنید و سپس یک break point را بر روی آن قرار دهید و برنامه را اجرا و در این حالت چندبار Login کنید.
امن سازی برنامههای ASP.NET Core توسط IdentityServer 4x - قسمت چهاردهم- آماده شدن برای انتشار برنامه
public void ConfigureServices(IServiceCollection services) { services.AddControllersWithViews(); IdentityModelEventSource.ShowPII = true; services.AddAuthentication(options => { options.DefaultScheme = "Cookies"; options.DefaultChallengeScheme = "oidc"; options.DefaultSignOutScheme = "oidc"; }) .AddCookie("Cookies", options => { options.AccessDeniedPath = "/Authorization/AccessDenied"; // set session lifetime options.ExpireTimeSpan = TimeSpan.FromHours(8); // sliding or absolute options.SlidingExpiration = false; // host prefixed cookie name options.Cookie.Name = "MVC"; // strict SameSite handling options.Cookie.SameSite = SameSiteMode.Strict; }) .AddOpenIdConnect("oidc", options => { options.SignInScheme = "Cookies"; options.Authority = Configuration["IDPBaseAddress"]; options.ClientId = Configuration["ClientId"]; options.ClientSecret = Configuration["ClientSecret"]; options.ResponseType = "code id_token"; options.ResponseMode = "query"; options.RequireHttpsMetadata = false; options.CallbackPath = new PathString("/Home/"); options.SignedOutCallbackPath = new PathString("/Home/"); options.MapInboundClaims = true; options.Scope.Clear(); options.Scope.Add("openid"); options.Scope.Add("profile"); options.Scope.Add("roles"); options.Scope.Add("PS.WebApi.Read"); options.Scope.Add("offline_access"); options.SaveTokens = true; options.GetClaimsFromUserInfoEndpoint = true; //options.UsePkce = true; //options.ClaimActions.MapJsonKey(claimType: "role", jsonKey: "role"); // for having 2 or more roles options.TokenValidationParameters = new TokenValidationParameters { NameClaimType = JwtClaimTypes.GivenName, RoleClaimType = JwtClaimTypes.Role }; }); //ServicePointManager.Expect100Continue = true; //ServicePointManager.SecurityProtocol = SecurityProtocolType.Tls // | SecurityProtocolType.Tls11 // | SecurityProtocolType.Tls12 // | SecurityProtocolType.Ssl3; } public void Configure(IApplicationBuilder app, IWebHostEnvironment env) { if (env.IsDevelopment()) { app.UseDeveloperExceptionPage(); } else { app.UseExceptionHandler("/Home/Error"); app.UseHsts(); } app.UseHttpsRedirection(); app.UseStaticFiles(); app.UseRouting(); app.UseAuthentication(); app.UseAuthorization(); app.UseEndpoints(endpoints => { endpoints.MapControllerRoute( name: "areas", pattern: "{area:exists}/{controller=Home}/{action=Index}/{id?}" ); //.RequireAuthorization(); endpoints.MapControllerRoute( name: "default", pattern: "{controller=Home}/{action=Index}/{id?}" ); //.RequireAuthorization(); }); //[HttpPost] //public IActionResult Logout() //{ // return SignOut("Cookies", "oidc"); //} }
پروژههای بنده به ترتیب Endpoint هاشون اینگونه هست:
"IDPBaseAddress": "https://localhost:44310",
"ClientId": "Mvc_ClientId",
"ClientSecret": "WebMvc"
{ "IdentityServerData": { "IdentityResources": [ { "Name": "roles", "Enabled": true, "DisplayName": "Roles", "UserClaims": [ "role" ] }, { "Name": "openid", "Enabled": true, "Required": true, "DisplayName": "Your user identifier", "UserClaims": [ "sub" ] }, { "Name": "profile", "Enabled": true, "DisplayName": "User profile", "Description": "Your user profile information (first name, last name, etc.)", "Emphasize": true, "UserClaims": [ "name", "family_name", "given_name", "middle_name", "nickname", "preferred_username", "profile", "picture", "website", "gender", "birthdate", "zoneinfo", "locale", "updated_at" ] }, { "Name": "email", "Enabled": true, "DisplayName": "Your email address", "Emphasize": true, "UserClaims": [ "email", "email_verified" ] }, { "Name": "address", "Enabled": true, "DisplayName": "Your address", "Emphasize": true, "UserClaims": [ "address" ] } ], "ApiScopes": [ { "Name": "Idp_Admin_ClientId_api", "DisplayName": "Idp_Admin_ClientId_api", "Required": true, "UserClaims": [ "role", "name" ] }, { "Name": "WebApi.Read", "DisplayName": "WebApi Read", "Required": true, "UserClaims": [ "role", "WebApi.Read" ] }, { "Name": "WebApi.Write", "DisplayName": "WebApi Write", "Required": true, "UserClaims": [ "role", "WebApi.Write" ] } ], "ApiResources": [ { "Name": "Idp_Admin_ClientId_api", "Scopes": [ "Idp_Admin_ClientId_api" ] }, { "Name": "WebApi", "Scopes": [ "WebApi.Read", "WebApi.Write" ] } ], "Clients": [ { "ClientId": "Idp_Admin_ClientId", "ClientName": "Idp_Admin_ClientId", "ClientUri": "https://localhost:44303", "AllowedGrantTypes": [ "authorization_code" ], "RequirePkce": true, "ClientSecrets": [ { "Value": "Idp_Admin_ClientSecret" } ], "RedirectUris": [ "https://localhost:44303/signin-oidc" ], "FrontChannelLogoutUri": "https://localhost:44303/signout-oidc", "PostLogoutRedirectUris": [ "https://localhost:44303/signout-callback-oidc" ], "AllowedCorsOrigins": [ "https://localhost:44303" ], "AllowedScopes": [ "openid", "email", "profile", "roles" ] }, { "ClientId": "Idp_Admin_ClientId_api_swaggerui", "ClientName": "Idp_Admin_ClientId_api_swaggerui", "AllowedGrantTypes": [ "authorization_code" ], "RequireClientSecret": false, "RequirePkce": true, "RedirectUris": [ "https://localhost:44302/swagger/oauth2-redirect.html" ], "AllowedScopes": [ "Idp_Admin_ClientId_api" ], "AllowedCorsOrigins": [ "https://localhost:44302" ] }, //WebApi { "ClientId": "WebApi_ClientId", "ClientName": "WebApi_ClientId", "ClientUri": "https://localhost:44365", "AllowedGrantTypes": [ "authorization_code" ], "RequirePkce": true, "ClientSecrets": [ { "Value": "WebApi" } ], "RedirectUris": [ "https://localhost:44303/signin-oidc" ], "FrontChannelLogoutUri": "https://localhost:44303/signout-oidc", "PostLogoutRedirectUris": [ "https://localhost:44303/signout-callback-oidc" ], "AllowedCorsOrigins": [ "https://localhost:44303", "https://localhost:44310" ], "AllowedScopes": [ "openid", "email", "profile", "roles" ] }, //Mvc { "ClientId": "Mvc_ClientId", "ClientName": "Mvc_ClientId", "ClientUri": "https://localhost:44332", "AllowedGrantTypes": [ "hybrid" ], //"RequirePkce": true, "AllowPlainTextPkce": false, "ClientSecrets": [ { "Value": "WebMvc" } ], "RedirectUris": [ "https://localhost:44332/signin-oidc" ], "FrontChannelLogoutUri": "https://localhost:44332/signout-oidc", "PostLogoutRedirectUris": [ "https://localhost:44332/signout-callback-oidc" ], "AllowedCorsOrigins": [ "https://localhost:44332", "https://localhost:44310" ], "AllowedScopes": [ "openid", "email", "profile", "roles", "address", "PS.webApi" ], "AllowAccessTokensViaBrowser": true, "RequireConsent": false, "AllowOfflineAccess": true //"UpdateAccessTokenClaimsOnRefresh": true } ] } }
public class HomeController : Controller { private readonly ILogger<HomeController> _logger; public HomeController(ILogger<HomeController> logger) { _logger = logger; } public IActionResult Default() { return View(); } public IActionResult Index() { return View(); } [Authorize] public IActionResult Privacy() { return View(); } [ResponseCache(Duration = 0, Location = ResponseCacheLocation.None, NoStore = true)] public IActionResult Error() { return View(new ErrorViewModel { RequestId = Activity.Current?.Id ?? HttpContext.TraceIdentifier }); } }
اما در نهایت بعد از اجرا و مراجعه به آدرس https://localhost:44332/home/privacy که مزین به اتریبیوت
[Authorize]میباشد با خطای زیر مواجه میشوم:
لازم به توضیح هست که پروپرتی RequireHttpsMetadata = false میباشد.
- تنظیمات پیش فرض باید تغییر کنند تا کلمات عبور حداقل 10 کاراکتر باشند
- کلمه عبور حداقل یک عدد و یک کاراکتر ویژه باید داشته باشد
- امکان استفاده از 5 کلمه عبور اخیری که ثبت شده وجود ندارد
ایجاد اپلیکیشن جدید
در پنجره Solution Explorer روی نام پروژه کلیک راست کنید و گزینه Manage NuGet Packages را انتخاب کنید. به قسمت Update بروید و تمام انتشارات جدید را در صورت وجود نصب کنید.
بگذارید تا به روند کلی ایجاد کاربران جدید در اپلیکیشن نگاهی بیاندازیم. این به ما در شناسایی نیازهای جدیدمان کمک میکند. در پوشه Controllers فایلی بنام AccountController.cs وجود دارد که حاوی متدهایی برای مدیریت کاربران است.
- کنترلر Account از کلاس UserManager استفاده میکند که در فریم ورک Identity تعریف شده است. این کلاس به نوبه خود از کلاس دیگری بنام UserStore استفاده میکند که برای دسترسی و مدیریت دادههای کاربران استفاده میشود. در مثال ما این کلاس از Entity Framework استفاده میکند که پیاده سازی پیش فرض است.
- متد Register POST یک کاربر جدید میسازد. متد CreateAsync به طبع متد 'ValidateAsync' را روی خاصیت PasswordValidator فراخوانی میکند تا کلمه عبور دریافتی اعتبارسنجی شود.
var user = new ApplicationUser() { UserName = model.UserName }; var result = await UserManager.CreateAsync(user, model.Password); if (result.Succeeded) { await SignInAsync(user, isPersistent: false); return RedirectToAction("Index", "Home"); }
قانون 1: کلمههای عبور باید حداقل 10 کاراکتر باشند
- مقدار حداقل کاراکترهای کلمه عبور به دو شکل میتواند تعریف شود. راه اول، تغییر کنترلر Account است. در متد سازنده این کنترلر کلاس UserManager وهله سازی میشود، همینجا میتوانید این تغییر را اعمال کنید. راه دوم، ساختن کلاس جدیدی است که از UserManager ارث بری میکند. سپس میتوان این کلاس را در سطح global تعریف کرد. در پوشه IdentityExtensions کلاس جدیدی با نام ApplicationUserManager بسازید.
public class ApplicationUserManager : UserManager<ApplicationUser> { public ApplicationUserManager(): base(new UserStore<ApplicationUser>(new ApplicationDbContext())) { PasswordValidator = new MinimumLengthValidator (10); } }
- حال باید کلاس ApplicationUserManager را در کنترلر Account استفاده کنیم. متد سازنده و خاصیت UserManager را مانند زیر تغییر دهید.
public AccountController() : this(new ApplicationUserManager()) { } public AccountController(ApplicationUserManager userManager) { UserManager = userManager; } public ApplicationUserManager UserManager { get; private set; }
- اپلیکیشن را اجرا کنید و سعی کنید کاربر محلی جدیدی ثبت نمایید. اگر کلمه عبور وارد شده کمتر از 10 کاراکتر باشد پیغام خطای زیر را دریافت میکنید.
قانون 2: کلمههای عبور باید حداقل یک عدد و یک کاراکتر ویژه داشته باشند
- در پوشه IdentityExtensions کلاس جدیدی بنام CustomPasswordValidator بسازید و اینترفیس مذکور را پیاده سازی کنید. از آنجا که نوع کلمه عبور رشته (string) است از <IIdentityValidator<string استفاده میکنیم.
public class CustomPasswordValidator : IIdentityValidator<string> { public int RequiredLength { get; set; } public CustomPasswordValidator(int length) { RequiredLength = length; } public Task<IdentityResult> ValidateAsync(string item) { if (String.IsNullOrEmpty(item) || item.Length < RequiredLength) { return Task.FromResult(IdentityResult.Failed(String.Format("Password should be of length {0}",RequiredLength))); } string pattern = @"^(?=.*[0-9])(?=.*[!@#$%^&*])[0-9a-zA-Z!@#$%^&*0-9]{10,}$"; if (!Regex.IsMatch(item, pattern)) { return Task.FromResult(IdentityResult.Failed("Password should have one numeral and one special character")); } return Task.FromResult(IdentityResult.Success); }
- قدم بعدی تعریف این اعتبارسنج سفارشی در کلاس UserManager است. باید مقدار خاصیت PasswordValidator را به این کلاس تنظیم کنیم. به کلاس ApplicationUserManager که پیشتر ساختید بروید و مقدار خاصیت PasswordValidator را به CustomPasswordValidator تغییر دهید.
public class ApplicationUserManager : UserManager<ApplicationUser> { public ApplicationUserManager() : base(new UserStore<ApplicationUser(new ApplicationDbContext())) { PasswordValidator = new CustomPasswordValidator(10); } }
قانون 3: امکان استفاده از 5 کلمه عبور اخیر ثبت شده وجود ندارد
public class PreviousPassword { public PreviousPassword() { CreateDate = DateTimeOffset.Now; } [Key, Column(Order = 0)] public string PasswordHash { get; set; } public DateTimeOffset CreateDate { get; set; } [Key, Column(Order = 1)] public string UserId { get; set; } public virtual ApplicationUser User { get; set; } }
- خاصیت جدیدی به کلاس ApplicationUser اضافه کنید تا لیست آخرین کلمات عبور استفاده شده را نگهداری کند.
public class ApplicationUser : IdentityUser { public ApplicationUser() : base() { PreviousUserPasswords = new List<PreviousPassword>(); } public virtual IList<PreviousPassword> PreviousUserPasswords { get; set; } }
public class ApplicationUserStore : UserStore<ApplicationUser> { public ApplicationUserStore(DbContext context) : base(context) { } public override async Task CreateAsync(ApplicationUser user) { await base.CreateAsync(user); await AddToPreviousPasswordsAsync(user, user.PasswordHash); } public Task AddToPreviousPasswordsAsync(ApplicationUser user, string password) { user.PreviousUserPasswords.Add(new PreviousPassword() { UserId = user.Id, PasswordHash = password }); return UpdateAsync(user); } }
public class ApplicationUserManager : UserManager<ApplicationUser> { private const int PASSWORD_HISTORY_LIMIT = 5; public ApplicationUserManager() : base(new ApplicationUserStore(new ApplicationDbContext())) { PasswordValidator = new CustomPasswordValidator(10); } public override async Task<IdentityResult> ChangePasswordAsync(string userId, string currentPassword, string newPassword) { if (await IsPreviousPassword(userId, newPassword)) { return await Task.FromResult(IdentityResult.Failed("Cannot reuse old password")); } var result = await base.ChangePasswordAsync(userId, currentPassword, newPassword); if (result.Succeeded) { var store = Store as ApplicationUserStore; await store.AddToPreviousPasswordsAsync(await FindByIdAsync(userId), PasswordHasher.HashPassword(newPassword)); } return result; } public override async Task<IdentityResult> ResetPasswordAsync(string userId, string token, string newPassword) { if (await IsPreviousPassword(userId, newPassword)) { return await Task.FromResult(IdentityResult.Failed("Cannot reuse old password")); } var result = await base.ResetPasswordAsync(userId, token, newPassword); if (result.Succeeded) { var store = Store as ApplicationUserStore; await store.AddToPreviousPasswordsAsync(await FindByIdAsync(userId), PasswordHasher.HashPassword(newPassword)); } return result; } private async Task<bool> IsPreviousPassword(string userId, string newPassword) { var user = await FindByIdAsync(userId); if (user.PreviousUserPasswords.OrderByDescending(x => x.CreateDate). Select(x => x.PasswordHash).Take(PASSWORD_HISTORY_LIMIT) .Where(x => PasswordHasher.VerifyHashedPassword(x, newPassword) != PasswordVerificationResult.Failed).Any()) { return true; } return false; } }
سورس کد این مثال را میتوانید از این لینک دریافت کنید. نام پروژه Identity-PasswordPolicy است، و زیر قسمت Samples/Identity قرار دارد.
EF Core چیست؟
EF Core یک ORM یا object-relational mapper چندسکویی است که امکان کار با بانکهای اطلاعاتی مختلف را از طریق اشیاء دات نتی میسر میکند. توسط آن قسمت عمدهی کدهای مستقیم کار با بانکهای اطلاعاتی حذف شده و تبدیل به کدهای دات نتی میشوند. مزیت این لایهی Abstraction اضافی (لایهای بر روی کدهای مستقیم لایه ADO.NET زیرین)، امکان تعویض بانک اطلاعاتی مورد استفاده، تنها با تغییر کدهای آغازین برنامهاست؛ بدون نیاز به تغییری در سایر قسمتهای برنامه. همچنین کار با اشیاء دات نتی و LINQ، مزایایی مانند تحت نظر قرار گرفتن کدها توسط کامپایلر و برخورداری از ابزارهای Refactoring پیشرفته را میسر میکنند. به علاوه SQL خودکار تولیدی توسط آن نیز همواره پارامتری بوده و مشکلات حملات تزریق SQL در این حالت تقریبا به صفر میرسند (اگر مستقیما SQL نویسی نکنید و صرفا از LINQ استفاده کنید). مزیت دیگر همواره پارامتری بودن کوئریها، رفتار بسیاری از بانکهای اطلاعاتی با آنها همانند رویههای ذخیره شده است که به عدم تولید Query planهای مجزایی به ازای هر کوئری رسیده منجر میشود که در نهایت سبب بالا رفتن سرعت اجرای کوئریها و مصرف حافظهی کمتری در سمت سرور بانک اطلاعاتی میگردد.
تفاوت EF Core با نگارشهای دیگر Entity framework در چیست؟
سورس باز بودن
EF از نگارشهای آخر آن بود که سورس باز شد؛ اما EF Core از زمان نگارشهای پیش نمایش آن به صورت سورس باز در GitHub قابل دسترسی است.
چند سکویی بودن
EF Core برخلاف EF 6.x (آخرین نگارش مبتنی بر Full Framework آن)، نه تنها چندسکویی است و قابلیت اجرای بر روی Mac و لینوکس را نیز دارا است، به علاوه امکان استفادهی از آن در انواع و اقسام برنامههای دات نتی مانند UWP یا Universal Windows Platform و Windows phone که پیشتر با EF 6.x میسر نبود، وجود دارد. لیست این نوع سکوها و برنامههای مختلف به شرح زیر است:
• All .NET application (Console, ASP.NET 4, WinForms, WPF) • Mac and Linux applications (Mono) • UWP (Universal Windows Platform) • ASP.NET Core applications • Can use EF Core in Windows phone and Windows store app
افزایش تعداد بانکهای اطلاعاتی پشتیبانی شده
در EF Full یا EF 6.x، هدف اصلی، تنها کار با بانکهای اطلاعاتی رابطهای بود و همچنین مایکروسافت صرفا نگارشهای مختلف SQL Server را به صورت رسمی پشتیبانی میکرد و برای سایر بانکهای اطلاعاتی دیگر باید از پروایدرهای ثالث استفاده کرد.
در EF Core علاوه بر افزایش تعداد پروایدرهای رسمی بانکهای اطلاعاتی رابطهای، پشتیبانی از بانکهای اطلاعاتی NoSQL هم اضافه شدهاست؛ به همراه پروایدر ویژهای به نام In Memory جهت انجام سادهتر Unit testing. کاری که با نگارشهای پیشین EF به سادگی و از روز اول پشتیبانی نمیشد.
حذف و یا عدم پیاده سازی تعدادی از قابلیتهای EF 6.x
اگر موارد فوق جزو مهمترین مزایای کار با EF Core باشند، باید درنظر داشت که به علت حذف و یا تقلیل یافتن یک سری از ویژگیها در NET Core.، مانند Reflection (جهت پشتیبانی از دات نت در سکوهای مختلف کاری و خصوصا پشتیبانی از حالتی که کامپایلر مخصوص برنامههای UWP نیاز دارد تمام نوعها را همانند زبانهای C و ++C، در زمان کامپایل بداند)، یک سری از قابلیتهای EF 6.x مانند Lazy loading هنوز در EF Core پشتیبانی نمیشوند. لیست کامل و به روز شدهی این موارد را در اینجا میتوانید مطالعه کنید.
بنابراین امکان انتقال برنامههای EF 6.x به EF Core 1.0 عموما وجود نداشته و نیاز به بازنویسی کامل دارند. هرچند بسیاری از مفاهیم آن با EF Code First یکی است.
برپایی تنظیمات اولیهی EF Core 1.0 در یک برنامهی ASP.NET Core 1.0
برای نصب تنظیمات اولیهی EF Core 1.0 در یک برنامهی ASP.NET Core 1.0، جهت کار با مشتقات SQL Server (و SQL LocalDB) نیاز است سه بستهی ذیل را نصب کرد (از طریق منوی Tools -> NuGet Package Manager -> Package Manager Console):
PM> Install-Package Microsoft.EntityFrameworkCore.SqlServer PM> Install-Package Microsoft.EntityFrameworkCore.Tools -Pre PM> Install-Package Microsoft.EntityFrameworkCore.SqlServer.Design
پس از اجرای سه دستور فوق، تغییرات مداخل فایل project.json برنامه به صورت ذیل خواهند بود:
{ "dependencies": { // same as before "Microsoft.EntityFrameworkCore.SqlServer": "1.0.0", "Microsoft.EntityFrameworkCore.Tools": "1.0.0-preview2-final", "Microsoft.EntityFrameworkCore.SqlServer.Design": "1.0.0" } }
{ "dependencies": { // same as before "Microsoft.EntityFrameworkCore.SqlServer": "1.0.0", "Microsoft.EntityFrameworkCore.Tools": { "version": "1.0.0-preview2-final", "type": "build" }, "Microsoft.EntityFrameworkCore.SqlServer.Design": { "version": "1.0.0", "type": "build" } }, "tools": { // same as before "Microsoft.EntityFrameworkCore.Tools": { "version": "1.0.0-preview2-final", "imports": [ "portable-net45+win8" ] } } }
بنابراین از همین ابتدای کار، بدون مراجعهی به Package Manager Console، چهار تغییر فوق را به فایل project.json اعمال کرده و آنرا ذخیره کنید؛ تا کار به روز رسانی و نصب بستهها، به صورت خودکار و همچنین صحیحی انجام شود.
فعال سازی صفحات مخصوص توسعه دهندههای EF Core 1.0
در مطلب «ارتقاء به ASP.NET Core 1.0 - قسمت 5 - فعال سازی صفحات مخصوص توسعه دهندهها» با تعدادی از اینگونه صفحات آشنا شدیم. برای EF Core نیز بستهی مخصوصی به نام Microsoft.AspNetCore.Diagnostics.EntityFrameworkCore وجود دارد که امکان فعال سازی صفحهی نمایش خطاهای بانک اطلاعاتی را میسر میکند. بنابراین ابتدا به فایل project.json مراجعه کرده و این بسته را اضافه کنید:
{ "dependencies": { // same as before "Microsoft.AspNetCore.Diagnostics.EntityFrameworkCore": "1.0.0" } }
public void Configure(IApplicationBuilder app, IHostingEnvironment env) { if (env.IsDevelopment()) { app.UseDatabaseErrorPage(); }
تعریف اولین Context برنامه و مشخص سازی رشتهی اتصالی آن
در این تصویر، زیر ساخت نگاشتهای EF Core را مشاهده میکنید. در سمت چپ، ظرفی را داریم به نام DB Context که در برگیرندهی Db Setها است. در سمت راست که بیانگر ساختار کلی یک بانک اطلاعاتی است، معادل اینها را مشاهده میکنیم. هر Db Set به یک جدول بانک اطلاعاتی نگاشت خواهد شد و متشکل است از کلاسی به همراه یک سری خواص که اینها نیز به فیلدها و ستونهای آن جدول در سمت بانک اطلاعاتی نگاشت میشوند.
بنابراین برای شروع کار، پوشهای را به نام Entities به پروژه اضافه کرده و سپس کلاس ذیل را به آن اضافه میکنیم:
namespace Core1RtmEmptyTest.Entities { public class Person { public int PersonId { get; set; } public string FirstName { get; set; } public string LastName { get; set; } } }
using Microsoft.EntityFrameworkCore; namespace Core1RtmEmptyTest.Entities { public class ApplicationDbContext : DbContext { public ApplicationDbContext(DbContextOptions<ApplicationDbContext> options) : base(options) { } public DbSet<Person> Persons { get; set; } } }
سازندهی این کلاس نیز به نحو خاصی تعریف شدهاست. اگر به سورسهای EF Core مراجعه کنیم، کلاس پایهی DbContext دارای دو سازندهی با و بدون پارامتر است:
protected DbContext() : this((DbContextOptions) new DbContextOptions<DbContext>()) { } public DbContext([NotNull] DbContextOptions options) { // … }
using Microsoft.EntityFrameworkCore; namespace Core1RtmEmptyTest.Entities { public class ApplicationDbContext : DbContext { public DbSet<Person> Persons { get; set; } protected override void OnConfiguring(DbContextOptionsBuilder optionsBuilder) { optionsBuilder.UseSqlServer(@"... connection string ..."); } } }
اما چون در یک برنامهی ASP.NET Core، کار ثبت سرویس مربوط به EF Core، در کلاس آغازین برنامه انجام میشود و در آنجا به سادگی میتوان به خاصیت Configuration برنامه دسترسی یافت و توسط آن رشتهی اتصالی را دریافت کرد، مرسوم است از سازندهی با پارامتر DbContext به نحوی که در ابتدا عنوان شد، استفاده شود.
بنابراین در ادامه، پس از مطالعهی مطلب «ارتقاء به ASP.NET Core 1.0 - قسمت 7 - کار با فایلهای config» به فایل appsettings.json مراجعه کرده و تنظیمات رشتهی اتصالی برنامه را به صورت ذیل در آن مشخص میکنیم:
{ "ConnectionStrings": { "ApplicationDbContextConnection": "Data Source=(local);Initial Catalog=TestDbCore2016;Integrated Security = true" } }
در اینجا به وهلهی پیش فرض SQL Server اشاره شدهاست؛ از حالت اعتبارسنجی ویندوزی SQL Server استفاده میشود و بانک اطلاعاتی جدیدی به نام TestDbCore2016 در آن مشخص گردیدهاست.
پس از تعریف رشتهی اتصالی، متد OnConfiguring را از کلاس ApplicationDbContext حذف کرده و از همان نگارش دارای سازندهی با پارامتر آن استفاده میکنیم. برای اینکار به کلاس آغازین برنامه مراجعه کرده و توسط متد AddDbContext این Context را به سرویسهای ASP.NET Core معرفی میکنیم:
public class Startup { public IConfigurationRoot Configuration { set; get; } public Startup(IHostingEnvironment env) { var builder = new ConfigurationBuilder() .SetBasePath(env.ContentRootPath) .AddJsonFile("appsettings.json", reloadOnChange: true, optional: false) .AddJsonFile($"appsettings.{env}.json", optional: true); Configuration = builder.Build(); } public void ConfigureServices(IServiceCollection services) { services.AddSingleton<IConfigurationRoot>(provider => { return Configuration; }); services.AddDbContext<ApplicationDbContext>(options => { options.UseSqlServer(Configuration["ConnectionStrings:ApplicationDbContextConnection"]); });
بنابراین قسمت options.UseSqlServer را یا در اینجا مقدار دهی میکنید و یا از طریق بازنویسی متد OnConfiguring کلاس Context برنامه.
یک نکته: امکان تزریق IConfigurationRoot به کلاس Context برنامه وجود دارد
با توجه به اینکه Context برنامه را به صورت یک سرویس به ASP.NET Core معرفی کردیم، امکان تزریق وابستگیها نیز در آن وجود دارد. یعنی بجای روش فوق، میتوان IConfigurationRoot را به سازندهی کلاس Context برنامه نیز تزریق کرد:
using Microsoft.EntityFrameworkCore; using Microsoft.Extensions.Configuration; namespace Core1RtmEmptyTest.Entities { public class ApplicationDbContext : DbContext { private readonly IConfigurationRoot _configuration; public ApplicationDbContext(IConfigurationRoot configuration) { _configuration = configuration; } //public ApplicationDbContext(DbContextOptions<ApplicationDbContext> options) : base(options) //{ //} public DbSet<Person> Persons { get; set; } protected override void OnConfiguring(DbContextOptionsBuilder optionsBuilder) { optionsBuilder.UseSqlServer(_configuration["ConnectionStrings:ApplicationDbContextConnection"]); } } }
در این حالت متد ConfigureServices کلاس آغازین برنامه، چنین شکلی را پیدا میکند و ساده میشود:
public void ConfigureServices(IServiceCollection services) { services.AddDbContext<ApplicationDbContext>();
یک نکته: امکان تزریق ApplicationDbContext به تمام کلاسهای برنامه وجود دارد
همینقدر که ApplicationDbContext را به عنوان سرویسی در ConfigureServices تعریف کردیم، امکان تزریق آن در اجزای مختلف یک برنامهی ASP.NET Core نیز وجود دارد:
using System.Linq; using Core1RtmEmptyTest.Entities; using Microsoft.AspNetCore.Mvc; namespace Core1RtmEmptyTest.Controllers { public class TestDBController : Controller { private readonly ApplicationDbContext _ctx; public TestDBController(ApplicationDbContext ctx) { _ctx = ctx; } public IActionResult Index() { var name = _ctx.Persons.First().FirstName; return Json(new { firstName = name }); } } }
در این حالت پس از اجرای برنامه، خطای ذیل را مشاهده خواهیم کرد:
علت اینجا است که هنوز این بانک اطلاعاتی ایجاد نشدهاست و همچنین ساختار جداول را به آن منتقل نکردهایم که این موارد را در قسمتهای بعدی مرور خواهیم کرد.
نمونه مثالی از ASP.NET Core و Entity Framework Core به همراه معماری DDD و CQRS و Event Sourcing
Full ASP.NET Core 2.2 application with DDD, CQRS and Event Sourcing
Technologies implemented:
- ASP.NET Core 2.2 (with .NET Core 2.2)
- ASP.NET MVC Core
- ASP.NET WebApi Core
- ASP.NET Identity Core
- Entity Framework Core 2.2
- .NET Core Native DI
- AutoMapper
- FluentValidator
- MediatR
- Swagger UI
Architecture:
- Full architecture with responsibility separation concerns, SOLID and Clean Code
- Domain Driven Design (Layers and Domain Model Pattern)
- Domain Events
- Domain Notification
- CQRS (Imediate Consistency)
- Event Sourcing
- Unit of Work
- Repository and Generic Repository