مطالب
بررسی نکات دریافت فایل‌های حجیم توسط HttpClient
HttpClient به عنوان جایگزینی برای HttpWebRequest API قدیمی، به همراه NET 4.5. ارائه شد و هدف آن یکپارچه کردن پیاده سازی‌های متفاوت موجود به همراه ارائه را‌ه‌حلی چندسکویی است که از WPF/UWP ، ASP.NET تا NET Core. و iOS/Android را نیز پشتیبانی می‌کند. تمام قابلیت‌های جدید پروتکل HTTP مانند HTTP/2 نیز از این پس تنها به همراه این API ارائه می‌شوند.
در مطلب «روش استفاده‌ی صحیح از HttpClient در برنامه‌های دات نت» با روش استفاده‌ی تک وهله‌ای آن آشنا شدیم. در این مطلب نکات ویژه‌ی دریافت فایل‌های حجیم آن‌را بررسی خواهیم کرد. بدون توجه به این نکات، یا OutOfMemoryException را دریافت خواهید کرد و یا پیش از پایان کار، با خطای Timeout این پروسه به پایان خواهد رسید.


مشکل اول: نیاز به تغییر Timeout پیش فرض

فرض کنید می‌خواهیم فایل حجیمی را با تنظیمات پیش‌فرض HttpClient دریافت کنیم:
using System;
using System.Net.Http;
using System.Threading.Tasks;

namespace HttpClientTips.LargeFiles
{
    class Program
    {
        private static readonly HttpClient _client = new HttpClient();

        static async Task Main(string[] args)
        {
            var bytes = await DownloadLargeFileAsync();
        }

        public static async Task<byte[]> DownloadLargeFileAsync()
        {
            Console.WriteLine("Downloading a 4K content - too much bytes.");
            var response = await _client.GetAsync("http://downloads.4ksamples.com/downloads/sample-Elysium.2013.2160p.mkv");
            var bytes = await response.Content.ReadAsByteArrayAsync();
            return bytes;
        }
    }
}
در این حالت باتوجه به اینکه Timeout پیش‌فرض HttpClient به 100 ثانیه تنظیم شده‌است، اگر سرعت دریافت بالایی را نداشته باشید و نتوانید این فایل را پیش از 2 دقیقه دریافت کنید، برنامه با استثنای TaskCancelledException متوقف خواهد شد.
بنابراین اولین تغییر مورد نیاز، تنظیم صریح Timeout آن است:
private static readonly HttpClient _client = new HttpClient
{
    Timeout = Timeout.InfiniteTimeSpan
};


مشکل دوم: دریافت استثنای OutOfMemoryExceptions

روش دریافت پیش‌فرض اطلاعات توسط HttpClient، نگهداری و بافر تمام آن‌ها در حافظه‌ی سیستم است. این روش برای اطلاعات کم حجم، مشکلی را به همراه نخواهد داشت. بنابراین در حین دریافت فایل‌های چندگیگابایتی با آن، حتما با استثنای OutOfMemoryException مواجه خواهیم شد.
namespace HttpClientTips.LargeFiles
{
    class Program
    {
        private static readonly HttpClient _client = new HttpClient
        {
            Timeout = Timeout.InfiniteTimeSpan
        };

        static async Task Main(string[] args)
        {
            await DownloadLargeFileAsync();
        }

        public static async Task DownloadLargeFileAsync()
        {
            Console.WriteLine("Downloading a 4K content. too much bytes.");
            var response = await _client.GetAsync("http://downloads.4ksamples.com/downloads/sample-Elysium.2013.2160p.mkv");
            using (var streamToReadFrom = await response.Content.ReadAsStreamAsync())
            {
                string fileToWriteTo = Path.GetTempFileName();
                Console.WriteLine($"Save path: {fileToWriteTo}");
                using (var streamToWriteTo = File.Open(fileToWriteTo, FileMode.Create))
                {
                    await streamToReadFrom.CopyToAsync(streamToWriteTo);
                }
            }
        }
    }
}
در این حالت برای رفع مشکل، از متد ReadAsStreamAsync آن استفاده می‌کنیم. به این ترتیب بجای یک آرایه‌ی بزرگ از بایت‌ها، با استریمی از آن‌ها سر و کار داشته و به این صورت مشکل مواجه شدن با کمبود حافظه برطرف می‌شود.
مشکل: در این حالت اگر برنامه را اجرا کنید، تا پایان کار متد DownloadLargeFileAsync، حجم فایل دریافتی تغییری نخواهد کرد. یعنی هنوز هم کل فایل در حافظه بافر می‌شود و سپس استریم آن در اختیار FileStream نهایی برای نوشتن قرار خواهد گرفت.
علت این‌جا است که متد client.GetAsync تا زمانیکه کل Response ارسالی از طرف سرور خوانده نشود (headers + content)، عملیات را سد کرده و منتظر می‌ماند. بنابراین با این تغییرات عملا به نتیجه‌ی دلخواه نرسیده‌ایم.


دریافت اطلاعات Header و سپس استریم کردن Content

چون متد client.GetAsync تا دریافت کامل headers + content متوقف می‌ماند، می‌توان به آن اعلام کرد تنها هدر را به صورت کامل دریافت کن و سپس باقیمانده‌ی عملیات دریافت بدنه‌ی Response را به صورت Stream در اختیار ادامه‌ی برنامه قرار بده. برای اینکار نیاز است پارامتر HttpCompletionOption را تکمیل کرد:
var response = await _client.GetAsync(
                "http://downloads.4ksamples.com/downloads/sample-Elysium.2013.2160p.mkv",
                HttpCompletionOption.ResponseHeadersRead);
پارامتر HttpCompletionOption.ResponseHeadersRead به متد GetAsync اعلام می‌کند که پس از خواندن هدر Response، ادامه‌ی عملیات را در اختیار سطرهای بعدی کد قرار بده و عملیات را تا پایان خواندن کامل Response در حافظه، متوقف نکن.


مشکل سوم: برنامه در دریافت سومین فایل از یک سرور هنگ می‌کند.

