مطالب دوره‌ها
لغو Lazy Loading در حین کار با AutoMapper و Entity Framework
پیشنیازها
- مطالعه‌ی مطالب گروه AutoMapper در سایت، دید خوبی را برای شروع به کار با آن فراهم می‌کنند و در اینجا قصد تکرار این مباحث پایه‌ای را نخواهیم داشت. هدف بیشتر بررسی یک سری نکات پیشرفته‌تر و عمیق‌تر است از کار با AutoMapper.
- آشنایی با Lazy loading و Eager loading در حین کار با EF


ساختار و پیشنیازهای برنامه‌ی مطلب جاری

جهت سهولت پیگیری مطلب و تمرکز بیشتر بر روی مفاهیم اصلی مورد بحث، یک برنامه‌ی کنسول را آغاز کرده و سپس بسته‌های نیوگت ذیل را به آن اضافه کنید:
PM> install-package AutoMapper
PM> install-package EntityFramework
به این ترتیب بسته‌های AutoMapper و EF به پروژه‌ی جاری اضافه خواهند شد.


آشنایی با ساختار مدل‌های برنامه

در اینجا ساختار جداول مطالب یک بلاگ را به همراه نویسندگان آن‌ها، مشاهده می‌کنید:
public class BlogPost
{
    public int Id { get; set; }
    public string Title { get; set; }
    public string Content { get; set; }
 
    [ForeignKey("UserId")]
    public virtual User User { get; set; }
    public int UserId { get; set; }
}

public class User
{
    public int Id { get; set; }
    public string Name { get; set; }
    public int Age { get; set; }
 
    public virtual ICollection<BlogPost> BlogPosts { get; set; }
}
هر کاربر می‌تواند تعدادی مطلب تهیه کند و هر مطلب توسط یک کاربر نوشته شده‌است.


هدف از این مثال

فرض کنید اطلاعاتی که قرار است به کاربر نمایش داده شوند، توسط ViewModel ذیل تهیه می‌شود:
public class UserViewModel
{
    public int Id { set; get; }
    public string Name { set; get; }
 
    public ICollection<BlogPost> BlogPosts { get; set; }
}
در اینجا می‌خواهیم اولین کاربر ثبت شده را یافته و سپس لیست مطالب آن‌را نمایش دهیم. همچنین می‌خواهیم این کوئری تهیه شده به صورت خودکار اطلاعاتش را بر اساس ساختار ViewModel ایی که مشخص کردیم (و این ViewModel الزاما تمام عناصر آن با عناصر مدل اصلی یکی نیست)، بازگشت دهیم.


تهیه نگاشت‌های AutoMapper

برای مدیریت بهتر نگاشت‌های AutoMapper توصیه شده‌است که کلاس‌های Profile ایی را به شکل ذیل تهیه کنیم:
public class TestProfile : Profile
{
    protected override void Configure()
    {
        this.CreateMap<User, UserViewModel>();
    }
 
    public override string ProfileName
    {
        get { return this.GetType().Name; }
    }
}
کار با ارث بری از کلاس پایه Profile کتابخانه‌ی AutoMapper شروع می‌شود. سپس باید متد Configure آن‌را بازنویسی کنیم. در اینجا می‌توان با استفاده از متدی مانند Create مشخص کنیم که قرار است اطلاعاتی با ساختار شیء User، به اطلاعاتی با ساختار از نوع شیء UserViewModel به صورت خودکار نگاشت شوند.


ثبت و معرفی پروفایل‌های AutoMapper

پس از تهیه‌ی پروفایل مورد نیاز، در ابتدای برنامه با استفاده از متد Mapper.Initialize، کار ثبت این تنظیمات صورت خواهد گرفت:
Mapper.Initialize(cfg => // In Application_Start()
{
    cfg.AddProfile<TestProfile>();
});


روش متداول کار با AutoMapper جهت نگاشت اطلاعات User به ViewModel آن

در ادامه به نحو متداولی، ابتدا اولین کاربر ثبت شده را یافته و سپس با استفاده از متد Mapper.Map اطلاعات این شیء user به ViewModel آن نگاشت می‌شود:
using (var context = new MyContext())
{
    var user1 = context.Users.FirstOrDefault();
    if (user1 != null)
    {
        var uiUser = new UserViewModel();
        Mapper.Map(source: user1, destination: uiUser);
 
        Console.WriteLine(uiUser.Name);
        foreach (var post in uiUser.BlogPosts)
        {
            Console.WriteLine(post.Title);
        }
    }
}
تا اینجا اگر برنامه را اجرا کنید، مشکلی را مشاهده نخواهید کرد، اما این کدها سبب اجرای حداقل دو کوئری خواهند شد:
الف) یافتن اولین کاربر
ب) واکشی لیست مطالب او در یک کوئری دیگر


کاهش تعداد رفت و برگشت‌ها به سرور با استفاده از متدهای ویژه‌ی AutoMapper

در حالت متداول کار با EF، با استفاده از متد Include می‌توان این Lazy loading را لغو کرد و در همان اولین کوئری، مطالب کاربر یافت شده را نیز دریافت نمود:
 var user1 = context.Users.Include(user => user.BlogPosts).FirstOrDefault();
و سپس این اطلاعات را توسط AutoMapper نگاشت کرد.
در این حالت، AutoMapper برای ساده سازی این مراحل، متدهای Project To را معرفی کرده‌است:
 var uiUser = context.Users.Project().To<UserViewModel>().FirstOrDefault();
در اینجا نیز Lazy loading لغو شده و به صورت خودکار جوینی به جدول مطالب کاربران ایجاد خواهد شد.
بنابراین با استفاده از متد‌های Project To می‌توان از ذکر Includeهای EF صرفنظر کرد و همچنین دیگر نیازی به نوشتن متد Select جهت نگاشت دستی خواص مورد نظر به خواص ViewModel نیست.

کدهای کامل این قسمت را از اینجا می‌توانید دریافت کنید:
AM_Sample01.zip
نظرات اشتراک‌ها
روش‌های مقابله با مشکل امنیتی Mass Assignment در ASP.NET Core
راه حل دیگر: استفاده از روش Containment بجای Inheritance
public class UserModel
{
    [MaxLength(200)]
    [Display(Name = "Full name")]
    [Required]
    public string Name { get; set; }
}

public class UserModalViewModel
{
    public UserModel Model { get; set; }
    public bool IsAdmin { get; set; }
    public IReadonlyList<lookupitem> Roles { get; set; }
}
‌‌‌
اکشن متد متناظر با درخواست GET
[HttpGet]
public async Task<IActionResult> Edit(int id)
{
    var user = await _service.FindAsync(id); //return Maybe<UserModel>
    if (!user.HasValue)
    {
        return NotFound();
    }

    // prepare model
    var model = new UserModalViewModel
    {
        Model = user.Value,
        IsAdmin = true,
        Roles = await _lookupService.ReadRolesAsync()
    };
    return View(model);
}

‌‌‌‌
اکشن متد متناظر با درخواست POST
[HttPost]
public async Task<IActionResult> Edit([Bind(Prefix = "Model")] UserModel model)
{
    //todo: check ModelState and save model
    await _service.EditAsync(model);
}

مطالب
شروع به کار با DNTFrameworkCore - قسمت 7 - ارتقاء به نسخه ‭4.5.x
بعد از انتشار قسمت 6 به عنوان آخرین قسمت مرتبط با تفکر مبتنی‌بر CRUD‏ ‎(‎CRUD-based thinking)‎ قصد دارم پشتیبانی از طراحی Application Layer مبتنی‌بر CQRS را نیز به این زیرساخت اضافه کنم.
در این مطلب تغییرات حاصل از طراحی مجدد و بازسازی انجام شده در نسخه جدید را مرور خواهیم کرد.

تغییرات کتابخانه DNTFrameworkCore

1- واسط‌های مورد استفاده جهت ردیابی موجودیت‌ها :
public interface ICreationTracking
{
    DateTime CreatedDateTime { get; set; }
}

public interface IModificationTracking
{
    DateTime? ModifiedDateTime { get; set; }
}
علاوه بر تغییر نام و نوع داده خصوصیت‌های تاریخ ایجاد و ویرایش، سایر خصوصیات به صورت خواص سایه‌ای در کتابخانه DNTFrameworkCore.EFCore مدیریت خواهند شد. 
2. با اضافه شدن واسط IHasRowIntegrity برای پشتیبانی از امکان تشخیص اصالت ردیف‌های یک بانک اطلاعاتی با استفاده از EF Core، خصوصیت RowVersion به Version تغییر نام پیدا کرد.
public interface IHasRowIntegrity
{
    string Hash { get; set; }
}

public interface IHasRowVersion
{
    byte[] Version { get; set; }
}
3- ارث‌بری از کلاس AggregateRoot در سناریوهای CRUD و در زمان استفاده از CrudService هیچ ضرورتی ندارد و صرفا برای پشتیبانی از طراحی مبتنی‌بر DDD کاربرد خواهد داشت. اگر قصد طراحی یک Rich Domain Model را دارید و رویکرد DDD را دنبال می‌کنید، با استفاده از کلاس پایه AggregateRoot امکان مدیریت DomainEventهای مرتبط با یک Aggregate را خواهید داشت. 
public abstract class AggregateRoot<TKey> : Entity<TKey>, IAggregateRoot
    where TKey : IEquatable<TKey>
{
    private readonly List<IDomainEvent> _events = new List<IDomainEvent>();
    public IReadOnlyCollection<IDomainEvent> Events => _events.AsReadOnly();

    protected virtual void AddDomainEvent(IDomainEvent newEvent)
    {
        _events.Add(newEvent);
    }

    public virtual void ClearEvents()
    {
        _events.Clear();
    }
}
4- امکان Publish رخ‌دادهای مرتبط با یک AggregateRoot به IEventBus اضافه شده است:
public static class EventBusExtensions
{
    public static Task TriggerAsync(this IEventBus bus, IEnumerable<IDomainEvent> events)
    {
        var tasks = events.Select(async domainEvent => await bus.TriggerAsync(domainEvent));
        return Task.WhenAll(tasks);
    }

    public static async Task PublishAsync(this IEventBus bus, IAggregateRoot aggregateRoot)
    {
        await bus.TriggerAsync(aggregateRoot.Events);
        aggregateRoot.ClearEvents();
    }
}
5- واسط IDbSeed به IDbSetup تغییر نام پیدا کرده است.

6- اضافه شدن یک سرویس برای ذخیره‌سازی اطلاعات به صورت Key/Value در بانک اطلاعاتی:
public interface IKeyValueService : IApplicationService
{
    Task SetValueAsync(string key, string value);
    Task<Maybe<string>> LoadValueAsync(string key);
    Task<bool> IsTamperedAsync(string key);
}

public class KeyValue : Entity, IModificationTracking, ICreationTracking, IHasRowIntegrity
{
    public string Key { get; set; }
    [Encrypted] public string Value { get; set; }
    public string Hash { get; set; }
    public DateTime CreatedDateTime { get; set; }
    public DateTime? ModifiedDateTime { get; set; }
}
7- AuthorizationProvider حذف شده و جمع آوری دسترسی‌های سیستم به عهده خود استفاده کننده از این زیرساخت می‌باشد.

8- اضافه شدن امکان Exception Mapping و همچنین سفارشی سازی پیغام‌های خطای عمومی:
    public class ExceptionOptions
    {
        public List<ExceptionMapItem> Mappings { get; } = new List<ExceptionMapItem>();

        [Required] public string DbException { get; set; }
        [Required] public string DbConcurrencyException { get; set; }
        [Required] public string InternalServerIssue { get; set; }

        public bool TryFindMapping(DbException dbException, out ExceptionMapItem mapping)
        {
            mapping = null;

            var words = new HashSet<string>(Regex.Split(dbException.ToStringFormat(), @"\W"));

            var mappingItem = Mappings.FirstOrDefault(a => a.Keywords.IsProperSubsetOf(words));
            if (mappingItem == null)
            {
                return false;
            }

            mapping = mappingItem;

            return true;
        }
    }
