اشتراک‌ها
نگارش نهایی Rider 2017.1 منتشر شد

Rider is a cross-platform .NET IDE: you can use it on Windows, Mac and Linux. Rider lets you develop ASP.NET, .NET Core, .NET Framework, Xamarin and Unity applications. 

نگارش نهایی Rider 2017.1 منتشر شد
نظرات مطالب
React 16x - قسمت 1 - معرفی و شروع به کار

URLs in HTTP Archive with specific frameworks detected
Framework Mobile URLs Desktop URLs
jQuery 4,615,474 3,714,643
React 489,827 241,023
Vue.js 85,649 43,691
Angular 19,423 18,088
مطالب
امن سازی برنامه‌های ASP.NET Core توسط IdentityServer 4x - قسمت نهم- مدیریت طول عمر توکن‌ها
توکن‌های صادر شده‌ی توسط IdentityServer به دلایل امنیتی، طول عمر محدودی دارند. بنابراین اولین سؤالی که در اینجا مطرح خواهد شد، این است: «اگر توکنی منقضی شد، چه باید کرد؟» و یا «اگر خواستیم به صورت دستی طول عمر توکنی را پایان دهیم، چه باید کرد؟»


بررسی طول عمر توکن‌ها

اگر مرورگر خود را پس از لاگین به سیستم، برای مدتی به حال خود رها کنید، پس از شروع به کار مجدد، مشاهده خواهید کرد که دیگر نمی‌توانید به API دسترسی پیدا کنید. علت اینجا است که Access token صادر شده، منقضی شده‌است. تمام توکن‌ها، دارای طول عمر مشخصی هستند و پس از سپری شدن این زمان، دیگر اعتبارسنجی نخواهند شد. زمان انقضای توکن، در خاصیت یا claim ویژه‌ای به نام exp ذخیره می‌شود.
در اینجا ما دو نوع توکن را داریم: Identity token و Access token
از Identity token برای ورود به سیستم کلاینت استفاده می‌شود و به صورت پیش‌فرض طول عمر کوتاه آن به 5 دقیقه تنظیم شده‌است. علت کوتاه بودن این زمان این است که این توکن‌ها تنها یکبار مورد استفاده قرار می‌گیرد و پس از ارائه‌ی آن به کلاینت، از طریق آن Claim Identity تولید می‌شود. پس از آن طول عمر Claim Identity تولید شده صرفا به تنظیمات برنامه‌ی کلاینت مرتبط است و می‌تواند از تنظیمات IDP کاملا مجزا باشد؛ مانند پیاده سازی sliding expiration. در این حالت تا زمانیکه کاربر در برنامه فعال است، در حالت logged in باقی خواهد ماند.

Access tokenها متفاوت هستند. طول عمر پیش‌فرض آن‌ها به یک ساعت تنظیم شده‌است و نسبت به Identity token طول عمر بیشتری دارند. پس از اینکه این زمان سپری شد، تنها با داشتن یک Access token جدید است که دسترسی ما مجددا به Web API برقرار خواهد شد. بنابراین در اینجا ممکن است هنوز در برنامه‌ی کلاینت در حالت logged in قرار داشته باشیم، چون هنوز طول عمر Claim Identity آن به پایان نرسیده‌است، اما نتوانیم با قسمت‌های مختلف برنامه کار کنیم، چون نمی‌توانیم از یک Access token منقضی شده جهت دسترسی به منابع محافظت شده‌ی سمت Web API استفاده نمائیم. در اینجا دیگر برنامه‌ی کلاینت هیچ نقشی بر روی تعیین طول عمر یک Access token ندارد و این طول عمر صرفا توسط IDP به تمام کلاینت‌های آن دیکته می‌شود.
در اینجا برای دریافت یک Access token جدید، نیاز به یک Refresh token داریم که صرفا برای «کلاینت‌های محرمانه» که در قسمت سوم این سری آن‌ها را بررسی کردیم، توصیه می‌شود.


چگونه می‌توان زمان انقضای توکن‌ها را صریحا تنظیم کرد؟

برای تنظیم زمان انقضای توکن‌ها، از کلاس src\IDP\DNT.IDP\Config.cs سمت IDP شروع می‌کنیم.
namespace DNT.IDP
{
    public static class Config
    {
        public static IEnumerable<Client> GetClients()
        {
            return new List<Client>
            {
                new Client
                {
                    ClientName = "Image Gallery",
                    // IdentityTokenLifetime = ... // defaults to 300 seconds / 5 minutes
                    // AuthorizationCodeLifetime = ... // defaults to 300 seconds / 5 minutes
                    // AccessTokenLifetime = ... // defaults to 3600 seconds / 1 hour
                }
             };
        }
    }
}
- در اینجا در تنظیمات یک کلاینت جدید، خاصیت IdentityTokenLifetime آن، به طول عمر Identity token تولید شده اشاره می‌کند که مقدار پیش‌فرض آن عدد صحیح 300 ثانیه است یا معادل 5 دقیقه.
- مقدار خاصیت AuthorizationCodeLifetime تنظیمات یک کلاینت، عدد صحیحی است با مقدار پیش‌فرض 300 ثانیه یا معادل 5 دقیقه که طول عمر AuthorizationCode را تعیین می‌کند. این مورد، طول عمر توکن خاصی نیست و در حین فراخوانی Token Endpoint مبادله می‌شود و در طی Hybrid flow رخ می‌دهد. بنابراین مقدار پیش‌فرض آن بسیار مناسب بوده و نیازی به تغییر آن نیست.
- مقدار خاصیت AccessTokenLifetime تنظیمات یک کلاینت، عدد صحیحی است با مقدار پیش‌فرض 3600 ثانیه و یا معادل 1 ساعت و طول عمر Access token تولید شده‌ی توسط این IDP را مشخص می‌کند.
البته باید درنظر داشت اگر طول عمر این توکن دسترسی را برای مثال به 120 یا 2 دقیقه تنظیم کنید، پس از سپری شدن این 2 دقیقه ... هنوز هم برنامه‌ی کلاینت قادر است به Web API دسترسی داشته باشد. علت آن وجود بازه‌ی 5 دقیقه‌ای است که در طی آن، انجام این عملیات مجاز شمرده می‌شود و برای کلاینت‌هایی درنظر گرفته شده‌است که ساعت سیستم آن‌ها ممکن است اندکی با ساعت سرور IDP تفاوت داشته باشند.