تعداد اتصالات همزمانی را که می‌توان توسط HttpClient به یک سرور گشود، محدود هستند. برای مثال این عدد در Full .NET Framework مساوی 2 است. بنابراین اگر اتصال سوم موازی را شروع کنیم، چون Timeout را به بی‌نهایت تنظیم کرده‌ایم، این قسمت از برنامه هیچگاه تکمیل نخواهد شد.
روش تنظیم تعداد اتصالات مجاز به یک سرور:
- در Full .NET Framework با تنظیم خاصیت ServicePointManager.DefaultConnectionLimit است که به 2 تنظیم شده‌است.
- این مورد در NET Core. توسط پارامتر HttpClientHandler و خاصیت MaxConnectionsPerServer آن تنظیم می‌شود:
private static readonly HttpClientHandler _handler = new HttpClientHandler
{
    MaxConnectionsPerServer = int.MaxValue, // default for .NET Core
    UseDefaultCredentials = true
};
private static readonly HttpClient _client = new HttpClient(_handler)
{
    Timeout = Timeout.InfiniteTimeSpan
};
البته مقدار پیش‌فرض آن int.MaxValue است که نسبت به حالت Full .NET Framework عدد بسیار بزرگتری است.
مطالب
آشنایی با NuGet - قسمت اول

NuGet چیست؟

روش متداول استفاده از کتابخانه‌های موجود دات نتی در Visual studio‌ عموما به این صورت است: مراجعه به سایت مربوطه، دریافت بسته مورد نظر، باز کردن آن و سپس افزودن ارجاعی به اسمبلی‌های آن کتابخانه. در این حالت زمانیکه نسخه‌ی جدیدی از کتابخانه‌ی مورد استفاده ارائه ‌شود (و عموما تا مدت‌ها شاید از آن بی‌اطلاع باشیم) تمام این مراحل باید از ابتدا تکرار شوند و همینطور الی آخر.
برای رفع این نقیصه، تیم ASP.NET، افزونه‌ای سورس باز و رایگان را به نام NuGet جهت VS.Net 2010 طراحی کرده‌اند که کار مدیریت بسته‌های کتابخانه‌های مورد استفاده را بسیار ساده کرده است. امکانات این افزونه پس از نصب، در دو حالت استفاده از رابط گرافیکی کاربری آن و یا با استفاده از خط فرمان PowerShell ویندوز در دسترس خواهد بود. این افزونه در زمان بارگذاری، با مراجعه به فید سایت مرکزی خود، لیست بسته‌های مهیا را در اختیار علاقمندان قرار می‌دهد. درب این سایت مرکزی به روی تمام توسعه‌ دهنده‌ها جهت افزودن بسته‌های خود باز است.
و ... فراگیری کار با NuGet برای تمام برنامه نویسان دات نت لازم و ضروری است! از این جهت که پیغام "این بسته تنها برای NuGet عرضه شده است" کم کم در حال متداول شدن می‌باشد و دیگر سایت‌های مرتبط، لینک مستقیمی را جهت دریافت کتابخانه‌های خود ارائه نمی‌دهند. حتی خبر به روز شدن محصولات خود را هم شاید دیگر به صورت منظم ارائه ندهند؛ زیرا NuGet کار مدیریت آن‌ها را به عهده خواهد داشت.


دریافت و نصب NuGet

NuGet را حداقل به سه طریق می‌توان دریافت و نصب کرد:
الف) با مراجعه به سایت CodePlex : (+)
ب) دریافت آن از سایت گالری‌های آن : (+)


ج) با استفاده از امکانات VS.NET

هر سه روش فوق به دریافت و نصب فایل NuGet.Tools.vsix منتهی می‌شوند. برای مثال در روش (ج) باید به منوی Tools و گزینه‌ی Extension Manager مراجعه کنید. سپس برگه‌ی Online Gallery را گشوده و اندکی صبر کنید تا اطلاعات آن دریافت و نمایش داده شود. سپس NuGet را در Search box بالای صفحه نوشته و NuGet Package manager ظاهر شده را انتخاب و نصب کنید.



نحوه استفاده از NuGet

فرض کنید یک پروژه جدید ASP.NET را ایجاد کرده‌اید و نیاز است تا کتابخانه‌ی ELMAH به آن اضافه شود. روش انجام اینکار را به کمک NuGet در ادامه بررسی خواهیم کرد (کمتر از یک دقیقه زمان خواهد برد):

الف) با کمک امکانات رابط گرافیکی کاربر آن
ساده‌ترین روش استفاده از NuGet ، کلیک راست بر روی پوشه References در Solution explorer و سپس انتخاب گزینه‌ی Add Library Package Reference می‌باشد:



در صفحه‌ی باز شده، برگه‌ی Online را باز کنید و مدتی صبر نمائید تا اطلاعات لازم دریافت گردد (در زمان نگارش این مطلب، 1135 بسته در این مخزن موجود است):



سپس در جعبه‌ی جستجوی سمت راست بالای صفحه، نام کتابخانه‌ی مورد نظر را نوشته و اندکی صبر کنید تا اطلاعات آن نمایش داده شود:



اکنون با کلیک بر روی دکمه Install ، بسته مرتبط با این کتابخانه دریافت شده و سپس به صورت خودکار ارجاعی به آن نیز افزوده خواهد شد. همچنین تنظیمات مرتبط با فایل Config برنامه هم اضافه می‌شوند.

روش دیگر ظاهر کردن این صفحه، مراجعه به منوی Tools و گزینه‌ی Library Package Manager می‌باشد:



جهت دریافت به روز رسانی‌های بسته‌های نصب شده تنها کافی است به برگه‌ی Updates این صفحه مراجعه کرده و موارد لیست شده را نصب نمائیم:



نکته: NuGet در SharpDevelop 4.1 به بعد هم پشتیبانی می‌شود:




ب) با استفاده از امکانات خط فرمان PowerShell ویندوز
برای استفاده از امکانات پاورشل ویندوز نیاز است تا پاورشل نگارش 2 بر روی سیستم شما نصب باشد (نیاز به Windows XP with Service Pack 3 به بعد دارد). سپس به منوی Tools ، قسمت Library Package Manager ، گزینه‌ی Package Manager Console آن جهت فعال سازی کنسول پاور شل در VS.NET مراجعه نمائید:


نکته: در تصویر فوق پس از نوشتن el ، دکمه tab فشرده شده است. در این حالت منوی پکیج‌های مهیای شروع شده با el، از سایت مرکزی NuGet ظاهر گردیده است.

فرامین مهمی که در اینجا در دسترس هستند شامل: List-Package ، Uninstall-Package ، Update-Package و Get-Package می‌باشند. برای مثال اگر قصد جستجو در بین بسته‌های موجود را داشته باشید Get-Package بسیار مفید است:



برای مثال جهت یافتن بسته‌های مرتبط با wpf و silverlight به صورت زیر می‌توان عمل کرد:
PM> get-package -remote -filter wpf
PM> get-package -remote -filter silverlight

نکته: روش دیگر جستجو در بین بسته‌های مهیا، مراجعه به سایت گالری آن است : (+) . در اینجا دستور پاورشل نصب هر بسته‌ی یافت شده نیز نمایش داده می‌شود.


ج) استفاده از برنامه NuGet.exe
برنامه NuGet.exe از سایت CodePlex قابل دریافت است. این روش هم جهت علاقمندان به خط فرمان تدارک دیده شده است!
پس از دریافت آن فرض کنید می‌خواهیم تمام بسته‌های شروع شده با nhi را لیست کنیم. برای این منظور دستور خط فرمان ذیل را صادر کنید:
D:\Test>nuget list nhi
سپس برای دریافت مثلا NHibernate تنها کافی است دستور زیر اجرا شود:
D:\Test>nuget install NHibernate

به این صورت کتابخانه NHibernate ‌به همراه تمام وابستگی‌های آن دریافت خواهد شد.

به روز رسانی خودکار NuGet
برای به روز رسانی برنامه nuget.exe دستور زیر را می‌توان صادر نمود:
D:\Test>NuGet.exe u
و یا جهت فعال سازی به روز رسانی‌های خودکار افزونه‌ها در VS.NET به منوی زیر مراجعه کنید:
Tools | Options, then Environment | Extension Manager and click "Automatically check for updates to installed extensions."





ادامه دارد ...

مطالب
بهینه سازی کوئری‌های LINQ - بخش اول
یکی از جذاب‌ترین لحظات کار با LINQ و EF زمانی است که به خاطر افزایش حجم دیتا، کوئری خود را بازنگری کرده و آن را بهینه می‌کنید.

برای یک مسئله می‌توان کوئری‌های متنوعی نوشت که همگی به یک جواب میرسند؛ ولی زمان اجرا و میزان حافظه‌ی مصرفی متفاوتی دارند. یک سناریوی رایج در نوشتن کوئری‌های LINQ، ترکیب اطلاعات جداول مختلف و محاسبه‌ی یک عدد معنی دار از ترکیب آن هاست.

برای نمونه دو Entity زیر را در مدل EF خود داریم:
public class User
{
   public int ID { get; set; }
   public string Name { get; set; }
   public int Age { get; set; }
}

public class Login
{
   public int ID { get; set; }
   public DateTime Date { get; set; }
   public int UserID { get; set; }
   public User User { get; set; }
}
موجودیت User، اطلاعات کاربر و موجودیت Login، اطلاعات مربوط به لوگین‌های هر کاربر را نگه می‌دارد. برای تست، یک دیتاست را به صورت تصادفی تولید کردیم که حاوی 1200 کاربر و 21000 لوگین هست.

برای تولید اطلاعات تصادفی می‌توان از کد زیر در LINQPad استفاده کرد:
int usersCount = 1200;
Random rnd = new Random();
for(int i=0; i<usersCount; i++)
{
   Users.Add(new User()
     {
       Name = $"User {i + 1}",
       Age = rnd.Next(10, i + 10) / 10
     });
}

SaveChanges();

$"Users: {Users.Count()}".Dump();

var usersID = Users.Select(x => x.ID).ToArray();

int loginsCount  = 20000;

for(int i=0; i<loginsCount; i++)
{
    Logins.Add(new Login()
    {
        UserID = usersID[rnd.Next(0, usersID.Length - 1)],
        Date = DateTime.Now.AddDays(rnd.Next(0, i))
    });

    if(i % 1000 == 0)
   {
      SaveChanges();
      $"Save {i + 1}".Dump();
   }
}

SaveChanges();
$"Logins: {Logins.Count()}".Dump();

$"Users: {Users.Count()}".Dump();
$"Logins: {Logins.Count()}".Dump();

Users: 1200
Logins: 21000

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

در سناریوهای این سبکی، باید خیلی با دقت عمل کرد و از تمام اطلاعات موجود استفاده کرد. اطلاعاتی که در اینجا برای ما مفید است، تعداد نسبی رکوردهای جداول دیتابیس است. مثلا در حال حاضر تعداد رکوردهای Logins تقریبا 17 برابر Users است و در آینده هم رشد Logins چند برابر Users خواهد بود. از طرفی در صورت مسئله، اطلاعات هر کاربر را می‌خواهیم، که به سادگی یک SELECT است. ولی بخش سنگین‌تر کوئری، محاسبه‌ی تعداد لوگین‌ها و تاریخ آخرین لوگین‌های هر فرد است که باز هم به جدول Logins بر می‌گردد.

روش اول:

راه حل اولی که به ذهن می‌رسد، JOIN کردن این دو جدول و محاسبه موارد لازم از ترکیب این دو جدول است:
var data =
(
   from u in Users
   join x in Logins on u.ID equals x.UserID into g
   from x in g.DefaultIfEmpty()
   select new
     {
        UserID = u.ID,
        Name = u.Name,
        Age = u.Age,
        Date = x.Date
     }
);

var result =
(
   from d in data
   group d by d.UserID into g
   select new
   {
       UserID = g.Key,
       Name = g.FirstOrDefault().Name,
       LoginsCount = g.Count(x => x.Date != null),
       LastLogin = g.Max(x => (DateTime?) x.Date) ?? null
   }
);
کد SQL تولید شده‌ی در این روش، ترکیبی از 11 دستور SELECT تو در تو و 4 دستور LEFT OUTER JOIN است که ممکن است در حجم اطلاعات بیشتر، کوئری را با کندی همراه کند. نکته‌ی جالب توجه اینست که دستور group by ما در خروجی ظاهر نشده است و تبدیل به دستور SELECT تو در تو شده است که مورد انتظار ما نبوده است.