و روش استفاده از آن را در پروژه DNTFrameworkCore.TestAPI می‌توانید مشاهده کنید. برای معرفی نگاشت‌ها، می‌توان به شکل زیر در فایل appsetting.json عمل کرد:
"Exception": {
  "Mappings": [
    {
      "Message": "به دلیل وجود اطلاعات وابسته امکان حذف وجود ندارد",
      "Keywords": [
        "DELETE",
        "REFERENCE"
      ]
    },
    {
      "Message": "یک تسک با این عنوان قبلا در سیستم ثبت شده است",
      "MemberName": "Title",
      "Keywords": [
        "Task",
        "UIX_Task_NormalizedTitle"
      ]
    }
  ],
  "DbException": "امکان ذخیره‌سازی اطلاعات وجود ندارد؛ دوباره تلاش نمائید",
  "DbConcurrencyException": "اطلاعات توسط کاربری دیگر در شبکه تغییر کرده است",
  "InternalServerIssue": "متأسفانه مشکلی در فرآیند انجام درخواست شما پیش آمده است!"
}

8- اطلاعات مرتبط با مستأجر جاری در سناریوهای چند مستأجری از واسط IUserSession حذف شده و به واسط ITenantSession منتقل شده است. نوع داده خصوصیت UserId به String تغییر پیدا کرده و بر اساس نیاز می‌توان به شکل زیر از آن استفاده کرد:
_session.UserId
_session.UserId<long>()
_session.UserId<int>()
_session.UserId<Guid>()

علاوه بر آن خصوصیت ImpersonatorUserId که می‌تواند حاوی UserId کاربری باشد که در نقش کاربر دیگری در سناریوهای Impersonation وارد سیستم شده است؛ این مورد در سیستم Logging مبتنی‌بر فایل سیستم و بانک اطلاعاتی موجود در این زیرساخت، ثبت و نگهداری می‌شود.
9- لیست ClaimTypeهای مورد استفاده در این زیرساخت:
public static class UserClaimTypes
{
    public const string UserName = ClaimTypes.Name;
    public const string UserId = ClaimTypes.NameIdentifier;
    public const string SerialNumber = ClaimTypes.SerialNumber;
    public const string Role = ClaimTypes.Role;
    public const string DisplayName = nameof(DisplayName);
    public const string BranchId = nameof(BranchId);
    public const string BranchName = nameof(BranchName);
    public const string IsHeadOffice = nameof(IsHeadOffice);
    public const string TenantId = nameof(TenantId);
    public const string TenantName = nameof(TenantName);
    public const string IsHeadTenant = nameof(IsHeadTenant);
    public const string Permission = nameof(Permission);
    public const string PackedPermission = nameof(PackedPermission);
    public const string ImpersonatorUserId = nameof(ImpersonatorUserId);
    public const string ImpersonatorTenantId = nameof(ImpersonatorTenantId);
}
از خصوصیات Branch*‎ برای سناریوهای چند شعبه‎‌ای می‌توان استفاده کرد که در این صورت اگر یکی از شعب به عنوان دفتر مرکزی در نظر گرفته شود باید Claim‌ای با نام IsHeadOffice با مقدار true از زمان ورود به سیستم برای کاربران آن شعبه در نظر گرفته شود. 
خصوصیات Tenant*‎ برای سناریوهای چند مستأجری در نظر گرفته شده است که اگرطراحی مورد نظرتان به نحوی باشد که بخش مدیریت مستأجرهای سیستم در همان سیستم پیاده‌سازی شده باشد یا به تعبیری سیستم Host و Tenant یکی باشند، می‌توان Claim‌ای با نام IsHeadTenant با مقدار true در زمان ورود به سیستم برای کاربران Host (مستأجر اصلی) در نظر گرفته شود.
‌‌
10- مکانیزم Logging مبتنی‌بر فایل سیستم:
/// <summary>
/// Adds a file logger named 'File' to the factory.
/// </summary>
/// <param name="builder">The <see cref="ILoggingBuilder"/> to use.</param>
public static ILoggingBuilder AddFile(this ILoggingBuilder builder)
{
    builder.Services.AddSingleton<ILoggerProvider, FileLoggerProvider>();
    return builder;
}


/// <summary>
/// Adds a file logger named 'File' to the factory.
/// </summary>
/// <param name="builder">The <see cref="ILoggingBuilder"/> to use.</param>
/// <param name="configure">Configure an instance of the <see cref="FileLoggerOptions" /> to set logging options</param>
public static ILoggingBuilder AddFile(this ILoggingBuilder builder, Action<FileLoggerOptions> configure)
{
    builder.AddFile();
    builder.Services.Configure(configure);

    return builder;
}
11- امکان TenantResolution برای شناسایی مستأجر جاری سیستم:
public interface ITenantResolutionStrategy
{
    string TenantId();
}

public interface ITenantStore
{
    Task<Tenant> FindTenantAsync(string tenantId);
}
از این واسط‌ها در میان افزار TenantResolutionMiddleware موجود در کتابخانه DNTFrameworkCore.Web.Tenancy استفاده شده است. و همچنین جهت دسترسی به اطلاعات مستأجر جاری سیستم می‌توان واسط زیر را تزریق و استفاده کرد:
public interface ITenantSession : IScopedDependency
{
    /// <summary>
    ///     Gets current TenantId or null.
    ///     This TenantId should be the TenantId of the <see cref="IUserSession.UserId" />.
    ///     It can be null if given <see cref="IUserSession.UserId" /> is a head-tenant user or no user logged in.
    /// </summary>
    string TenantId { get; }

    /// <summary>
    ///     Gets current TenantName or null.
    ///     This TenantName should be the TenantName of the <see cref="IUserSession.UserId" />.
    ///     It can be null if given <see cref="IUserSession.UserId" /> is a head-tenant user or no user logged in.
    /// </summary>
    string TenantName { get; }

    /// <summary>
    ///     Represents current tenant is head-tenant.
    /// </summary>
    bool IsHeadTenant { get; }

    /// <summary>
    ///     TenantId of the impersonator.
    ///     This is filled if a user with <see cref="IUserSession.ImpersonatorUserId" /> performing actions behalf of the
    ///     <see cref="IUserSession.UserId" />.
    /// </summary>
    string ImpersonatorTenantId { get; }
}
12- استفاده از SystemTime و IClock برای افزایش تست‌پذیری سناریوهای درگیر با DateTime:
public static class SystemTime
{
    public static Func<DateTime> Now = () => DateTime.UtcNow;

    public static Func<DateTime, DateTime> Normalize = (dateTime) =>
        DateTime.SpecifyKind(dateTime, DateTimeKind.Utc);
}
public interface IClock : ITransientDependency
{
    DateTime Now { get; }
    DateTime Normalize(DateTime dateTime);
}

internal sealed class Clock : IClock
{
    public DateTime Now => SystemTime.Now();

    public DateTime Normalize(DateTime dateTime)
    {
        return SystemTime.Normalize(dateTime);
    }
}
13- تغییر واسط عمومی کلاس Result:
public class Result
{
    private static readonly Result _ok = new Result(false, string.Empty);
    private readonly List<ValidationFailure> _failures;

    protected Result(bool failed, string message) : this(failed, message,
        Enumerable.Empty<ValidationFailure>())
    {
        Failed = failed;
        Message = message;
    }

    protected Result(bool failed, string message, IEnumerable<ValidationFailure> failures)
    {
        Failed = failed;
        Message = message;
        _failures = failures.ToList();
    }

    public bool Failed { get; }
    public string Message { get; }
    public IEnumerable<ValidationFailure> Failures => _failures.AsReadOnly();

    [DebuggerStepThrough]
    public static Result Ok() => _ok;

    [DebuggerStepThrough]
    public static Result Fail(string message)
    {
        return new Result(true, message);
    }

    //...
}

روش معرفی سرویس‌های مرتبط با کتابخانه DNTFrameworkCore
services.AddFramework()
    .WithModelValidation()
    .WithFluentValidation()
    .WithMemoryCache()
    .WithSecurityService()
    .WithBackgroundTaskQueue()
    .WithRandomNumber();
متد WithFluentValidation یک متد الحاقی برای FrameworkBuilder می‌باشد که در کتابخانه DNTFrameworkCore.FluentValidation تعریف شده است.

تغییرات کتابخانه DNTFrameworkCore.EFCore

1- اگر از CrudService پایه موجود استفاده می‌کنید، محدودیت ارث‌بری از TrackableEntity از موجودیت اصلی برداشته شده است. همچنین همانطور که در نظرات مطالب قبلی در قالب نکته تکمیلی اشاره شد، متد  MapToEntity  به نحوی تغییر کرد که پاسخگوی اکثر نیازها باشد.
2- امکان تنظیم ModifiedProperties  برای موجودیت‌های وابسته در سناریوهایی با موجودیت‌های وابسته Master-Detail نیز مهیا شده است.
public abstract class TrackableEntity<TKey> : Entity<TKey>, ITrackable where TKey : IEquatable<TKey>
{
    [NotMapped] public TrackingState TrackingState { get; set; }
    [NotMapped] public ICollection<string> ModifiedProperties { get; set; }
}
3-  امکان ذخیره سازی تنظیمات برنامه‌های ASP.NET Core در یک بانک اطلاعاتی با استفاده از EF ، اضافه شده است که از همان موجودیت KeyValue برای نگهداری مقادیر استفاده می‌کند:
public static class ConfigurationBuilderExtensions
{
    public static IConfigurationBuilder AddEFCore(this IConfigurationBuilder builder,
        IServiceProvider provider)
    {
        return builder.Add(new EFConfigurationSource(provider));
    }
}
4- واسط IHookEngine حذف شده و سازنده کلاس پایه DbContextCore لیستی از IHook را به عنوان پارامتر می‌پذیرد:
protected DbContextCore(DbContextOptions options, IEnumerable<IHook> hooks) : base(options)
{
    _hooks = hooks ?? throw new ArgumentNullException(nameof(hooks));
}
 همچنین امکان IgnoreHook برای غیرفعال کردن یک Hook خاص با استفاده از نام آن مهیا شده است:
public void IgnoreHook(string hookName)
{
    _ignoredHookList.Add(hookName);
}
امکان پیاده سازی Hook سفارشی را برای سناریوهای خاص هم با پیاده سازی واسط IHook و یا با ارث‌بری از کلاس‌های پایه موجود در زیرساخت، خواهید داشت. به عنوان مثال:
internal sealed class RowIntegrityHook : PostActionHook<IHasRowIntegrity>
{
    public override string Name => HookNames.RowIntegrity;
    public override int Order => int.MaxValue;
    public override EntityState HookState => EntityState.Unchanged;

    protected override void Hook(IHasRowIntegrity entity, HookEntityMetadata metadata, IUnitOfWork uow)
    {
        metadata.Entry.Property(EFCore.Hash).CurrentValue = uow.EntityHash(entity);
    }
}
در بازطراحی انجام شده، دسترسی به وهله جاری DbContext هم از طریق واسط IUnitOfWork مهیا شده است.
5- متد EntityHash به واسط IUnitOfWork اضافه شده است که امکان محاسبه هش مرتبط با یک رکورد از یک موجودیت خاص را مهیا می‌کند؛ همچنین امکان تغییر الگوریتم و سفارشی سازی آن را به شکل زیر خواهید داشت:
//DbContextCore : IUnitOfWork