درخواست تولید یک Access Token جدید با استفاده از Refresh Tokens

زمانیکه توکنی منقضی می‌شود، کاربر باید مجددا به سیستم لاگین کند تا توکن جدیدی برای او صادر گردد. برای بهبود این تجربه‌ی کاربری، می‌توان در کلاینت‌های محرمانه با استفاده از Refresh token، در پشت صحنه عملیات دریافت توکن جدید را انجام داد و در این حالت دیگر کاربر نیازی به لاگین مجدد ندارد. در این حالت برنامه‌ی کلاینت یک درخواست از نوع POST را به سمت IDP ارسال می‌کند. در این حالت عملیات Client Authentication نیز صورت می‌گیرد. یعنی باید مشخصات کامل کلاینت را به سمت IDP ارسال کرد. در اینجا اطلاعات هویت کلاینت در هدر درخواست و Refresh token در بدنه‌ی درخواست به سمت سرور IDP ارسال خواهند شد. پس از آن IDP اطلاعات رسیده را تعیین اعتبار کرده و در صورت موفقیت آمیز بودن عملیات، یک Access token جدید را به همراه Identity token و همچنین یک Refresh token جدید دیگر، صادر می‌کند.
برای صدور مجوز درخواست یک Refresh token، نیاز است scope جدیدی را به نام offline_access معرفی کنیم. به این معنا که امکان دسترسی به برنامه حتی در زمانیکه offline است، وجود داشته باشد. بنابراین offline در اینجا به معنای عدم لاگین بودن شخص در سطح IDP است.
بنابراین اولین قدم پیاده سازی کار با Refresh token، مراجعه‌ی به کلاس src\IDP\DNT.IDP\Config.cs و افزودن خاصیت AllowOfflineAccess با مقدار true به خواص یک کلاینت است:
namespace DNT.IDP
{
    public static class Config
    {
        public static IEnumerable<Client> GetClients()
        {
            return new List<Client>
            {
                new Client
                {
                    ClientName = "Image Gallery",
                    // IdentityTokenLifetime = ... // defaults to 300 seconds / 5 minutes
                    // AuthorizationCodeLifetime = ... // defaults to 300 seconds / 5 minutes
                    // AccessTokenLifetime = ... // defaults to 3600 seconds / 1 hour
                    AllowOfflineAccess = true,
                    // AbsoluteRefreshTokenLifetime = ... // Defaults to 2592000 seconds / 30 days
                    // RefreshTokenExpiration = TokenExpiration.Sliding
                    UpdateAccessTokenClaimsOnRefresh = true,
                    // ...
                }
             };
        }
    }
}
- در اینجا می‌توان خاصیت AbsoluteRefreshTokenLifetime را که بیانگر طول عمر Refresh token است، تنظیم کرد. مقدار پیش‌فرض آن 2592000  ثانیه و یا معادل 30 روز است.
- البته RefreshToken ضرورتی ندارد که طول عمر Absolute و یا کاملا تعیین شده‌ای را داشته باشد. این رفتار را توسط خاصیت RefreshTokenExpiration می‌توان به TokenExpiration.Sliding نیز تنظیم کرد. البته حالت پیش‌فرض آن بسیار مناسب است.
- در اینجا می‌توان خاصیت UpdateAccessTokenClaimsOnRefresh را نیز به true تنظیم کرد. فرض کنید یکی از Claims کاربر مانند آدرس او تغییر کرده‌است. به صورت پیش‌فرض با درخواست مجدد توکن توسط RefreshToken، این Claims به روز رسانی نمی‌شوند. با تنظیم این خاصیت به true این مشکل برطرف خواهد شد.


پس از تنظیم IDP جهت صدور RefreshToken، اکنون کلاس ImageGallery.MvcClient.WebApp\Startup.cs برنامه‌ی MVC Client را به صورت زیر تکمیل می‌کنیم:
ابتدا در متد تنظیمات AddOpenIdConnect، نیاز است صدور درخواست scope جدید offline_access را صادر کنیم:
options.Scope.Add("offline_access");
همین اندازه تنظیم در سمت برنامه‌ی کلاینت برای دریافت refresh token و ذخیره سازی آن جهت استفاده‌های آتی کفایت می‌کند.

در ادامه نیاز است به سرویس ImageGalleryHttpClient مراجعه کرده و کدهای آن‌را به صورت زیر تغییر داد:
using System;
using System.Collections.Generic;
using System.Globalization;
using System.Net.Http;
using System.Net.Http.Headers;
using System.Threading.Tasks;
using IdentityModel.Client;
using Microsoft.AspNetCore.Authentication;
using Microsoft.AspNetCore.Http;
using Microsoft.Extensions.Configuration;
using Microsoft.Extensions.Logging;
using Microsoft.IdentityModel.Protocols.OpenIdConnect;

namespace ImageGallery.MvcClient.Services
{
    public interface IImageGalleryHttpClient
    {
        Task<HttpClient> GetHttpClientAsync();
    }

    /// <summary>
    /// A typed HttpClient.
    /// </summary>
    public class ImageGalleryHttpClient : IImageGalleryHttpClient
    {
        private readonly HttpClient _httpClient;
        private readonly IConfiguration _configuration;
        private readonly IHttpContextAccessor _httpContextAccessor;
        private readonly ILogger<ImageGalleryHttpClient> _logger;

