تکمیل ساختار پروژهی IDP
تا اینجا برای IDP، یک پروژهی خالی وب را ایجاد و به مرور، آنرا تکمیل کردیم. اما اکنون نیاز است پشتیبانی از بانک اطلاعاتی را نیز به آن اضافه کنیم. برای این منظور چهار پروژهی Class library کمکی را نیز به Solution آن اضافه میکنیم:
- DNT.IDP.DomainClasses
در این پروژه، کلاسهای متناظر با موجودیتهای جداول مرتبط با اطلاعات کاربران قرار میگیرند.
- DNT.IDP.DataLayer
این پروژه Context برنامه و Migrations آنرا تشکیل میدهد. همچنین به همراه تنظیمات و Seed اولیهی اطلاعات بانک اطلاعاتی نیز میباشد.
رشتهی اتصالی آن نیز در فایل DNT.IDP\appsettings.json ذخیره شدهاست.
- DNT.IDP.Common
الگوریتم هش کردن اطلاعات، در این پروژهی مشترک بین چند پروژهی دیگر قرار گرفتهاست. از آن جهت هش کردن کلمات عبور، در دو پروژهی DataLayer و همچنین Services استفاده میکنیم.
- DNT.IDP.Services
کلاس سرویس کاربران که با استفاده از DataLayer با بانک اطلاعاتی ارتباط برقرار میکند، در این پروژه قرار گرفتهاست.
ساختار بانک اطلاعاتی کاربران IdentityServer
در اینجا ساختار بانک اطلاعاتی کاربران IdentityServer، بر اساس جداول کاربران و Claims آنها تشکیل میشود:
namespace DNT.IDP.DomainClasses { public class User { [Key] [MaxLength(50)] public string SubjectId { get; set; } [MaxLength(100)] [Required] public string Username { get; set; } [MaxLength(100)] public string Password { get; set; } [Required] public bool IsActive { get; set; } public ICollection<UserClaim> UserClaims { get; set; } public ICollection<UserLogin> UserLogins { get; set; } } }
ساختار Claims او نیز به صورت زیر تعریف میشود که با تعریف یک Claim استاندارد، سازگاری دارد:
namespace DNT.IDP.DomainClasses { public class UserClaim { public int Id { get; set; } [MaxLength(50)] [Required] public string SubjectId { get; set; } public User User { get; set; } [Required] [MaxLength(250)] public string ClaimType { get; set; } [Required] [MaxLength(250)] public string ClaimValue { get; set; } } }
namespace DNT.IDP.DomainClasses { public class UserLogin { public int Id { get; set; } [MaxLength(50)] [Required] public string SubjectId { get; set; } public User User { get; set; } [Required] [MaxLength(250)] public string LoginProvider { get; set; } [Required] [MaxLength(250)] public string ProviderKey { get; set; } } }
در پروژهی DNT.IDP.DataLayer در پوشهی Configurations آن، کلاسهای UserConfiguration و UserClaimConfiguration را مشاهده میکنید که حاوی اطلاعات اولیهای برای تشکیل User 1 و User 2 به همراه Claims آنها هستند. این اطلاعات را دقیقا از فایل استاتیک Config که در قسمتهای قبل تکمیل کردیم، به این دو کلاس جدید IEntityTypeConfiguration منتقل کردهایم تا به این ترتیب متد GetUsers فایل استاتیک Config را با نمونهی دیتابیسی آن جایگزین کنیم.
سرویسی که از طریق Context برنامه با بانک اطلاعاتی ارتباط برقرار میکند، چنین ساختاری را دارد:
public interface IUsersService { Task<bool> AreUserCredentialsValidAsync(string username, string password); Task<User> GetUserByEmailAsync(string email); Task<User> GetUserByProviderAsync(string loginProvider, string providerKey); Task<User> GetUserBySubjectIdAsync(string subjectId); Task<User> GetUserByUsernameAsync(string username); Task<IEnumerable<UserClaim>> GetUserClaimsBySubjectIdAsync(string subjectId); Task<IEnumerable<UserLogin>> GetUserLoginsBySubjectIdAsync(string subjectId); Task<bool> IsUserActiveAsync(string subjectId); Task AddUserAsync(User user); Task AddUserLoginAsync(string subjectId, string loginProvider, string providerKey); Task AddUserClaimAsync(string subjectId, string claimType, string claimValue); }
تنظیمات نهایی این سرویسها و Context برنامه نیز در فایل DNT.IDP\Startup.cs جهت معرفی به سیستم تزریق وابستگیها، صورت گرفتهاند. همچنین در اینجا متد initializeDb را نیز مشاهده میکنید که با فراخوانی متد context.Database.Migrate، تمام کلاسهای Migrations پروژهی DataLayer را به صورت خودکار به بانک اطلاعاتی اعمال میکند.
غیرفعال کردن صفحهی Consent در Quick Start UI
در «قسمت چهارم - نصب و راه اندازی IdentityServer» فایلهای Quick Start UI را به پروژهی IDP اضافه کردیم. در ادامه میخواهیم قدم به قدم این پروژه را تغییر دهیم.
در صفحهی Consent در Quick Start UI، لیست scopes درخواستی برنامهی کلاینت ذکر شده و سپس کاربر انتخاب میکند که کدامیک از آنها، باید به برنامهی کلاینت ارائه شوند. این صفحه، برای سناریوی ما که تمام برنامههای کلاینت توسط ما توسعه یافتهاند، بیمعنا است و صرفا برای کلاینتهای ثالثی که قرار است از IDP ما استفاده کنند، معنا پیدا میکند. برای غیرفعال کردن آن کافی است به فایل استاتیک Config مراجعه کرده و خاصیت RequireConsent کلاینت مدنظر را به false تنظیم کرد.
تغییر نام پوشهی Quickstart و سپس اصلاح فضای نام پیشفرض کنترلرهای آن
در حال حاضر کدهای کنترلرهای Quick Start UI داخل پوشهی Quickstart برنامهی IDP قرار گرفتهاند. با توجه به اینکه قصد داریم این کدها را تغییر دهیم و همچنین این پوشه در اساس، همان پوشهی استاندارد Controllers است، ابتدا نام این پوشه را به Controllers تغییر داده و سپس در تمام کنترلرهای ذیل آن، فضای نام پیشفرض IdentityServer4.Quickstart.UI را نیز به فضای نام متناسبی با پوشه بندی پروژهی جاری تغییر میدهیم. برای مثال کنترلر Account واقع در پوشهی Account، اینبار دارای فضای نام DNT.IDP.Controllers.Account خواهد شد و به همین ترتیب برای مابقی کنترلها عمل میکنیم.
پس از این تغییرات، عبارات using موجود در Viewها را نیز باید تغییر دهید تا برنامه در زمان اجرا به مشکلی برنخورد. البته ASP.NET Core 2.1 در زمان کامپایل برنامه، تمام Viewهای آنرا نیز کامپایل میکند و اگر خطایی در آنها وجود داشته باشد، امکان بررسی و رفع آنها پیش از اجرای برنامه، میسر است.
و یا میتوان جهت سهولت کار، فایل DNT.IDP\Views\_ViewImports.cshtml را جهت معرفی این فضاهای نام جدید ویرایش کرد تا نیازی به تغییر Viewها نباشد:
@using DNT.IDP.Controllers.Account; @using DNT.IDP.Controllers.Consent; @using DNT.IDP.Controllers.Grants; @using DNT.IDP.Controllers.Home; @using DNT.IDP.Controllers.Diagnostics; @addTagHelper *, Microsoft.AspNetCore.Mvc.TagHelpers
تعامل با IdentityServer از طریق کدهای سفارشی
پس از تشکیل «ساختار بانک اطلاعاتی کاربران IdentityServer» و همچنین تهیه سرویسهای متناظری جهت کار با آن، اکنون نیاز است مطمئن شویم IdentityServer از این بانک اطلاعاتی برای دریافت اطلاعات کاربران خود استفاده میکند.
در حال حاضر، با استفاده از متد الحاقی AddTestUsers معرفی شدهی در فایل DNT.IDP\Startup.cs، اطلاعات کاربران درون حافظهای برنامه را از متد ()Config.GetUsers دریافت میکنیم.
بنابراین اولین قدم، بررسی ساختار متد AddTestUsers است. برای این منظور به مخزن کد IdentityServer4 مراجعه کرده و کدهای متد الحاقی AddTestUsers را بررسی میکنیم:
public static class IdentityServerBuilderExtensions { public static IIdentityServerBuilder AddTestUsers(this IIdentityServerBuilder builder, List<TestUser> users) { builder.Services.AddSingleton(new TestUserStore(users)); builder.AddProfileService<TestUserProfileService>(); builder.AddResourceOwnerValidator<TestUserResourceOwnerPasswordValidator>(); return builder; } }
- سپس سرویس پروفایل کاربران را اضافه کردهاست. این سرویس با پیاده سازی اینترفیس IProfileService تهیه میشود. کار آن اتصال یک User Store سفارشی به سرویس کاربران و دریافت اطلاعات پروفایل آنها مانند Claims است.
- در آخر TestUserResourceOwnerPasswordValidator، کار اعتبارسنجی کلمهی عبور و نام کاربری را در صورت استفادهی از Flow ویژهای به نام ResourceOwner که استفادهی از آن توصیه نمیشود (ROBC Flow)، انجام میدهد.
برای جایگزین کردن AddTestUsers، کلاس جدید IdentityServerBuilderExtensions را در ریشهی پروژهی IDP با محتوای ذیل اضافه میکنیم:
using DNT.IDP.Services; using Microsoft.Extensions.DependencyInjection; namespace DNT.IDP { public static class IdentityServerBuilderExtensions { public static IIdentityServerBuilder AddCustomUserStore(this IIdentityServerBuilder builder) { // builder.Services.AddScoped<IUsersService, UsersService>(); builder.AddProfileService<CustomUserProfileService>(); return builder; } } }
سپس یک ProfileService سفارشی را ثبت کردهایم. این سرویس، با پیاده سازی IProfileService به صورت زیر پیاده سازی میشود:
namespace DNT.IDP.Services { public class CustomUserProfileService : IProfileService { private readonly IUsersService _usersService; public CustomUserProfileService(IUsersService usersService) { _usersService = usersService; } public async Task GetProfileDataAsync(ProfileDataRequestContext context) { var subjectId = context.Subject.GetSubjectId(); var claimsForUser = await _usersService.GetUserClaimsBySubjectIdAsync(subjectId); context.IssuedClaims = claimsForUser.Select(c => new Claim(c.ClaimType, c.ClaimValue)).ToList(); } public async Task IsActiveAsync(IsActiveContext context) { var subjectId = context.Subject.GetSubjectId(); context.IsActive = await _usersService.IsUserActiveAsync(subjectId); } } }
در متدهای آن، ابتدا subjectId و یا همان Id منحصربفرد کاربر جاری سیستم، دریافت شده و سپس بر اساس آن میتوان از usersService، جهت دریافت اطلاعات مختلف کاربر، کوئری گرفت و نتیجه را در خواص context جاری، برای استفادههای بعدی، ذخیره کرد.
اکنون به کلاس src\IDP\DNT.IDP\Startup.cs مراجعه کرده و متد AddTestUsers را با AddCustomUserStore جایگزین میکنیم:
namespace DNT.IDP { public class Startup { public void ConfigureServices(IServiceCollection services) { services.AddIdentityServer() .AddDeveloperSigningCredential() .AddCustomUserStore() .AddInMemoryIdentityResources(Config.GetIdentityResources()) .AddInMemoryApiResources(Config.GetApiResources()) .AddInMemoryClients(Config.GetClients());
اتصال IdentityServer به User Store سفارشی
در ادامه، سازندهی کنترلر DNT.IDP\Quickstart\Account\AccountController.cs را بررسی میکنیم:
public AccountController( IIdentityServerInteractionService interaction, IClientStore clientStore, IAuthenticationSchemeProvider schemeProvider, IEventService events, TestUserStore users = null) { _users = users ?? new TestUserStore(TestUsers.Users); _interaction = interaction; _clientStore = clientStore; _schemeProvider = schemeProvider; _events = events; }
- IClientStore پیاده سازی محل ذخیره سازی اطلاعات کلاینتها را ارائه میدهد که در حال حاضر توسط متد استاتیک Config در اختیار آن قرار میگیرد.
- IEventService رخدادهایی مانند لاگین موفقیت آمیز یک کاربر را گزارش میدهد.
- در آخر، TestUserStore تزریق شدهاست که میخواهیم آنرا با User Store سفارشی خودمان جایگزین کنیم. بنابراین در ابتدا TestUserStore را با UserStore سفارشی خودمان جایگزین میکنیم:
private readonly TestUserStore _users; private readonly IUsersService _usersService; public AccountController( // ... IUsersService usersService) { _usersService = usersService; // ... }
پس از معرفی فیلد usersService_، اکنون در قسمت زیر از آن استفاده میکنیم:
در اکشن متد لاگین، جهت بررسی صحت نام کاربری و کلمهی عبور و همچنین یافتن کاربر متناظر با آن:
public async Task<IActionResult> Login(LoginInputModel model, string button) { //... if (ModelState.IsValid) { if (await _usersService.AreUserCredentialsValidAsync(model.Username, model.Password)) { var user = await _usersService.GetUserByUsernameAsync(model.Username);
افزودن امکان ثبت کاربران جدید به برنامهی IDP
پس از اتصال قسمت login برنامهی IDP به بانک اطلاعاتی، اکنون میخواهیم امکان ثبت کاربران را نیز به آن اضافه کنیم.
این قسمت شامل تغییرات ذیل است:
الف) اضافه شدن RegisterUserViewModel
این ViewModel که فیلدهای فرم ثبتنام را تشکیل میدهد، ابتدا با نام کاربری و کلمهی عبور شروع میشود:
public class RegisterUserViewModel { // credentials [MaxLength(100)] public string Username { get; set; } [MaxLength(100)] public string Password { get; set; }
public class RegisterUserViewModel { // ... // claims [Required] [MaxLength(100)] public string Firstname { get; set; } [Required] [MaxLength(100)] public string Lastname { get; set; } [Required] [MaxLength(150)] public string Email { get; set; } [Required] [MaxLength(200)] public string Address { get; set; } [Required] [MaxLength(2)] public string Country { get; set; }
ب) افزودن UserRegistrationController
این کنترلر، RegisterUserViewModel را دریافت کرده و سپس بر اساس آن، شیء User ابتدای بحث را تشکیل میدهد. ابتدا نام کاربری و کلمهی عبور را در جدول کاربران ثبت میکند و سپس سایر خواص این ViewModel را در جدول UserClaims:
varuserToCreate=newUser { Password=model.Password.GetSha256Hash(), Username=model.Username, IsActive=true }; userToCreate.UserClaims.Add(newUserClaim("country",model.Country)); userToCreate.UserClaims.Add(newUserClaim("address",model.Address)); userToCreate.UserClaims.Add(newUserClaim("given_name",model.Firstname)); userToCreate.UserClaims.Add(newUserClaim("family_name",model.Lastname)); userToCreate.UserClaims.Add(newUserClaim("email",model.Email)); userToCreate.UserClaims.Add(newUserClaim("subscriptionlevel","FreeUser"));
این فایل، view متناظر با ViewModel فوق را ارائه میدهد که توسط آن، کاربری میتواند اطلاعات خود را ثبت کرده و وارد سیستم شود.
د) اصلاح فایل ViewImports.cshtml_ جهت تعریف فضای نام UserRegistration
در RegisterUser.cshtml از RegisterUserViewModel استفاده میشود. به همین جهت بهتر است فضای نام آنرا به ViewImports اضافه کرد.
ه) افزودن لینک ثبت نام به صفحهی لاگین در Login.cshtml
این لینک دقیقا در ذیل چکباکس Remember My Login اضافه شدهاست.
اکنون اگر برنامه را اجرا کنیم، ابتدا مشاهده میکنیم که صفحهی لاگین به همراه لینک ثبت نام ظاهر میشود:
و پس از کلیک بر روی آن، صفحهی ثبت کاربر جدید به صورت زیر نمایش داده خواهد شد:
برای آزمایش، کاربری را ثبت کنید. پس از ثبت اطلاعات، بلافاصله وارد سیستم خواهید شد. البته چون در اینجا subscriptionlevel به FreeUser تنظیم شدهاست، این کاربر یکسری از لینکهای برنامهی MVC Client را به علت نداشتن دسترسی، مشاهده نخواهد کرد.
کدهای کامل این قسمت را از اینجا میتوانید دریافت کنید.
برای اجرای برنامه:
- ابتدا به پوشهی src\WebApi\ImageGallery.WebApi.WebApp وارد شده و dotnet_run.bat آنرا اجرا کنید تا WebAPI برنامه راه اندازی شود.
- سپس به پوشهی src\IDP\DNT.IDP مراجعه کرده و و dotnet_run.bat آنرا اجرا کنید تا برنامهی IDP راه اندازی شود.
- در آخر به پوشهی src\MvcClient\ImageGallery.MvcClient.WebApp وارد شده و dotnet_run.bat آنرا اجرا کنید تا MVC Client راه اندازی شود.
اکنون که هر سه برنامه در حال اجرا هستند، مرورگر را گشوده و مسیر https://localhost:5001 را درخواست کنید. در صفحهی login نام کاربری را User 1 و کلمهی عبور آنرا password وارد کنید.