Generated SQL
SELECT 
    [Project7].[ID] AS [ID], 
    [Project7].[C2] AS [C1], 
    [Project7].[C3] AS [C2], 
    [Project7].[C1] AS [C3]
    FROM ( SELECT 
        [Project6].[ID] AS [ID], 
        CASE WHEN ([Project6].[C3] IS NULL) THEN CAST(NULL AS datetime2) ELSE [Project6].[C4] END AS [C1], 
        [Project6].[C1] AS [C2], 
        [Project6].[C2] AS [C3]
        FROM ( SELECT 
            [Project5].[ID] AS [ID], 
            [Project5].[C1] AS [C1], 
            [Project5].[C2] AS [C2], 
            [Project5].[C3] AS [C3], 
            (SELECT 
                MAX( CAST( [Extent9].[Date] AS datetime2)) AS [A1]
                FROM  [dbo].[Users] AS [Extent8]
                LEFT OUTER JOIN [dbo].[Logins] AS [Extent9] ON [Extent8].[ID] = [Extent9].[UserID]
                WHERE [Project5].[ID] = [Extent8].[ID]) AS [C4]
            FROM ( SELECT 
                [Project4].[ID] AS [ID], 
                [Project4].[C1] AS [C1], 
                [Project4].[C2] AS [C2], 
                (SELECT 
                    MAX( CAST( [Extent7].[Date] AS datetime2)) AS [A1]
                    FROM  [dbo].[Users] AS [Extent6]
                    LEFT OUTER JOIN [dbo].[Logins] AS [Extent7] ON [Extent6].[ID] = [Extent7].[UserID]
                    WHERE [Project4].[ID] = [Extent6].[ID]) AS [C3]
                FROM ( SELECT 
                    [Project3].[ID] AS [ID], 
                    [Project3].[C1] AS [C1], 
                    (SELECT 
                        COUNT(1) AS [A1]
                        FROM [dbo].[Logins] AS [Extent5]
                        WHERE [Project3].[ID] = [Extent5].[UserID]) AS [C2]
                    FROM ( SELECT 
                        [Distinct1].[ID] AS [ID], 
                        (SELECT TOP (1) 
                            [Extent3].[Name] AS [Name]
                            FROM  [dbo].[Users] AS [Extent3]
                            LEFT OUTER JOIN [dbo].[Logins] AS [Extent4] ON [Extent3].[ID] = [Extent4].[UserID]
                            WHERE [Distinct1].[ID] = [Extent3].[ID]) AS [C1]
                        FROM ( SELECT DISTINCT 
                            [Extent1].[ID] AS [ID]
                            FROM  [dbo].[Users] AS [Extent1]
                            LEFT OUTER JOIN [dbo].[Logins] AS [Extent2] ON [Extent1].[ID] = [Extent2].[UserID]
                        )  AS [Distinct1]
                    )  AS [Project3]
                )  AS [Project4]
            )  AS [Project5]
        )  AS [Project6]
    )  AS [Project7]
    ORDER BY [Project7].[C3] ASC, [Project7].[ID] ASC

روش دوم:
روش دوم اینست که داده‌های سنگین‌تر (اطلاعات Logins) را ابتدا محاسبه کرده و سپس JOIN را انجام دهیم:
var data =
(
  from x in Logins
  group x by x.UserID into g
  orderby g.Key descending
  select new
  {
    UserID = g.Key,
    LoginsCount = g.Count(),
    LastLogin = g.Max(d => d.Date)
  }
);

var result =
(
  from u in Users
  join d in data on u.ID equals d.UserID into g
  from d in g.DefaultIfEmpty()
  select new
  {
    UserID = u.ID,
    LoginsCount = d != null ? d.LoginsCount : 0,
    LastLogin = d != null ? (DateTime?)d.LastLogin : null
  }
);
در روش دوم، ابتدا فقط به Logins کوئری می‌زنیم و برای محاسبه‌ی تعداد لوگین و آخرین لوگین، از Group By استفاده می‌کنیم. استفاده از این دستور باعث می‌شود که محاسبه‌ی سنگین ما در سریعترین حالت ممکن توسط  SQL انجام شود. در مرحله‌ی بعد، این اطلاعات را با جدول Users از طریق LEFT OUTER JOIN ترکیب می‌کنیم. علت استفاده از DefaultIfEmpty بدین سبب است که برخی از کاربران ممکن است تاکنون لوگینی را انجام نداده باشند؛ در نتیجه باید تعداد صفر و تاریخ null برای آنها نمایش داده شود.

اکنون اگر کد SQL روش دوم را بررسی کنیم خواهیم دید که تنها 2 دستور SELECT ، یک LEFT OUTER JOIN به همراه یک GROUP BY تولید شده است که با توجه به ماهیت مسئله و ساختار دیتای ما، این دستورات منطقی‌ترین و بهینه‌ترین دستورات ممکن به نظر می‌رسد.

Generated SQL
SELECT 
    [Project1].[ID] AS [ID], 
    [Project1].[C1] AS [C1], 
    [Project1].[C2] AS [C2]
    FROM ( SELECT 
        [Extent1].[ID] AS [ID], 
        CASE WHEN ([GroupBy1].[K1] IS NOT NULL) THEN [GroupBy1].[A1] ELSE 0 END AS [C1], 
        CASE WHEN ([GroupBy1].[K1] IS NOT NULL) THEN  CAST( [GroupBy1].[A2] AS datetime2) END AS [C2]
        FROM  [dbo].[Users] AS [Extent1]
        LEFT OUTER JOIN  (SELECT 
            [Extent2].[UserID] AS [K1], 
            COUNT(1) AS [A1], 
            MAX([Extent2].[Date]) AS [A2]
            FROM [dbo].[Logins] AS [Extent2]
            GROUP BY [Extent2].[UserID] ) AS [GroupBy1] ON [Extent1].[ID] = [GroupBy1].[K1]
    )  AS [Project1]
    ORDER BY [Project1].[C1] ASC, [Project1].[ID] ASC