        public ImageGalleryHttpClient(
            HttpClient httpClient,
            IConfiguration configuration,
            IHttpContextAccessor httpContextAccessor,
            ILogger<ImageGalleryHttpClient> logger)
        {
            _httpClient = httpClient;
            _configuration = configuration;
            _httpContextAccessor = httpContextAccessor;
            _logger = logger;
        }

        public async Task<HttpClient> GetHttpClientAsync()
        {
            var accessToken = string.Empty;

            var currentContext = _httpContextAccessor.HttpContext;
            var expires_at = await currentContext.GetTokenAsync("expires_at");
            if (string.IsNullOrWhiteSpace(expires_at)
                || ((DateTime.Parse(expires_at).AddSeconds(-60)).ToUniversalTime() < DateTime.UtcNow))
            {
                accessToken = await RenewTokens();
            }
            else
            {
                accessToken = await currentContext.GetTokenAsync(OpenIdConnectParameterNames.AccessToken);
            }

            if (!string.IsNullOrWhiteSpace(accessToken))
            {
                _logger.LogInformation($"Using Access Token: {accessToken}");
                _httpClient.SetBearerToken(accessToken);
            }

            _httpClient.BaseAddress = new Uri(_configuration["WebApiBaseAddress"]);
            _httpClient.DefaultRequestHeaders.Accept.Clear();
            _httpClient.DefaultRequestHeaders.Accept.Add(new MediaTypeWithQualityHeaderValue("application/json"));

            return _httpClient;
        }

        private async Task<string> RenewTokens()
        {
            // get the current HttpContext to access the tokens
            var currentContext = _httpContextAccessor.HttpContext;

            // get the metadata
            var discoveryClient = new DiscoveryClient(_configuration["IDPBaseAddress"]);
            var metaDataResponse = await discoveryClient.GetAsync();

            // create a new token client to get new tokens
            var tokenClient = new TokenClient(
                metaDataResponse.TokenEndpoint,
                _configuration["ClientId"],
                _configuration["ClientSecret"]);

            // get the saved refresh token
            var currentRefreshToken = await currentContext.GetTokenAsync(OpenIdConnectParameterNames.RefreshToken);

            // refresh the tokens
            var tokenResult = await tokenClient.RequestRefreshTokenAsync(currentRefreshToken);
            if (tokenResult.IsError)
            {
                throw new Exception("Problem encountered while refreshing tokens.", tokenResult.Exception);
            }

            // update the tokens & expiration value
            var updatedTokens = new List<AuthenticationToken>();
            updatedTokens.Add(new AuthenticationToken
            {
                Name = OpenIdConnectParameterNames.IdToken,
                Value = tokenResult.IdentityToken
            });
            updatedTokens.Add(new AuthenticationToken
            {
                Name = OpenIdConnectParameterNames.AccessToken,
                Value = tokenResult.AccessToken
            });
            updatedTokens.Add(new AuthenticationToken
            {
                Name = OpenIdConnectParameterNames.RefreshToken,
                Value = tokenResult.RefreshToken
            });

            var expiresAt = DateTime.UtcNow + TimeSpan.FromSeconds(tokenResult.ExpiresIn);
            updatedTokens.Add(new AuthenticationToken
            {
                Name = "expires_at",
                Value = expiresAt.ToString("o", CultureInfo.InvariantCulture)
            });

            // get authenticate result, containing the current principal & properties
            var currentAuthenticateResult = await currentContext.AuthenticateAsync("Cookies");

            // store the updated tokens
            currentAuthenticateResult.Properties.StoreTokens(updatedTokens);

            // sign in
            await currentContext.SignInAsync("Cookies",
             currentAuthenticateResult.Principal, currentAuthenticateResult.Properties);

            // return the new access token
            return tokenResult.AccessToken;
        }
    }
}
تفاوت این کلاس با نمونه‌ی قبلی آن در اضافه شدن متد RenewTokens آن است.
پیشتر در قسمت ششم، روش کار مستقیم با DiscoveryClient و TokenClient را در حین کار با UserInfo Endpoint جهت دریافت دستی اطلاعات claims از IDP بررسی کردیم. در اینجا به همین ترتیب با TokenEndpoint کار می‌کنیم. به همین جهت توسط DiscoveryClient، متادیتای IDP را که شامل آدرس TokenEndpoint است، استخراج کرده و توسط آن TokenClient را به همراه اطلاعات کلاینت تشکیل می‌دهیم.
سپس مقدار refresh token فعلی را نیاز داریم. زیرا توسط آن است که می‌توانیم درخواست دریافت یکسری توکن جدید را ارائه دهیم. پس از آن با فراخوانی tokenClient.RequestRefreshTokenAsync(currentRefreshToken)، تعدادی توکن جدید را از سمت IDP دریافت می‌کنیم. لیست آن‌ها را تهیه کرده و توسط آن کوکی جاری را به روز رسانی می‌کنیم. در این حالت نیاز است مجددا SignInAsync فراخوانی شود تا کار به روز رسانی کوکی نهایی گردد.
خروجی این متد، مقدار access token جدید است.
پس از آن در متد GetHttpClientAsync بررسی می‌کنیم که آیا نیاز است کار refresh token صورت گیرد یا خیر؟ برای این منظور مقدار expires_at را دریافت و با زمان جاری با فرمت UTC مقایسه می‌کنیم. 60 ثانیه پیش از انقضای توکن، متد RenewTokens فراخوانی شده و توسط آن access token جدیدی برای استفاده‌ی در برنامه صادر می‌شود. مابقی این متد مانند قبل است و این توکن دسترسی را به همراه درخواست از Web API به سمت آن ارسال می‌کنیم.


معرفی Reference Tokens

