Vivasoft-logo

মাইক্রোসার্ভিসের সিকিউরিটি ও সিঙ্গেল সাইন-অন/সাইন-আউট(SSO): IdentityServer4(OAuth2,OpenID Connect), ASP.NET Identity

আজ আমরা দেখব 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 অ্যাড/ব্যবহার করে তার কিছু ধারনা দিতে পেরেছি।
ধন্যবাদ।।

স্প্রিং সিকিউরিটিঃ আপনার অ্যাপ্লিকেশনের সুরক্ষায়

স্প্রিং সিকিউরিটিঃ আপনার অ্যাপ্লিকেশনের সুরক্ষায় যেকোন ওয়েব অ্যাপ্লিকেশন এর ক্ষেত্রে সিকিউরিটি একটি অত্যন্ত গুরুত্বপুর্ণ ব্যাপার। আমরা জানি ইন্টারনেট প্রচুর দুষ্ট লোকজন দিয়ে ভর্তি  । অনেক অনেক...

স্ট্রিং এর আদ্যোপান্ত

কম্পিউটার প্রোগ্রামিং এ বহুল পরিচিত আর ব্যবহৃত একটা বিষয় হল স্ট্রিং। এক কথায় স্ট্রিং হচ্ছে কতগুলো ক্যারেক্টার এর সিকুয়েন্স বা অনুক্রম।সহজ ভাষায় যখন বেশ কিছু  ক্যারেক্টার একসাথে মিলেমিশে কিছু একটা...

লোকাল স্টোরেজ এবং সেশন স্টোরেজ

localStorageএবং sessionStorage হল ওয়েব স্টোরেজ অবজেক্ট যা ব্রাউজারে কী/মান জোড়া সংরক্ষণ করার অনুমতি দেয়। তাদের সম্পর্কে যা মজার তা হল যে ডেটা একটি পেইজ রিফ্রেশ (এর জন্য sessionStorage) এবং এমনকি...