public string EntityHash<TEntity>(TEntity entity) where TEntity : class
{
    var row = Entry(entity).ToDictionary(p => p.Metadata.Name != EFCore.Hash &&
                                              !p.Metadata.ValueGenerated.HasFlag(ValueGenerated.OnUpdate) &&
                                              !p.Metadata.IsShadowProperty());
    return EntityHash<TEntity>(row);
}

protected virtual string EntityHash<TEntity>(Dictionary<string, object> row) where TEntity : class
{
    var json = JsonConvert.SerializeObject(row, Formatting.Indented);
    using (var hashAlgorithm = SHA256.Create())
    {
        var byteValue = Encoding.UTF8.GetBytes(json);
        var byteHash = hashAlgorithm.ComputeHash(byteValue);
        return Convert.ToBase64String(byteHash);
    }
}
همچنین از طریق متدهای الحاقی زیر که مرتبط با واسط IUnitOfWork می‌باشند، امکان دسترسی به رکوردهای دستکاری شده را خواهید داشت:
IsTamperedAsync
HasTamperedEntryAsync
TamperedEntryListAsync

 
6- همانطور که اشاره شد، خواص سایه‌ای مرتبط با سیستم ردیابی موجودیت‌ها نیز به شکل زیر تغییر نام پیدا کرده‌اند:
public const string CreatedDateTime = nameof(ICreationTracking.CreatedDateTime);
public const string CreatedByUserId = nameof(CreatedByUserId);
public const string CreatedByBrowserName = nameof(CreatedByBrowserName);
public const string CreatedByIP = nameof(CreatedByIP);

public const string ModifiedDateTime = nameof(IModificationTracking.ModifiedDateTime);
public const string ModifiedByUserId = nameof(ModifiedByUserId);
public const string ModifiedByBrowserName = nameof(ModifiedByBrowserName);
public const string ModifiedByIP = nameof(ModifiedByIP);
7- یک تبدیلگر سفارشی برای ذخیره سازی اشیا به صورت JSON اضافه شده است که برگرفته از کتابخانه Innofactor.EfCoreJsonValueConverter می‌باشد.
 8- دو متد الحاقی زیر برای نرمال‌سازی خصوصیات تاریخ از نوع DateTime و خصوصیات عددی از نوع Decimal به ModelBuilder اضافه شده‌اند:
protected override void OnModelCreating(ModelBuilder modelBuilder)
{  
    modelBuilder.NormalizeDateTime();
    modelBuilder.NormalizeDecimalPrecision(precision: 20, scale: 6);
    
    base.OnModelCreating(modelBuilder);
}

9-  متد MigrateDbContext به این کتابخانه منتقل شده است:
MigrateDbContext<TContext>(this IHost host)
متد Seed واسط IDbSetup در صورت معرفی یک پیاده‌سازی از آن به سیستم تزریق وابستگی‌ها، در بدنه این متد فراخوانی خواهد شد.

روش معرفی سرویس‌های مرتبط با کتابخانه DNTFrameworkCore.EFCore
services.AddEFCore<ProjectDbContext>()
    .WithTrackingHook<long>()
    .WithDeletedEntityHook()
    .WithRowIntegrityHook()
    .WithNumberingHook(options =>
    {
        options.NumberedEntityMap[typeof(Task)] = new NumberedEntityOption
        {
            Prefix = "Task",
            FieldNames = new[] {nameof(Task.BranchId)}
        };
    });
همانطور که عنوان شد، محدودیت نوع خصوصیات CreatedByUserId و ModifiedByUserId برداشته شده است و از طریق متد WithTrackingHook قابل تنظیم می‎‌باشد.

تغییرات کتابخانه DNTFrameworkCore.Web.Tenancy


فعلا امکان شناسایی مستأجر جاری و دسترسی به اطلاعات آن از طریق واسط ITenantSession در دسترس می‌باشد؛ همچنین امکان تغییر و تعیین رشته اتصال به بانک اطلاعاتی هر مستأجر از طریق متد UseConnectionString واسط IUnitOfWork فراهم می‌باشد.
services.AddTenancy()
    .WithTenantSession()
    .WithStore<InMemoryTenantStore>()
    .WithResolutionStrategy<HostResolutionStrategy>();
app.UseTenancy();


سایر کتابخانه‌ها تغییرات خاصی نداشتند و صرفا نحوه معرفی سرویس‌های آنها ممکن است تغییر کند و یا وابستگی‌های آنها به آخرین نسخه موجود ارتقاء داده شده باشند که در پروژه DNTFrameworkCore.TestAPI اعمال شده‌اند.
لیست بسته‌های نیوگت نسخه ۴.۵.۳
PM> Install-Package DNTFrameworkCore
PM> Install-Package DNTFrameworkCore.EFCore
PM> Install-Package DNTFrameworkCore.EFCore.SqlServer
PM> Install-Package DNTFrameworkCore.Web
PM> Install-Package DNTFrameworkCore.FluentValidation
PM> Install-Package DNTFrameworkCore.Web.Tenancy
PM> Install-Package DNTFrameworkCore.Licensing
مطالب
راه اندازی دیتابیس postgresql در برنامه‌های ASP.NET Core – قسمت 2

در قسمت قبل به معرفی postgresql پرداختیم; در این قسمت قصد ایجاد و راه اندازی یک api با استفاده از دیتابیس postgresql و استفاده از تکنولوژی‌های آن را با استفاده از docker داریم.


ابتدا با استفاده از دستور زیر یک پروژه‌ی جدید asp.net core را ایجاد کنید:

dotnet new webapi --minimal -o YourDirectoryPath:\YourFolderName

سپس فایل docker-compose.yaml را به روت پروژه اضافه کنید که شامل کانفیگ‌های زیر میباشد: 

version: '3.1'

services:

  db:
    image: postgres
    container_name: db
    restart: always
    environment:
      POSTGRES_PASSWORD: postgres
      POSTGRES_USERNAME: postgres
      POSTGRES_DB: BloggingDb
    ports:
        - "5432:5432"
    volumes:
      - postgres_data:/data/db

  adminer:
    image: adminer
    restart: always
    ports:
      - 8080:8080
  
  pgadmin4:
    image: dpage/pgadmin4
    restart: always
    environment:
      PGADMIN_DEFAULT_EMAIL: pgadmin4@pgadmin.org
      PGADMIN_DEFAULT_PASSWORD: admin
      PGADMIN_CONFIG_SERVER_MODE: 'False'
    ports:
      - 5050:80
    volumes:
      - pgadmin:/var/lib/pgadmin
    depends_on:
      - db

volumes:
  postgres_data:
  pgadmin:

سپس با اجرای دستور زیر در روت پروژه، سرویس‌ها را راه اندازی کنید: 

docker compose up -d


معرفی سرویس‌های استفاده شده در تنظیمات فایل بالا: 

سرویس db

نمونه ایمیج اصلی، volume، تنظیمات connection string در آن استفاده شده است.

سرویس adminer :

https://hub.docker.com/_/adminer /

Adminer - Database management in a single PHP file

یک برنامه تحت وب مدیریت پایگاه داده ساده میباشد که ویژگی‌ها MySql را در کنار سرعت و امنیت ارائه میدهد و در آدرس http://localhost:8080 / اجرا خواهد شد.

سرویس pgadmin4 :

pgAdmin - PostgreSQL Tools

dpage/pgadmin4 - Docker Image | Docker Hub

در حال حاضر این برنامه محبوب‌ترین برنامه مدیریت پایگاه داده میباشد که ویژگی‌های پیشرفته‌ای را نیز پوشش میدهد و در آدرس http://localhost:5050 / اجرا خواهد شد. 


اکنون نوبت نوشتن کد‌ها میباشد. 

- تنظیم connection string در فایل appsettings.json:

"ConnectionStrings": {
    "BloggingContext": "Username=postgres;Password=postgres;Server=localhost;Database=BloggingDb”
}

- و همینطور پکیج‌های زیر را به برنامه خود رفرنس دهید: 

dotnet add package Npgsql.EntityFrameworkCore.PostgreSQL
dotnet add package Microsoft.EntityFrameworkCore
dotnet add package Microsoft.EntityFrameworkCore.Design

- مدل‌های برنامه را در مسیر /Models ایجاد کنید: 

namespace NpgsqlAPI.Models;

public class Post
{
    public int PostId { get; set; }
    public string Title { get; set; } = null!;
    public string Content { get; set; } = null!;

    public int BlogId { get; set; }
    public Blog Blog { get; set; } = null!;
}

namespace NpgsqlAPI.Models;

public class Blog
{
    public int BlogId { get; set; }
    public string? Url { get; set; }

    public List<Post>? Posts { get; set; }
}

- سپس BloggingContext را در مسیر /Data ایجاد کنید:

using Microsoft.EntityFrameworkCore;
using NpgsqlAPI.Models;

namespace NpgsqlAPI.Data;

public class BloggingContext : DbContext
{
    public BloggingContext(DbContextOptions<BloggingContext> options)
        : base(options)
    {
    }
    public DbSet<Blog> Blogs => Set<Blog>();
    public DbSet<Post


- سپس اینترفیس IBlogServices را در مسیر  /Servicec/Blogs ایجاد کنید: 

using NpgsqlAPI.Models;

namespace NpgsqlAPI.Services.Blogs;
public interface IBlogServices
{
    Task<IEnumerable<Blog>> GetList();
    Task<Blog?> Get(uint id);
    Task<uint> Add(Blog obj);
    Task AddRange(Blog[] obj);
    Task Update(Blog obj);
    Task UpdateRange(Blog[] obj);
    Task Remove(uint id);
}


-  و سپس پیاده سازی آن را در فایل BlogEFServices و در کنار اینترفیس آن قرار دهید: 

using Microsoft.EntityFrameworkCore;
using NpgsqlAPI.Data;
using NpgsqlAPI.Models;

namespace NpgsqlAPI.Services.Blogs;
public sealed class BlogEFServices : IBlogServices
{
    private readonly BloggingContext _context;
    public BlogEFServices(BloggingContext context)
    {
        _context = context;
    }

    public async Task<uint> Add(Blog obj)
    {
        await _context.Blogs.AddAsync(obj);
        return (uint)await SaveChangesAsync();
    }

    public async Task AddRange(Blog[] obj)
    {
        await _context.Blogs.AddRangeAsync(obj);
        await SaveChangesAsync();
    }

    public async Task<Blog?> Get(uint id)
    {
        return await _context.Blogs.FirstOrDefaultAsync(x=>x.BlogId == id);
    }

    public async Task<IEnumerable<Blog>> GetList()
    {
       return await _context.Blogs.ToListAsync();
    }

    public async Task Remove(uint id)
    {
        var entity = await Get(id);
        _context.Blogs.Remove(entity!);
        await SaveChangesAsync();
    }

    public async Task Update(Blog obj)
    {
        _context.Blogs.Update(obj);
        await SaveChangesAsync();
    }

    public async Task UpdateRange(Blog[] obj)
    {
        _context.Blogs.UpdateRange(obj);
        await SaveChangesAsync();
    }