تا اینجا با توکن‌هایی از نوع JWT کار کردیم. این نوع توکن‌ها، به همراه تمام اطلاعات مورد نیاز جهت اعتبارسنجی آن‌ها در سمت کلاینت، بدون نیاز به فراخوانی مجدد IDP به ازای هر درخواست هستند. اما این نوع توکن‌ها به همراه یک مشکل نیز هستند. زمانیکه صادر شدند، دیگر نمی‌توان طول عمر آن‌ها را کنترل کرد. اگر طول عمر یک Access token به مدت 20 دقیقه تنظیم شده باشد، می‌توان مطمئن بود که در طی این 20 دقیقه حتما می‌توان از آن استفاده کرد و دیگر نمی‌توان در طی این بازه‌ی زمانی دسترسی آن‌را بست و یا آن‌را برگشت زد. اینجاست که Reference Tokens معرفی می‌شوند. بجای قرار دادن تمام اطلاعات در یک JWT متکی به خود، این نوع توکن‌های مرجع، فقط یک Id هستند که به توکن اصلی ذخیره شده‌ی در سطح IDP لینک می‌شوند و به آن اشاره می‌کنند. در این حالت هربار که نیاز به دسترسی منابع محافظت شده‌ی سمت API را با یک چنین توکن دسترسی لینک شده‌ای داشته باشیم، Reference Token در پشت صحنه (back channel) به IDP ارسال شده و اعتبارسنجی می‌شود. سپس محتوای اصلی آن به سمت API ارسال می‌شود. این عملیات از طریق endpoint ویژه‌ای در IDP به نام token introspection endpoint انجام می‌شود. به این ترتیب می‌توان طول عمر توکن صادر شده را کاملا کنترل کرد؛ چون تنها تا زمانیکه در data store مربوط به IDP وجود خارجی داشته باشند، قابل استفاده خواهند بود. بنابراین نسبت به حالت استفاده‌ی از JWTهای متکی به خود، تنها عیب آن زیاد شدن ترافیک به سمت IDP جهت اعتبارسنجی Reference Token‌ها به ازای هر درخواست به سمت Web API است.


چگونه از Reference Token‌ها بجای JWTهای متکی به خود استفاده کنیم؟