پس، همواره کد SQL دستورات LINQ خود را یا از طریق SQL Profiler یا برنامه‌ای مثل LINQPad حتما تست کنید و کوئری خود را در مقابل حجم زیاد اطلاعات هم بررسی کنید. چرا که LINQ به علت سادگی و قدرتی که دارد، گاهی شما را به اشتباه می‌اندازد و باعث می‌شود شما کوئری ای بزنید که جواب شما را می‌دهد، ولی فقط برای حجم کم دیتای کنونی بهینه است و در صورت افزایش رکوردها، یا خیلی کند می‌شود یا کلا شما را با  Timeout مواجه می‌کند.
مطالب
اجزاء معماری سیستم عامل اندروید (قسمت اول رمزنگاری اندروید) :: بخش پنجم
پیاده سازی رمزنگاری کجا و به چه صورتی انجام می‌شود؟

ما داده‌ها را قبل از اینکه آن‌ها را به کارت SD ارسال کنیم، نگهداری و رمز می‌کنیم. به این ترتیب داده‌های ما در کارت SD در فرمتی که می‌تواند توسط هر کسی خوانده شود نوشته شده و هرگز اجازه دسترسی به آنها مقدور نمی‌باشد. یک مهاجم که اطلاعات رمزنگاری شده شما را جمع‌آوری می‌کند باید ابتدا از رمز عبور برای رمزگشایی داده‌ها قبل از دسترسی به آن‌ها استفاده کند که در این مرحله دچار سردرگمی خواهد شد؛ چرا که فرمت هر نوع از داده‌ها یکسان نخواهند بود! ما از الگوریتم AES برای رمزکردن داده‌ها با استفاده از یک گذرواژه یا کلید استفاده خواهیم کرد. در این صورت یک کلید برای رمزگذاری و رمزگشایی داده‌ها مورد نیاز است. این رمزگذاری کلید متقارن ( Symmetric-key ) نیز نامیده می‌شود. 
برخلاف رمزنگاری کلید عمومی، این کلید تنها کلید استفاده‌شده برای رمزگذاری و رمزگشایی داده‌ها است. این کلید باید به طور ایمن ذخیره شود؛ چون اگر از دست رفته یا فاش شده باشد، یک مهاجم می‌تواند از آن برای رمزگشایی استفاده کند. کدهای زیر روشی از رمزگذاری را نشان می‌دهد:
privatestaticbyte[] encrypt(byte[] key, byte[] data) {
 SecretKeySpec sKeySpec = new SecretKeySpec(key, "AES");
 Cipher cipher;
 byte[] ciphertext = null;
 try {
  cipher = Cipher.getInstance("AES");
  cipher.init(Cipher.ENCRYPT_MODE, sKeySpec);
  ciphertext = cipher.doFinal(data);
 } catch (NoSuchAlgorithmException e) {
  Log.e(TAG, "NoSuchAlgorithmException");
 } catch (NoSuchPaddingException e) {
  Log.e(TAG, "NoSuchPaddingException");
 } catch (IllegalBlockSizeException e) {
  Log.e(TAG, "IllegalBlockSizeException");
 } catch (BadPaddingException e) {
  Log.e(TAG, "BadPaddingException");
 } catch (InvalidKeyException e) {
  Log.e(TAG, "InvalidKeyException");
 }
 return ciphertext;
}
اجازه دهید این قسمت را بخش به بخش توضیح دهم. اولین سطر کد از کلاس SecretKeySpec استفاده کرده و یک نمونه جدید از کلاس Cipher را برای تهیه یک کلید مخفی AES به وجود می‌آورد. 
SecretKeySpec sKeySpec = new SecretKeySpec(key,"AES");
Cipher cipher;
byte[] ciphertext = null;
همچنین کد بالا یک آرایه از بایت‌ها را برای ذخیره متن رمزی در ciphertext ایجاد می‌کند. بخش بعدی این کد برای استفاده از الگوریتم AES استفاده شده است که مشاهده می‌کنید.
cipher = Cipher.getI nstance ( " AES " ) ;
cipher.init ( Cipher.ENCR ypt _ MODE , sKeySpec ) ;
 تابع cipher.init شئ ای از تابع Cipher است؛ بنابراین می‌تواند با استفاده از کلید رمز تولید شده، رمزنگاری را انجام دهد. خط بعدی کد، داده‌های متنی را تغییر می‌دهد و محتویات رمز شده را در آرایه‌ای از بایت کدها ذخیره می‌کند که در کد زیر مشاهده می‌شود:
ciphertext = cipher.doFinal(data);
مهم است که ما از همان کلید برای جریان رمزگشایی نیز استفاده کنیم. در غیر این صورت، شکست خواهیم خورد و کلید، عمومی می‌شود. به طور کلی بهتر است که والد کلید اصلی را بنویسید و یک عدد تصادفی را تولید کند. این کار باعث می‌شود که فرد مهاجم به چیزی بیش از یک گذرواژه عادی فکر کند! برای نمونه کدهای زیر، کلید تولید شده با استفاده از الگوریتم را نشان می‌دهند:
publicstaticbyte[] generateKey(byte[] randomNumberSeed) {
 SecretKey sKey = null;
 try {
  KeyGenerator keyGen = KeyGenerator.getInstance("AES");
  SecureRandom random = SecureRandom.getInstance("SHA1PRNG");
  random.setSeed(randomNumberSeed);
  keyGen.init(256, random);
  sKey = keyGen.generateKey();
 } catch (NoSuchAlgorithmException e) {
  Log.e(TAG, "No such algorithm exception");
 }
 return sKey.getEncoded();
}
در کد بالا، اعداد تصادفی با استفاده از SHA1 کدگذاری می‌شوند. الگوریتم SHA1 یا دیگر الگوریتم‌های هش، توابع رمزنگاری را مدیریت می‌کنند. این الگوریتم بر روی یک قطعه از داده‌ها عمل می‌کند که دارای یک طول غیریکسان هستند و یک رشته کوتاه با اندازه ثابت را تولید می‌کند. اگر هر قطعه‌ای از داده‌ها تغییر کند، آنگاه نتایج نهایی در آن مجموعه دچار تغییر خواهند شد و رمزنگاری، نتایج دیگری خواهد داشت! این نشانه‌ای است از اینکه یک قطعه از داده‌ها دستکاری شده‌است!
مطالب
ASP.NET MVC #17