    private async Task<int> SaveChangesAsync()
    {
        return await _context.SaveChangesAsync();
    }
}


- اکنون  endpoint‌های api را در فایل program.cs ایجاد کنید:

using System.Data;
using Microsoft.EntityFrameworkCore;
using Npgsql;
using NpgsqlAPI.Services.Blogs;
using NpgsqlAPI.Data;
using NpgsqlAPI.Models;

var builder = WebApplication.CreateBuilder(args);

builder.Services.AddEndpointsApiExplorer();
builder.Services.AddSwaggerGen();

string connectionString = builder.Configuration.GetConnectionString("BloggingContext")!;

builder.Services.AddDbContext<BloggingContext>(options =>
        options.UseNpgsql(connectionString));

builder.Services.AddTransient<IDbConnection>(_ => new NpgsqlConnection(connectionString));

// builder.Services.AddScoped<IBlogServices, BlogDapperServices>();
// builder.Services.AddScoped<IBlogServices, BlogEFRawQueryServices>();
builder.Services.AddScoped<IBlogServices, BlogEFServices>();

var app = builder.Build();

if (app.Environment.IsDevelopment())
{
    app.UseSwagger();
    app.UseSwaggerUI();
}

app.UseHttpsRedirection();

app.MapGet("/blogs", async (IBlogServices service) => await service.GetList())
.WithName("GetBlogs")
.WithOpenApi();

app.MapGet("/blogs/{id}", async (IBlogServices service, uint id) => await service.Get(id))
.WithName("GetBlog")
.WithOpenApi();

app.MapPost("/blogs", async (IBlogServices service, Blog blog) => await service.Add(blog))
.WithName("AddBlog")
.WithOpenApi();

app.MapDelete("/blogs/{id}", async (IBlogServices service, uint id) => await service.Remove(id))
.WithName("RemoveBlog")
.WithOpenApi();

app.MapPut("/blogs", async (IBlogServices service, Blog blog) => await service.Update(blog))
.WithName("UpdateBlog")
.WithOpenApi();

app.MapPut("/blogs/Bulk", async (IBlogServices service, Blog[] blogs) =>
 await service.UpdateRange(blogs))
.WithName("UpdateBulkBlog")
.WithOpenApi();

app.MapPost("/blogs/Bulk", async (IBlogServices service, Blog[] blogs) =>
 await service.AddRange(blogs))
.WithName("AddBulkBlog")
.WithOpenApi();

app.Run();

تمامی کد‌های برنامه تا به اینجا نوشته شده‌اند. اکنون migration را پس از اطمینان از اجرا بودن داکر اجرا کنید 

dotnet ef migrations add Init
dotnet ef database update

و برنامه را اجرا و تست کنید. 


کد‌های کامل این مطلب

مطالب
امن سازی برنامه‌های ASP.NET Core توسط IdentityServer 4x - قسمت ششم - کار با User Claims
از Claims جهت ارائه‌ی اطلاعات مرتبط با هویت هر کاربر و همچنین Authorization استفاده می‌شود. برای مثال درنگارش‌های قبلی ASP.NET، مفاهیمی مانند «نقش‌ها» وجود دارند. در نگارش‌های جدیدتر آن، «نقش‌ها» تنها یکی از انواع «User Claims» هستند. در قسمت‌های قبل روش تعریف این Claims را در IDP و همچنین تنظیمات مورد نیاز جهت دریافت آن‌ها را در سمت برنامه‌ی Mvc Client بررسی کردیم. در اینجا مطالب تکمیلی کار با User Claims را مرور خواهیم کرد.


بررسی Claims Transformations

می‌خواهیم Claims بازگشت داده شده‌ی توسط IDP را به یکسری Claims که کار کردن با آن‌ها در برنامه‌ی MVC Client ساده‌تر است، تبدیل کنیم.
زمانیکه اطلاعات Claim، توسط میان‌افزار oidc دریافت می‌شود، ابتدا بررسی می‌شود که آیا دیکشنری نگاشت‌ها وجود دارد یا خیر؟ اگر بله، کار نگاشت‌ها از یک claim type به claim type دیگر انجام می‌شود.
برای مثال لیست claims اصلی بازگشت داده شده‌ی توسط IDP، پس از تبدیلات و نگاشت‌های آن در برنامه‌ی کلاینت، یک چنین شکلی را پیدا می‌کند:
Claim type: sid - Claim value: f3940d6e58cbb576669ee49c90e22cb1
Claim type: http://schemas.xmlsoap.org/ws/2005/05/identity/claims/nameidentifier - Claim value: d860efca-22d9-47fd-8249-791ba61b07c7
Claim type: http://schemas.microsoft.com/identity/claims/identityprovider - Claim value: local
Claim type: http://schemas.microsoft.com/claims/authnmethodsreferences - Claim value: pwd
Claim type: given_name - Claim value: Vahid
Claim type: family_name - Claim value: N
در ادامه می‌خواهیم نوع‌های آن‌ها را ساده‌تر کنیم و آن‌ها را دقیقا تبدیل به همان claim typeهایی کنیم که در سمت IDP تنظیم شده‌اند. برای این منظور به فایل src\MvcClient\ImageGallery.MvcClient.WebApp\Startup.cs مراجعه کرده و سازنده‌ی آن‌را به صورت زیر تغییر می‌دهیم:
namespace ImageGallery.MvcClient.WebApp
{
    public class Startup
    {
        public Startup(IConfiguration configuration)
        {
            Configuration = configuration;
            JwtSecurityTokenHandler.DefaultInboundClaimTypeMap.Clear();
        }
در اینجا جدول نگاشت‌های پیش‌فرض Claims بازگشت داده شده‌ی توسط IDP به Claims سمت کلاینت، پاک می‌شود.
در ادامه اگر مجددا لیست claims را پس از logout و login، بررسی کنیم، به این صورت تبدیل شده‌است:
• Claim type: sid - Claim value: 91f5a09da5cdbbe18762526da1b996fb
• Claim type: sub - Claim value: d860efca-22d9-47fd-8249-791ba61b07c7
• Claim type: idp - Claim value: local
• Claim type: given_name - Claim value: Vahid
• Claim type: family_name - Claim value: N
اکنون این نوع‌ها دقیقا با آن‌چیزی که IDP ارائه می‌دهد، تطابق دارند.


کار با مجموعه‌ی User Claims

تا اینجا لیست this.User.Claims، به همراه تعدادی Claims است که به آن‌ها نیازی نداریم؛ مانند sid که بیانگر session id در سمت IDP است و یا idp به معنای identity provider می‌باشد. حذف آن‌ها حجم کوکی نگهداری کننده‌ی آن‌ها را کاهش می‌دهد. همچنین می‌خواهیم تعدادی دیگر را نیز به آن‌ها اضافه کنیم.
علاوه بر این‌ها میان‌افزار oidc، یکسری از claims دریافتی را راسا فیلتر و حذف می‌کند؛ مانند زمان منقضی شدن توکن و امثال آن که در عمل واقعا به تعدادی از آن‌ها نیازی نداریم. اما می‌توان این سطح تصمیم گیری فیلتر claims رسیده را نیز کنترل کرد. در تنظیمات متد AddOpenIdConnect، خاصیت options.ClaimActions نیز وجود دارد که توسط آن می‌توان بر روی حذف و یا افزوده شدن Claims، کنترل بیشتری را اعمال کرد:
namespace ImageGallery.MvcClient.WebApp
{
        public void ConfigureServices(IServiceCollection services)
        {
// ...
              .AddOpenIdConnect("oidc", options =>
              {
  // ...
                  options.ClaimActions.Remove("amr");
                  options.ClaimActions.DeleteClaim("sid");
                  options.ClaimActions.DeleteClaim("idp");
              });
        }
در اینجا فراخوانی متد Remove، به معنای حذف فیلتری است که کار حذف کردن خودکار claim ویژه‌ی amr را که بیانگر نوع authentication است، انجام می‌دهد (متد Remove در اینجا یعنی مطمئن شویم که amr در لیست claims باقی می‌ماند). همچنین فراخوانی متد DeleteClaim، به معنای حذف کامل یک claim موجود است.

اکنون اگر پس از logout و login، لیست this.User.Claims را بررسی کنیم، دیگر خبری از sid و idp در آن نیست. همچنین claim از نوع amr نیز به صورت پیش‌فرض حذف نشده‌است:
• Claim type: sub - Claim value: d860efca-22d9-47fd-8249-791ba61b07c7
• Claim type: amr - Claim value: pwd
• Claim type: given_name - Claim value: Vahid
• Claim type: family_name - Claim value: N

افزودن Claim جدید آدرس کاربر

برای افزودن Claim جدید آدرس کاربر، به کلاس src\IDP\DNT.IDP\Config.cs مراجعه کرده و آن‌را به صورت زیر تکمیل می‌کنیم:
namespace DNT.IDP
{
    public static class Config
    {
        // identity-related resources (scopes)
        public static IEnumerable<IdentityResource> GetIdentityResources()
        {
            return new List<IdentityResource>
            {
                new IdentityResources.OpenId(),
                new IdentityResources.Profile(),
                new IdentityResources.Address()
            };
        }
در اینجا در لیست Resources، گزینه‌ی IdentityResources.Address نیز اضافه شده‌است که به Claim آدرس مرتبط است.
همین مورد را به لیست AllowedScopes متد GetClients نیز اضافه می‌کنیم:
AllowedScopes =
{
    IdentityServerConstants.StandardScopes.OpenId,
    IdentityServerConstants.StandardScopes.Profile,
    IdentityServerConstants.StandardScopes.Address
},
در آخر Claim متناظر با Address را نیز به اطلاعات هر کاربر، در متد GetUsers، اضافه می‌کنیم:
namespace DNT.IDP
{
    public static class Config
    {
        public static List<TestUser> GetUsers()
        {
            return new List<TestUser>
            {
                new TestUser
                {
// ...
                    Claims = new List<Claim>
                    {
// ...
                        new Claim("address", "Main Road 1")
                    }
                },
                new TestUser
                {
// ...
                    Claims = new List<Claim>
                    {
// ...
                        new Claim("address", "Big Street 2")
                    }
                }
            };
        }
تا اینجا تنظیمات IDP برای افزودن Claim جدید آدرس به پایان می‌رسد.
پس از آن به کلاس ImageGallery.MvcClient.WebApp\Startup.cs مراجعه می‌کنیم تا درخواست این claim را به لیست scopes میان‌افزار oidc اضافه کنیم:
namespace ImageGallery.MvcClient.WebApp
{
    public class Startup
    {
        public void ConfigureServices(IServiceCollection services)
        {
// ...
              .AddOpenIdConnect("oidc", options =>
              {
   // ...
                  options.Scope.Add("address");
   // …
   options.ClaimActions.DeleteClaim("address");
              });
        }
همچنین می‌خواهیم مطمئن شویم که این claim درون claims identity قرار نمی‌گیرد. به همین جهت DeleteClaim در اینجا فراخوانی شده‌است تا این Claim به کوکی نهایی اضافه نشود تا بتوانیم همواره آخرین مقدار به روز شده‌ی آن‌را از UserInfo Endpoint دریافت کنیم.

یک نکته: فراخوانی DeleteClaim بر روی address غیر ضروری است و می‌شود این سطر را حذف کرد. از این جهت که اگر به سورس OpenID Connect Options مایکروسافت مراجعه کنیم، مشاهده خواهیم کرد که میان‌افزار اعتبارسنجی استاندارد ASP.NET Core، تنها تعداد معدودی از claims را نگاشت می‌کند. به این معنا که هر claim ای که در token وجود داشته باشد، اما اینجا نگاشت نشده باشد، در claims نهایی حضور نخواهند داشت و address claim یکی از این‌ها نیست. بنابراین در لیست نهایی this.User.Claims حضور نخواهد داشت؛ مگر اینکه مطابق همین سورس، با استفاده از متد options.ClaimActions.MapUniqueJsonKey، یک نگاشت جدید را برای آن تهیه کنیم و البته چون نمی‌خواهیم آدرس در لیست claims وجود داشته باشد، این نگاشت را تعریف نخواهیم کرد.


دریافت اطلاعات بیشتری از کاربران از طریق UserInfo Endpoint

همانطور که در قسمت قبل با بررسی «تنظیمات بازگشت Claims کاربر به برنامه‌ی کلاینت» عنوان شد، میان‌افزار oidc با UserInfo Endpoint کار می‌کند که تمام عملیات آن خودکار است. در اینجا امکان کار با آن از طریق برنامه نویسی مستقیم نیز جهت دریافت اطلاعات بیشتری از کاربران، وجود دارد. برای مثال شاید به دلایل امنیتی نخواهیم آدرس کاربر را در لیست Claims او قرار دهیم. این مورد سبب کوچک‌تر شدن کوکی متناظر با این اطلاعات و همچنین دسترسی به اطلاعات به روزتری از کاربر می‌شود.
درخواستی که به سمت UserInfo Endpoint ارسال می‌شود، باید یک چنین فرمتی را داشته باشد:
GET idphostaddress/connect/userinfo
Authorization: Bearer R9aty5OPlk
یک درخواست از نوع GET و یا POST است که به سمت آدرس UserInfo Endpoint ارسال می‌شود. در اینجا ذکر Access token نیز ضروری است. از این جهت که بر اساس scopes ذکر شده‌ی در آن، لیست claims درخواستی مشخص شده و بازگشت داده می‌شوند.

اکنون برای دریافت دستی اطلاعات آدرس از IDP و UserInfo Endpoint آن، ابتدا نیاز است بسته‌ی نیوگت IdentityModel را به پروژه‌ی Mvc Client اضافه کنیم:
dotnet add package IdentityModel
توسط این بسته می‌توان به DiscoveryClient دسترسی یافت و به کمک آن آدرس UserInfo Endpoint را استخراج می‌کنیم:
namespace ImageGallery.MvcClient.WebApp.Controllers
{
    [Authorize]
    public class GalleryController : Controller
    {
        public async Task<IActionResult> OrderFrame()
        {
            var discoveryClient = new DiscoveryClient(_configuration["IDPBaseAddress"]);
            var metaDataResponse = await discoveryClient.GetAsync();

            var userInfoClient = new UserInfoClient(metaDataResponse.UserInfoEndpoint);

            var accessToken = await HttpContext.GetTokenAsync(OpenIdConnectParameterNames.AccessToken);
            var response = await userInfoClient.GetAsync(accessToken);
            if (response.IsError)
            {
                throw new Exception("Problem accessing the UserInfo endpoint.", response.Exception);
            }

            var address = response.Claims.FirstOrDefault(c => c.Type == "address")?.Value;
            return View(new OrderFrameViewModel(address));
        }
در اینجا یک اکشن متد جدید را جهت سفارش نگارش قاب شده‌ی تصاویر گالری اضافه کرده‌ایم. کار این اکشن متد، دریافت آخرین آدرس شخص از IDP و سپس ارسال آن به View متناظر است. برای این منظور ابتدا یک DiscoveryClient را بر اساس آدرس IDP تشکیل داده‌ایم. حاصل آن متادیتای این IDP است که یکی از مشخصات آن UserInfoEndpoint می‌باشد. سپس بر این اساس، UserInfoClient را تشکیل داده و Access token جاری را به سمت این endpoint ارسال می‌کنیم. حاصل آن، آخرین Claims کاربر است که مستقیما از IDP دریافت شده و از کوکی جاری کاربر خوانده نمی‌شود. سپس از این لیست، مقدار Claim متناظر با address را استخراج کرده و آن‌را به View ارسال می‌کنیم.
OrderFrameViewModel ارسالی به View، یک چنین شکلی را دارد:
namespace ImageGallery.MvcClient.ViewModels
{
    public class OrderFrameViewModel
    {
        public string Address { get; } = string.Empty;