برای استفاده‌ی از Reference Tokenها بجای JWTها، ابتدا نیاز به مراجعه‌ی به کلاس src\IDP\DNT.IDP\Config.cs و تغییر مقدار خاصیت AccessTokenType هر کلاینت است:
namespace DNT.IDP
{
    public static class Config
    {
        public static IEnumerable<Client> GetClients()
        {
            return new List<Client>
            {
                new Client
                {
                    ClientName = "Image Gallery",
// ...
                    AccessTokenType = AccessTokenType.Reference
                }
             };
        }
    }
}
مقدار پیش‌فرض AccessTokenType، همان Jwt یا توکن‌های متکی به خود است که در اینجا به Reference Token تغییر یافته‌است.
اینبار اگر برنامه را اجرا کنید و در کلاس ImageGalleryHttpClient برنامه‌ی کلاینت، بر روی سطر httpClient.SetBearerToken یک break-point قرار دهید، مشاهده خواهید کرد فرمت این توکن ارسالی به سمت Web API تغییر یافته و اینبار تنها یک Id ساده‌است که دیگر قابل decode شدن و استخراج اطلاعات دیگری از آن نیست. با ادامه جریان برنامه و رسیدن این توکن به سمت Web API، درخواست رسیده برگشت خواهد خورد و اجرا نمی‌شود.
علت اینجا است که هنوز تنظیمات کار با token introspection endpoint انجام نشده و این توکن رسیده‌ی در سمت Web API قابل اعتبارسنجی و استفاده نیست. برای تنظیم آن نیاز است یک ApiSecret را در سطح Api Resource مربوط به IDP تنظیم کنیم:
namespace DNT.IDP
{
    public static class Config
    {
        // api-related resources (scopes)
        public static IEnumerable<ApiResource> GetApiResources()
        {
            return new List<ApiResource>
            {
                new ApiResource(
                    name: "imagegalleryapi",
                    displayName: "Image Gallery API",
                    claimTypes: new List<string> {"role" })
                {
                  ApiSecrets = { new Secret("apisecret".Sha256()) }
                }
            };
        }
اکنون فایل startup در سطح API را جهت معرفی این تغییرات به صورت زیر ویرایش می‌کنیم:
namespace ImageGallery.WebApi.WebApp
{
    public class Startup
    {
        public void ConfigureServices(IServiceCollection services)
        {
            services.AddAuthentication(defaultScheme: IdentityServerAuthenticationDefaults.AuthenticationScheme)
               .AddIdentityServerAuthentication(options =>
               {
                   options.Authority = Configuration["IDPBaseAddress"];
                   options.ApiName = "imagegalleryapi";
                   options.ApiSecret = "apisecret";
               });
در اینجا نیاز است ApiSecret تنظیم شده‌ی در سطح IDP معرفی شود.

اکنون اگر برنامه را اجرا کنید، ارتباط با token introspection endpoint به صورت خودکار برقرار شده، توکن رسیده اعتبارسنجی گردیده و برنامه بدون مشکل اجرا خواهد شد.


چگونه می‌توان Reference Tokenها را از IDP حذف کرد؟

هدف اصلی استفاده‌ی از Reference Tokenها به دست آوردن کنترل بیشتری بر روی طول عمر آن‌ها است و حذف کردن آن‌ها می‌تواند به روش‌های مختلفی رخ دهد. برای مثال یک روش آن تدارک یک صفحه‌ی Admin و ارائه‌ی رابط کاربری برای حذف توکن‌ها از منبع داده‌ی IDP است. روش دیگر آن حذف این توکن‌ها از طریق برنامه‌ی کلاینت با برنامه نویسی است؛ برای مثال در زمان logout شخص. برای این منظور، endpoint ویژه‌ای به نام token revocation endpoint در نظر گرفته شده‌است. فراخوانی آن از سمت برنامه‌ی کلاینت، امکان حذف توکن‌های ذخیره شده‌ی در سمت IDP را میسر می‌کند.
به همین جهت به کنترلر ImageGallery.MvcClient.WebApp\Controllers\GalleryController.cs مراجعه کرده و متد Logout آن‌را تکمیل می‌کنیم:
namespace ImageGallery.MvcClient.WebApp.Controllers
{
    [Authorize]
    public class GalleryController : Controller
    {
        public async Task Logout()
        {
            await revokeTokens();
            // Clears the  local cookie ("Cookies" must match the name of the scheme)
            await HttpContext.SignOutAsync("Cookies");
            await HttpContext.SignOutAsync("oidc");
        }

        private async Task revokeTokens()
        {
            var discoveryClient = new DiscoveryClient(_configuration["IDPBaseAddress"]);
            var metaDataResponse = await discoveryClient.GetAsync();
            var tokenRevocationClient = new TokenRevocationClient(
                metaDataResponse.RevocationEndpoint,
                _configuration["ClientId"],
                _configuration["ClientSecret"]
            );

            var accessToken = await HttpContext.GetTokenAsync(OpenIdConnectParameterNames.AccessToken);
            if (!string.IsNullOrWhiteSpace(accessToken))
            {
                var response = await tokenRevocationClient.RevokeAccessTokenAsync(accessToken);
                if (response.IsError)
                {
                    throw new Exception("Problem accessing the TokenRevocation endpoint.", response.Exception);
                }
            }

            var refreshToken = await HttpContext.GetTokenAsync(OpenIdConnectParameterNames.RefreshToken);
            if (!string.IsNullOrWhiteSpace(refreshToken))
            {
                var response = await tokenRevocationClient.RevokeRefreshTokenAsync(refreshToken);
                if (response.IsError)
                {
                    throw new Exception("Problem accessing the TokenRevocation endpoint.", response.Exception);
                }
            }
        }
در اینجا در متد جدید revokeTokens، ابتدا توسط DiscoveryClient، به آدرس RevocationEndpoint دسترسی پیدا می‌کنیم. سپس توسط آن، TokenRevocationClient را تشکیل می‌دهیم. اکنون می‌توان توسط این کلاینت حذف توکن‌ها، دو متد RevokeAccessTokenAsync و RevokeRefreshTokenAsync آن‌را بر اساس مقادیر فعلی این توکن‌ها در سیستم، فراخوانی کرد تا سبب حذف آن‌ها در سمت IDP شویم.



کدهای کامل این قسمت را از اینجا می‌توانید دریافت کنید.
برای اجرای برنامه:
- ابتدا به پوشه‌ی 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 وارد کنید.
مطالب
تبدیل خودکار استثنای HttpRequestValidationException به یک ModelError در ASP.NET MVC
فرم هایی که اطلاعاتی را از یک کاربر دریافت کرده و به سمت سرور Post می‌کنند، از مهمترین اجزای لاینفک یک وب سایت می‌باشند. بی شک همه‌ی ما از چنین فرمهایی حتی در یک پروژه‌ی هرچند کوچک استفاده کرده‌ایم. ممکن است هنگام ارسال این فرمها، کاربری شیطنت به خرج داده و درون یکی از فیلدهای فرم، از عبارت‌های HTML و یا یک اسکریپت استفاده کرده باشد. در ASP.Net + MVC از مکانیزم ValidationRequest برای مقابله با چنین حملاتی (XSS) استفاده شده است.
یک فرم با یک Textbox در یک صفحه قرار دهید و درون Textbox یک تگ HTML بنویسید و آنرا به سرور ارسال کنید، در حالت پیش فرض اتفاقی که خواهد افتاد اینست که با یک صفحه‌ی خطا روبرو خواهید شد که به شما هشدار می‌دهد داده‌های ارسال شده، دارای مقادیر غیرمجازی می‌باشند. شما می‌توانید این مکانیزم اعتبارسنجی-امنیتی را غیرفعال کنید. برای غیرفعال کردن آن هم در لینکی که در فوق معرفی گردید توضیحات کافی داده شده است. ولی توصیه میشود برای جلوگیری از حملات XSS هیچگاه این مکانیزم را غیرفعال نکنید، مگر اینکه هیچ داده‌ای را بدون Encode کردن در صفحه نمایش ندهید.
راه‌های زیادی برای هندل کردن این خطا و مطلع کردن کاربر وجود دارند و معمولا از صفحه‌ی خطای سفارشی برای اینکار استفاده میشود. اما ایده‌ی بهتری نیز برای مقابله با این خطا وجود دارد!
فرض کنید اطلاعات فرم شما از طریق Ajax به سرور ارسال می‌شود و نتیجه بصورت Json به مروگر برگشت داده می‌شود. در حالت معمول در صورت رخ دادن خطای فوق، سرور کد 500 را برگشت می‌دهد و تنها راه هندل کردن آن در رویداد error شئ Ajax شما می‌باشد؛ آن‌هم با یک پیغام ساده. ولی من ترجیح می‌دهم به جای صادر کردن خطای 500، در صورت وقوع این خطا آن‌را بصورت یک خطای ModelState به کاربر نمایش دهم. به نظر من این‌کار وجهه‌ی بهتری دارد و ضمنا لازم نیست در هر رویدادی این خطا را هندل کنید. برای رسیدن به این هدف هم چندین راه وجود دارد که ساده‌ترین آن‌ها اینست که یک ModelBinder سفارشی ایجاد کنید و با هندل کردن این خطا، یک پیام خطا را به ModelState اضافه کنید و بعد به MVC بگویید که از این ModelBinder برای پروژه‌ی من استفاده کن. با این روش هرگاه کاربر داده‌ای حاوی کدهای HTML درون فیلدهای فرم وارد کرده باشد، بدون هیچ کار اضافه‌ای وضعیت ModelState شما Invalid می‌شود و خطای مدل به کاربر نمایش داده خواهد شد.
ابتدا کلاسی برای ModelBinder سفارشی خود ایجاد کنید و از کلاس DefaultModelBinder ارث بری کنید. سپس با بازنویسی متد BindModel آن منطق خود را پیاده سازی کنید:
using System.Web;
using System.Web.Mvc;
using System.Web.Helpers;
using System.Globalization;

namespace Parsnet
{
    public class ParsnetModelBinder : DefaultModelBinder
    {
        public override object BindModel(ControllerContext controllerContext, ModelBindingContext bindingContext)
        {
            try
            {
                return base.BindModel(controllerContext, bindingContext);
            }
            catch (HttpRequestValidationException ex)
            {
                var modelState = new ModelState();
                modelState.Errors.Add("اطلاعات ارسالی شما دارای کدهای HTML می‌باشند.");
                var key = bindingContext.ModelName;
                var value = controllerContext.RequestContext.HttpContext.Request.Unvalidated().Form[key];
                modelState.Value = new ValueProviderResult(value, value, CultureInfo.InvariantCulture);
                bindingContext.ModelState.Add(key, modelState);
            }

            return null;
        }
    }
}

در این کلاس ابتدا سعی میکنیم بطور عادی کار را به متد BindModel کلاس پایه بسپاریم. اگر داده‌های ارسال شده حاوی کد HTML نباشد، بدون هیچ خطایی Model Binding صورت می‌گیرد. ولی در صورتیکه کاربر در فیلدی، از کدهای HTML استفاده کرده باشد، یک خطای HttpRequestValidationException  رخ خواهد داد که با هندل کردن آن هدف خود را تامین می‌کنیم.
برای اینکار در قسمت catch بلوک مدیریت خطا، ابتدا یک نمونه از کلاس ModelState را میسازیم. بعد پیام خطای مورد نظر خود را به Errors‌های آن اضافه میکنیم. حال باید یک زوج کلید/مقدار برای این ModelState تعریف کنیم و به bindingContext اضافه کنیم. کلید ما در اینجا نام مدل جاری و مقدار آن هم نام فیلدی از فرم است که سبب بروز این خطا شده است.
حالا نهایت کاری که باید انجام دهیم اینست که در رویداد Application_Start این مدل بایندیگ سفارشی را جایگزین مدل بایندیگ پیش فرض کنیم:
    ModelBinders.Binders.DefaultBinder = new ParsnetModelBinder();

کار تمام است. از حالا به بعد در صورتیکه کاربر در فیلدهای هر فرمی از سایت شما از کدهای HTML استفاده کند دیگر خطایی رخ نمی‌دهد و فقط ModelState در وضعیت Invalid قرار میگیرد که میتوانید با قرار دادن یک ValidationMessage یا ValidationSummary به راحتی خطا را به کاربر نشان دهید.
مطالب دوره‌ها
ساخت یک Mini ORM با AutoMapper
Mini ORM‌ها برخلاف ORMهای کاملی مانند Entity framework یا NHibernate، کوئری‌های LINQ را تبدیل به SQL نمی‌کنند. در اینجا کار با SQL نویسی مستقیم شروع می‌شود و مهم‌ترین کار این کتابخانه‌ها، نگاشت نتیجه‌ی دریافتی از بانک اطلاعاتی به اشیاء دات نتی هستند. خوب ... AutoMapper هم دقیقا همین کار را انجام می‌دهد! بنابراین در ادامه قصد داریم یک Mini ORM را به کمک AutoMapper طراحی کنیم.


کلاس پایه AdoMapper

public abstract class AdoMapper<T> where T : class
{
    private readonly SqlConnection _connection;
 
    protected AdoMapper(string connectionString)
    {
        _connection = new SqlConnection(connectionString);
    }
 
    protected virtual IEnumerable<T> ExecuteCommand(SqlCommand command)
    {
        command.Connection = _connection;
        command.CommandType = CommandType.StoredProcedure;
        _connection.Open();
 
        try
        {
            var reader = command.ExecuteReader();
            try
            {
                return Mapper.Map<IDataReader, IEnumerable<T>>(reader);
            }
            finally
            {
                reader.Close();
            }
        }
        finally
        {
            _connection.Close();
        }
    }
 
    protected virtual T GetRecord(SqlCommand command)
    {
        command.Connection = _connection;
        _connection.Open();
        try
        {
            var reader = command.ExecuteReader();
            try
            {
                reader.Read();
                return Mapper.Map<IDataReader, T>(reader);
            }
            finally
            {
                reader.Close();
            }
        }
        finally
        {
            _connection.Close();
        }
    }
 
    protected virtual IEnumerable<T> GetRecords(SqlCommand command)
    {
        command.Connection = _connection;
        _connection.Open();
        try
        {
            var reader = command.ExecuteReader();
            try
            {
                return Mapper.Map<IDataReader, IEnumerable<T>>(reader);
            }
            finally
            {
                reader.Close();
            }
        }
        finally
        {
            _connection.Close();
        }
    }
}
در اینجا کلاس پایه Mini ORM طراحی شده را ملاحظه می‌کنید. برای نمونه قسمت GetRecords آن مانند مباحث استاندارد ADO.NET است. فقط کار خواندن و همچنین نگاشت رکوردهای دریافت شده از بانک اطلاعاتی به شیء‌ایی از نوع T توسط AutoMapper انجام خواهد شد.


نحوه‌ی استفاده از کلاس پایه AdoMapper

در کدهای ذیل نحوه‌ی ارث بری از کلاس پایه AdoMapper و سپس استفاده از متدهای آن‌را ملاحظه می‌کنید:
public class UsersService : AdoMapper<User>, IUsersService
{
    public UsersService(string connectionString)
        : base(connectionString)
    {
    }
 
    public IEnumerable<User> GetAll()
    {
        using (var command = new SqlCommand("SELECT * FROM Users"))
        {
            return GetRecords(command);
        }
    }
 
    public User GetById(int id)
    {
        using (var command = new SqlCommand("SELECT * FROM Users WHERE Id = @id"))
        {
            command.Parameters.Add(new SqlParameter("id", id));
            return GetRecord(command);
        }
    }
}
در این مثال نحوه‌ی تعریف کوئری‌های پارامتری نیز در متد GetById به نحو متداولی مشخص شده‌است. کار نگاشت حاصل این کوئری‌ها به اشیاء دات نتی را AutoMapper انجام خواهد داد. نحوه‌ی کار نیز، نگاشت فیلد f1 به خاصیت f1 است (هم نام‌ها به هم نگاشت می‌شوند).


تعریف پروفایل مخصوص AutoMapper

ORMهای تمام عیار، کار نگاشت فیلدهای بانک اطلاعاتی را به خواص اشیاء دات نتی، به صورت خودکار انجام می‌دهند. در اینجا همانند روش‌های متداول کار با AutoMapper نیاز است این نگاشت را به صورت دستی یکبار تعریف کرد:
public class UsersProfile : Profile
{
    protected override void Configure()
    {
        this.CreateMap<IDataRecord, User>();
    }
 
    public override string ProfileName
    {
        get { return this.GetType().Name; }
    }
}
و سپس در ابتدای برنامه آن‌را به AutoMapper معرفی نمود:
Mapper.Initialize(cfg => // In Application_Start()
{
    cfg.AddProfile<UsersProfile>();
});


سفارشی سازی نگاشت‌های AutoMapper

فرض کنید کلاس Advertisement زیر، معادل است با جدول Advertisements بانک اطلاعاتی؛ با این تفاوت که در کلاس تعریف شده، خاصیت TitleWithOtherName تطابقی با هیچکدام از فیلدهای بانک اطلاعاتی ندارد. بنابراین اطلاعاتی نیز به آن نگاشت نخواهد شد.
public class Advertisement
{
    public int Id { set; get; }
    public string Title { get; set; }
    public string Description { get; set; }
    public int UserId { get; set; }
 
    public string TitleWithOtherName { get; set; }
}
برای رفع این مشکل می‌توان حین تعریف پروفایل مخصوص Advertisement، آن‌را سفارشی سازی نیز نمود:
public class AdvertisementsProfile : Profile
{
    protected override void Configure()
    {
        this.CreateMap<IDataRecord, Advertisement>()
            .ForMember(dest => dest.TitleWithOtherName,
                       options => options.MapFrom(src =>
                            src.GetString(src.GetOrdinal("Title"))));
    }
 
    public override string ProfileName
    {
        get { return this.GetType().Name; }
    }
}
در اینجا پس از تعریف نگاشت مخصوص کار با IDataRecordها، عنوان شده‌است که هر زمانیکه به خاصیت TitleWithOtherName رسیدی، مقدارش را از فیلد Title دریافت و جایگزین کن.


کدهای کامل این مطلب را از اینجا می‌توانید دریافت کنید.
نظرات مطالب
اعتبارسنجی مبتنی بر JWT در ASP.NET Core 2.0 بدون استفاده از سیستم Identity
سلام. اگر به جای اینکه AccessToken را به صورت دیتا برای کلاینت ارسال کنیم آنرا در Response Header قرار دهیم به طور مثال بعد از لاگین موفق به شکل ذیل:
Response.Headers.Add("AccessToken", accessToken);
در این حالت کلاینت هم این مقدار را ذخیره کرده و در درخواست‌های خود به سمت سرور ارسال می‌کند و در سمت سرور هم برای پیاده سازی امکان sliding expiration در رخداد OnTokenValidated بعد از چک کردن موارد مورد نظر دوباره AccessToken را ساخته و به 
Response Header اضافه نماییم آیا همین قدر کافی نمی‌باشد به جای ذخیره فیزیکی Token در دیتابیس؟
مطالب
راه‌اندازی Http Interceptor در Angular
ماژول Http در Angular، برای برقراری ارتباط بین کلاینت و سمت سرور، مورد استفاده قرار می‌گیرد. معمولا هنگام ساخت درخواست‌های Http، یکسری کدهای تکراری برای تنظیم هدر (برای اعتبارسنجی و همچنین تنظیمات دیگر) نوشته می‌شوند که در هر درخواست یکسان هستند. همچنین بعد از آمدن جواب (Response) از سمت سرور نیز یکسری کدهای تکراری جهت برسی کد response و یا تغییر فرمت اطلاعات رسیده، به ساختار مورد توافق نوشته خواهند شد.

برای مثال در صورتیکه در برنامه خود از اعتبار سنجی مبتنی بر توکن (Token Base Authentication) استفاده می‌کنید، قبل از ارسال هر درخواست (request)، کدهایی مشابه کد زیر باید نوشته شوند:
let headers = new Headers();
let token = localStorage.getItem('token');
headers.append('Authorization', 'bearer ' + token);
this.http.get('/api/controller/action', { headers: headers })

همچنین فرض کنید بعد از رسیدن جواب هر درخواست، می‌خواهید response code را چک کنید و خطاهای احتمالی را مدیریت کنید. مثلا درصورت دریافت کد 401، کاربر را به صفحه «ورود» و با دریافت کد 404 آنرا را به صفحه «یافت نشد» هدایت کنید و یا با دریافت کد 403 یا 500 پیغام مناسبی را نمایش دهید. بدیهی است در این صورت بعد از هر آمدن پاسخ از سمت سرور (response)، این کدها بایستی تکرار شوند.

جهت پرهیز از این کدهای تکراری، می‌توان برای ماژول Http، یک interceptor واحد درنظر گرفت که تمامی کدهای تکراری را تنها یکبار داخل آن پیاده سازی کرد. مزیت این روش، مدیریت راحت کد، کاهش پیچیدگی‌ها و همچنین حذف کدهای تکراری و یکسان سازی آنها است.
هرچند در Angular دیگر به مانند Angular 1.x مفهوم intercept بر روی Http را به صورت توکار نداریم، ولی به دلایل زیر ما نیاز به پیاده سازی interceptor برای ماژول Http را داریم:
- تنظیم هدرهای سفارشی و اصلاح آدرس، قبل از ارسال درخواست به سمت سرور
- تنظیم token در هدر درخواست، جهت اعتبار سنجی
- مدیریت سراسری خطاهای Http
- انجام هرگونه عملیات crosscutting

حالا که Angular مفهموم intercept را برای ماژول Http خود به صورت توکار درنظر نگرفته است، راه‌حل چیست؟ بهترین راه‌حل برای پیاده سازی موارد مطرح شده در بالا، ارث بری و یا گسترش (extend) مستقیم ماژول Http است:
import { Injectable } from "@angular/core";
import { ConnectionBackend, RequestOptions, Request, RequestOptionsArgs, Response, Http, Headers } from "@angular/http";
import { Observable } from "rxjs/Rx";
import 'rxjs/add/operator/catch';
import 'rxjs/add/observable/throw';

@Injectable()
export class InterceptedHttp extends Http {
    constructor(backend: ConnectionBackend, defaultOptions: RequestOptions) {
        super(backend, defaultOptions);
    }

    request(url: string | Request, options?: RequestOptionsArgs): Observable<Response> {
        // اصلاح url
        if (typeof (url) === 'string')
            url = this.updateUrl((url as string));
        else
            url.url = this.updateUrl((url as Request).url);

        return super.request(url, this.getRequestOptionArgs(options)).catch((err: Response) => {
            // Exception handling
            switch (err.status) {
                case 400:
                    console.log('interceptor: 400');
                    console.log(err);
                    break;
                case 404:
                    console.log('interceptor: 404');
                    console.log(err);
                    break;
                case 500:
                    console.log('interceptor: 500');
                    console.log(err);
                    break;
                case 401:
                    console.log('interceptor: 401');
                    console.log(err);
                    break;
                case 403:
                    console.log('interceptor: 403');
                    console.log(err);
                    break;
                default:
                    console.log('interceptor: ' + err.status);
                    console.log(err);
            }
            return Observable.throw(err);

        });
    }

    private updateUrl(req: string) {
        return `http://localhost:61366/api/${req}`
    }

    private getRequestOptionArgs(options?: RequestOptionsArgs): RequestOptionsArgs {
        if (options == null) {
            options = new RequestOptions();
        }
        if (options.headers == null) {
            options.headers = new Headers();
        }
        // هدر درخواست تنظیم
        let token = localStorage.getItem('token');
        options.headers.append('Authorization', 'bearer ' + token);


        return options;
    }
}
تمامی افعال Http، از جمله get ،post ،put ،delete ،patch ،head و options در نهایت از متد request موجود در ماژول http برای ارسال درخواست خود استفاده می‌کنند. به همین جهت تمامی عملیات crosscutting را در این متد پیاده سازی کرده‌ایم. به علاوه قبل از ارسال درخواست، توسط متد updateUrl آدرس url خود را اصلاح کرده‌ایم. همچنین توسط متد getRequestOptionArgs هدر درخواست را جهت اعتبار سنجی مقداردهی کرده‌ایم. در اینجا بعد از ارسال درخواست و آمدن response از سمت سرور، توسط catch خطاهای احتمالی را برسی کرده‌ایم.

نکته: به عنوان مثال، در صورتیکه قصد دارید، برای درخواست‌هایی از جنس get، هدر متفاوتی نسبت به دیگر درخواست‌ها داشته باشید، آنگاه پیاده سازی عملیات اصلاح هدر در متد request جواب کار را نخواهد داد. برای حل این موضوع می‌توانید به جای اصلاح header در متد request، تمامی متدهای get ،post، put ،delete ،patch ،head و options را باز نویسی کرده و در هرکدام از این متدها اینکار را انجام دهید.

حالا با تغییر قسمت providers در ماژول اصلی برنامه به شکل زیر، Angular را مجبور می‌کنیم بجای استفاده از ماژول Http توکار خود، از ماژول جدید InterceptedHttp استفاده کند:
//…
providers: [{
        provide: Http,
        useFactory: (backend: XHRBackend, options: RequestOptions) => {
            return new InterceptedHttp(backend, options);
        },
        deps: [XHRBackend, RequestOptions],
    }],
//…

همه چیز آماده است. اکنون کافی است ماژول Http را در سرویس یا کامپوننت‌های خود تزریق کرده و درخواست‌های Http را بدون هیچگونه نوشتن کد اضافی برای تنظیم هدر و غیره (با فرض اینکه تمامی آنها در متد request از ماژول http نوشته شده‌اند)، به مانند قبل صادر کنید. برای نمونه کد زیر را ببینید.
import { Http, URLSearchParams } from '@angular/http';

//…
constructor(private _http: Http) { }

ngOnInit() {
    let urlSearchParams: URLSearchParams = new URLSearchParams();
    urlSearchParams.append('page', page.toString());
    urlSearchParams.append('count', count.toString());
    let params = urlSearchParams.toString();
    this._http.get(`/cars`, { params: params })
        .subscribe(result => {
            console.log('service: Succ');
            this.cars = result.json();
        }, err => {
            console.log('service: error');
        });
}
//…
با اینکه Angular از interceptor پشتیبانی نمی‌کند، ولی کتابخانه‌هایی برای ایجاد قابلیت مشابه interceptor به وجود آمده‌اند که برخی از آنها عبارتند از:  angular2-cool-http ، ng2-http-interceptor ، ng2-interceptors . به جای extend مستقیم ماژول Http توسط خودتان، اینکار را می‌توانید به این کتابخانه‌ها بسپارید.