/// <summary> /// /// </summary> /// <typeparam name="T"></typeparam> /// <param name="expression"></param> /// <returns></returns> public static string PropertyName<T>(this Expression<Func<T, object>> expression) { return new PropertyHelper().GetNestedPropertyName(expression); }
"securitySchemes": { "basicAuth": { "type":"http", "description":"Input your username and password to access this API", "scheme":"basic" } } … "security":[ {"basicAuth":[]} ]
- خاصیت security کار اعمال Scheme تعریف شده را به کل API یا صرفا قسمتهای خاصی از آن، انجام میدهد.
در ادامه مثالی را بررسی خواهیم کرد که مبتنی بر basic authentication کار میکند و در این حالت به ازای هر درخواست به API، نیاز است یک نام کاربری و کلمهی عبور نیز ارسال شوند. البته روش توصیه شده، کار با JWT و یا OpenID Connect است؛ اما جهت تکمیل سادهتر این قسمت، بدون نیاز به برپایی مقدماتی پیچیده، کار با basic authentication را بررسی میکنیم و اصول کلی آن از دیدگاه مستندات OpenAPI Specification تفاوتی نمیکند.
افزودن Basic Authentication به API برنامه
برای پیاده سازی Basic Authentication نیاز به یک AuthenticationHandler سفارشی داریم:
using Microsoft.AspNetCore.Authentication; using Microsoft.Extensions.Logging; using Microsoft.Extensions.Options; using System; using System.Net.Http.Headers; using System.Security.Claims; using System.Text; using System.Text.Encodings.Web; using System.Threading.Tasks; namespace OpenAPISwaggerDoc.Web.Authentication { public class BasicAuthenticationHandler : AuthenticationHandler<AuthenticationSchemeOptions> { public BasicAuthenticationHandler( IOptionsMonitor<AuthenticationSchemeOptions> options, ILoggerFactory logger, UrlEncoder encoder, ISystemClock clock) : base(options, logger, encoder, clock) { } protected override Task<AuthenticateResult> HandleAuthenticateAsync() { if (!Request.Headers.ContainsKey("Authorization")) { return Task.FromResult(AuthenticateResult.Fail("Missing Authorization header")); } try { var authenticationHeader = AuthenticationHeaderValue.Parse(Request.Headers["Authorization"]); var credentialBytes = Convert.FromBase64String(authenticationHeader.Parameter); var credentials = Encoding.UTF8.GetString(credentialBytes).Split(':'); var username = credentials[0]; var password = credentials[1]; if (username == "DNT" && password == "123") { var claims = new[] { new Claim(ClaimTypes.NameIdentifier, username) }; var identity = new ClaimsIdentity(claims, Scheme.Name); var principal = new ClaimsPrincipal(identity); var ticket = new AuthenticationTicket(principal, Scheme.Name); return Task.FromResult(AuthenticateResult.Success(ticket)); } return Task.FromResult(AuthenticateResult.Fail("Invalid username or password")); } catch { return Task.FromResult(AuthenticateResult.Fail("Invalid Authorization header")); } } } }
پس از دریافت مقدار هدر Authorization، ابتدا مقدار آنرا از base64 به حالت معمولی تبدیل کرده و سپس بر اساس حرف ":"، دو قسمت را از آن جداسازی میکنیم. قسمت اول را به عنوان نام کاربری و قسمت دوم را به عنوان کلمهی عبور پردازش خواهیم کرد. در این مثال جهت سادگی، این دو باید مساوی DNT و 123 باشند. اگر اینچنین بود، یک AuthenticationTicket دارای Claim ای حاوی نام کاربری را ایجاد کرده و آنرا به عنوان حاصل موفقیت آمیز بودن عملیات بازگشت میدهیم.
مرحلهی بعد، استفاده و معرفی این BasicAuthenticationHandler تهیه شده به برنامه است:
namespace OpenAPISwaggerDoc.Web { public class Startup { public void ConfigureServices(IServiceCollection services) { services.AddAuthentication(defaultScheme: "Basic") .AddScheme<AuthenticationSchemeOptions, BasicAuthenticationHandler>("Basic", null);
همچنین نیاز است میانافزار اعتبارسنجی را نیز با فراخوانی متد app.UseAuthentication، به برنامه اضافه کرد که باید پیش از فراخوانی app.UseMvc صورت گیرد تا به آن اعمال شود:
namespace OpenAPISwaggerDoc.Web { public class Startup { public void Configure(IApplicationBuilder app, IHostingEnvironment env) { // ... app.UseStaticFiles(); app.UseAuthentication(); app.UseMvc(); } } }
همچنین برای اینکه تمام اکشن متدهای موجود را نیز محافظت کنیم، میتوان فیلتر Authorize را به صورت سراسری اعمال کرد:
namespace OpenAPISwaggerDoc.Web { public class Startup { public void ConfigureServices(IServiceCollection services) { // ... services.AddMvc(setupAction => { setupAction.Filters.Add(new AuthorizeFilter()); // ...
تکمیل مستندات API جهت انعکاس تنظیمات محافظت از اکشن متدهای آن
پس از تنظیم محافظت دسترسی به اکشن متدهای برنامه، اکنون نوبت به مستند کردن آن است و همانطور که در ابتدای بحث نیز عنوان شد، برای این منظور نیاز به تعریف خواص securitySchemes و security در OpenAPI Specification است:
namespace OpenAPISwaggerDoc.Web { public class Startup { public void ConfigureServices(IServiceCollection services) { services.AddSwaggerGen(setupAction => { // ... setupAction.AddSecurityDefinition("basicAuth", new OpenApiSecurityScheme { Type = SecuritySchemeType.Http, Scheme = "basic", Description = "Input your username and password to access this API" }); });
پس از این تنظیم اگر برنامه را اجرا کنیم، یک دکمهی authorize اضافه شدهاست:
با کلیک بر روی آن، صفحهی ورود نام کاربری و کلمهی عبور ظاهر میشود:
اگر آنرا تکمیل کرده و سپس برای مثال لیست نویسندگان را درخواست کنیم (با کلیک بر روی دکمهی try it out آن و سپس کلیک بر روی دکمهی execute ذیل آن)، تنها خروجی 401 یا unauthorized را دریافت میکنیم:
- بنابراین برای تکمیل آن، مطابق نکات قسمت چهارم، ابتدا باید status code مساوی 401 را به صورت سراسری، مستند کنیم:
namespace OpenAPISwaggerDoc.Web { public class Startup { public void ConfigureServices(IServiceCollection services) { services.AddMvc(setupAction => { setupAction.Filters.Add(new ProducesResponseTypeAttribute(StatusCodes.Status401Unauthorized));
- همچنین هرچند با کلیک بر روی دکمهی Authorize در Swagger UI و ورود نام کاربری و کلمهی عبور توسط آن، در همانجا پیام Authorized را دریافت کردیم، اما اطلاعات آن به ازای هر درخواست، به سمت سرور ارسال نمیشود. به همین جهت در حین درخواست لیست نویسندگان، پیام unauthorized را دریافت کردیم. برای رفع این مشکل نیاز است به OpenAPI Spec اعلام کنیم که تعامل با API، نیاز به Authentication دارد:
namespace OpenAPISwaggerDoc.Web { public class Startup { public void ConfigureServices(IServiceCollection services) { services.AddSwaggerGen(setupAction => { // ... setupAction.AddSecurityRequirement(new OpenApiSecurityRequirement { { new OpenApiSecurityScheme { Reference = new OpenApiReference { Type = ReferenceType.SecurityScheme, Id = "basicAuth" } }, new List<string>() } }); });
پس از این تنظیمات، Swagger UI با افزودن یک آیکن قفل به مداخل APIهای محافظت شده، به صورت زیر تغییر میکند:
در این حالت اگر بر روی آیکن قفل کلیک کنیم، همان صفحهی دیالوگ ورود نام کاربری و کلمهی عبوری که پیشتر با کلیک بر روی دکمهی Authorize ظاهر شد، نمایش داده میشود. با تکمیل آن و کلیک مجدد بر روی آیکن قفل، جهت گشوده شدن پنل API و سپس کلیک بر روی try it out آن، برای مثال میتوان به API محافظت شدهی دریافت لیست نویسندگان، بدون مشکلی، دسترسی یافت:
کدهای کامل این قسمت را از اینجا میتوانید دریافت کنید: OpenAPISwaggerDoc-06.zip
بنابراین ابتدا دو بستهی Rx-Main و Rx-WPF را توسط نیوگت، به یک برنامهی جدید WPF اضافه کنید:
PM> Install-Package Rx-Main PM> Install-Package Rx-WPF
private static IEnumerable<string> readFile(string filename) { using (TextReader reader = File.OpenText(filename)) { string line; while ((line = reader.ReadLine()) != null) { Thread.Sleep(100); yield return line; } } }
UI برنامه نیز به نحو ذیل است:
<Window x:Class="WpfApplicationRxTests.MainWindow" xmlns="http://schemas.microsoft.com/winfx/2006/xaml/presentation" xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml" Title="MainWindow" Height="450" Width="525"> <Grid> <Grid.RowDefinitions> <RowDefinition Height="Auto" /> <RowDefinition Height="*" /> <RowDefinition Height="Auto" /> </Grid.RowDefinitions> <Button Grid.Row="0" Name="btnGenerateSequence" Click="btnGenerateSequence_Click">Generate sequence</Button> <ListBox Grid.Row="1" Name="lstNumbers" /> <Button Grid.Row="2" IsEnabled="False" Name="btnStop" Click="btnStop_Click">Stop</Button> </Grid> </Window>
using System; using System.Collections.Generic; using System.Collections.ObjectModel; using System.IO; using System.Reactive.Concurrency; using System.Reactive.Linq; using System.Threading; using System.Windows; namespace WpfApplicationRxTests { public partial class MainWindow { public MainWindow() { InitializeComponent(); } private static IEnumerable<string> readFile(string filename) { using (TextReader reader = File.OpenText(filename)) { string line; while ((line = reader.ReadLine()) != null) { Thread.Sleep(100); yield return line; } } } private IDisposable _subscribe; private void btnGenerateSequence_Click(object sender, RoutedEventArgs e) { btnGenerateSequence.IsEnabled = false; btnStop.IsEnabled = true; var items = new ObservableCollection<string>(); lstNumbers.ItemsSource = items; _subscribe = readFile("test.txt").ToObservable() .SubscribeOn(ThreadPoolScheduler.Instance) .ObserveOn(DispatcherScheduler.Current) .Finally(finallyAction: () => { btnGenerateSequence.IsEnabled = true; btnStop.IsEnabled = false; }) .Subscribe(onNext: line => { items.Add(line); }, onError: ex => { }, onCompleted: () => { //lstNumbers.ItemsSource = items; }); } private void btnStop_Click(object sender, RoutedEventArgs e) { _subscribe.Dispose(); } } }
توضیحات
حاصل متد readFile را که یک توالی معمولی IEnumerable را ایجاد میکند، توسط فراخوانی متد ToObservable، تبدیل به یک خروجی IObservable کردهایم تا بتوانیم هربار که سطری از فایل مدنظر خوانده میشود، نسبت به آن واکنش نشان دهیم.
متد SubscribeOn مشخص میکند که این توالی Observable باید بر روی چه تردی اجرا شود. در اینجا از ThreadPoolScheduler.Instance استفاده شدهاست تا در حین خواندن فایل، رابط کاربری در حالت هنگ به نظر نرسد و ترد جاری (ترد اصلی برنامه) به صورت خودکار آزاد گردد.
از متد ObserveOn با پارامتر DispatcherScheduler.Current استفاده کردهایم، تا نتیجهی واکنشهای به خوانده شدن سطرهای یک فایل مفروض، در ترد اصلی برنامه صورت گیرد. در غیر اینصورت امکان کار کردن با عناصر رابط کاربری در یک ترد دیگر وجود نخواهد داشت و برنامه کرش میکند.
در قسمتهای قبل، صرفا متد Subscribe را مشاهده کرده بودید. در اینجا از متد Finally نیز استفاده شدهاست. علت اینجا است که اگر در حین خواندن فایل خطایی رخ دهد، قسمت onError متد Subscribe اجرا شده و دیگر به پارامتر onCompleted آن نخواهیم رسید. اما متد Finally آن همیشه در پایان عملیات اجرا میشود.
خروجی حاصل از متد Subscribe، از نوع IDisposable است. Rx به صورت خودکار پس از پردازش آخرین عنصر توالی، این شیء را Dispose میکند. اینجا است که callback متد Finally یاد شده فراخوانی خواهد شد. اما اگر در حین خواندن یک فایل طولانی، کاربر علاقمند باشد تا عملیات را متوقف کند، تنها کافی است که به صورت صریح، این شیء را Dispose نماید. به همین جهت است که مشاهده میکنید، این خروجی به صورت یک فیلد تعریف شدهاست تا در متد Stop بتوانیم آنرا در صورت نیاز Dispose کنیم.
مثال فوق را از اینجا نیز میتوانید دریافت کنید:
WpfApplicationRxTests.zip
خوب، تا اینجا ممکن است مشکلی به نظر نرسد و هدف از کش کردن اطلاعات یک اکشن متد نیز همین مورد است. اما اگر این اکشن متد کش شده، به اشتباه در یک کنترلر مزین شده با ویژگی Authorize قرار گیرد، چه خواهد شد؟ مثلا این کنترلر امن، برای ارائه فایلها یا حتی نمایش قسمتی از صفحه یا کل صفحه، از کش استفاده کرده است. در بار اول دریافت فایل، بدیهی است که تمام مسایل اعتبارسنجی باید مطابق طول عمر یک کنترلر روی دهند. اما در بار دوم فراخوانی آدرس دریافت صفحه یا فایل، اصلا کار به فراخوانی کنترلر نمیرسد. به عبارتی کلیه کاربران سایت (اعم از لاگین شده، نشده، دارای دسترسی مشاهده صفحه یا آدرس امن و یا بدون دسترسی)، به این محتوای خاص بدون مشکلی دسترسی خواهند داشت (فقط کافی است که از آدرس نهایی به نحوی مطلع شوند).
سؤال: چگونه میتوان کلیه اکشن متدهای یک پروژه ASP.NET MVC را که دارای ویژگی OutputCache در یک کنترلر امن هستند، یافت؟
using System; using System.Linq; using System.Reflection; // Add a ref. to \Program Files\Microsoft ASP.NET\ASP.NET MVC 4\Assemblies\System.Web.Mvc.dll using System.Web.Mvc; // Add a ref. to System.Web using System.Web.UI; namespace FindOutputCaches { class Program { static void Main(string[] args) { var path = @"D:\site\bin\Web.dll"; var asmTarget = Assembly.LoadFrom(path); checkSecuredControllers(asmTarget); Console.WriteLine("Press a key..."); Console.Read(); } private static void checkSecuredControllers(Assembly asmTarget) { // یافتن کلیه کنترلرهایی که فیلتر اوتورایز دارند var securedControllers = asmTarget.GetTypes() .Where(type => typeof(IController).IsAssignableFrom(type) && Attribute.IsDefined(type, typeof(AuthorizeAttribute)) && !type.Name.StartsWith("T4MVC")) .ToList(); foreach (var controller in securedControllers) { // یافتن کلیه اکشن متدهای کنترلر جاری var actionMethods = controller.GetMethods(BindingFlags.Public | BindingFlags.Instance | BindingFlags.DeclaredOnly) .Where(method => typeof(ActionResult).IsAssignableFrom(method.ReturnType)) .ToList(); foreach (var method in actionMethods) { // یافتن متدهایی که دارای آوت پوت کش هستند var attributes = method.GetCustomAttributes(typeof(OutputCacheAttribute), true); if (attributes == null || !attributes.Any()) continue; var outputCache = (OutputCacheAttribute)attributes[0]; // AllowMultiple = false if (outputCache.Location == OutputCacheLocation.None) continue; //سبب عدم کش شدن شده است؛ مثلا برای کارهای ایجکسی Console.WriteLine("Detected incorrect usage of OutputCache in:\n {0}-->{1}", controller.FullName, method.Name); } } } } }
ابتدا مسیر اسمبلی کامپایل شده پروژه ASP.NET MVC که حاوی کنترلرهای برنامه است، باید مشخص گردد.
سپس در این اسمبلی کلیه نوعهای تعریف شده، یافت گردیده و آنهایی که پیاده سازی کننده IController هستند (یعنی کلاسهای کنترلر واقعی برنامه) و همچنین دارای ویژگی AuthorizeAttribute نیز میباشند، جدا خواهند شد.
در ادامه، در هر کنترلر امن یافت شده، متدهایی را بررسی خواهیم کرد که دارای خروجی از نوع ActionResult باشند (فقط اکشن متدها مدنظر هستند). اگر این اکشن متد یافت شده دارای ویژگی OutputCacheAttribute بود و همچنین Location آن به None تنظیم نشده بود ... یعنی مشکل امنیتی وجود دارد که باید برطرف شود.
البته برای تکمیل این مطلب باید دو حالت زیر هم پیاده سازی و بررسی شوند:
- کلیه Viewهای برنامه بررسی شوند. اگر در View خاصی که متعلق است به یک کنترلر یا حتی اکشن متد امن، ارجاعی به اکشن متدی کش شده در کنترلری دیگر وجود داشت، این مورد هم یک باگ امنیتی است.
- کلیه کنترلرهای عمومی که دارای اکشن متدی امن هستند نیز باید جهت یافتن OutputCache بررسی شوند.
یکی از شروط تهیه آزمونهای واحد، خارج نشدن از مرزهای سیستم در حین بررسی آزمونهای مورد نظر است؛ تا بتوان تمام آزمونها را با سرعت بسیار بالایی، بدون نگرانی از در دسترس نبودن منابع خارجی، درست در لحظه انجام آزمونها، به پایان رساند. اگر این خروج صورت گیرد، بجای unit tests با integration tests سر و کار خواهیم داشت. در این میان، کار با فایلها نیز مصداق بارز خروج از مرزهای سیستم است.
برای حل این مشکل راه حلهای زیادی توصیه شدهاند؛ منجمله تهیه یک اینترفیس محصور کننده فضای نام System.IO و سپس استفاده از فریم ورکهای mocking و امثال آن. یک نمونه از پیاده سازی آنرا اینجا میتوانید پیدا کنید : (+)
اما راه حل سادهتری نیز برای این مساله وجود دارد و آن هم افزودن فایلهای مورد نظر به پروژه آزمون واحد جاری و سپس مراجعه به خواص فایلها و تغییر Build Action آنها به Embedded Resource میباشد. به این صورت پس از کامپایل پروژه، فایلهای ما در قسمت منابع اسمبلی جاری قرار گرفته و به کمک متد زیر قابل دسترسی خواهند بود:
using System.IO;
using System.Reflection;
public class UtHelper
{
public static string GetInputFile(string filename)
{
var thisAssembly = Assembly.GetExecutingAssembly();
var stream = thisAssembly.GetManifestResourceStream(filename);
return new StreamReader(stream).ReadToEnd();
}
}
نکتهای را که اینجا باید به آن دقت داشت، filename متد GetInputFile است. چون این فایل دیگر به صورت متداول از فایل سیستم خوانده نخواهد شد، نام واقعی آن به صورت namespace.filename میباشد (همان نام منبع اسمبلی جاری).
اگر جهت یافتن این نام با مشکل مواجه شدید، تنها کافی است اسمبلی آزمون واحد را با برنامه Reflector یا ابزارهای مشابه گشوده و نام منابع آنرا بررسی کنید.
ابتدا یک پروژه با دو Console Application با نام های Service و Client ایجاد کنید. سپس در پروژه Service یک سرویس به نام BookService ایجاد کنید و کدهای زیر را در آن کپی نمایید:
Contract مربوطه به صورت زیر است:
[ServiceContract] public interface IBookService { [OperationContract] int GetCountOfBook(); }
[ServiceBehavior(IncludeExceptionDetailInFaults = true)] public class BookService : IBookService { public int GetCountOfBook() { return 10; } }
class Program { static void Main(string[] args) { ServiceHost host = new ServiceHost(typeof(BookService)); var binding = new BasicHttpBinding(); host.AddServiceEndpoint(typeof(IBookService), binding, "http://localhost/BookService"); host.Open(); Console.Write("BookService host"); Console.ReadKey(); } }
حال نوبت به پیاده سازی سمت کلاینت میرسد. فایل Program سمت کلاینت را باز کرده و کدهای زیر را نیز در آن کپی نمایید:
static void Main(string[] args) { Thread.Sleep(2000); BasicHttpBinding binding = new BasicHttpBinding(); ChannelFactory<IBookService> channel = new ChannelFactory<IBookService>(binding, new EndpointAddress("http://localhost/BookService")); Console.WriteLine("Count of book: {0}", channel.CreateChannel().GetCountOfBook()); Console.ReadKey(); }
خروجی زیر مشاهده میشود:
تا اینجا هیچ گونه اعتبار سنجی انجام نشد. برای پیاده سازی اعتبار سنجی باید یک سری تنظیمات بر روی Binding و Hosting سمت سرور و البته کلاینت بر قرار شود. فایل Program پروزه Service را باز نمایید و محتویات آن را به صورت زیر تغییر دهید:
static void Main(string[] args) { ServiceHost host = new ServiceHost(typeof(BookService)); var binding = new BasicHttpBinding(); binding.Security = new BasicHttpSecurity(); binding.Security.Mode = BasicHttpSecurityMode.TransportCredentialOnly; binding.Security.Transport.ClientCredentialType = HttpClientCredentialType.Basic; host.Credentials.UserNameAuthentication.UserNamePasswordValidationMode = System.ServiceModel.Security.UserNamePasswordValidationMode.Custom; host.Credentials.UserNameAuthentication.CustomUserNamePasswordValidator = new CustomUserNamePasswordValidator(); host.AddServiceEndpoint(typeof(IBookService), binding, "http://localhost/BookService"); host.Open(); Console.Write("BookService host"); Console.ReadKey(); }
ابتدا نوع Security در Binding را به حالت TransportCredentialOnly تنظیم کردیم. در یک جمله هیچ گونه تضمینی برای صحت اطلاعات انتقالی در این حالت وجود ندارد و فقط یک اعتبار سنجی اولیه انجام خواهد شد. در نتیجه هنگام استفاده از این حالت باید با دقت عمل نمود و نباید فقط به پیاده سازی این حالت اکتفا کرد.( Encryption اطلاعات سرویسها مورد بحث این پست نیست)
ClientCredentialType نیز باید به حالت Basic تنظیم شود. در WCF اعتبار سنجی به صورت پیش فرض در حالت Windows است (بعنی UserNamePasswordValidationMode برابر مقدار Windows است و اعتبار سنجی بر اساس کاربر انجام میشود) . این مورد باید به مقدار Custom تغییر یابد. در انتها نیز باید مدل اعتبار سنجی دلخواه خود را به صورت زیر پیاده سازی کنیم:
در پروژه سرویس یک کلاس به نام CustomUserNamePasswordValidator بسازید و کدهای زیر را در آن کپی کنید:
public class CustomUserNamePasswordValidator : UserNamePasswordValidator { public override void Validate(string userName, string password) { if (userName != "Masoud" || password != "Pakdel") throw new SecurityException("Incorrect userName or password"); } }
تغییرات مورد نیاز سمت کلاینت:
اگر در این حالت پروژه را اجرا نمایید از آن جا که از این به بعد، درخواستها سمت سرور اعتبار سنجی میشوند در نتیجه با خطای زیر روبرو خواهید شد:
این خطا از آن جا ناشی میشود که تنظیمات کلاینت و سرور از نظر امنیتی با هم تناسب ندارد. در نتیجه باید تنظیمات Binding کلاینت و سرور یکی شود. برای این کار کد زیر را به فایل Program سمت کلاینت اضافه میکنیم:
static void Main(string[] args) { Thread.Sleep(2000); BasicHttpBinding binding = new BasicHttpBinding(); binding.Security = new BasicHttpSecurity(); binding.Security.Mode = BasicHttpSecurityMode.TransportCredentialOnly; binding.Security.Transport.ClientCredentialType = HttpClientCredentialType.Basic; ChannelFactory<IBookService> channel = new ChannelFactory<IBookService>(binding, new EndpointAddress("http://localhost/BookService")); channel.Credentials.UserName.UserName = "WrongUserName"; channel.Credentials.UserName.Password = "WrongPassword";
Console.WriteLine("Count of book: {0}", channel.CreateChannel().GetCountOfBook()); Console.ReadKey(); }
channel.Credentials.UserName.UserName = "WrongUserName"; channel.Credentials.UserName.Password = "WrongPassword";
دریافت سورس مثال بالا
بررسی ویجت Kendo UI File Upload
- اگر قرار هست تعدادی read و write از و به بانک اطلاعاتی به صورت یک atomic operation عمل کنند، باید از تراکنشها به صورت صریح استفاده کنید:
using (var transaction = new TransactionScope(TransactionScopeOption.Required, new TransactionOptions { IsolationLevel = IsolationLevel.ReadCommitted })) { var database = new DatabaseContext(); // DBC: BEGIN TRANSACTION var userA = database.Users.Find(1); var userB = database.Users.Find(2); userA.Name = "Admin"; database.SaveChanges(); userB.Age = 28; database.SaveChanges(); // DBC: COMMIT TRANSACTION transaction.Complete(); }
«اصول پایگاه داده - تراکنش ها»
«آشنایی با TransactionScope»
«مفهوم READ_COMMITTED_SNAPSHOT در EF 6»
« بررسی Transactions و Locks در SQL Server»
بسیاری از برنامههای دسکتاپ نیاز به نمایش پنجرههای دیالوگ استاندارد ویندوز مانند OpenFileDialog و SaveFileDialog را دارند و سؤال اینجا است که چگونه اینگونه موارد را باید از طریق پیاده سازی صحیح الگوی MVVM مدیریت کرد؛ از آنجائیکه خیلی راحت در فایل ViewModel میتوان نوشت new OpenFileDialog و الی آخر. این مورد هم یکی از دلایل اصلی استفاده از الگوی MVVM را زیر سؤال میبرد : این ViewModel دیگر قابل تست نخواهد بود. همیشه شرایط آزمونهای واحد را به این صورت در نظر بگیرید:
سروری وجود دارد در جایی که به آن دسترسی نداریم. روی این سرور با اتوماسیونی که راه انداختهایم، آخر هر روز آزمونهای واحد موجود به صورت خودکار انجام شده و یک گزارش تهیه میشود (مثلا یک نوع continuous integration سرور). بنابراین کسی دسترسی به سرور نخواهد داشت تا این OpenFileDialog ظاهر شده را مدیریت کرده، فایلی را انتخاب و به برنامه آزمون واحد معرفی کند. به صورت خلاصه ظاهر شدن هر نوع دیالوگی حین انجام آزمونهای واحد «مسخره» است!
یکی از روشهای حل این نوع مسایل، استفاده از dependency injection یا تزریق وابستگیها است و در ادامه خواهیم دید که چگونه WPF بدون نیاز به هیچ نوع فریم ورک تزریق وابستگی خارجی، از این مفهوم پشتیبانی میکند.
مروری مقدماتی بر تزریق وابستگیها
امکان نوشتن آزمون واحد برای new OpenFileDialog وجود ندارد؟ اشکالی نداره، یک Interface بر اساس نیاز نهایی برنامه درست کنید (نیاز نهایی برنامه از این ماجرا فقط یک رشته LoadPath است و بس) سپس در ViewModel با این اینترفیس کار کنید؛ چون به این ترتیب امکان «تقلید» آن فراهم میشود.
یک مثال عملی:
ViewModel نیاز دارد تا مسیر فایلی را از کاربر بپرسد. این مساله را با کمک dependency injection در ادامه حل خواهیم کرد.
ابتدا سورس کامل این مثال:
ViewModel برنامه (تعریف شده در پوشه ViewModels برنامه):
namespace WpfFileDialogMvvm.ViewModels
{
public interface IFilePathContract
{
string GetFilePath();
}
public class MainWindowViewModel
{
IFilePathContract _filePathContract;
public MainWindowViewModel(IFilePathContract filePathContract)
{
_filePathContract = filePathContract;
}
//...
private void load()
{
string loadFilePath = _filePathContract.GetFilePath();
if (!string.IsNullOrWhiteSpace(loadFilePath))
{
// Do something
}
}
}
}
دو نمونه از پیاده سازی اینترفیس IFilePathContract تعریف شده (در پوشه Dialogs برنامه):
using Microsoft.Win32;
using WpfFileDialogMvvm.ViewModels;
namespace WpfFileDialogMvvm.Dialogs
{
public class OpenFileDialogProvider : IFilePathContract
{
public string GetFilePath()
{
var ofd = new OpenFileDialog
{
Filter = "XML files (*.xml)|*.xml"
};
string filePath = null;
bool? dialogResult = ofd.ShowDialog();
if (dialogResult.HasValue && dialogResult.Value)
{
filePath = ofd.FileName;
}
return filePath;
}
}
public class FakeOpenFileDialogProvider : IFilePathContract
{
public string GetFilePath()
{
return @"c:\path\data.xml";
}
}
}
و View برنامه:
<Window x:Class="WpfFileDialogMvvm.MainWindow"
xmlns="http://schemas.microsoft.com/winfx/2006/xaml/presentation"
xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
xmlns:vm="clr-namespace:WpfFileDialogMvvm.ViewModels"
xmlns:dialogs="clr-namespace:WpfFileDialogMvvm.Dialogs"
Title="MainWindow" Height="350" Width="525">
<Window.Resources>
<ObjectDataProvider x:Key="mainWindowViewModel"
ObjectType="{x:Type vm:MainWindowViewModel}">
<ObjectDataProvider.ConstructorParameters>
<dialogs:OpenFileDialogProvider/>
</ObjectDataProvider.ConstructorParameters>
</ObjectDataProvider>
</Window.Resources>
<Grid DataContext="{Binding Source={StaticResource mainWindowViewModel}}">
</Grid>
</Window>
توضیحات:
ما در ViewModel نیاز داریم تا مسیر نهایی فایل را دریافت کنیم و این عملیات نیاز به فراخوانی متد ShowDialog ایی را دارد که امکان نوشتن آزمون واحد خودکار را از ViewModel ما سلب خواهد کرد. بنابراین بر اساس نیاز برنامه یک اینترفیس عمومی به نام IFilePathContract را طراحی میکنیم. در حالت کلی کلاسی که این اینترفیس را پیاده سازی میکند، قرار است مسیری را برگرداند. اما به کمک استفاده از اینترفیس، به صورت ضمنی اعلام میکنیم که «برای ما مهم نیست که چگونه». میخواهد OpenFileDialogProvider ذکر شده باشد، یا نمونه تقلیدی مانند FakeOpenFileDialogProvider. از نمونه واقعی OpenFileDialogProvider در برنامه اصلی استفاده خواهیم کرد، از نمونه تقلیدی FakeOpenFileDialogProvider در آزمون واحد و نکته مهم هم اینجا است که ViewModel ما چون بر اساس اینترفیس IFilePathContract پیاده سازی شده، با هر دو DialogProvider یاد شده میتواند کار کند.
مرحله آخر نوبت به وهله سازی نمونه واقعی، در View برنامه است. یا میتوان در Code behind مرتبط با View نوشت:
namespace WpfFileDialogMvvm
{
public partial class MainWindow
{
public MainWindow()
{
InitializeComponent();
this.DataContext = new MainWindowViewModel(new OpenFileDialogProvider());
}
}
}
و یا از روش ObjectDataProvider توکار WPF هم میشود استفاده کرد؛ که مثال آنرا در کدهای XAML مرتبط با View ذکر شده میتوانید مشاهده کنید. ابتدا دو فضای نام vm و dialog تعریف شده (با توجه به اینکه مثلا در این مثال، دو پوشه ViewModels و Dialogs وجود دارند). سپس کار تزریق وابستگیها به سازنده کلاس MainWindowViewModel، از طریق ObjectDataProvider.ConstructorParameters انجام میشود:
<ObjectDataProvider x:Key="mainWindowViewModel"
ObjectType="{x:Type vm:MainWindowViewModel}">
<ObjectDataProvider.ConstructorParameters>
<dialogs:OpenFileDialogProvider/>
</ObjectDataProvider.ConstructorParameters>
</ObjectDataProvider>
آسیب پذیری SQL Injection یا به اختصار SQLi
تزریق SQL، یکی از قدیمی ترین، شایعترین و مخربترین آسیب پذیریها، برای برنامهها میباشد و در صورت برقراری شرایط مناسب جهت حمله و با اعمال نفوذ، از طریق تزریق SQL ، مهاجم میتواند با دور زدن فرآیندهای اعتبارسنجی و احراز هویت یک برنامه، به تمامی محتوای پایگاه دادهی آن و گاها کنترل سرور، دسترسی پیدا کند. این حمله برای افزودن، ویرایش و حذف رکوردهای یک پایگاه داده مبتنی بر SQL انجام میشود.
عملکرد SQL Injection
برای اجرای SQLهای مخرب در برنامههایی که از پایگاههای دادهی مبتنی بر SQL مانند (SQL Server ،MySQL ،PostgreSQL ،Oracle و ...) استفاده میکنند، هکر یا مهاجم در اولین گام باید به دنبال ورودیهایی در برنامه باشد که درون یک درخواست SQL قرار گرفته باشند (مانند صفحات لاگین، ثبت نام، جستجو و ...).
کد زیر را در نظر بگیرید:
# Define POST variables uname = request.POST['username'] passwd = request.POST['password'] # SQL query vulnerable to SQLi sql = "SELECT id FROM users WHERE username='" + uname + "' AND password='" + passwd + "'" # Execute the SQL statement database.execute(sql)
اکنون ورودی password را برای نفوذ، تست میکنیم. مهاجم بدون داشتن نام کاربری، قصد دور زدن احراز هویت را دارد. بجای password عبارت زیر را قرار میدهد:
password' OR 1=1
در نهایت در بانک اطلاعاتی دستور زیر اجرا میشود:
SELECT id FROM users WHERE username='username' AND password= 'password' OR 1=1'
میدانیم که 1=1 است. پس بدون در نظر گرفتن اینکه شما برای username و password چه چیزی را وارد نمودید، عبارت درست در نظر گرفته میشود:
شرط اول and شرط دوم = نتیجه or 1=1 چون 1=1 است همیشه شرط کوئری درست خواهد بود
معمولا در بانک اطلاعاتی، اولین کاربری که وارد میکنند Administrator برنامه میباشد. پس به احتمال قوی شما میتوانید با مجوز ادمین به برنامه وارد شوید. البته میتوان با دانستن تنها نام کاربری هم بهراحتی با گذاشتن در قسمت username بدون دانستن password، به برنامه وارد شد؛ زیرا میتوان شرط چک کردن password را کامنت نمود:
-- MySQL, MSSQL, Oracle, PostgreSQL, SQLite ' OR '1'='1' -- ' OR '1'='1' /* -- MySQL ' OR '1'='1' # -- Access (using null characters) ' OR '1'='1' %00 ' OR '1'='1' %16
ابزارهایی برای تست آسیب پذیری SQLi
2) اسکنر اکانتیکس
چگونه از SQL Injection جلوگیری کنیم
1) روی دادههایی که از کاربر دریافت میگردد، اعتبار سنجی سمت کلاینت و سرور انجام شود. اگر فقط به اعتبارسنجی سمت کلاینت اکتفا کنید، هکر بهراحتی با استفاده از پروکسی، دادهها را تغییر میدهد. ورودیها را فیلتر و پاکسازی و با لیست سفید یا سیاه بررسی کنید ( ^ , ^, ^, ^ ).
2) از کوئریهایی که بدون استفاده از پارامتر از کاربر ورودی گرفته و درون یک درخواستSQL قرار میگیرند، اجتناب کنید:
[HttpGet] [Route("nonsensitive")] public string GetNonSensitiveDataById() { using (SqlConnection connection = new SqlConnection(_configuration.GetValue<string>("ConnectionString"))) { connection.Open(); SqlCommand command = new SqlCommand($"SELECT * FROM NonSensitiveDataTable WHERE Id = {Request.Query["id"]}", connection); using (var reader = command.ExecuteReader()) { if (reader.Read()) { string returnString = string.Empty; returnString += $"Name : {reader["Name"]}. "; returnString += $"Description : {reader["Description"]}"; return returnString; } else { return string.Empty; } } } }
با استفاده از پارامتر: (بهتر است نوع دیتا تایپ پارامتر و طول آن ذکر شود)
[HttpGet] [Route("nonsensitivewithparam")] public string GetNonSensitiveDataByNameWithParam() { using (SqlConnection connection = new SqlConnection(_configuration.GetValue<string>("ConnectionString"))) { connection.Open(); SqlCommand command = new SqlCommand($"SELECT * FROM NonSensitiveDataTable WHERE Name = @name", connection); command.Parameters.AddWithValue("@name", Request.Query["name"].ToString()); using (var reader = command.ExecuteReader()) { if (reader.Read()) { string returnString = string.Empty; returnString += $"Name : {reader["Name"]}. "; returnString += $"Description : {reader["Description"]}"; return returnString; } else { return string.Empty; } } } }
3) از Stored Procedureها استفاده کنید و بصورت پارامتری دادههای مورد نیاز را به آنها پاس دهید: (بهتر است نوع دیتا تایپ پارامتر و طول آن ذکر شود)
[HttpGet] [Route("nonsensitivewithsp")] public string GetNonSensitiveDataByNameWithSP() { using (SqlConnection connection = new SqlConnection(_configuration.GetValue<string>("ConnectionString"))) { connection.Open(); SqlCommand command = new SqlCommand("SP_GetNonSensitiveDataByName", connection); command.CommandType = System.Data.CommandType.StoredProcedure; command.Parameters.AddWithValue("@name", Request.Query["name"].ToString()); using (var reader = command.ExecuteReader()) { if (reader.Read()) { string returnString = string.Empty; returnString += $"Name : {reader["Name"]}. "; returnString += $"Description : {reader["Description"]}"; return returnString; } else { return string.Empty; } } } }
4) اگر از داینامیک کوئری استفاده میکنید، دادههای مورد استفادهی در کوئری را بصورت پارامتری ارسال کنید:
فرض کنید چنین جدولی دارید
CREATE TABLE tbl_Product ( Name NVARCHAR(50), Qty INT, Price FLOAT ) GO INSERT INTO tbl_Product (Name, Qty, Price) VALUES (N'Shampoo', 200, 10.0); INSERT INTO tbl_Product (Name, Qty, Price) VALUES (N'Hair Clay', 400, 20.0); INSERT INTO tbl_Product (Name, Qty, Price) VALUES (N'Hair Tonic', 300, 30.0);
یک پروسیجر را دارید که عملیات جستجو را انجام میدهد و از داینامیک کوئری استفاده میکند.
ALTER PROCEDURE sp_GetProduct(@Name NVARCHAR(50)) AS BEGIN DECLARE @sqlcmd NVARCHAR(MAX); SET @sqlcmd = N'SELECT * FROM tbl_Product WHERE Name = ''' + @Name + ''''; EXECUTE(@sqlcmd) END
با اینکه از Stored Procedure استفاده میکنید، باز هم در معرض خطر SQLi میباشید. فرض کنید هکر چنین درخواستی را ارسال میکند:
Shampoo'; DROP TABLE tbl_Product; --
نتیجه، تبدیل به دستور زیر میشود:
SELECT * FROM tbl_Product WHERE Name = 'Shampoo'; DROP TABLE tbl_Product; --'
برای جلوگیری از SQLi در کوئریهای داینامیک SP بشکل زیر عمل میکنیم:
ALTER PROCEDURE sp_GetProduct(@Name NVARCHAR(50)) AS BEGIN DECLARE @sqlcmd NVARCHAR(MAX); DECLARE @params NVARCHAR(MAX); SET @sqlcmd = N'SELECT * FROM tbl_Product WHERE Name = @Name'; SET @params = N'@Name NVARCHAR(50)'; EXECUTE sp_executesql @sqlcmd, @params, @Name; END
5) میتوان از تنظیمات IIS یا وب سرورهای دیگر برای جلوگیری از SQLi استفاده نمود.
6) استفاده از چند کاربرِ دیتابیس در برنامه و بکارگیری سطح دسترسی محدود و مناسب( ^ , ^ ).
7) از ORM استفاده کنید و اگر نیاز به سرعت بیشتری دارید از یک Micro ORM استفاده کنید؛ با در نظر داشتن نکات لازم.