فیلترهای امنیتی ASP.NET MVC

ASP.NET MVC به همراه تعدادی فیلتر امنیتی توکار است که در این قسمت به بررسی آ‌ن‌ها خواهیم پرداخت.


بررسی اعتبار درخواست (Request Validation) در ASP.NET MVC

ASP.NET MVC امکان ارسال اطلاعاتی را که دارای تگ‌های HTML باشند، نمی‌دهد. این قابلیت به صورت پیش فرض فعال است و جلوی ارسال انواع و اقسام اطلاعاتی که ممکن است سبب بروز حملات XSS Cross site scripting attacks شود را می‌گیرد. نمونه‌ای از خطای نمایش داده:

A potentially dangerous Request.Form value was detected from the client (Html="<a>"). 

بنابراین تصمیم گرفته شده صحیح است؛ اما ممکن است در قسمتی از سایت نیاز باشد تا کاربران از یک ویرایشگر متنی پیشرفته استفاده کنند. خروجی این نوع ویرایشگرها هم HTML است. در این حالت می‌توان صرفا برای متدی خاص امکانات Request Validation را به کمک ویژگی ValidateInput غیرفعال کرد:

[HttpPost]
[ValidateInput(false)]
public ActionResult CreateBlogPost(BlogPost post)

از ASP.NET MVC 3.0 به بعد راه حل بهتری به کمک ویژگی AllowHtml معرفی شده است. غیرفعال کردن ValidateInput ‌ایی که معرفی شد، بر روی تمام خواص شیء BlogPost اعمال می‌شود. اما اگر فقط بخواهیم که مثلا خاصیت Text آن از مکانیزم بررسی اعتبار درخواست خارج شود، بهتر است دیگر از ویژگی ValidateInput استفاده نشده و به نحو زیر عمل گردد:

using System;
using System.Web.Mvc;

namespace MvcApplication14.Models
{
public class BlogPost
{
public int Id { set; get; }
public DateTime AddDate { set; get; }
public string Title { set; get; }

[AllowHtml]
public string Text { set; get; }
}
}

در اینجا فقط خاصیت Text مجاز به دریافت محتوای HTML ایی خواهد بود. اما خاصیت Title چنین مجوزی را ندارد. همچنین دیگر نیازی به استفاده از ویژگی ValidateInput غیرفعال شده نیز نخواهد بود.
به علاوه همانطور که در قسمت‌های قبل نیز ذکر شد، خروجی Razor به صورت پیش فرض Html encoded است مگر اینکه صریحا آن‌را تبدیل به HTML کنیم (مثلا استفاده از متد Html.Raw). به عبارتی خروجی Razor در حالت پیش فرض در مقابل حملات XSS مقاوم است مگر اینکه آگاهانه بخواهیم آن‌را غیرفعال کنیم.

مطلب تکمیلی
مقابله با XSS ؛ یکبار برای همیشه!



فیلتر RequireHttps

به کمک ویژگی یا فیلتر RequireHttps، تمام درخواست‌های رسیده به یک متد خاص باید از طریق HTTPS انجام شوند و حتی اگر شخصی سعی به استفاده از پروتکل HTTP معمولی کند، به صورت خودکار به HTTPS هدایت خواهد شد:

[RequireHttps]
public ActionResult LogOn()
{
}


فیلتر ValidateAntiForgeryToken

نوع دیگری از حملات که باید در برنامه‌های وب به آن‌ها دقت داشت به نام CSRF یا Cross site request forgery معروف هستند.
برای مثال فرض کنید کاربری قبل از اینکه بتواند در سایت شما کار خاصی را انجام دهد، نیاز به اعتبار سنجی داشته باشد. پس از لاگین شخص و ایجاد کوکی و سشن معتبر، همین شخص به سایت دیگری مراجعه می‌کند که در آن مهاجمی بر اساس وضعیت جاری اعتبار سنجی او مثلا لینک حذف کاربری یا افزودن اطلاعات جدیدی را به برنامه ارائه می‌دهد. چون سشن شخص و کوکی مرتبط به سایت اول هنوز معتبر هستند و شخص سایت را نبسته است، «احتمال» اجرا شدن درخواست مهاجم بالا است (خصوصا اگر از مرورگرهای قدیمی استفاده کند).
بنابراین نیاز است بررسی شود آیا درخواست رسیده واقعا از طریق فرم‌های برنامه ما صادر شده است یا اینکه شخصی از طریق سایت دیگری اقدام به جعل درخواست‌ها کرده است.
برای مقابله با این نوع خطاها ابتدا باید داخل فرم‌های برنامه از متد Html.AntiForgeryToken استفاده کرد. کار این متد ایجاد یک فیلد مخفی با مقداری منحصربفرد بر اساس اطلاعات سشن جاری کاربر است، به علاوه ارسال یک کوکی خودکار تا بتوان از تطابق اطلاعات اطمینان حاصل کرد:

@using (Html.BeginForm()) {
@Html.AntiForgeryToken()

در مرحله بعد باید فیلتر ValidateAntiForgeryToken جهت بررسی مقدار token دریافتی به متد ثبت اطلاعات اضافه شود:

[HttpPost]
[ValidateAntiForgeryToken]
public ActionResult CreateBlogPost(BlogPost post)

در اینجا مقدار دریافتی از فیلد مخفی فرم :

<input name="__RequestVerificationToken" type="hidden" value="C0iPfy/3T....=" />

با مقدار موجود در کوکی سایت بررسی و تطابق داده خواهند شد. اگر این مقادیر تطابق نداشته باشند، یک استثنا صادر شده و از پردازش اطلاعات رسیده جلوگیری می‌شود.
علاوه بر این‌ها بهتر است حین استفاده از متد و فیلتر یاد شده، از یک salt مجزا نیز به ازای هر فرم، استفاده شود:

[ValidateAntiForgeryToken(Salt="1234")]

@Html.AntiForgeryToken(salt:"1234")

به این ترتیب tokenهای تولید شده در فرم‌های مختلف سایت یکسان نخواهند بود.
به علاوه باید دقت داشت که ValidateAntiForgeryToken فقط با فعال بودن کوکی‌ها در مرورگر کاربر کار می‌کند و اگر کاربری پذیرش کوکی‌ها را غیرفعال کرده باشد، قادر به ارسال اطلاعاتی به برنامه نخواهد بود. همچنین این فیلتر تنها در حالت HttpPost قابل استفاده است. این مورد هم در قسمت‌های قبل تاکید گردید که برای مثال بهتر است بجای داشتن لینک delete در برنامه که با HttpGet ساده کار می‌کند،‌ آن‌را تبدیل به HttpPost نمود تا میزان امنیت برنامه بهبود یابد. از HttpGet فقط برای گزارشگیری و خواندن اطلاعات از برنامه استفاده کنید و نه ثبت اطلاعات.
بنابراین استفاده از AntiForgeryToken را به چک لیست اجباری تولید تمام فرم‌های برنامه اضافه نمائید.

مطلب مشابه
Anti CSRF module for ASP.NET



فیلتر سفارشی بررسی Referrer

یکی دیگر از روش‌های مقابله با CSRF، بررسی اطلاعات هدر درخواست ارسالی است. اگر اطلاعات Referrer آن با دومین جاری تطابق نداشت، به معنای مشکل دار بودن درخواست رسیده است. فیلتر سفارشی زیر می‌تواند نمونه‌ای باشد جهت نمایش نحوه بررسی UrlReferrer درخواست رسیده:

using System.Web.Mvc;

namespace MvcApplication14.CustomFilter
{
public class CheckReferrerAttribute : AuthorizeAttribute
{
public override void OnAuthorization(AuthorizationContext filterContext)
{
if (filterContext.HttpContext != null)
{
if (filterContext.HttpContext.Request.UrlReferrer == null)
throw new System.Web.HttpException("Invalid submission");

if (filterContext.HttpContext.Request.UrlReferrer.Host != "mysite.com")
throw new System.Web.HttpException("This form wasn't submitted from this site!");
}

base.OnAuthorization(filterContext);
}
}
}

و برای استفاده از آن:
[HttpPost]
[CheckReferrer]
[ValidateAntiForgeryToken]
public ActionResult DeleteTask(int id)


نکته‌ای امنیتی در مورد آپلود فایل‌ها در ASP.NET

هر جایی که کاربر بتواند فایلی را به سرور شما آپلود کند، مشکلات امنیتی هم از همانجا شروع خواهند شد. مثلا در متد Upload قسمت 11 این سری، منعی در آپلود انواع فایل‌ها نیست و کاربر می‌تواند انواع و اقسام شل‌ها را جهت تحت کنترل گرفتن سایت و سرور آپلود و اجرا کند. راه حل چیست؟
از همان روش امنیتی مورد استفاده توسط تیم ASP.NET MVC استفاده می‌کنیم. فایل web.config قرار گرفته در پوشه Views را باز کنید (نه فایل وب کانفیگ ریشه اصلی سایت‌را). چنین تنظیمی را می‌توان مشاهده کرد:
برای IIS6 :

<system.web>
<httpHandlers>
<add path="*" verb="*" type="System.Web.HttpNotFoundHandler"/>
</httpHandlers>
</system.web>
برای IIS7 :
<system.webServer>
<handlers>
<remove name="BlockViewHandler"/>
<add name="BlockViewHandler" path="*" verb="*" preCondition="integratedMode" type="System.Web.HttpNotFoundHandler" />
</handlers>
</system.webServer>


تنظیم فوق، موتور اجرایی ASP.NET را در این پوشه خاص از کار می‌اندازد. به عبارتی اگر شخصی مسیر یک فایل aspx یا cshtml یا هر فایل قرار گرفته در پوشه Views را مستقیما در مرورگر خود وارد کند، با پیغام HttpNotFound مواجه خواهد شد.
این روش هم با ASP.NET Web forms سازگار است و هم با ASP.NET MVC؛ چون مرتبط است به موتور اجرایی ASP.NET که هر دوی این فریم ورک‌ها برفراز آن معنا پیدا می‌کنند.
بنابراین در پوشه فایل‌های آپلودی به سرور خود یک web.config را با محتوای فوق ایجاد کنید (و فقط باید مواظب باشید که این فایل حین آپلود فایل‌های جدید، overwrite نشود. مهم!). به این ترتیب این مسیر دیگر از طریق مرورگر قابل دسترسی نخواهد بود (با هر محتوایی). سپس برای ارائه فایل‌های آپلودی به کاربران از روش زیر استفاده کنید:

public ActionResult Download()
{
return File(Server.MapPath("~/Myfiles/test.txt"), "text/plain");
}

مزیت مهم روش ذکر شده این است که کاربران مجاز به آپلود هر نوع فایلی خواهند بود و نیازی نیست لیست سیاه تهیه کنید که مثلا فایل‌هایی با پسوند‌های خاص آپلود نشوند (که در این بین ممکن است لیست سیاه شما کامل نباشد ...).




علاوه بر تمام فیلترهای امنیتی که تاکنون بررسی شدند،‌ فیلتر دیگری نیز به نام Authorize وجود دارد که در قسمت‌های بعدی بررسی خواهد شد.
مطالب
امن سازی برنامه‌های 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 وارد کنید.
نظرات اشتراک‌ها
تبدیلگر ایران سیستم به یونیکد
من در ثبت اطلاعات در فایل dbf یک مشکل دارم. وقتی فیلد جدول از نوع nvarchar هست چطور باید دیتا رو به کوئری بفرستم؟ تو اینترنت جستجو کردم و چند راه مختلف رو امتحان کردم اما همش syntax error میده. 
این کد رو نوشتم:
string dbfDirectory = Server.MapPath("~/Content/") + "myDBF.dbf";
            
            string connectionString = "Provider=VFPOLEDB.1;Data Source=" + dbfDirectory + ";Password=;Collating Sequence=MACHINE"; 
            using (OleDbConnection connection = new OleDbConnection(connectionString))
            {
                connection.Open();
                string CreateTableK = "Create Table myDBF (dsw_id1 int, dsw_fname v(250), dsw_lname v(250) )";
                OleDbCommand cmdCreateTable = new OleDbCommand(CreateTableK, connection);
                cmdCreateTable.ExecuteNonQuery();

                OleDbCommand cmd2 = new OleDbCommand("Insert Into myDBF Values (1, @param1, @param2)", connection);
                cmd2.Parameters.AddWithValue("@param1", "شهرزاد");
                cmd2.Parameters.AddWithValue("@param2", "جواهری");
                OleDbCommand cmd3 = new OleDbCommand("Insert Into myDBF Values (2, 'ma', 'bi')", connection);
                
                cmd2.ExecuteNonQuery();
                cmd3.ExecuteNonQuery();

                connection.Close();
            }
روی خط cmd2.ExecuteNonQuery();  خطای syntax error میده.
باید عبارت فارسی رو به ایران سیستم تبدیل و بعد در فایل اضافه کنم. برای این تبدیل هم این لینک  رو دیدم، اما بعد از تبدیل یک رشته از بایت میده، چطور باید در فایل نوشته بشه؟
نظرات مطالب
اعتبارسنجی مبتنی بر کوکی‌ها در ASP.NET Core 2.0 بدون استفاده از سیستم Identity
مشکلی در پیاده سازی BlazorServerCookieAuthentication  پیدا کرده ام.
برای اینکه بتوانم در سرویسی از برنامه که اطلاعات کاربری لاگ می‌شود به آی دی کاربر دسترسی داشته باشم از کد زیر استفاده کردم که قبلا در SO راهنمایی کرده بودید بنده را. 
using System.Security.Claims;
using Microsoft.AspNetCore.Components.Authorization;
using Microsoft.AspNetCore.Components.Server;

namespace BlazorServerTestDynamicAccess.Services;

public class CustomAuthenticationStateProvider : RevalidatingServerAuthenticationStateProvider
{
    private readonly IServiceScopeFactory _scopeFactory;

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

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

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

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

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

        var user = await userManager.FindUserAsync(userId);
        return user is not null;
    }
}
حال مشکل اینجاست که اگر CustomAuthenticationStateProvider را به صورت AddSingleton ثبت کنم با این مشکل روبرو می‌شوم. اگر هم که به صورت AddScoped ثبت کنم با مشکل دیگری روبرو می‌شوم. اگر ممکن است لطفا راهنمایی فرمایید.
نظرات مطالب
معرفی System.Text.Json در NET Core 3.0.
یک نکته‌ی تکمیلی: استفاده از System.Text.Json در ASP.NET Core 3.0 و از کار افتادن تعدادی از اکشن متدها