        public OrderFrameViewModel(string address)
        {
            Address = address;
        }
    }
}
و View متناظر با این اکشن متد در آدرس Views\Gallery\OrderFrame.cshtml به صورت زیر تعریف شده‌است که آدرس شخص را نمایش می‌دهد:
@model ImageGallery.MvcClient.ViewModels.OrderFrameViewModel
<div class="container">
    <div class="h3 bottomMarginDefault">Order a framed version of your favorite picture.</div>
    <div class="text bottomMarginSmall">We've got this address on record for you:</div>
    <div class="text text-info bottomMarginSmall">@Model.Address</div>
    <div class="text">If this isn't correct, please contact us.</div>
</div>

سپس به Shared\_Layout.cshtml مراجعه کرده و لینکی را به این اکشن متد و View، اضافه می‌کنیم:
<li><a asp-area="" asp-controller="Gallery" asp-action="OrderFrame">Order a framed picture</a></li>

اکنون اگر برنامه را اجرا کنیم، پس از login، یک چنین خروجی قابل مشاهده است:


همانطور که ملاحظه می‌کنید، آدرس شخص به صورت مستقیم از UserInfo Endpoint دریافت و نمایش داده شده‌است.


بررسی Authorization مبتنی بر نقش‌ها

تا اینجا مرحله‌ی Authentication را که مشخص می‌کند کاربر وار شده‌ی به سیستم کیست، بررسی کردیم که اطلاعات آن از Identity token دریافتی از IDP استخراج می‌شود. مرحله‌ی پس از ورود به سیستم، مشخص کردن سطوح دسترسی کاربر و تعیین این مورد است که کاربر مجاز به انجام چه کارهایی می‌باشد. به این مرحله Authorization می‌گویند و روش‌های مختلفی برای مدیریت آن وجود دارد:
الف) RBAC و یا Role-based Authorization و یا تعیین سطوح دسترسی بر اساس نقش‌های کاربر
در این حالت claim ویژه‌ی role، از IDP دریافت شده و توسط آن یکسری سطوح دسترسی کاربر مشخص می‌شوند. برای مثال کاربر وارد شده‌ی به سیستم می‌تواند تصویری را اضافه کند و یا آیا مجاز است نگارش قاب شده‌ی تصویری را درخواست دهد؟
ب) ABAC و یا Attribute based access control روش دیگر مدیریت سطوح دسترسی است و عموما آن‌را نسبت به حالت الف ترجیح می‌دهند که آن‌را در قسمت‌های بعدی بررسی خواهیم کرد.

در اینجا روش «تعیین سطوح دسترسی بر اساس نقش‌های کاربر» را بررسی می‌کنیم. برای این منظور به تنظیمات IDP در فایل src\IDP\DNT.IDP\Config.cs مراجعه کرده و claims جدیدی را تعریف می‌کنیم:
namespace DNT.IDP
{
    public static class Config
    {
        public static List<TestUser> GetUsers()
        {
            return new List<TestUser>
            {
                new TestUser
                {
//...
                    Claims = new List<Claim>
                    {
//...
                        new Claim("role", "PayingUser")
                    }
                },
                new TestUser
                {
//...
                    Claims = new List<Claim>
                    {
//...
                        new Claim("role", "FreeUser")
                    }
                }
            };
        }
در اینجا دو نقش FreeUser و PayingUser به صورت claimهایی جدید به دو کاربر IDP اضافه شده‌اند.

سپس باید برای این claim جدید یک scope جدید را نیز به قسمت GetIdentityResources اضافه کنیم تا توسط client قابل دریافت شود:
namespace DNT.IDP
{
    public static class Config
    {
        public static IEnumerable<IdentityResource> GetIdentityResources()
        {
            return new List<IdentityResource>
            {
                // ...
                new IdentityResource(
                    name: "roles",
                    displayName: "Your role(s)",
                    claimTypes: new List<string>() { "role" })
            };
        }
چون roles یکی از scopeهای استاندارد نیست، آن‌را به صورت یک IdentityResource سفارشی تعریف کرده‌ایم. زمانیکه scope ای به نام roles درخواست می‌شود، لیستی از نوع claimTypes بازگشت داده خواهد شد.

همچنین باید به کلاینت مجوز درخواست این scope را نیز بدهیم. به همین جهت آن‌را به AllowedScopes مشخصات Client نیز اضافه می‌کنیم:
namespace DNT.IDP
{
    public static class Config
    {
        public static IEnumerable<Client> GetClients()
        {
            return new List<Client>
            {
                new Client
                {
// ...
                    AllowedScopes =
                    {
// ...
                        "roles"
                    }
// ...
                }
             };
        }

در ادامه قرار است تنها کاربری که دارای نقش PayingUser است،  امکان دسترسی به سفارش نگارش قاب شده‌ی تصاویر را داشته باشد. به همین جهت به کلاس ImageGallery.MvcClient.WebApp\Startup.cs برنامه‌ی کلاینت مراجعه کرده و درخواست scope نقش‌های کاربر را به متد تنظیمات AddOpenIdConnect اضافه می‌کنیم:
options.Scope.Add("roles");

برای آزمایش آن، یکبار از برنامه خارج شده و مجددا به آن وارد شوید. اینبار در صفحه‌ی consent، از کاربر مجوز دسترسی به نقش‌های او نیز سؤال پرسیده می‌شود:


اما اگر به لیست موجود در this.User.Claims در برنامه‌ی کلاینت مراجعه کنیم، نقش او را مشاهده نخواهیم کرد و به این لیست اضافه نشده‌است:
• Claim type: sub - Claim value: d860efca-22d9-47fd-8249-791ba61b07c7
• Claim type: amr - Claim value: pwd
• Claim type: given_name - Claim value: Vahid
• Claim type: family_name - Claim value: N

همانطور که در نکته‌ای پیشتر نیز ذکر شد، چون role جزو لیست نگاشت‌های OpenID Connect Options مایکروسافت نیست، آن‌را به صورت خودکار به لیست claims اضافه نمی‌کند؛ دقیقا مانند آدرسی که بررسی کردیم. برای رفع این مشکل و افزودن نگاشت آن در متد تنظیمات AddOpenIdConnect، می‌توان از متد MapUniqueJsonKey به صورت زیر استفاده کرد:
options.ClaimActions.MapUniqueJsonKey(claimType: "role", jsonKey: "role");
برای آزمایش آن، یکبار از برنامه خارج شده و مجددا به آن وارد شوید. اینبار لیست this.User.Claims به صورت زیر تغییر کرده‌است که شامل role نیز می‌باشد:
• Claim type: sub - Claim value: d860efca-22d9-47fd-8249-791ba61b07c7
• Claim type: amr - Claim value: pwd
• Claim type: given_name - Claim value: Vahid
• Claim type: family_name - Claim value: N
• Claim type: role - Claim value: PayingUser


استفاده از نقش تعریف شده جهت محدود کردن دسترسی به سفارش تصاویر قاب شده

در ادامه قصد داریم لینک درخواست تصاویر قاب شده را فقط برای کاربرانی که دارای نقش PayingUser هستند، نمایش دهیم. به همین جهت به فایل Views\Shared\_Layout.cshtml مراجعه کرده و آن‌را به صورت زیر تغییر می‌دهیم:
@if(User.IsInRole("PayingUser"))
{
   <li><a asp-area="" asp-controller="Gallery" asp-action="OrderFrame">Order a framed picture</a></li>
}
در این حالت از متد استاندارد User.IsInRole برای بررسی نقش کاربر جاری استفاده شده‌است. اما این متد هنوز نمی‌داند که نقش‌ها را باید از کجا دریافت کند و اگر آن‌را آزمایش کنید، کار نمی‌کند. برای تکمیل آن به فایل ImageGallery.MvcClient.WebApp\Startup.cs مراجعه کرده تنظیمات متد AddOpenIdConnect را به صورت زیر تغییر می‌دهیم:
options.TokenValidationParameters = new TokenValidationParameters
{
    NameClaimType = JwtClaimTypes.GivenName,
    RoleClaimType = JwtClaimTypes.Role,
};
TokenValidationParameters نحوه‌ی اعتبارسنجی توکن دریافتی از IDP را مشخص می‌کند. همچنین مشخص می‌کند که چه نوع claim ای از token دریافتی  از IDP به RoleClaimType سمت ASP.NET Core نگاشت شود.
اکنون برای آزمایش آن یکبار از سیستم خارج شده و مجددا به آن وارد شوید. پس از آن لینک درخواست نگارش قاب شده‌ی تصاویر، برای کاربر User 1 نمایان خواهد بود و نه برای User 2 که FreeUser است.

البته هرچند تا این لحظه لینک نمایش View متناظر با اکشن متد OrderFrame را امن کرده‌ایم، اما هنوز خود این اکشن متد به صورت مستقیم با وارد کردن آدرس https://localhost:5001/Gallery/OrderFrame در مرورگر قابل دسترسی است. برای رفع این مشکل به کنترلر گالری مراجعه کرده و دسترسی به اکشن متد OrderFrame را توسط فیلتر Authorize و با مقدار دهی خاصیت Roles آن محدود می‌کنیم:
namespace ImageGallery.MvcClient.WebApp.Controllers
{
    [Authorize]
    public class GalleryController : Controller
    {
        [Authorize(Roles = "PayingUser")]
        public async Task<IActionResult> OrderFrame()
        {
خاصیت Roles فیلتر Authorize، می‌تواند چندین نقش جدا شده‌ی توسط کاما را نیز دریافت کند.
برای آزمایش آن، توسط مشخصات کاربر User 2 به سیستم وارد شده و آدرس https://localhost:5001/Gallery/OrderFrame را مستقیما در مرورگر وارد کنید. در این حالت یک چنین تصویری نمایان خواهد شد:


همانطور که مشاهده می‌کنید، علاوه بر عدم دسترسی به این اکشن متد، به صفحه‌ی Account/AccessDenied که هنوز در برنامه‌ی کلاینت تعریف نشده‌است، هدایت شده‌ایم. به همین جهت خطای 404 و یا یافت نشد، نمایش داده شده‌است.
برای تغییر مقدار پیش‌فرض صفحه‌ی عدم دسترسی، ابتدا Controllers\AuthorizationController.cs را با این محتوا ایجاد می‌کنیم:
using Microsoft.AspNetCore.Mvc;

public class AuthorizationController : Controller
{
    public IActionResult AccessDenied()
    {
        return View();
    }
}
سپس View آن‌را در فایل Views\Authorization\AccessDenied.cshtml به صورت زیر تکمیل خواهیم کرد که لینک به logout نیز در آن وجود دارد:
<div class="container">
    <div class="h3">Woops, looks like you're not authorized to view this page.</div>
    <div>Would you prefer to <a asp-controller="Gallery" asp-action="Logout">log in as someone else</a>?</div>
</div>

اکنون نیاز است تا این آدرس جدید را به کلاس ImageGallery.MvcClient.WebApp\Startup.cs معرفی کنیم.
namespace ImageGallery.MvcClient.WebApp
{
    public class Startup
    {
        public void ConfigureServices(IServiceCollection services)
        {
            services.AddAuthentication(options =>
            {
                // ...
            }).AddCookie("Cookies", options =>
              {
                  options.AccessDeniedPath = "/Authorization/AccessDenied";
              })
// ...
آدرس جدید Authorization/AccessDenied باید به تنظیمات AddCookie اضافه شود تا توسط سیستم شناخته شده و مورد استفاده قرار گیرد. علت اینجا است که role claims، از کوکی رمزنگاری شده‌ی برنامه استخراج می‌شوند. به همین جهت تنظیم آن مرتبط است به AddCookie.
برای آزمایش آن، یکبار از برنامه خارج شده و مجددا با اکانت User 2 به آن وارد شوید و آدرس https://localhost:5001/Gallery/OrderFrame را مستقیما در مرورگر وارد کنید. اینبار تصویر زیر که همان آدرس جدید تنظیم شده‌است نمایش داده خواهد شد:




کدهای کامل این قسمت را از اینجا می‌توانید دریافت کنید.
برای اجرای برنامه:
- ابتدا به پوشه‌ی 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 وارد کنید.
بازخوردهای پروژه‌ها
کاهش رفت و برگشت به دیتابیس در حین ذخیره سازی اطلاعات
در حین لاگین شما SetAuthCookie رو به userName تنظیم کردید. بعد نتیجه‌اش این شده، هرجایی که نیاز به یافتن Id کاربر بوده، مجبور هستیم یکبار کوئری بگیریم و این مورد زیاد در برنامه وجود داره. بهتره در زمان فراخوانی SetAuthCookie، مقدار userId رو که داریم به عنوان پارامتر بهش پاس بدیم. به این ترتیب در خیلی از جاها جلو خواهیم افتاد.
مثلا الان اکثر کلاس‌های موجودیت‌ها چنین تعریفی رو دارند:
 public class SomeName
{
    public virtual User User { get; set; }
}
میشه کمی این تعریف رو اصلاح کرد و بجایش نوشت:
   [ForeignKey("UserId")]
  public virtual User User { set; get; }
  public int? UserId { set; get; }
حالا موقع ست کردن اطلاعات این کلاس، دیگر نیازی نیست تا یکبار id کاربر از دیتابیس Find شود (چون در User.Identity.Name موجود هست؛ مهم نیست که Name اینجا داره، مهم کاربرد آن است در برنامه). فقط کافی هست این مقدار به UserId نسبت داده شود.
تقریبا اکثر کلاس‌های دومین شما نکته کار با کلیدهای خارجی رو جهت کاهش رفت و برگشت به دیتابیس ندارند.
مطالب
سفارشی سازی ASP.NET Core Identity - قسمت سوم - نرمال سازها و اعتبارسنج‌ها
چندی قبل مطلب «نرمال سازی اطلاعات کاربران در حین ثبت نام» را در سایت جاری مطالعه کردید. پیاده سازی یک چنین قابلیتی به صورت توکار در ASP.NET Core Identity پیش بینی شده‌است. همچنین تمام اعتبارسنج‌های نام‌های کاربران، کلمات عبور آن‌ها، ایمیل‌های آن‌ها و غیره را نیز می‌توان سفارشی سازی کرد و بجای سرویس‌های پیش‌فرض آن‌ها معرفی و جایگزین نمود.


سفارشی سازی نرمال سازها

اگر به طراحی جداول ASP.NET Core Identity دقت کنید، تعدادی فیلد اضافی حاوی کلمه‌ی Normalized را هم مشاهده خواهید کرد. برای مثال:


در جدول کاربران، فیلدهای Email و UserName به همراه دو فیلد اضافه‌ی NormalizedEmail و NormalizedUserName وجود دارند.
مقدار دهی و مدیریت این فیلدهای ویژه به صورت خودکار توسط کلاسی به نام UpperInvariantLookupNormalizer صورت می‌گیرد:
 public class UpperInvariantLookupNormalizer : ILookupNormalizer
این کلاس اینترفیس ILookupNormalizer را پیاده سازی کرده و تنها کاری را که انجام می‌دهد، تبدیل نام کاربر، نام نقش‌ها و یا ایمیل کاربر به حالت upper case آن است. اما هدف اصلی از آن چیست؟
همانطور که در مطلب «نرمال سازی اطلاعات کاربران در حین ثبت نام» نیز عنوان شد، برای مثال ایمیل‌های جی‌میل را می‌توان با چندین حالت مختلف ثبت کرد و یک کاربر به این صورت می‌تواند شرط یکتا بودن آدرس ایمیل‌های تنظیم شده‌ی در کلاس IdentityServicesRegistry را دور بزند:
 identityOptionsUser.RequireUniqueEmail = true;
به همین جهت برای سفارشی سازی آن کلاس CustomNormalizer با سفارشی سازی UpperInvariantLookupNormalizer پیاده سازی شده‌است.
چون تنها یک اینترفیس ILookupNormalizer وجود دارد، باید بر اساس محتوای کلیدی که به آن ارسال می‌شود:
   public override string Normalize(string key)
تصمیم‌گیری کرد که آیا ایمیل است یا خیر. چون از این نرمال کننده هم برای ایمیل‌ها و هم برای نام‌ها استفاده می‌شود. سپس می‌توان منطق‌های سفارشی خود مانند حذف نقطه‌های اضافی ایمیل‌ها و یا حذف کاراکترهای اضافی اعمالی به نام‌های کاربری را اعمال کرد.
پس از تدارک کلاس CustomNormalizer، تنها کاری را که باید در جهت معرفی و جایگرینی آن انجام داد، تغییر ذیل در کلاس IdentityServicesRegistry است:
services.AddScoped<ILookupNormalizer, CustomNormalizer>();
services.AddScoped<UpperInvariantLookupNormalizer, CustomNormalizer>();
یکبار CustomNormalizer را به عنوان پیاده سازی کننده‌ی ILookupNormalizer معرفی کرده‌ایم. همچنین یکبار هم سرویس توکار UpperInvariantLookupNormalizer را به سرویس سفارشی خودمان هدایت کرده‌ایم. به این ترتیب مطمئن خواهیم شد که همواره از CustomNormalizer ما استفاده خواهد شد.
بنابراین دیگر نیازی نیست تا در حین ثبت‌نام نسبت به تمیزسازی ایمیل‌ها و یا نام‌های کاربری اقدام کنیم. سرویس ILookupNormalizer در پشت صحنه به صورت خودکار در تمام مراحل ثبت نام و به روز رسانی‌ها توسط ASP.NET Core Identity استفاده می‌شود.


سفارشی سازی UserValidator

ASP.NET Core Identity به همراه یک سرویس توکار اعتبارسنج کاربران است که با پیاده سازی اینترفیس IUserValidator ارائه شده‌است:
 public class UserValidator<TUser> : IUserValidator<TUser> where TUser : class
این سرویس پیش‌فرض و توکار، تنظیمات Options.User.RequireUniqueEmail، Options.User.AllowedUserNameCharacters و امثال آن‌را در مورد نام‌های کاربری و ایمیل‌ها بررسی می‌کند (تنظیم شده‌ی در متد setUserOptions کلاس IdentityServicesRegistry).
بنابراین اگر قصد تهیه‌ی یک IUserValidator جدید را داشته باشیم، از تمام تنظیمات و بررسی‌های پیش فرض سرویس توکار UserValidator فوق محروم می‌شویم. به همین جهت برای سفارشی سازی این سرویس، از خود کلاس UserValidator ارث بری کرده و سپس base.ValidateAsync آن‌را فراخوانی می‌کنیم. با این‌کار سبب خواهیم شد تا تمام اعتبارسنجی‌های پیش‌فرض ASP.NET Core Identity اعمال شده و پس از آن منطق‌های سفارشی اعتبارسنجی خود را که در کلاس CustomUserValidator‌ قابل مشاهده هستند، اضافه می‌کنیم.
        public override async Task<IdentityResult> ValidateAsync(UserManager<User> manager, User user)
        {
            // First use the built-in validator
            var result = await base.ValidateAsync(manager, user).ConfigureAwait(false);
            var errors = result.Succeeded ? new List<IdentityError>() : result.Errors.ToList();

            // Extending the built-in validator
            validateEmail(user, errors);
            validateUserName(user, errors);

            return !errors.Any() ? IdentityResult.Success : IdentityResult.Failed(errors.ToArray());
        }
در اینجا برای مثال در متد validateEmail سفارشی تهیه شده، لیست یک سری fake email provider اضافه شده‌اند (مدخل EmailsBanList در فایل appsettings.json برنامه) تا کاربران نتوانند از آن‌ها جهت ثبت‌نام استفاده کنند و یا در متد validateUserName سفارشی، اگر نام کاربری برای مثال عددی وارد شده بود، یک new IdentityError بازگشت داده می‌شود.

پس از تدارک کلاس CustomUserValidator، تنها کاری را که باید در جهت معرفی و جایگرینی آن انجام داد، تغییر ذیل در کلاس IdentityServicesRegistry است:
 services.AddScoped<IUserValidator<User>, CustomUserValidator>();
services.AddScoped<UserValidator<User>, CustomUserValidator>();
یکبار CustomUserValidator را به عنوان پیاده سازی کننده‌ی IUserValidator معرفی کرده‌ایم. همچنین یکبار هم سرویس توکار UserValidator را به سرویس سفارشی خودمان هدایت کرده‌ایم. به این ترتیب مطمئن خواهیم شد که همواره از CustomUserValidator ما استفاده خواهد شد (حتی اگر UserValidator اصلی از سیستم تزریق وابستگی‌ها درخواست شود).


سفارشی سازی PasswordValidator

مراحل سفارشی سازی اعتبارسنج کلمات عبور نیز همانند تهیه‌ی CustomUserValidator فوق است.
ASP.NET Core Identity به همراه یک سرویس توکار اعتبارسنج کلمات عبور کاربران است که با پیاده سازی اینترفیس IPasswordValidator ارائه شده‌است:
 public class PasswordValidator<TUser> : IPasswordValidator<TUser> where TUser : class
در این کلاس، از اطلاعات متد setPasswordOptions کلاس IdentityServicesRegistry
        private static void setPasswordOptions(PasswordOptions identityOptionsPassword, SiteSettings siteSettings)
        {
            identityOptionsPassword.RequireDigit = siteSettings.PasswordOptions.RequireDigit;
            identityOptionsPassword.RequireLowercase = siteSettings.PasswordOptions.RequireLowercase;
            identityOptionsPassword.RequireNonAlphanumeric = siteSettings.PasswordOptions.RequireNonAlphanumeric;
            identityOptionsPassword.RequireUppercase = siteSettings.PasswordOptions.RequireUppercase;
            identityOptionsPassword.RequiredLength = siteSettings.PasswordOptions.RequiredLength;
        }
که از فایل appsettings.json و مدخل PasswordOptions آن تامین می‌شود:
"PasswordOptions": {
   "RequireDigit": false,
   "RequiredLength": 6,
   "RequireLowercase": false,
   "RequireNonAlphanumeric": false,
   "RequireUppercase": false
},
جهت اعتبارسنجی کلمات عبور وارد شده‌ی توسط کاربران در حین ثبت نام و یا به روز رسانی اطلاعات خود، استفاده می‌شود.

بنابراین در اینجا نیز ارائه‌ی یک پیاده سازی خام از IPasswordValidator سبب خواهد شد تا تمام اعتبارسنجی‌های توکار کلاس PasswordValidator اصلی را از دست بدهیم. به همین جهت کار را با ارث بری از همین کلاس توکار شروع کرده و ابتدا متد base.ValidateAsync آن‌را فراخوانی می‌کنیم تا مطمئن شویم، مدخل PasswordOptions تنظیمات یاد شده، حتما پردازش خواهند شد. سپس منطق سفارشی خود را اعمال می‌کنیم.
برای مثال در کلاس CustomPasswordValidator تهیه شده، به مدخل PasswordsBanList فایل appsettings.json مراجعه شده و کاربران را از انتخاب کلمات عبوری به شدت ساده، منع می‌کند.

پس از تدارک کلاس CustomPasswordValidator‌، تنها کاری را که باید در جهت معرفی و جایگرینی آن انجام داد، تغییر ذیل در کلاس IdentityServicesRegistry است:
services.AddScoped<IPasswordValidator<User>, CustomPasswordValidator>();
services.AddScoped<PasswordValidator<User>, CustomPasswordValidator>();
یکبار CustomPasswordValidator را به عنوان پیاده سازی کننده‌ی IPasswordValidator معرفی کرده‌ایم. همچنین یکبار هم سرویس توکار PasswordValidator را به سرویس سفارشی خودمان هدایت کرده‌ایم. به این ترتیب مطمئن خواهیم شد که همواره از CustomPasswordValidator ما استفاده خواهد شد (حتی اگر PasswordValidator اصلی از سیستم تزریق وابستگی‌ها درخواست شود).


پردازش نتایج اعتبارسنج‌ها

این اعتبارسنج‌ها در خروجی‌های IdentityResult تمام متدهای ASP.NET Core Identity ظاهر می‌شوند. بنابراین فراخوانی ساده‌ی UpdateUserAsync اشتباه است و حتما باید خروجی آن‌را جهت پردازش IdentityResult آن بررسی کرد. به همین جهت تعدادی متد الحاقی به کلاس IdentityExtensions اضافه شده‌اند تا کارکردن با IdentityResult را ساده‌تر کنند.
   public static void AddErrorsFromResult(this ModelStateDictionary modelStat, IdentityResult result)
متد AddErrorsFromResult خطاهای حاصل از عملیات ASP.NET Core Identity را به ModelState جاری اضافه می‌کند. به این ترتیب می‌توان این خطاها را به کاربر در Viewهای برنامه و در قسمت اعتبارسنجی مدل آن نمایش داد.

   public static string DumpErrors(this IdentityResult result, bool useHtmlNewLine = false)
و یا متد DumpErrors تمام خطاهای موجود در IdentityResult  را تبدیل به یک رشته می‌کند. برای مثال می‌توان این رشته را در Remote validationها مورد استفاده قرار داد.
استفاده‌ی از این متدهای الحاقی را در کنترلرهای برنامه می‌توانید مشاهده کنید.


استفاده‌ی از اعتبارسنج‌ها جهت انجام Remote validation

اگر به RegisterController دقت کنید، اکشن متدهای ValidateUsername و ValidatePassword قابل مشاهده هستند:
  [AjaxOnly, HttpPost, ValidateAntiForgeryToken]
  [ResponseCache(Location = ResponseCacheLocation.None, NoStore = true)]
  public async Task<IActionResult> ValidateUsername(string username, string email)

  [AjaxOnly, HttpPost, ValidateAntiForgeryToken]
  [ResponseCache(Location = ResponseCacheLocation.None, NoStore = true)]
  public async Task<IActionResult> ValidatePassword(string password, string username)
این اکشن متدها توسط سرویس‌های
IPasswordValidator<User> passwordValidator,
IUserValidator<User> userValidator,
تزریق شده‌ی به سازنده‌ی کلاس، پیاده سازی شده‌اند. در مورد تامین آن‌ها و سفارشی سازی آن‌ها نیز پیشتر بحث شد. این اینترفیس‌ها دقیقا همان وهله‌های CustomUserValidator و CustomPasswordValidator را در اختیار ما قرار می‌دهند. تنها کاری را که باید انجام دهیم، فراخوانی متد ValidateAsync آن‌ها است. این متد یک خروجی از نوع IdentityResult را دارد. به همین جهت متد DumpErrors را برای پردازش این نتیجه تدارک دیدیم.
به این ترتیب کاربران در حین ثبت نام، راهنمای بهتری را جهت انتخاب کلمات عبور و نام کاربری مشاهده خواهند کرد و این بررسی‌ها نیز Ajax ایی هستند و پیش از ارسال فرم نهایی به سرور اتفاق می‌افتند.

برای فعالسازی Remote validation، علاوه بر ثبت اسکریپت‌های Ajax ایی، خواص کلاس RegisterViewModel نیز از ویژگی Remote استفاده می‌کنند:
  [Required(ErrorMessage = "(*)")]
  [Display(Name = "نام کاربری")]
  [Remote("ValidateUsername", "Register",
AdditionalFields = nameof(Email) + "," + ViewModelConstants.AntiForgeryToken, HttpMethod = "POST")]
  [RegularExpression("^[a-zA-Z_]*$", ErrorMessage = "لطفا تنها از حروف انگلیسی استفاده نمائید")]
  public string Username { get; set; }

یک نکته: برای اینکه Remote Validation را به همراه ValidateAntiForgeryToken استفاده کنیم، تنها کافی است نام فیلد مخفی آن‌را به لیست AdditionalFields به نحوی که مشاهده می‌کنید، اضافه کنیم.


کدهای کامل این سری را در مخزن کد DNT Identity می‌توانید ملاحظه کنید.
مطالب
Blazor 5x - قسمت 24 - تهیه API مخصوص Blazor WASM - بخش 1 - ایجاد تنظیمات ابتدایی
تا اینجا با اصول توسعه‌ی برنامه‌های مبتنی بر Blazor Server آشنا شدیم. در ادامه‌ی این سری، روش توسعه برنامه‌های مبتنی بر Blazor WASM را بررسی خواهیم کرد و پیش از شروع آن، باید بتوان امکانات سمت سرور مورد نیاز این نوع برنامه‌های سمت کلاینت را از طریق یک Web API تامین کرد که شامل دریافت و ارائه‌ی اطلاعات و همچنین اعتبارسنجی و احراز هویت مبتنی بر JWT یکپارچه‌ی با ASP.NET Core Identity است.


ایجاد پروژه‌ی ASP.NET Core Web API

برای تامین اطلاعات برنامه‌ی سمت کلاینت Blazor WASM و همچنین فراهم آوردن زیرساخت اعتبارسنجی کاربران آن، نیاز به یک پروژه‌ی ASP.NET Core Web API داریم که آن‌را با اجرای دستور dotnet new webapi در یک پوشه‌ی خالی، برای مثال به نام BlazorWasm.WebApi ایجاد می‌کنیم.
البته این پروژه، از زیرساختی که در برنامه‌ی Blazor Server بررسی شده‌ی تا این قسمت، ایجاد کردیم نیز استفاده خواهد کرد. همانطور که پیشتر نیز عنوان شد، هدف از قسمت Blazor Server مثال این سری، آشنایی با مدل برنامه نویسی خاص آن بود؛ وگرنه می‌توان کل این پروژه را با Blazor Server و یا کل آن‌را با Web API + Blazor WASM نیز پیاده سازی کرد. در این مثال، قسمت‌های مدیریتی برنامه‌ی مدیریت هتل را توسط Blazor Server (مانند قسمت‌های تعریف اتاق‌ها و امکانات رفاهی هتل) و قسمت مخصوص کاربران آن‌را مانند رزرو کردن اتاق‌ها، توسط Blazor WASM پیاده سازی می‌کنیم. به همین جهت قسمت‌هایی از این دو پروژه، مانند سرویس‌های استفاده شده‌ی در پروژه‌ی Blazor server، در پروژه‌ی Web API مکمل Blazor WASM، قابلیت استفاده‌ی مجدد را دارند.


افزودن سرویس‌های آغازین مورد نیاز، به پروژه‌ی Web API

در فایل آغازین BlazorWasm\BlazorWasm.WebApi\Startup.cs، برای شروع به تکمیل Web API، نیاز به این سرویس‌ها را داریم:
namespace BlazorWasm.WebApi
{
    public class Startup
    {
        //...

        public void ConfigureServices(IServiceCollection services)
        {
            services.AddAutoMapper(typeof(MappingProfile).Assembly);

            services.AddScoped<IHotelRoomService, HotelRoomService>();
            services.AddScoped<IAmenityService, AmenityService>();
            services.AddScoped<IHotelRoomImageService, HotelRoomImageService>();

            var connectionString = Configuration.GetConnectionString("DefaultConnection");
            services.AddDbContext<ApplicationDbContext>(options => options.UseSqlServer(connectionString));

            services.AddIdentity<IdentityUser, IdentityRole>()
                .AddEntityFrameworkStores<ApplicationDbContext>()
                .AddDefaultTokenProviders();

            //...
در اینجا سرویس‌های AutoMapper، تنظیمات ابتدایی DbContext برنامه، به همراه سرویس‌های Identity (بدون UI آن) و افزودن سرویس‌های اتاق‌ها و امکانات رفاهی هتل را نیاز داریم. به همین جهت ارجاعات و وابستگی‌های زیر را به فایل csproj جاری اضافه می‌کنیم تا پروژه‌های DataAccess ،Services و Mappings قابل دسترسی و استفاده شوند:
<Project Sdk="Microsoft.NET.Sdk.Web">
  <PropertyGroup>
    <TargetFramework>net5.0</TargetFramework>
  </PropertyGroup>

  <ItemGroup>
    <PackageReference Include="AutoMapper.Extensions.Microsoft.DependencyInjection" Version="8.1.1" />
    <PackageReference Include="Swashbuckle.AspNetCore" Version="5.6.3" />
  </ItemGroup>

  <ItemGroup>
    <ProjectReference Include="..\..\BlazorServer\BlazorServer.DataAccess\BlazorServer.DataAccess.csproj" />
    <ProjectReference Include="..\..\BlazorServer\BlazorServer.Services\BlazorServer.Services.csproj" />
    <ProjectReference Include="..\..\BlazorServer\BlazorServer.Models.Mappings\BlazorServer.Models.Mappings.csproj" />
  </ItemGroup>
</Project>
همچنین در این پروژه نیز از همان بانک اطلاعاتی پروژه‌ی Blazor Server که تاکنون تکمیل کردیم، استفاده می‌کنیم. بنابراین محتوای فایل BlazorWasm\BlazorWasm.WebApi\appsettings.json آن نیز مشابه‌است:
{
  "ConnectionStrings": {
    "DefaultConnection": "Server=(localdb)\\mssqllocaldb;Database=HotelManagement;Trusted_Connection=True;MultipleActiveResultSets=true"
  }
}


تعریف کنترلر HotelRoom

در ادامه کدهای اولین کنترلر Web API را مشاهده می‌کنید که مرتبط است با بازگشت اطلاعات تمام اتاق‌های ثبت شده و یا بازگشت اطلاعات یک اتاق ثبت شده:
using BlazorServer.Models;
using BlazorServer.Services;
using Microsoft.AspNetCore.Http;
using Microsoft.AspNetCore.Mvc;
using System.Collections.Generic;
using System.Threading.Tasks;

namespace BlazorWasm.WebApi.Controllers
{
    [Route("api/[controller]")]
    public class HotelRoomController : ControllerBase
    {
        private readonly IHotelRoomService _hotelRoomService;

        public HotelRoomController(IHotelRoomService hotelRoomService)
        {
            _hotelRoomService = hotelRoomService;
        }

        [HttpGet]
        public IAsyncEnumerable<HotelRoomDTO> GetHotelRooms()
        {
            return _hotelRoomService.GetAllHotelRoomsAsync();
        }

        [HttpGet("{roomId}")]
        public async Task<IActionResult> GetHotelRoom(int? roomId)
        {
            if (roomId == null)
            {
                return BadRequest(new ErrorModel
                {
                    Title = "",
                    ErrorMessage = "Invalid Room Id",
                    StatusCode = StatusCodes.Status400BadRequest
                });
            }

            var roomDetails = await _hotelRoomService.GetHotelRoomAsync(roomId.Value);
            if (roomDetails == null)
            {
                return BadRequest(new ErrorModel
                {
                    Title = "",
                    ErrorMessage = "Invalid Room Id",
                    StatusCode = StatusCodes.Status404NotFound
                });
            }

            return Ok(roomDetails);
        }
    }
}
- این کنترلر، از سرویس IHotelRoomService که در قسمت‌های قبل تکمیل کردیم، استفاده می‌کند.
- ErrorModel آن‌را در همان پروژه‌ی قبلی مدل‌ها، در فایل BlazorServer\BlazorServer.Models\ErrorModel.cs به صورت زیر ایجاد کرده‌ایم:
namespace BlazorServer.Models
{
    public class ErrorModel
    {
        public string Title { get; set; }

        public int StatusCode { get; set; }

        public string ErrorMessage { get; set; }
    }
}
در این حالت اگر برنامه‌ی Web API را اجرا کنیم، به خروجی Swagger زیر می‌رسیم که جزئیات این فناوری را در سری «مستند سازی ASP.NET Core 2x API توسط OpenAPI Swagger» پیشتر بررسی کردیم:


یکی از مزایای آن، امکان آزمایش API تهیه شده، بدون نیاز به تهیه‌ی هیچ نوع کلاینت خاصی است. برای مثال اگر بر روی api​/hotelroom آن کلیک کنیم، گزینه‌ی «try it out» آن ظاهر شده و با کلیک بر روی آن، اینبار دکمه‌ی execute ظاهر می‌شود. در ادامه با کلیک بر روی دکمه‌ی اجرای آن، اکشن متد GetHotelRooms اجرا شده و خروجی زیر ظاهر می‌شود:


و یا اگر بخواهیم متد GetHotelRoom را توسط آن آزمایش کنیم، بر اساس پارامترهای آن، رابط کاربری زیر را تشکیل می‌دهد که امکان دریافت شماره‌ی اتاق را دارد:



انجام تنظیمات ابتدایی CORS و خروجی JSON برنامه

قرار است این API را از طریق پروژه‌ی Blazor سمت کلاینت خود استفاده کنیم که آدرس آن، با آدرس API یکی نیست. به همین جهت نیاز است تنظیمات CORS را به صورت زیر اضافه کنیم:
namespace BlazorWasm.WebApi
{
    public class Startup
    {
        public void ConfigureServices(IServiceCollection services)
        {
         // ... 

            services.AddCors(o => o.AddPolicy("HotelManagement", builder =>
            {
                builder.AllowAnyOrigin().AllowAnyMethod().AllowAnyHeader();
            }));

            services.AddControllers()
                    .AddJsonOptions(options =>
                    {
                        options.JsonSerializerOptions.PropertyNamingPolicy = null;
                        // To avoid `JsonSerializationException: Self referencing loop detected error`
                        options.JsonSerializerOptions.ReferenceHandler = ReferenceHandler.Preserve;
                    });
         // ... 
        }

        public void Configure(IApplicationBuilder app, IWebHostEnvironment env)
        {
            // ... 

            app.UseCors("HotelManagement");
            app.UseRouting();

            app.UseAuthentication();
            // ...
        }
    }
}
در اینجا علاوه بر تنظیمات CORS، تنظیمات JsonSerializer را هم تغییر داده‌ایم تا خطاهای Self referencing loop را در حین ارائه‌ی خروجی‌های Web API، مشاهده نکنیم (همان نکته‌ی «تهیه خروجی JSON از مدل‌های مرتبط، بدون Stack overflow»).


کدهای کامل این مطلب را از اینجا می‌توانید دریافت کنید: Blazor-5x-Part-24.zip
نظرات مطالب
React 16x - قسمت 26 - احراز هویت و اعتبارسنجی کاربران - بخش 1 - ثبت نام و ورود به سیستم
آیا زمان ثبت نام در سمت سرور نباید هم access_token و هم refresh_token رو تولید و به سمت کلاینت ارسال کرد؟
با بررسی کدهای مربوط به backend این سری و مبحث مربوط به اعتبارسنجی مبتنی بر JWT در ASP.NET Core 2.0 بدون استفاده از سیستم Identity  عملیات ثبت نام رو به صورت زیر پیاده سازی کردم.
//do register action and send sms message to client
//then call activateRegisterCode action from client to server
//In the following run operation authentication for user
 [AllowAnonymous]
        [HttpPost("[action]")]
        [IgnoreAntiforgeryToken]
        public async Task<IActionResult> ActivateRegisterCode([FromBody] ActiveCodeModel model)
        {
            if (model == null)
            { return BadRequest("درخواست نامعتبر"); }

            var user = await _userService.FindUserByKeyCodeAsync(model.Keycode);
            if (user == null)
            {
                return BadRequest("کد امنیتی معتبر نیست لطفا مجددا درخواست کد امنیتی کنید");
            }

            user.IsActive = true;
            var userUpdated = await _userService.UpdateAsync(user, CancellationToken.None);
            var jwt = await _tokenFactoryService.CreateJwtTokensAsync(userUpdated);

            this.Response.Headers.Add("x-auth-token", jwt.AccessToken);
            this.Response.Headers.Add("access-control-expose-headers", "x-auth-token");
            _antiforgery.RegeneratedAntiForgeryCookie(jwt.Claims);
            return Ok();


        }
در ادامه پیاده سازی action logout  برای حذف توکن‌ها از دیتابیس
 [AllowAnonymous]
        [HttpGet("[action]"), HttpPost("[action]")]
        public async Task<bool> Logout(string refreshtoken)
        {
            var claimsIdentity = this.User.Identity as ClaimsIdentity;
            var userIdValue = claimsIdentity.FindFirst(ClaimTypes.UserData)?.Value;

            if (!string.IsNullOrWhiteSpace(userIdValue) && Guid.TryParse(userIdValue, out Guid userId))
            {
                await _tokenStoreService.InvalidateUserTokensAsync(userId).ConfigureAwait(false);
            }
            await _tokenStoreService.DeleteExpiredTokensAsync().ConfigureAwait(false);
            await _uow.SaveChangesAsync().ConfigureAwait(false);
            _antiforgery.DeleteAntiForgeryCookie();
            return true;
        }
مشکلی که هست تو اکشن logout شی userIdValue خالیه و بنظر میرسه که کوکی مقدار نداره!
آیا باید زمان فراخوانی این اکشن refreshtoken رو هم بهش ارسال کنیم؟
تو اکشن لاگین هم کوکی‌ها مقدار نمیگیره یا اینکه اصلا با کوکی‌ها کار نداریم و بر اساس refreshtoken باید مقادیر claim‌های داخلش رو decode و بررسی کرد و سپس بر اساس اون در بانک اطلاعاتی عملیات خذف توکن‌های صادر شده در زمان ثبت نام و یا لاگین رو انجام داد.
نظرات مطالب
اعتبارسنجی مبتنی بر کوکی‌ها در ASP.NET Core 2.0 بدون استفاده از سیستم Identity
مشکلی در پیاده سازی BlazorServerCookieAuthentication  پیدا کرده ام.
برای اینکه بتوانم در سرویسی از برنامه که اطلاعات کاربری لاگ می‌شود به آی دی کاربر دسترسی داشته باشم از کد زیر استفاده کردم که قبلا در SO راهنمایی کرده بودید بنده را. 
using System.Security.Claims;
using Microsoft.AspNetCore.Components.Authorization;
using Microsoft.AspNetCore.Components.Server;

namespace BlazorServerTestDynamicAccess.Services;

public class CustomAuthenticationStateProvider : RevalidatingServerAuthenticationStateProvider
{
    private readonly IServiceScopeFactory _scopeFactory;

    public CustomAuthenticationStateProvider(ILoggerFactory loggerFactory, IServiceScopeFactory scopeFactory)
        : base(loggerFactory) =>
        _scopeFactory = scopeFactory ?? throw new ArgumentNullException(nameof(scopeFactory));

    protected override TimeSpan RevalidationInterval { get; } = TimeSpan.FromMinutes(30);

    protected override async Task<bool> ValidateAuthenticationStateAsync(
        AuthenticationState authenticationState, CancellationToken cancellationToken)
    {
        // Get the user from a new scope to ensure it fetches fresh data
        var scope = _scopeFactory.CreateScope();
        try
        {
            var userManager = scope.ServiceProvider.GetRequiredService<IUsersService>();
            return await ValidateUserAsync(userManager, authenticationState?.User);
        }
        finally
        {
            if (scope is IAsyncDisposable asyncDisposable)
            {
                await asyncDisposable.DisposeAsync();
            }
            else
            {
                scope.Dispose();
            }
        }
    }

    private async Task<bool> ValidateUserAsync(IUsersService userManager, ClaimsPrincipal? principal)
    {
        if (principal is null)
        {
            return false;
        }

        var userIdString = principal.FindFirst(ClaimTypes.UserData)?.Value;
        if (!int.TryParse(userIdString, out var userId))
        {
            return false;
        }

        var user = await userManager.FindUserAsync(userId);
        return user is not null;
    }
}
حال مشکل اینجاست که اگر CustomAuthenticationStateProvider را به صورت AddSingleton ثبت کنم با این مشکل روبرو می‌شوم. اگر هم که به صورت AddScoped ثبت کنم با مشکل دیگری روبرو می‌شوم. اگر ممکن است لطفا راهنمایی فرمایید.