আজ আমরা দেখব IdentityServer4(OAuth2,OpenID), ASP.NET Identity -র মাধ্যমে কিভাবে মাইক্রোসার্ভিসের সিকিউরিটি ও সিঙ্গেল সাইন-অন/সাইন-আউট(SSO) সুবিধা ইমপ্লিমেন্ট করা যায়।
প্রথমে আমরা কিছু বেসিক জেনে নেই-
OAuth2: একটি ইন্ডাস্ট্রি স্ট্যান্ডার্ড অথোরাইজেশন প্রোটোকল, এটা সার্ভিস ব্যাবহারকারির অথোরাইজেশন নিয়ে কাজ করে, সিকিউরিটি সার্ভিস হতে এক্সেস টোকেন সংগ্রহ করে তা অন্য সার্ভিস বা APIs -র সাথে যোগাযোগ রক্ষা করে থাকে।
OpenID Connect: অথেন্টিকেশন প্রোটোকল যা মূলত OAuth2 এর এক্সটেনশন, যা ব্যাবহারকারির অথেন্টিকেশন নিয়ে কাজ করে ।
IdentityServer4: একটি ওপেন সোর্স ফ্রেমওয়ার্ক, যেখানে OAuth2, OpenID Connect একত্রে ব্যবহার করে মোবাইল, ওয়েব আপ্লিকেশনের সিকিউরিটি নিশ্চিত করা হয়। .NET আপ্লিকেশনে মিডিলওয়্যার হিসাবে খুব সহজে যুক্ত করা আর ব্যাবহার করা যায়। IdentityServer4 ব্যাবহারকারিদের নিয়ে কাজ করে না শুধু তাদের অথোরাইজেশন ও অথেন্টিকেশন নিয়ে কাজ করে যেমন – টোকেন এক্সেস, টোকেন এন্ডপয়েন্ট , ক্লায়েন্ট, স্কোপ ইত্যাদি।
ASP.NET Identity: এটি মাইক্রোসফটের ইউজার ম্যানেজমেন্ট লাইব্রেরী, ইউজারের বেসিক ইনফরমেশন, রোল, ক্লেইম ইত্যাদি নিয়ে কাজ করে ও সংরক্ষন করে থাকে। যেহেতু IdentityServer4 সরাসরি ইউজার নিয়ে কাজ করে না তাই IdentityServer4 এর ইউজার ইনফরমেশন ASP.NET Identity এর মাধ্যমে ব্যবহার করবো।
এই এক্সাম্পলের তিনটি পার্ট- সিকিউরিটির জন্য IdentityServer, একটি API ডাটা প্রভাইড করে অন্যটি API ক্লায়েন্ট যে API হতে ডাটা সংগ্রহ করে ও প্রদর্শন করে ।
প্রথমে IdentityServer এর জন্য একটি ASP.NET Core Empty প্রোজেক্ট নেই এবং IdentityServer4.Quickstart.UI থেকে এই কমান্ড টি কপি করে নেই।
iex ((New-Object System.Net.WebClient).DownloadString(‘https://raw.githubusercontent.com/IdentityServer/IdentityServer4.Quickstart.UI/main/getmain.ps1’))
তারপর প্রজেক্টের উপর রাইট ক্লিক করে Open in Terminal এ ক্লিক করে কপি করে কমান্ডটি পেস্ট করে রান করে দেই।
এবার বাকি দুটি API অ্যাড করে নেই যথাক্রমে- IdentityServer.API, IdentityServer.Client। প্রত্যেকের launchSettings.json ওপেন করে applicationUrl সেট করে নেই।
তারপর ধাপে ধাপে IdentityServer4 এর IdentityResource, ApiScope, Client সেটআপ এবং ConfigurationStore, OperationalStore, AspNetIdentity সার্ভিসে অ্যাড করতে হবে।
IdentityResource, ApiScope, Client সেটআপ:
using IdentityServer4; using IdentityServer4.Models; using System.Collections.Generic; namespace IdentityServer { public static class Config { public static IEnumerable<IdentityResource> IdentityResources => new List<IdentityResource> { new IdentityResources.OpenId(), new IdentityResources.Profile(), }; public static IEnumerable<ApiScope> ApiScopes => new List<ApiScope> { new ApiScope("apiscope", "API") }; public static IEnumerable<Client> Clients => new List<Client> { // interactive ASP.NET Core MVC client new Client { ClientId = "client1", ClientName = "MVC WEB API", AllowedGrantTypes = GrantTypes.Code, ClientSecrets = { new Secret("secret".Sha256()) }, //RequireClientSecret = false, //RequirePkce = false, //AllowRememberConsent = false, // where to redirect to after login RedirectUris = { "https://localhost:5002/signin-oidc" }, // where to redirect to after logout PostLogoutRedirectUris = { "https://localhost:5002/signout-callback-oidc" }, AllowedScopes = new List<string> { IdentityServerConstants.StandardScopes.OpenId, IdentityServerConstants.StandardScopes.Profile, "apiscope" } }, // machine to machine client new Client { ClientId = "client2", AllowedGrantTypes = GrantTypes.ClientCredentials, ClientSecrets = { new Secret("secret".Sha256()) }, // scopes that client has access to AllowedScopes = { "apiscope" } } }; } }
ConfigurationStore, OperationalStore, AspNetIdentity সার্ভিস রেজিস্ট্রি:
IdentityServer Program.cs
builder.Services.AddIdentityServer() .AddInMemoryClients(Config.Clients) .AddInMemoryApiScopes(Config.ApiScopes) .AddInMemoryIdentityResources(Config.IdentityResources) .AddTestUsers(TestUsers.Users) .AddDeveloperSigningCredential();
IdentityServer.API Program.cs
// accepts any access token issued by identity server builder.Services.AddAuthentication("Bearer") .AddJwtBearer(options => { options.Authority = "https://localhost:5001"; options.TokenValidationParameters = new TokenValidationParameters { ValidateAudience = false }; }); // adds an authorization policy to make sure the token is for scope 'apiscope' builder.Services.AddAuthorization(options => { options.AddPolicy("apiscope", policy => { policy.RequireAuthenticatedUser(); policy.RequireClaim("scope", "apiscope"); }); }); app.UseRouting(); app.UseAuthentication(); app.UseAuthorization(); app.UseEndpoints(endpoints => { endpoints.MapControllers() .RequireAuthorization("apiscope"); //endpoints.MapControllers(); });
IdentityServer.Client Program.cs
builder.Services.AddAuthentication(options => { options.DefaultScheme = "Cookies"; options.DefaultChallengeScheme = "oidc"; //options.DefaultScheme = CookieAuthenticationDefaults.AuthenticationScheme; //options.DefaultChallengeScheme = OpenIdConnectDefaults.AuthenticationScheme; }) .AddCookie("Cookies") .AddOpenIdConnect("oidc", options => // .AddCookie(CookieAuthenticationDefaults.AuthenticationScheme) //.AddOpenIdConnect(OpenIdConnectDefaults.AuthenticationScheme, options => { options.Authority = "https://localhost:5001"; options.RequireHttpsMetadata = false; options.ClientId = "client1"; options.ClientSecret = "secret"; options.ResponseType = "code"; //options.ResponseType = "code id_token"; options.Scope.Add("apiscope"); options.SaveTokens = true; }); app.UseRouting(); app.UseAuthentication(); app.UseAuthorization(); app.UseEndpoints(endpoints => { endpoints.MapControllerRoute( name: "default", pattern: "{controller=Home}/{action=Index}/{id?}"); });
IdentityServer4 সাধারণত কনফিগারেশন গুলো মেমরিতে রাখে আর IdentityServer4.Quickstart.UI কমান্ড TestUsers.Users তৈরি করে যা আমাদের টেস্ট এ ব্যবহার করা হবে।
এখন আপ্লিকেশন রান করে আমরা নিন্মক্ত UI গুলো দেখতে পাব–
ক্লায়েন্ট থেকে লগইন এর পর
IdentityServer4 হতে লগআউট এর পর
এবার IdentityServer.API হতে ডাটা IdentityServer.Clent এ দেখাব, এর জন্য IdentityServer.Clent এর Weather একশন নিন্মরুপ হবে-
public async Task<IActionResult> Weather() { var client = new HttpClient(); var disco = await client.GetDiscoveryDocumentAsync("https://localhost:5001"); if (disco.IsError) { Console.WriteLine(disco.Error); return View(); } // request token var tokenResponse = await client.RequestClientCredentialsTokenAsync(new ClientCredentialsTokenRequest { Address = disco.TokenEndpoint, ClientId = "client2", ClientSecret = "secret", Scope = "apiscope" }); if (tokenResponse.IsError) { Console.WriteLine(tokenResponse.Error); return View(); } Console.WriteLine(tokenResponse.Json); Console.WriteLine("\n\n"); // call api var apiClient = new HttpClient(); apiClient.SetBearerToken(tokenResponse.AccessToken); var response = await apiClient.GetAsync("https://localhost:5003/WeatherForecast"); if (!response.IsSuccessStatusCode) { Console.WriteLine(response.StatusCode); } else { var content = await response.Content.ReadAsStringAsync(); var movieList = JsonConvert.DeserializeObject<List<WeatherForecast>>(content); Console.WriteLine(JArray.Parse(content)); return View(movieList); } return View(); }
আমরা যদি IdentityServer4 এর কনফিগারেশন গুলো এবং Users কে আমাদের ডাটাবেসে রাখতে চাই তবে ConfigurationStore, OperationalStore, AspNetIdentity নিন্মরুপে রেজিস্টার করতে হবে এবং ডাটাবেসে মাইগ্রেসন করে সিড ডাটা হিসাবে অ্যাড করতে হবে-
builder.Services.AddDbContext<ApplicationDbContext>(options => options .UseSqlServer(connectionString, sql => sql.MigrationsAssembly(migrationsAssembly))); builder.Services.AddIdentity<ApplicationUser, IdentityRole>() .AddEntityFrameworkStores<ApplicationDbContext>() .AddDefaultTokenProviders(); builder.Services.AddIdentityServer(options => { options.Events.RaiseErrorEvents = true; options.Events.RaiseInformationEvents = true; options.Events.RaiseFailureEvents = true; options.Events.RaiseSuccessEvents = true; // see https://identityserver4.readthedocs.io/en/latest/topics/resources.html options.EmitStaticAudienceClaim = true; options.Authentication = new AuthenticationOptions() { CookieLifetime = TimeSpan.FromHours(10), // ID server cookie timeout set to 10 hours CookieSlidingExpiration = true }; }) .AddConfigurationStore(options => { options.ConfigureDbContext = b => b.UseSqlServer(connectionString, sql => sql.MigrationsAssembly(migrationsAssembly)); }) .AddOperationalStore(options => { options.ConfigureDbContext = b => b.UseSqlServer(connectionString, sql => sql.MigrationsAssembly(migrationsAssembly)); }) .AddAspNetIdentity<ApplicationUser>() .AddDeveloperSigningCredential(); //builder.Services.AddIdentityServer() // .AddInMemoryClients(Config.Clients) // .AddInMemoryApiScopes(Config.ApiScopes) // .AddInMemoryIdentityResources(Config.IdentityResources) // .AddTestUsers(TestUsers.Users) // .AddDeveloperSigningCredential(); // not recommended for production - you need to store your key material somewhere secure //builder.Services.AddDeveloperSigningCredential(); builder.Services.AddAuthentication() .AddGoogle(options => { options.SignInScheme = IdentityServerConstants.ExternalCookieAuthenticationScheme; // register your IdentityServer with Google at https://console.developers.google.com // enable the Google+ API // set the redirect URI to https://localhost:5001/signin-google options.ClientId = "copy client ID from Google here"; options.ClientSecret = "copy client secret from Google here"; }); builder.Services.Configure<IdentityOptions>(options => { // Default Lockout settings. options.Lockout.DefaultLockoutTimeSpan = TimeSpan.FromMinutes(5); options.Lockout.MaxFailedAccessAttempts = 5; options.Lockout.AllowedForNewUsers = false; // Default Password settings. options.Password.RequireDigit = false; options.Password.RequireLowercase = false; options.Password.RequireNonAlphanumeric = false; options.Password.RequireUppercase = false; options.Password.RequiredLength = 3; options.Password.RequiredUniqueChars = 1; }); //hosted services for seeding primary data builder.Services.AddHostedService<DatabaseSeedingService>();
DatabaseSeedingService:
using IdentityModel; using IdentityServer.Data; using IdentityServer.Models; using IdentityServer4.EntityFramework.DbContexts; using IdentityServer4.EntityFramework.Mappers; using Microsoft.AspNetCore.Identity; using Microsoft.EntityFrameworkCore; using Serilog; using System.Security.Claims; namespace IdentityServer { public class DatabaseSeedingService : IHostedService { private readonly IServiceProvider _serviceProvider; public DatabaseSeedingService(IServiceProvider serviceProvider) { _serviceProvider = serviceProvider; } public async Task StartAsync(CancellationToken cancellationToken) { await SeedInitialData(cancellationToken); } private async Task SeedInitialData(CancellationToken cancellationToken) { await SeedDefaultApplicationUserAsync(); // Initialize PersistedGrantDb & ConfigurationDb await SeedPersistedGrantDbConfigurationDbAsync(); } public Task StopAsync(CancellationToken cancellationToken) => Task.CompletedTask; #region Internal functions public async Task SeedDefaultApplicationUserAsync() { using var scope = _serviceProvider.CreateScope(); var context = scope.ServiceProvider.GetRequiredService<ApplicationDbContext>(); context.Database.Migrate(); var userMgr = scope.ServiceProvider.GetRequiredService<UserManager<ApplicationUser>>(); var alice = await userMgr.FindByNameAsync("alice"); if (alice == null) { alice = new ApplicationUser { UserName = "alice", Email = "AliceSmith@email.com", EmailConfirmed = true, }; var result = userMgr.CreateAsync(alice, "alice").Result; if (!result.Succeeded) { throw new Exception(result.Errors.First().Description); } result = userMgr.AddClaimsAsync(alice, new Claim[]{ new Claim(JwtClaimTypes.Name, "Alice Smith"), new Claim(JwtClaimTypes.GivenName, "Alice"), new Claim(JwtClaimTypes.FamilyName, "Smith"), new Claim(JwtClaimTypes.WebSite, "http://alice.com"), }).Result; if (!result.Succeeded) { throw new Exception(result.Errors.First().Description); } Log.Debug("alice created"); } else { Log.Debug("alice already exists"); } var bob = userMgr.FindByNameAsync("bob").Result; if (bob == null) { bob = new ApplicationUser { UserName = "bob", Email = "BobSmith@email.com", EmailConfirmed = true }; var result = userMgr.CreateAsync(bob, "alice").Result; if (!result.Succeeded) { throw new Exception(result.Errors.First().Description); } result = userMgr.AddClaimsAsync(bob, new Claim[]{ new Claim(JwtClaimTypes.Name, "Bob Smith"), new Claim(JwtClaimTypes.GivenName, "Bob"), new Claim(JwtClaimTypes.FamilyName, "Smith"), new Claim(JwtClaimTypes.WebSite, "http://bob.com"), new Claim("location", "somewhere") }).Result; if (!result.Succeeded) { throw new Exception(result.Errors.First().Description); } Log.Debug("bob created"); } else { Log.Debug("bob already exists"); } } public async Task SeedPersistedGrantDbConfigurationDbAsync() { using var scope = _serviceProvider.CreateScope(); var context = scope.ServiceProvider.GetRequiredService<ConfigurationDbContext>(); context.Database.Migrate(); if (!context.Clients.Any()) { foreach (var client in Config.Clients) { context.Clients.Add(client.ToEntity()); } await context.SaveChangesAsync(); } if (!context.IdentityResources.Any()) { foreach (var resource in Config.IdentityResources) { context.IdentityResources.Add(resource.ToEntity()); } context.SaveChanges(); } if (!context.ApiScopes.Any()) { foreach (var resource in Config.ApiScopes) { context.ApiScopes.Add(resource.ToEntity()); } context.SaveChanges(); } } #endregion } }
মাইগ্রেসন কমান্ড:
Add-Migration InitialPersistedGrantDbMigration -c PersistedGrantDbContext -o Data/Migrations/IdentityServer/PersistedGrantDb Add-Migration InitialConfigurationDbMigration -c ConfigurationDbContext -o Data/Migrations/IdentityServer/ConfigurationDb Add-Migration initial -c ApplicationDbContext -o Data/Migrations/AspNetIdentity Update-Database -Context PersistedGrantDbContext Update-Database -Context ConfigurationDbContext Update-Database -Context ApplicationDbContext
এরপর IdentityServer এর লগইন একশন কে আপডেট করতে হবে-
[HttpPost] [ValidateAntiForgeryToken] public async Task<IActionResult> Login(LoginInputModel model, string button) { // check if we are in the context of an authorization request var context = await _interaction.GetAuthorizationContextAsync(model.ReturnUrl); // the user clicked the "cancel" button if (button != "login") { if (context != null) { // if the user cancels, send a result back into IdentityServer as if they // denied the consent (even if this client does not require consent). // this will send back an access denied OIDC error response to the client. await _interaction.DenyAuthorizationAsync(context, AuthorizationError.AccessDenied); // we can trust model.ReturnUrl since GetAuthorizationContextAsync returned non-null if (context.IsNativeClient()) { // The client is native, so this change in how to // return the response is for better UX for the end user. return this.LoadingPage("Redirect", model.ReturnUrl); } return Redirect(model.ReturnUrl); } else { // since we don't have a valid context, then we just go back to the home page return Redirect("~/"); } } if (ModelState.IsValid) { // validate username/password against in-memory store //if (_users.ValidateCredentials(model.Username, model.Password)) //{ // var user = _users.FindByUsername(model.Username); // await _events.RaiseAsync(new UserLoginSuccessEvent(user.Username, user.SubjectId, user.Username, clientId: context?.Client.ClientId)); // // only set explicit expiration here if user chooses "remember me". // // otherwise we rely upon expiration configured in cookie middleware. // AuthenticationProperties props = null; // if (AccountOptions.AllowRememberLogin && model.RememberLogin) // { // props = new AuthenticationProperties // { // IsPersistent = true, // ExpiresUtc = DateTimeOffset.UtcNow.Add(AccountOptions.RememberMeLoginDuration) // }; // }; // // issue authentication cookie with subject ID and username // var isuser = new IdentityServerUser(user.SubjectId) // { // DisplayName = user.Username // }; // await HttpContext.SignInAsync(isuser, props); // if (context != null) // { // if (context.IsNativeClient()) // { // // The client is native, so this change in how to // // return the response is for better UX for the end user. // return this.LoadingPage("Redirect", model.ReturnUrl); // } // // we can trust model.ReturnUrl since GetAuthorizationContextAsync returned non-null // return Redirect(model.ReturnUrl); // } // // request for a local page // if (Url.IsLocalUrl(model.ReturnUrl)) // { // return Redirect(model.ReturnUrl); // } // else if (string.IsNullOrEmpty(model.ReturnUrl)) // { // return Redirect("~/"); // } // else // { // // user might have clicked on a malicious link - should be logged // throw new Exception("invalid return URL"); // } //} // validate username/password against parsist store var result = await _signInManager.PasswordSignInAsync(model.Username, model.Password, model.RememberLogin, lockoutOnFailure: true); if (result.Succeeded) { var user = await _userManager.FindByNameAsync(model.Username); await _events.RaiseAsync(new UserLoginSuccessEvent(user.UserName, user.Id, user.UserName, clientId: context?.Client.ClientId)); if (context != null) { if (context.IsNativeClient()) { // The client is native, so this change in how to // return the response is for better UX for the end user. return this.LoadingPage("Redirect", model.ReturnUrl); } // we can trust model.ReturnUrl since GetAuthorizationContextAsync returned non-null return Redirect(model.ReturnUrl); } // request for a local page if (Url.IsLocalUrl(model.ReturnUrl)) { return Redirect(model.ReturnUrl); } else if (string.IsNullOrEmpty(model.ReturnUrl)) { return Redirect("~/"); } else { // user might have clicked on a malicious link - should be logged throw new Exception("invalid return URL"); } } await _events.RaiseAsync(new UserLoginFailureEvent(model.Username, "invalid credentials", clientId: context?.Client.ClientId)); ModelState.AddModelError(string.Empty, AccountOptions.InvalidCredentialsErrorMessage); } // something went wrong, show form with error var vm = await BuildLoginViewModelAsync(model); return View(vm); }
আশাকরি, মাইক্রোসার্ভিসের সিকিউরিটি ও সিঙ্গেল সাইন-অন/সাইন-আউট(SSO) এর জন্য কিভাবে IdentityServer4(OAuth2,OpenID), ASP.NET Identity অ্যাড/ব্যবহার করে তার কিছু ধারনা দিতে পেরেছি।
ধন্যবাদ।।