فرض کنید مدلی را به این صورت تعریف کرده‌اید:
public class ModelIdViewModel
{
   public string Id { set; get; }
}
و اکشن متدی که آن‌را دریافت می‌کند، به این نحو تعریف شده‌است:
public async Task<IActionResult> RenderRole([FromBody]ModelIdViewModel model)

در سمت کلاینت نیز اطلاعات Ajax ای متناظر با آن‌را به صورت زیر ارسال می‌کنید:
data: JSON.stringify({ "id": 1 }),
contentType: "application/json; charset=utf-8",
dataType: "json",
این اکشن متد تا نگارش 2.2، بدون مشکل کار می‌کرد. اما اکنون در نگارش 3، مقدار model آن نال شده‌است.
برای دیباگ آن اگر قطعه کد زیر را اضافه کنیم:
public async Task<IActionResult> RenderRole([FromBody]ModelIdViewModel model)
{
   if (!ModelState.IsValid)
   {
      return BadRequest(ModelState);
   }
یک چنین خروجی در قسمت network ابزارهای توسعه دهندگان مرورگر، ظاهر می‌شود:
 {"$.id":["The JSON value could not be converted to System.String. Path: $.id | LineNumber: 0 | BytePositionInLine: 7."]}
عنوان می‌کند که مقدار id دریافتی را نمی‌تواند به string تبدیل کند.

برای رفع این مشکل، فقط کافی است نوع Id را در model به int تبدیل کرد:
public class ModelIdViewModel
{
   public int Id { set; get; }
}
به عبارتی System.Text.Json جدید، همانند Newtonsoft.Json قبلی، سعی نمی‌کند int دریافتی از کاربر را به string درخواستی در model تبدیل کند. نوع‌ها حتما باید تناظر داشته باشند.
نظرات مطالب
ساخت یک Form Generator ساده در MVC
با سلام و تشکر بابت مطلب مفیدتان
من برای ساخت پروژه فرم ساز به مسائلی برخورد کردم که به شرح زیر است:
1- دیتاهای وارد شده به چه صورت در دیتابیس ذخیره شوند؟من ابتدا در نظر داشتم به ازای هر فرمی که کاربر تعریف می‌کند یک جدول در دیتابیس ایجاد کنم  و برای هر کنترلی که در جدول گذاشته می‌شود یک فیلد به جدول فرم اضافه شود.اما با توجه به صحبت شما و روش XML که فرمودید در حال حاضر نمی‌توانم تصمیم گیری کنم که کدام روش بهتر است چون  طبق جستجویی که در نت کردم اگر نیاز به نوشتن کوئری‌های پیچیده بر روی داده‌های وارده شده باشد بهتر است جدول جداگانه ایجاد شود طبق این منبع:
http://stackoverflow.com/questions/14939973/storing-dynamic-form-and-data-entered-using-json
پروژه من هم نیاز به ساخت گزارش و داشبورد و BI از روی داده‌های وارد شده است.حال شما کدام انتخاب را انجام می‌دادید؟
2- نحوه ذخیره طراحی فرم چگونه است؟مثلا اگر در فرم لوگوی شرکت باید قرار گیرد چگونه باشد یا در فرم جدول 3 ستونی باشد که در هر سلول آن یک TextBox قرار می‌گیرد.اندازه هر کنترل  و مقدار پیش فرض   .....
آیا بهتر نیست اطلاعات کل فرم (شکل ظاهری فعلی فرم بدون داده) را به شکل JSON یا XML یا nvarchar(max)  ذخیره کرد؟
3- چطوری میتوان Enable  یا Visible بودن یک کنترل را وابسته کرد به مقدار کنترل دیگر در فرم مثلا اگر کمبو سال مالی برابر با 95 بود TextBox درصد سال 95 فعال در غیر اینصورت غیرفعال شود
ممنون میشم نظر خودتون را بفرمایید