استفاده از این پروژه مشکل است. به عبارتی کدهای فایل default.aspx.cs را باید بتوان در یک کنترل یا حداقل یک یوزر کنترل کپسوله کرد و کاربر نهایی صرفا آنرا روی فرم قرار دهد و یک سری خواص را چک کند. پیاده سازی پشت صحنه آن بهتر است تا حد امکان مخفی شود.
معماری لایه بندی نرم افزار #1
تعریف تزریق وابستگی تنظیمات برنامه
در مطلب «تزریق وابستگیها فراتر از کلاسها در برنامههای Angular» با روش تزریق ثوابت برنامه آشنا شدیم. در این مثال، برنامهی کلاینت بر روی پورت 4200 اجرا میشود و برنامهی سمت سرور وب، بر روی پورت 5000. به همین جهت نیاز است این آدرس پایه سمت سرور را در تمام قسمتهای برنامه که با سرور کار میکنند، در دسترس داشته باشیم و روش مناسب برای پیاده سازی آن همان قسمت «تزریق تنظیمات برنامه توسط تامین کنندهی مقادیر» مطلب یاد شدهاست. به همین جهت فایل جدید src\app\core\services\app.config.ts را در پوشهی core\services برنامه ایجاد میکنیم:
import { InjectionToken } from "@angular/core"; export let APP_CONFIG = new InjectionToken<string>("app.config"); export interface IAppConfig { apiEndpoint: string; loginPath: string; logoutPath: string; refreshTokenPath: string; accessTokenObjectKey: string; refreshTokenObjectKey: string; } export const AppConfig: IAppConfig = { apiEndpoint: "http://localhost:5000/api", loginPath: "account/login", logoutPath: "account/logout", refreshTokenPath: "account/RefreshToken", accessTokenObjectKey: "access_token", refreshTokenObjectKey: "refresh_token" };
سپس تنظیمات ابتدایی تزریق وابستگیهای IAppConfig را در فایل src\app\core\core.module.ts به صورت ذیل انجام میدهیم:
import { AppConfig, APP_CONFIG } from "./app.config"; @NgModule({ providers: [ { provide: APP_CONFIG, useValue: AppConfig } ] }) export class CoreModule {}
طراحی سرویس Auth
پس از لاگین باید بتوان به اطلاعات اطلاعات کاربر وارد شدهی به سیستم، در تمام قسمتهای برنامه دسترسی پیدا کرد. به همین جهت نیاز است این اطلاعات را در یک سرویس سراسری singleton قرار داد تا همواره یک وهلهی از آن در کل برنامه قابل استفاده باشد. مرسوم است این سرویس را AuthService بنامند. بنابراین محل قرارگیری این سرویس سراسری در پوشهی Core\services و محل تعریف آن در قسمت providers آن خواهد بود. به همین جهت ابتدا ساختار این سرویس را با دستور ذیل ایجاد میکنیم:
ng g s Core/services/Auth
create src/app/Core/services/auth.service.ts (110 bytes)
import { AuthService } from "./services/auth.service"; @NgModule({ providers: [ // global singleton services of the whole app will be listed here. BrowserStorageService, AuthService, { provide: APP_CONFIG, useValue: AppConfig } ] }) export class CoreModule {}
در ادامه به تکمیل AuthService خواهیم پرداخت و قسمتهای مختلف آنرا مرور میکنیم.
اطلاع رسانی به کامپوننت Header در مورد وضعیت لاگین
در مطلب «صدور رخدادها از سرویسها به کامپوننتها در برنامههای Angular» با نحوهی کار با BehaviorSubject آشنا شدیم. در اینجا میخواهیم توسط آن، پس از لاگین موفق، وضعیت لاگین را به کامپوننت هدر صادر کنیم، تا لینک لاگین را مخفی کرده و لینک خروج از سیستم را نمایش دهد:
import { BehaviorSubject } from "rxjs/BehaviorSubject"; @Injectable() export class AuthService { private authStatusSource = new BehaviorSubject<boolean>(false); authStatus$ = this.authStatusSource.asObservable(); constructor() { this.updateStatusOnPageRefresh(); } private updateStatusOnPageRefresh(): void { this.authStatusSource.next(this.isLoggedIn()); }
در اینجا در سازندهی کلاس، بر اساس خروجی متد وضعیت لاگین شخص، برای اولین بار، متد next این BehaviorSubject فراخوانی میشود. علت قرار دادن این متد در سازندهی کلاس سرویس، عکس العمل نشان دادن به refresh کامل صفحه، توسط کاربر است و یا عکس العمل نشان دادن به وضعیت بهخاطر سپاری کلمهی عبور، در اولین بار مشاهدهی سایت و برنامه. در این حالت متد isLoggedIn، کش مرورگر را بررسی کرده و با واکشی توکنها و اعتبارسنجی آنها، گزارش وضعیت لاگین را ارائه میدهد. پس از آن، خروجی آن (true/false) به مشترکین اطلاع رسانی میشود.
در ادامه، متد next این BehaviorSubject را در متدهای login و logout نیز فراخوانی خواهیم کرد.
تدارک ذخیره سازی توکنها در کش مرورگر
از طرف سرور، دو نوع توکن access_token و refresh_token را دریافت میکنیم. به همین جهت یک enum را جهت مشخص سازی آنها تعریف خواهیم کرد:
export enum AuthTokenType { AccessToken, RefreshToken }
import { BrowserStorageService } from "./browser-storage.service"; export enum AuthTokenType { AccessToken, RefreshToken } @Injectable() export class AuthService { private rememberMeToken = "rememberMe_token"; constructor(private browserStorageService: BrowserStorageService) { } rememberMe(): boolean { return this.browserStorageService.getLocal(this.rememberMeToken) === true; } getRawAuthToken(tokenType: AuthTokenType): string { if (this.rememberMe()) { return this.browserStorageService.getLocal(AuthTokenType[tokenType]); } else { return this.browserStorageService.getSession(AuthTokenType[tokenType]); } } deleteAuthTokens() { if (this.rememberMe()) { this.browserStorageService.removeLocal(AuthTokenType[AuthTokenType.AccessToken]); this.browserStorageService.removeLocal(AuthTokenType[AuthTokenType.RefreshToken]); } else { this.browserStorageService.removeSession(AuthTokenType[AuthTokenType.AccessToken]); this.browserStorageService.removeSession(AuthTokenType[AuthTokenType.RefreshToken]); } this.browserStorageService.removeLocal(this.rememberMeToken); } private setLoginSession(response: any): void { this.setToken(AuthTokenType.AccessToken, response[this.appConfig.accessTokenObjectKey]); this.setToken(AuthTokenType.RefreshToken, response[this.appConfig.refreshTokenObjectKey]); } private setToken(tokenType: AuthTokenType, tokenValue: string): void { if (this.rememberMe()) { this.browserStorageService.setLocal(AuthTokenType[tokenType], tokenValue); } else { this.browserStorageService.setSession(AuthTokenType[tokenType], tokenValue); } } }
- متد rememberMe مشخص میکند که آیا وضعیت بهخاطر سپاری کلمهی عبور توسط کاربر انتخاب شدهاست یا خیر؟ این مقدار را نیز در local storage ماندگار ذخیره میکنیم تا در صورت بستن مرورگر و مراجعهی مجدد به آن، در دسترس باشد و به صورت خودکار پاک نشود.
- متد setToken، بر اساس وضعیت rememberMe، مقادیر توکنهای دریافتی از سرور را در local storage و یا session storage ذخیره میکند.
- متد getRawAuthToken بر اساس یکی از مقادیر enum ارسالی به آن، مقدار خام access_token و یا refresh_token ذخیره شده را بازگشت میدهد.
- متد deleteAuthTokens جهت حذف تمام توکنهای ذخیره شدهی توسط برنامه استفاده خواهد شد. نمونهی کاربرد آن در متد logout است.
- متد setLoginSession پس از لاگین موفق فراخوانی میشود. کار آن ذخیره سازی توکنهای دریافتی از سرور است. فرض آن نیز بر این است که خروجی json از طرف سرور، توکنها را با کلیدهایی دقیقا مساوی access_token و refresh_token بازگشت میدهد:
{"access_token":"...","refresh_token":"..."}
تکمیل متد ورود به سیستم
در صفحهی لاگین، کاربر نام کاربری، کلمهی عبور و همچنین گزینهی «بهخاطر سپاری ورود» را باید تکمیل کند. به همین جهت اینترفیسی را برای این کار به نام Credentials در محل src\app\core\models\credentials.ts ایجاد میکنیم:
export interface Credentials { username: string; password: string; rememberMe: boolean; }
@Injectable() export class AuthService { constructor( @Inject(APP_CONFIG) private appConfig: IAppConfig, private http: HttpClient, private browserStorageService: BrowserStorageService ) { this.updateStatusOnPageRefresh(); } login(credentials: Credentials): Observable<boolean> { const headers = new HttpHeaders({ "Content-Type": "application/json" }); return this.http .post(`${this.appConfig.apiEndpoint}/${this.appConfig.loginPath}`, credentials, { headers: headers }) .map((response: any) => { this.browserStorageService.setLocal(this.rememberMeToken, credentials.rememberMe); if (!response) { this.authStatusSource.next(false); return false; } this.setLoginSession(response); this.authStatusSource.next(true); return true; }) .catch((error: HttpErrorResponse) => Observable.throw(error)); } }
در اینجا نیاز است اطلاعات شیء Credentials را به مسیر http://localhost:5000/api/account/login ارسال کنیم. به همین جهت نیاز به سرویس IAppConfig تزریق شدهی در سازندهی کلاس وجود دارد تا با دسترسی به this.appConfig.apiEndpoint، مسیر تنظیم شدهی در فایل src\app\core\services\app.config.ts را دریافت کنیم.
پس از لاگین موفق:
- ابتدا وضعیت rememberMe انتخاب شدهی توسط کاربر را در local storage مرورگر جهت مراجعات آتی ذخیره میکنیم.
- سپس متد setLoginSession، توکنهای دریافتی از شیء response را بر اساس وضعیت rememberMe در local storage ماندگار و یا session storage فرار، ذخیره میکند.
- در آخر با فراخوانی متد next مربوط به authStatusSource با پارامتر true، به تمام کامپوننتهای مشترک به این سرویس اعلام میکنیم که وضعیت لاگین موفق بودهاست و اکنون میتوانید نسبت به آن عکس العمل نشان دهید.
تکمیل متد خروج از سیستم
کار خروج، با فراخوانی متد logout صورت میگیرد:
@Injectable() export class AuthService { constructor( @Inject(APP_CONFIG) private appConfig: IAppConfig, private http: HttpClient, private router: Router ) { this.updateStatusOnPageRefresh(); } logout(navigateToHome: boolean): void { this.http .get(`${this.appConfig.apiEndpoint}/${this.appConfig.logoutPath}`) .finally(() => { this.deleteAuthTokens(); this.unscheduleRefreshToken(); this.authStatusSource.next(false); if (navigateToHome) { this.router.navigate(["/"]); } }) .map(response => response || {}) .catch((error: HttpErrorResponse) => Observable.throw(error)) .subscribe(result => { console.log("logout", result); }); } }
اعتبارسنجی وضعیت لاگین و توکنهای ذخیره شدهی در مرورگر
برای اعتبارسنجی access token دریافتی از طرف سرور، نیاز به بستهی jwt-decode است. به همین جهت دستور ذیل را در خط فرمان صادر کنید تا بستهی آن به پروژه اضافه شود:
> npm install jwt-decode --save
import * as jwt_decode from "jwt-decode";
getDecodedAccessToken(): any { return jwt_decode(this.getRawAuthToken(AuthTokenType.AccessToken)); }
{ "jti": "d1272eb5-1061-45bd-9209-3ccbc6ddcf0a", "iss": "http://localhost/", "iat": 1513070340, "http://schemas.xmlsoap.org/ws/2005/05/identity/claims/nameidentifier": "1", "http://schemas.xmlsoap.org/ws/2005/05/identity/claims/name": "Vahid", "DisplayName": "وحید", "http://schemas.microsoft.com/ws/2008/06/identity/claims/serialnumber": "709b64868a1d4d108ee58369f5c3c1f3", "http://schemas.microsoft.com/ws/2008/06/identity/claims/userdata": "1", "http://schemas.microsoft.com/ws/2008/06/identity/claims/role": [ "Admin", "User" ], "nbf": 1513070340, "exp": 1513070460, "aud": "Any" }
getDisplayName(): string { return this.getDecodedAccessToken().DisplayName; }
getAccessTokenExpirationDateUtc(): Date { const decoded = this.getDecodedAccessToken(); if (decoded.exp === undefined) { return null; } const date = new Date(0); // The 0 sets the date to the epoch date.setUTCSeconds(decoded.exp); return date; }
isAccessTokenTokenExpired(): boolean { const expirationDateUtc = this.getAccessTokenExpirationDateUtc(); if (!expirationDateUtc) { return true; } return !(expirationDateUtc.valueOf() > new Date().valueOf()); }
isLoggedIn(): boolean { const accessToken = this.getRawAuthToken(AuthTokenType.AccessToken); const refreshToken = this.getRawAuthToken(AuthTokenType.RefreshToken); const hasTokens = !this.isEmptyString(accessToken) && !this.isEmptyString(refreshToken); return hasTokens && !this.isAccessTokenTokenExpired(); } private isEmptyString(value: string): boolean { return !value || 0 === value.length; }
در قسمت بعد، از این سرویس اعتبارسنجی تکمیل شده جهت ورود به سیستم و تکمیل کامپوننت header استفاده خواهیم کرد.
کدهای کامل این سری را از اینجا میتوانید دریافت کنید.
برای اجرای آن فرض بر این است که پیشتر Angular CLI را نصب کردهاید. سپس از طریق خط فرمان به ریشهی پروژهی ASPNETCore2JwtAuthentication.AngularClient وارد شده و دستور npm install را صادر کنید تا وابستگیهای آن دریافت و نصب شوند. در آخر با اجرای دستور ng serve -o، برنامه ساخته شده و در مرورگر پیش فرض سیستم نمایش داده خواهد شد (و یا همان اجرای فایل ng-serve.bat). همچنین باید به پوشهی ASPNETCore2JwtAuthentication.WebApp نیز مراجعه کرده و فایل dotnet_run.bat را اجرا کنید، تا توکن سرور برنامه نیز فعال شود.
یکی از قابلیتهای جالب NHibernate امکان تعریف فیلدها به صورت پویا هستند. به این معنا که زیرساخت طراحی یک برنامه "فرم ساز" هم اکنون در اختیار شما است! سیستمی که امکان افزودن فیلدهای سفارشی را دارا است که توسط برنامه نویس در زمان طراحی اولیه آن ایجاد نشدهاند. در ادامه نحوهی تعریف و استفاده از این قابلیت را توسط Fluent NHibernate بررسی خواهیم کرد.
در اینجا کلاسی که قرار است توانایی افزودن فیلدهای سفارشی را داشته باشد به صورت زیر تعریف میشود:
using System.Collections;
namespace TestModel
{
public class DynamicEntity
{
public virtual int Id { get; set; }
public virtual IDictionary Attributes { set; get; }
}
}
Attributes در عمل همان فیلدهای سفارشی مورد نظر خواهند بود. جهت معرفی صحیح این قابلیت نیاز است تا نگاشت آنرا از نوع dynamic component تعریف کنیم:
using FluentNHibernate.Automapping;
using FluentNHibernate.Automapping.Alterations;
namespace TestModel
{
public class DynamicEntityMapping : IAutoMappingOverride<DynamicEntity>
{
public void Override(AutoMapping<DynamicEntity> mapping)
{
mapping.Table("tblDynamicEntity");
mapping.Id(x => x.Id);
mapping.IgnoreProperty(x => x.Attributes);
mapping.DynamicComponent(x => x.Attributes,
c =>
{
c.Map(x => (string)x["field1"]);
c.Map(x => (string)x["field2"]).Length(300);
c.Map(x => (int)x["field3"]);
c.Map(x => (double)x["field4"]);
});
}
}
}
ابتدا از IgnoreProperty جهت ندید گرفتن Attributes استفاده کردیم. زیرا درغیراینصورت در حالت Auto mapping ، یک رابطه چند به یک به علت وجود IDictionary به صورت خودکار ایجاد خواهد شد که نیازی به آن نیست (یافتن این نکته نصف روز کار برد ....! چون مرتبا خطای An association from the table DynamicEntity refers to an unmapped class: System.Collections.Idictionary ظاهر میشد و مشخص نبود که مشکل از کجاست).
سپس Attributes به عنوان یک DynamicComponent معرفی شده است. در اینجا چهار فیلد سفارشی را اضافه کردهایم. به این معنا که اگر نیاز باشد تا فیلد سفارشی دیگری به سیستم اضافه شود باید یکبار Session factory ساخته شود و SchemaUpdate فراخوانی گردد و خوشبختانه با وجود Fluent NHibernate ، تمام این تغییرات در کدهای برنامه قابل انجام است (بدون نیاز به سر و کار داشتن با فایلهای XML نگاشتها و ویرایش دستی آنها). از تغییر نام جدول که برای مثال در اینجا tblDynamicEntity در نظر گرفته شده تا افزودن فیلدهای دیگر در قسمت DynamicComponent فوق.
همچنین باتوجه به اینکه این نوع تغییرات (ساخت دوبار سشن فکتوری) مواردی نیستند که قرار باشد هر ساعت انجام شوند، بنابراین سربار آنچنانی را به سیستم تحمیل نمیکنند.
drop table tblDynamicEntity
create table tblDynamicEntity (
Id INT IDENTITY NOT NULL,
field1 NVARCHAR(255) null,
field2 NVARCHAR(300) null,
field3 INT null,
field4 FLOAT null,
primary key (Id)
)
اگر با SchemaExport، اسکریپت خروجی معادل با نگاشت فوق را تهیه کنیم به جدول فوق خواهیم رسید. نوع و طول این فیلدهای سفارشی بر اساس نوعی که برای اشیاء دیکشنری مشخص میکنید، تعیین خواهند شد.
چند مثال جهت کار با این فیلدهای سفارشی یا پویا :
نحوهی افزودن رکوردهای جدید بر اساس خاصیتهای سفارشی:
//insert
object savedId = 0;
using (var session = sessionFactory.OpenSession())
{
using (var tx = session.BeginTransaction())
{
var obj = new DynamicEntity();
obj.Attributes = new Hashtable();
obj.Attributes["field1"] = "test1";
obj.Attributes["field2"] = "test2";
obj.Attributes["field3"] = 1;
obj.Attributes["field4"] = 1.1;
savedId = session.Save(obj);
tx.Commit();
}
}
با خروجی
INSERT
INTO
tblDynamicEntity
(field1, field2, field3, field4)
VALUES
(@p0, @p1, @p2, @p3);
@p0 = 'test1' [Type: String (0)], @p1 = 'test2' [Type: String (0)], @p2 = 1
[Type: Int32 (0)], @p3 = 1.1 [Type: Double (0)]
نحوهی کوئری گرفتن از این اطلاعات (فعلا پایدارترین روشی را که برای آن یافتهام استفاده از HQL میباشد ...):
//query
using (var session = sessionFactory.OpenSession())
{
using (var tx = session.BeginTransaction())
{
//using HQL
var list = session
.CreateQuery("from DynamicEntity d where d.Attributes.field1=:p0")
.SetString("p0", "test1")
.List<DynamicEntity>();
if (list != null && list.Any())
{
Console.WriteLine(list[0].Attributes["field2"]);
}
tx.Commit();
}
}
select
dynamicent0_.Id as Id1_,
dynamicent0_.field1 as field2_1_,
dynamicent0_.field2 as field3_1_,
dynamicent0_.field3 as field4_1_,
dynamicent0_.field4 as field5_1_
from
tblDynamicEntity dynamicent0_
where
dynamicent0_.field1=@p0;
@p0 = 'test1' [Type: String (0)]
استفاده از HQL هم یک مزیت مهم دارد: چون به صورت رشته قابل تعریف است، به سادگی میتوان آنرا داخل دیتابیس ذخیره کرد. برای مثال یک سیستم گزارش ساز پویا هم در این کنار طراحی کرد ....
نحوهی به روز رسانی و حذف اطلاعات بر اساس فیلدهای پویا:
//update
using (var session = sessionFactory.OpenSession())
{
using (var tx = session.BeginTransaction())
{
var entity = session.Get<DynamicEntity>(savedId);
if (entity != null)
{
entity.Attributes["field2"] = "new-val";
tx.Commit(); // Persist modification
}
}
}
//delete
using (var session = sessionFactory.OpenSession())
{
using (var tx = session.BeginTransaction())
{
var entity = session.Get<DynamicEntity>(savedId);
if (entity != null)
{
session.Delete(entity);
tx.Commit();
}
}
}
در مثالهای زیر مجموعهای از Reflectionهای ساده و کاملا کاربردی است که من با آنها روبرو شده ام.
کوتاه سازی کدهای نمایش یک View در ASP.NET MVC با Reflection
یکی از قسمتهایی که مرتبا با آن سر و کار دارید، نمایش اطلاعات است. حتی یک جدول را هم که میخواهید بسازید، باید ستونهای آن جدول را یک به یک معرفی کنید. ولی در عمل، یک Reflection ساده این کار به یک تابع چند خطی و سپس برای ترسیم هر ستون جدول از دو خط استفاده خواهید کرد ولی مزیتی که دارد این است که این تابع برای تمامی جدولها کاربردی عمومی پیدا میکند. برای نمونه دوست داشتم برای بخش مدیر، قسمت پروفایلی را ایجاد کنم و در آن اطلاعاتی چون نام، نام خانوادگی، تاریخ تولد، تاریخ ایجاد و خیلی از اطلاعات دیگر را نمایش دهم. به جای اینکه بیایم برای هر قسمت یک خط partial ایجاد کنم، با استفاده از reflection و یک حلقه، تمامی اطلاعات را به آن پارشال پاس میکنم. مزیت این روش این است که اگر بخواهم در یک جای دیگر، اطلاعات یک محصول یا یک فاکتور را هم نمایش دهم، باز هم همین تابع برایم کاربرد خواهد داشت:
تصویر زیر را که برگرفته از یک قالب Bootstrap است، ملاحظه کنید. اصلا علاقه ندارم که برای یک به یک آنها، یک سطر جدید را تعریف کنم و به View بگویم این پراپرتی را نشان بده؛ دوباره مورد بعدی هم به همین صورت و دوباره و دوباره و ... . دوست دارم یک تابع عمومی، همهی این کارها را خودکار انجام دهد.
ساختار اطلاعاتی تصویر فوق به شرح زیر است:
<div> <div> <div> <p><span>First Name </span>: Jonathan</p> </div> </div> </div>
BioRow_
@model System.Web.UI.WebControls.ListItem <div> <p><span>@Model.Text </span>: @Model.Value</p> </div>
@using System.Web.UI.WebControls @using ZekrWepApp.Filters @model ZekrModel.Admin <div> <h1>Bio Graph</h1> <div> @{ ListItemCollection collection = GetCustomProperties.Get(Model,exclude:new string[]{"Poems","Id"}); foreach (var item in collection) { Html.RenderPartial(MVC.Shared.Views._BioRow, item); } } </div> </div>
کد درون این کلاس ایستا را بررسی میکنیم؛ این کلاس دو متد دارد یکی عمومی و دیگری خصوصی است:
public class GetCustomProperties { private static PropertyInfo[] getObjectsInfos(object obj,string[] inclue,string[] exclude ) { var list = obj.GetType().GetProperties(); PropertyInfo[] outputPropertyInfos = null; if (inclue != null) { return list.Where(propertyInfo => inclue.Contains(propertyInfo.Name)).ToArray(); } if (exclude != null) { return list.Where(propertyInfo => !exclude.Contains(propertyInfo.Name)).ToArray(); } return list; } }
متد عمومی که در این کلاس قرار دارد به شرح زیر است:
public static ListItemCollection Get(object obj,string[] inclue=null,string[] exclude=null) { var propertyInfos = getObjectsInfos(obj, inclue, exclude); if (propertyInfos == null) throw new ArgumentNullException("propertyInfos is null"); var collection = new ListItemCollection(); foreach (PropertyInfo propertyInfo in propertyInfos) { string name = propertyInfo.Name; foreach (Attribute attribute in propertyInfo.GetCustomAttributes(true)) { DisplayAttribute displayAttribute = attribute as DisplayAttribute; if (displayAttribute != null) { name = displayAttribute.Name; break; } } string value = ""; object objvalue = propertyInfo.GetValue(obj); if (objvalue != null) value = objvalue.ToString(); collection.Add(new ListItem(name,value)); } return collection; }
کد بالا پراپرتیها را دریافت و یک به یک متادیتاهای آن را بررسی کرده و در صورتی که از متادیتای Display استفاده کرده باشند، مقدار آن را جایگزین نام پراپرتی خواهد کرد. در مورد مقدار هم از آنجا که اگر پراپرتی با Null پر شده باشد، تبدیل به رشتهای با پیام خطای روبرو خواهد شد. در نتیجه بهتر است یک شرط احتیاط هم روی آن پیاده شود. در آخر هم از متن و مقدار، یک آیتم ساخته و درون Collection اضافه میکنیم و بعد از اینکه همه پراپرتیها بررسی شدند، Collection را بر میگردانیم.
[Display(Name = "نام کاربری")] public string UserName { get; set; }
using System; using System.Collections.Generic; using System.ComponentModel.DataAnnotations; using System.Linq; using System.Linq.Expressions; using System.Reflection; using System.Web; using System.Web.Mvc.Html; using System.Web.UI.WebControls; using Links; namespace ZekrWepApp.Filters { public class GetCustomProperties { public static ListItemCollection Get(object obj,string[] inclue=null,string[] exclude=null) { var propertyInfos = getObjectsInfos(obj, inclue, exclude); if (propertyInfos == null) throw new ArgumentNullException("propertyInfos is null"); var collection = new ListItemCollection(); foreach (PropertyInfo propertyInfo in propertyInfos) { string name = propertyInfo.Name; foreach (Attribute attribute in propertyInfo.GetCustomAttributes(true)) { DisplayAttribute displayAttribute = attribute as DisplayAttribute; if (displayAttribute != null) { name = displayAttribute.Name; break; } } string value = ""; object objvalue = propertyInfo.GetValue(obj); if (objvalue != null) value = objvalue.ToString(); collection.Add(new ListItem(name,value)); } return collection; } private static PropertyInfo[] getObjectsInfos(object obj,string[] include,string[] exclude ) { var list = obj.GetType().GetProperties(); PropertyInfo[] outputPropertyInfos = null; if (include != null) { return list.Where(propertyInfo => include.Contains(propertyInfo.Name)).ToArray(); } if (exclude != null) { return list.Where(propertyInfo => !exclude.Contains(propertyInfo.Name)).ToArray(); } return list; } } }
لیستی از پارامترها با Reflection
مورد بعدی که سادهتر بوده و از کد بالا مختصرتر هم هست، این است که قرار بود برای یک درگاه، یک سری اطلاعات را با متد Post ارسال کنم که نحوهی ارسال اطلاعات به شکل زیر بود:
amount=1000&orderId=452&Pid=xxx&....
using System; using System.Collections.Generic; using System.Linq; namespace Utils { public class QueryStringParametersList { private string Symbol = "&"; private List<KeyValuePair<string, string>> list { get; set; } public QueryStringParametersList() { list = new List<KeyValuePair<string, string>>(); } public QueryStringParametersList(string symbol) { Symbol = symbol; list = new List<KeyValuePair<string, string>>(); } public int Size { get { return list.Count; } } public void Add(string key, string value) { list.Add(new KeyValuePair<string, string>(key, value)); } public string GetQueryStringPostfix() { return string.Join(Symbol, list.Select(p => Uri.EscapeDataString(p.Key) + "=" + Uri.EscapeDataString(p.Value))); } } }
یک متغیر به نام symbol دارد و در صورتی در شرایط متفاوت، قصد چسپاندن چیزی را به یکدیگر با علامتی خاص داشته باشید، این تابع میتواند کاربرد داشته باشد. این متد از یک لیست کلید و مقدار استفاده کرده و پارامترهایی را که به آن پاس میشود، نگهداری و سپس توسط متد GetQueryStringPostfix آنها را با یکدیگر الحاق کرده و در قالب یک رشته بر میگرداند.
کاربرد Reflection در اینجا این است که من باید دوبار به شکل زیر، دو نوع اطلاعات متفاوت را پست کنم. یکی موقع ارسال به درگاه و دیگری موقع بازگشت از درگاه.
QueryStringParametersList queryparamsList = new QueryStringParametersList(); ueryparamsList.Add("consumer_key", requestPayment.Consumer_Key); queryparamsList.Add("amount", requestPayment.Amount.ToString()); queryparamsList.Add("callback", requestPayment.Callback); queryparamsList.Add("description", requestPayment.Description); queryparamsList.Add("email", requestPayment.Email); queryparamsList.Add("mobile", requestPayment.Mobile); queryparamsList.Add("name", requestPayment.Name); queryparamsList.Add("irderid", requestPayment.OrderId.ToString());
ولی با استفاده از کد Reflection که در بالاتر عنوان شد، باید نام و مقدار پراپرتی را گرفته و در یک حلقه آنها را اضافه کنیم، بدین شکل:
private QueryStringParametersList ReadParams(object obj) { PropertyInfo[] propertyInfos = obj.GetType().GetProperties(); QueryStringParametersList queryparamsList = new QueryStringParametersList(); for (int i = 0; i < propertyInfos.Count(); i++) { queryparamsList.Add(propertyInfos[i].Name.ToLower(),propertyInfos[i].GetValue(obj).ToString() ); } return queryparamsList; }
خاصیتهای جدید VirtualizingPanel در دات نت 4.5
تمام کنترلهای مشتق شدهی از ListBox مانند ListView و امثال آن، در WPF 4.5 امکان تنظیم یک چنین خواصی را یافتهاند:
<ListBox ItemsSource="{Binding Persons}" VirtualizingPanel.IsVirtualizing="True" VirtualizingPanel.ScrollUnit="Pixel" VirtualizingPanel.IsVirtualizingWhenGrouping="True" VirtualizingPanel.CacheLength="100" VirtualizingPanel.CacheLengthUnit="Pixel"/>
در اینجا میتوان دو خاصیت CacheLength و CacheLengthUnit را مقدار دهی کرد. مقدار پیش فرض CacheLength مساوی 1.1 است. CacheLengthUnit میتواند یکی از مقادیر Pixel, Item یا Page را بپذیرد. هر Page در اینجا بر اساس اندازهی viewport یا قسمت نمایان لیست، تعریف میشود. مقدار پیش فرض آن نیز Item است.
همچنین در اینجا میتوان ScrollUnit را نیز تنظیم کرد که مقادیر Item یا Pixel را میپذیرد. حالت پیش فرض آن Item است؛ به این معنا که اگر ردیفی کاملا در viewport جای نگرفت، نمایش داده نمیشود. این مورد را با تنظیم مقدار ScrollUnit به Pixel میتوان بهبود بخشید.
IsVirtualizingWhenGrouping سبب فعال سازی مجازی سازی حتی در حالت گروه بندی اطلاعات میگردد. به صورت پیش فرض اگر گروه بندی فعال شود، دیگر مجازی سازی رخ نخواهد داد.
فعال سازی قابلیتهای دات نت 4.5 در برنامههای WPF 4
اگر برنامهی WPF 4 خود را فعلا قصد ندارید به دات نت 4.5 ارتقاء دهید، با توجه به اینکه اگر کاربر دات نت 4.5 را نصب کرده باشد، برنامهی شما به صورت خودکار همانند یک برنامهی WPF 4.5 رفتار میکند (دات نت 4.5 جایگزین دات نت 4 میشود)، میتوان با اندکی Reflection این قابلیتها را در صورت وجود، فعال کرد:
using System; using System.Reflection; using System.Windows; using System.Windows.Controls; namespace DNTProfiler.Common.Behaviors { /// <summary> /// Smooth scrolling VirtualizingStackPanels, without sacrificing virtualization. /// </summary> public static class PixelBasedScrollingBehavior { private static readonly MethodInfo _setScrollUnit = typeof(VirtualizingPanel) .GetMethod("SetScrollUnit", BindingFlags.Public | BindingFlags.Static); private static readonly MethodInfo _setCacheLengthUnit = typeof(VirtualizingPanel) .GetMethod("SetCacheLengthUnit", BindingFlags.Public | BindingFlags.Static); private static readonly MethodInfo _setCacheLength = typeof(VirtualizingPanel) .GetMethod("SetCacheLength", BindingFlags.Public | BindingFlags.Static); public static bool GetIsEnabled(DependencyObject obj) { return (bool)obj.GetValue(IsEnabledProperty); } public static void SetIsEnabled(DependencyObject obj, bool value) { obj.SetValue(IsEnabledProperty, value); } public static readonly DependencyProperty IsEnabledProperty = DependencyProperty.RegisterAttached("IsEnabled", typeof(bool), typeof(PixelBasedScrollingBehavior), new UIPropertyMetadata(false, handleIsEnabledChanged)); private static void handleIsEnabledChanged(DependencyObject obj, DependencyPropertyChangedEventArgs e) { var listView = obj as ListView; if (listView == null) { throw new InvalidOperationException("This behavior can only be attached to a ListView."); } if (_setScrollUnit != null) { // It's .NET 4.5 _setScrollUnit.Invoke(listView, new object[] { listView, /*Pixel*/ 0 }); } if (_setCacheLengthUnit != null) { // It's .NET 4.5 _setCacheLengthUnit.Invoke(listView, new object[] { listView, /*Pixel*/ 0 }); } if (_setCacheLength != null) { // It's .NET 4.5 var type = Type.GetType("System.Windows.Controls.VirtualizationCacheLength, PresentationFramework, Version=4.0.0.0, Culture=neutral, PublicKeyToken=31bf3856ad364e35"); if (type == null) return; var instance = Activator.CreateInstance(type, 100.0); _setCacheLength.Invoke(listView, new[] { listView, instance }); } } } }
برای استفاده از آن خواهیم داشت:
<ListView ItemsSource="{Binding}" behaviors:PixelBasedScrollingBehavior.IsEnabled="True">
کار با Kendo UI DataSource
[DisplayName("تاریخ درج")] public DateTime CreateDate { get; set; } [DisplayName("تاریخ انتشار")] public DateTime PublishDate { get; set; }
[DisplayName("نوع مطلب")] public string BlogType { get; set; }
<script type="text/javascript"> $(function () { var blogDataSource = new kendo.data.DataSource({ transport: { read: { url: "@Url.Action("GetLastBlogs", "Admin")", dataType: "json", contentType: 'application/json; charset=utf-8', type: 'GET' }, parameterMap: function (options) { return kendo.stringify(options); } }, schema: { data: "data", total: "total", model: { fields: { "id": { type: "number" }, //تعیین نوع فیلد برای جستجوی پویا مهم است "title": { type: "string" }, "content": { type: "string" }, "imagepath": { type: "string" }, "attachmentid": { type: "number" }, "createdate": { type: "string" }, "publishdate": { type: "date" }, "blogtype": { type: "string" }, "lastmodified": { type: "date" }, "visitcount": { type: "number" }, "isarchived": { type: "boolean" }, "ispublished": { type: "boolean" }, "isdeleted": { type: "boolean" }, "tags": { type: "string" } } } }, error: function (e) { alert(e.errorThrown); }, pageSize: 10, sort: { field: "id", dir: "desc" }, serverPaging: true, serverFiltering: true, serverSorting: true }); $("#report-grid").kendoGrid({ dataSource: blogDataSource, autoBind: true, scrollable: false, pageable: true, sortable: true, filterable: true, reorderable: true, columnMenu: true, columns: [ { field: "id", title: "شماره", width: "130px" }, { field: "title", title: "عنوان مطلب" }, { field: "createdate", title: "تاریخ درج" }, { field: "publishdate", title: "تاریخ انتشار" }, { field: "content", title: "خلاصه مطلب" }, { field: "blogtype", title: "نوع" }, { field: "lastmodified", title: "آخرین ویرایش" }, { field: "visitcount", title: "تعداد بازدید" }, { field: "isarchived", title: "آرشیو شده", template: '<input type="checkbox" #= isarchived ? checked="checked" : "" # disabled="disabled" ></input>' }, { field: "ispublished", title: "منتشر شده", template: '<input type="checkbox" #= ispublished ? checked="checked" : "" # disabled="disabled" ></input>' } ] }); }); </script>
- «پیاده سازی حذف منطقی در Entity framework» حذف منطقی، یکی از الگوهای بسیار پرکاربرد در برنامههای تجاری است. توسط آن بجای حذف فیزیکی اطلاعات، آنها را تنها به عنوان رکوردی حذف شده، «علامتگذاری» میکنیم. مزایای آن نیز به شرح زیر هستند:
- داشتن سابقهی حذف اطلاعات
- جلوگیری از cascade delete
- امکان بازیابی رکوردها و امکان ایجاد قسمتی به نام recycle bin در برنامه (شبیه به recycle bin در ویندوز که امکان بازیابی موارد حذف شده را میدهد)
- امکان داشتن رکوردهایی که در یک برنامه (به ظاهر) حذف شدهاند، اما هنوز در برنامهی دیگری در حال استفاده هستند.
- بالابردن میزان امنیت برنامه. فرض کنید سایت شما هک شده و شخصی، دسترسی به پنل مدیریتی و سطوح دسترسی مدیریتی برنامه را پیدا کردهاست. در این حالت حذف تمام رکوردهای سایت توسط او، تنها به معنای تغییر یک بیت، از یک به صفر است و بازگرداندن این درجه از خسارت، تنها با روشن کردن این بیت، برطرف میشود.
پیاده سازی حذف منطقی در EF Core شامل مراحل خاصی است که در این مطلب، جزئیات آنها را بررسی خواهیم کرد.
نیاز به تعریف دو خاصیت جدید در هر جدول
هر جدولی که قرار است soft delete به آن اعمال شود، باید دارای دو فیلد جدید bool IsDeleted و DateTime? DeletedAt باشد. میتوان این خواص را به هر موجودیتی به صورت دستی اضافه کرد و یا میتوان ابتدا یک کلاس پایهی abstract را برای آن ایجاد کرد:
using System; namespace EFCoreSoftDelete.Entities { public abstract class BaseEntity { public int Id { get; set; } public bool IsDeleted { set; get; } public DateTime? DeletedAt { set; get; } } }
using System.Collections.Generic; using Microsoft.EntityFrameworkCore; using Microsoft.EntityFrameworkCore.Metadata.Builders; namespace EFCoreSoftDelete.Entities { public class Blog : BaseEntity { public string Name { set; get; } public virtual ICollection<Post> Posts { set; get; } } public class BlogConfiguration : IEntityTypeConfiguration<Blog> { public void Configure(EntityTypeBuilder<Blog> builder) { builder.Property(blog => blog.Name).HasMaxLength(450).IsRequired(); builder.HasIndex(blog => blog.Name).IsUnique(); builder.HasData(new Blog { Id = 1, Name = "Blog 1" }); builder.HasData(new Blog { Id = 2, Name = "Blog 2" }); builder.HasData(new Blog { Id = 3, Name = "Blog 3" }); } } }
using Microsoft.EntityFrameworkCore; using Microsoft.EntityFrameworkCore.Metadata.Builders; namespace EFCoreSoftDelete.Entities { public class Post : BaseEntity { public string Title { set; get; } public Blog Blog { set; get; } public int BlogId { set; get; } } public class PostConfiguration : IEntityTypeConfiguration<Post> { public void Configure(EntityTypeBuilder<Post> builder) { builder.Property(post => post.Title).HasMaxLength(450); builder.HasOne(post => post.Blog).WithMany(blog => blog.Posts).HasForeignKey(post => post.BlogId); builder.HasData(new Post { Id = 1, BlogId = 1, Title = "Post 1" }); builder.HasData(new Post { Id = 2, BlogId = 1, Title = "Post 2" }); builder.HasData(new Post { Id = 3, BlogId = 1, Title = "Post 3" }); builder.HasData(new Post { Id = 4, BlogId = 1, Title = "Post 4" }); builder.HasData(new Post { Id = 5, BlogId = 2, Title = "Post 5" }); } } }
حذف خودکار رکوردهایی که Soft Delete شدهاند، از نتیجهی کوئریها و گزارشات
تا اینجا فقط دو خاصیت ساده را به کلاسهای مدنظر خود اضافه کردهایم. پس از آن یا میتوان در هر جائی برای مثال شرط context.Blogs.Where(blog => !blog.IsDeleted) را به صورت دستی اعمال کرد و در گزارشات، رکوردهای حذف منطقی شده را نمایش نداد و یا از زمان ارائهی EF Core 2x میتوان برای آنها Query Filter تعریف کرد. برای مثال میتوان به تنظیمات موجودیت Blog و یا Post مراجعه نمود و با استفاده از متد HasQueryFilter، همان شرط blog => !blog.IsDeleted را به صورت سراسری به تمام کوئریهای مرتبط با این موجودیتها اعمال کرد:
public class BlogConfiguration : IEntityTypeConfiguration<Blog> { public void Configure(EntityTypeBuilder<Blog> builder) { // ... builder.HasQueryFilter(blog => !blog.IsDeleted); } }
در این حالت کوئریهای نهایی به صورت خودکار دارای شرط زیر خواهند شد:
SELECT [b].[Id], [b].[DeletedAt], [b].[IsDeleted], [b].[Name] FROM [Blogs] AS [b] WHERE [b].[IsDeleted] <> CAST(1 AS bit)
اعمال خودکار QueryFilter مخصوص Soft Delete به تمام موجودیتها
همانطور که عنوان شد، مزیت علامتگذاری موجودیتها با کلاس پایهی BaseEntity، امکان کوئری گرفتن از آنها است:
namespace EFCoreSoftDelete.DataLayer { public static class GlobalFiltersManager { public static void ApplySoftDeleteQueryFilters(this ModelBuilder modelBuilder) { foreach (var entityType in modelBuilder.Model .GetEntityTypes() .Where(eType => typeof(BaseEntity).IsAssignableFrom(eType.ClrType))) { entityType.addSoftDeleteQueryFilter(); } } private static void addSoftDeleteQueryFilter(this IMutableEntityType entityData) { var methodToCall = typeof(GlobalFiltersManager) .GetMethod(nameof(getSoftDeleteFilter), BindingFlags.NonPublic | BindingFlags.Static) .MakeGenericMethod(entityData.ClrType); var filter = methodToCall.Invoke(null, new object[] { }); entityData.SetQueryFilter((LambdaExpression)filter); } private static LambdaExpression getSoftDeleteFilter<TEntity>() where TEntity : BaseEntity { return (Expression<Func<TEntity, bool>>)(entity => !entity.IsDeleted); } } }
محل اعمال آن نیز در انتهای متد OnModelCreating است تا به صورت خودکار به تمام موجودیتهای موجود اعمال شود:
namespace EFCoreSoftDelete.DataLayer { public class ApplicationDbContext : DbContext { //... protected override void OnModelCreating(ModelBuilder modelBuilder) { base.OnModelCreating(modelBuilder); modelBuilder.ApplyConfigurationsFromAssembly(typeof(BaseEntity).Assembly); modelBuilder.ApplySoftDeleteQueryFilters(); }
مشکل! هنوز هم حذف فیزیکی رخ میدهد!
تنظیمات فوق، تنها بر روی کوئریهای نوشته شده تاثیر دارند؛ اما هیچگونه تاثیری را بر روی متد Remove و سپس SaveChanges نداشته و در این حالت، هنوز هم حذف واقعی و فیزیکی رخ میدهد.
برای رفع این مشکل باید به EF Core گفت، هر چند دستور حذف صادر شده، اما آنرا تبدیل به دستور Update کن؛ یعنی فیلد IsDelete را به 1 و فیلد DeletedAt را با زمان جاری مقدار دهی کن:
namespace EFCoreSoftDelete.DataLayer { public static class AuditableEntitiesManager { public static void SetAuditableEntityOnBeforeSaveChanges(this ApplicationDbContext context) { var now = DateTime.UtcNow; foreach (var entry in context.ChangeTracker.Entries<BaseEntity>()) { switch (entry.State) { case EntityState.Added: //TODO: ... break; case EntityState.Modified: //TODO: ... break; case EntityState.Deleted: entry.State = EntityState.Unchanged; //NOTE: For soft-deletes to work with the original `Remove` method. entry.Entity.IsDeleted = true; entry.Entity.DeletedAt = now; break; } } } } }
var post1 = context.Posts.Find(1); if (post1 != null) { context.Remove(post1); context.SaveChanges(); }
Executing DbCommand [Parameters=[@p2='1', @p0='2020-09-17T05:11:32' (Nullable = true), @p1='True'], CommandType='Text', CommandTimeout='30'] SET NOCOUNT ON; UPDATE [Posts] SET [DeletedAt] = @p0, [IsDeleted] = @p1 WHERE [Id] = @p2; SELECT @@ROWCOUNT;
محل اعمال متد SetAuditableEntityOnBeforeSaveChanges فوق، پیش از فراخوانی SaveChanges و به صورت زیر است:
namespace EFCoreSoftDelete.DataLayer { public class ApplicationDbContext : DbContext { // ... public override int SaveChanges(bool acceptAllChangesOnSuccess) { ChangeTracker.DetectChanges(); beforeSaveTriggers(); ChangeTracker.AutoDetectChangesEnabled = false; // for performance reasons, to avoid calling DetectChanges() again. var result = base.SaveChanges(acceptAllChangesOnSuccess); ChangeTracker.AutoDetectChangesEnabled = true; return result; } // ... private void beforeSaveTriggers() { setAuditProperties(); } private void setAuditProperties() { this.SetAuditableEntityOnBeforeSaveChanges(); } } }
حالت پیشفرض حذف رکوردها در EFCore به cascade delete تنظیم شدهاست. یعنی اگر blog با id=1 حذف شود، نه فقط این blog، بلکه تمام مطالب وابستهی به آن نیز حذف خواهند شد. اما در اینجا اگر این بلاگ را حذف کنیم:
ar blog1 = context.Blogs.FirstOrDefault(blog => blog.Id == 1); if (blog1 != null) { context.Remove(blog1); context.SaveChanges(); }
var blog1AndItsRelatedPosts = context.Blogs .Include(blog => blog.Posts) .FirstOrDefault(blog => blog.Id == 1); if (blog1AndItsRelatedPosts != null) { context.Remove(blog1AndItsRelatedPosts); context.SaveChanges(); }
SELECT [t].[Id], [t].[DeletedAt], [t].[IsDeleted], [t].[Name], [t0].[Id], [t0].[BlogId], [t0].[DeletedAt], [t0].[IsDeleted], [t0].[Title] FROM ( SELECT TOP(1) [b].[Id], [b].[DeletedAt], [b].[IsDeleted], [b].[Name] FROM [Blogs] AS [b] WHERE ([b].[IsDeleted] <> CAST(1 AS bit)) AND ([b].[Id] = 1) ) AS [t] LEFT JOIN ( SELECT [p].[Id], [p].[BlogId], [p].[DeletedAt], [p].[IsDeleted], [p].[Title] FROM [Posts] AS [p] WHERE [p].[IsDeleted] <> CAST(1 AS bit) ) AS [t0] ON [t].[Id] = [t0].[BlogId] ORDER BY [t].[Id], [t0].[Id] Executing DbCommand [Parameters=[@p2='1', @p0='2020-09-17T05:25:00' (Nullable = true), @p1='True', @p5='2', @p3='2020-09-17T05:25:00' (Nullable = true), @p4='True', @p8='3', @p6='2020-09-17T05:25:00' (Nullable = true), @p7='True', @p11='4', @p9='2020-09-17T05:25:00' (Nullable = true), @p10='True'], CommandType='Text', CommandTimeout='30'] SET NOCOUNT ON; UPDATE [Blogs] SET [DeletedAt] = @p0, [IsDeleted] = @p1 WHERE [Id] = @p2; SELECT @@ROWCOUNT; UPDATE [Posts] SET [DeletedAt] = @p3, [IsDeleted] = @p4 WHERE [Id] = @p5; SELECT @@ROWCOUNT; UPDATE [Posts] SET [DeletedAt] = @p6, [IsDeleted] = @p7 WHERE [Id] = @p8; SELECT @@ROWCOUNT; UPDATE [Posts] SET [DeletedAt] = @p9, [IsDeleted] = @p10 WHERE [Id] = @p11; SELECT @@ROWCOUNT;
یک نکته: مشکل حذف منطقی و رکوردهای منحصربفرد
فرض کنید در جدولی، فیلد نام کاربری را به عنوان یک فیلد منحصربفرد تعریف کردهاید و اکنون رکوردی در این بین، حذف منطقی شدهاست. مشکلی که در آینده بروز خواهد کرد، عدم امکان ثبت رکورد جدیدی با همان نام کاربری است که حذف منطقی شدهاست؛ چون یک unique index بر روی آن وجود دارد. در این حالت اگر از SQL Server استفاده میکنید، از قابلیتی به نام filtered indexes پشتیبانی میکند که در آن امکان تعریف یک شرط و predicate، در حین تعریف ایندکسها وجود دارد. در این حالت میتوان رکوردهای حذف منطقی شده را به ایندکس وارد نکرد.
کدهای کامل این مطلب را از اینجا میتوانید دریافت کنید: EFCoreSoftDelete.zip
جهت نگهداری بعضی از اطلاعات در صفحات کاربر، از فیلدهای مخفی ( Hidden Inputs ) استفاده میکنیم. مشکلی که در این روش وجود دارد این است که اگر این اطلاعات مهم باشند (مانند کلیدها) کاربر میتواند توسط ابزارهایی این اطلاعات را تغییر دهد و این مورد مسئلهای خطرناک میباشد.
راه حل رفع این مسئلهی امنیتی، استفاده از یک Html Helper جهت رمزنگاری این فیلد مخفی در مرورگر کاربر و رمز گشایی آن هنگام Post شدن سمت سرور میباشد.
برای رسیدن به این هدف یک Controller Factory ( Understanding and Extending Controller Factory in MVC ) سفارشی را جهت دستیابی به مقادیر فرم ارسالی، قبل از استفاده در Actionها و به همراه کلاسهای زیر ایجاد کردیم.
کلاس EncryptSettingsProvider :public interface IEncryptSettingsProvider { byte[] EncryptionKey { get; } string EncryptionPrefix { get; } } public class EncryptSettingsProvider : IEncryptSettingsProvider { private readonly string _encryptionPrefix; private readonly byte[] _encryptionKey; public EncryptSettingsProvider() { //read settings from configuration var useHashingString = ConfigurationManager.AppSettings["UseHashingForEncryption"]; var useHashing = System.String.Compare(useHashingString, "false", System.StringComparison.OrdinalIgnoreCase) != 0; _encryptionPrefix = ConfigurationManager.AppSettings["EncryptionPrefix"]; if (string.IsNullOrWhiteSpace(_encryptionPrefix)) { _encryptionPrefix = "encryptedHidden_"; } var key = ConfigurationManager.AppSettings["EncryptionKey"]; if (useHashing) { var hash = new SHA256Managed(); _encryptionKey = hash.ComputeHash(Encoding.UTF8.GetBytes(key)); hash.Clear(); hash.Dispose(); } else { _encryptionKey = Encoding.UTF8.GetBytes(key); } } #region ISettingsProvider Members public byte[] EncryptionKey { get { return _encryptionKey; } } public string EncryptionPrefix { get { return _encryptionPrefix; } } #endregion }
EncryptionKey : کلید رمز نگاری میباشد و در فایل Config برنامه ذخیره میباشد.
EncryptionPrefix : پیشوند نام Hidden فیلدها میباشد، این پیشوند برای یافتن Hidden فیلد هایی که رمزنگاری شده اند استفاده میشود. میتوان این فیلد را در فایل Config برنامه ذخیره کرد.
<appSettings> <add key="EncryptionKey" value="asdjahsdkhaksj dkashdkhak sdhkahsdkha kjsdhkasd"/> </appSettings>
کلاس RijndaelStringEncrypter :
public interface IRijndaelStringEncrypter : IDisposable { string Encrypt(string value); string Decrypt(string value); } public class RijndaelStringEncrypter : IRijndaelStringEncrypter { private RijndaelManaged _encryptionProvider; private ICryptoTransform _cryptoTransform; private readonly byte[] _key; private readonly byte[] _iv; public RijndaelStringEncrypter(IEncryptSettingsProvider settings, string key) { _encryptionProvider = new RijndaelManaged(); var keyBytes = Encoding.UTF8.GetBytes(key); var derivedbytes = new Rfc2898DeriveBytes(settings.EncryptionKey, keyBytes, 3); _key = derivedbytes.GetBytes(_encryptionProvider.KeySize / 8); _iv = derivedbytes.GetBytes(_encryptionProvider.BlockSize / 8); } #region IEncryptString Members public string Encrypt(string value) { var valueBytes = Encoding.UTF8.GetBytes(value); if (_cryptoTransform == null) { _cryptoTransform = _encryptionProvider.CreateEncryptor(_key, _iv); } var encryptedBytes = _cryptoTransform.TransformFinalBlock(valueBytes, 0, valueBytes.Length); var encrypted = Convert.ToBase64String(encryptedBytes); return encrypted; } public string Decrypt(string value) { var valueBytes = Convert.FromBase64String(value); if (_cryptoTransform == null) { _cryptoTransform = _encryptionProvider.CreateDecryptor(_key, _iv); } var decryptedBytes = _cryptoTransform.TransformFinalBlock(valueBytes, 0, valueBytes.Length); var decrypted = Encoding.UTF8.GetString(decryptedBytes); return decrypted; } #endregion #region IDisposable Members public void Dispose() { if (_cryptoTransform != null) { _cryptoTransform.Dispose(); _cryptoTransform = null; } if (_encryptionProvider != null) { _encryptionProvider.Clear(); _encryptionProvider.Dispose(); _encryptionProvider = null; } } #endregion }
Rijndael :Represents the base class from which all implementations of the Rijndael symmetric encryption algorithm must inherit
متغیر key در سازنده کلاس کلیدی جهت رمزنگاری و رمزگشایی میباشد. این کلید میتواند AntiForgeryToken تولیدی در View ها و یا کلیدی باشد که در سیستم خودمان ذخیره سازی میکنیم.
در این پروژه از کلید سیستم خودمان استفاده میکنیم.
کلاس ActionKey :
public class ActionKey { public string Area { get; set; } public string Controller { get; set; } public string Action { get; set; } public string ActionKeyValue { get; set; } }
در اینجا هر View که بخواهد از این فیلد رمزنگاری شده استفاده کند بایستی دارای کلیدی در سیستم باشد.مدل متناظر مورد استفاده را مشاهده مینمایید. در این مدل، ActionKeyValue کلیدی جهت رمزنگاری این فیلد مخفی میباشد.
کلاس ActionKeyService :
/// <summary> /// پیدا کردن کلید متناظر هر ویو.ایجاد کلید جدید در صورت عدم وجود کلید در سیستم /// </summary> /// <param name="action"></param> /// <param name="controller"></param> /// <param name="area"></param> /// <returns></returns> string GetActionKey(string action, string controller, string area = ""); } public class ActionKeyService : IActionKeyService { private static readonly IList<ActionKey> ActionKeys; static ActionKeyService() { ActionKeys = new List<ActionKey> { new ActionKey { Area = "", Controller = "Product", Action = "dit", ActionKeyValue = "E702E4C2-A3B9-446A-912F-8DAC6B0444BC", } }; } /// <summary> /// پیدا کردن کلید متناظر هر ویو.ایجاد کلید جدید در صورت عدم وجود کلید در سیستم /// </summary> /// <param name="action"></param> /// <param name="controller"></param> /// <param name="area"></param> /// <returns></returns> public string GetActionKey(string action, string controller, string area = "") { area = area ?? ""; var actionKey= ActionKeys.FirstOrDefault(a => a.Action.ToLower() == action.ToLower() && a.Controller.ToLower() == controller.ToLower() && a.Area.ToLower() == area.ToLower()); return actionKey != null ? actionKey.ActionKeyValue : AddActionKey(action, controller, area); } /// <summary> /// اضافه کردن کلید جدید به سیستم /// </summary> /// <param name="action"></param> /// <param name="controller"></param> /// <param name="area"></param> /// <returns></returns> private string AddActionKey(string action, string controller, string area = "") { var actionKey = new ActionKey { Action = action, Controller = controller, Area = area, ActionKeyValue = Guid.NewGuid().ToString() }; ActionKeys.Add(actionKey); return actionKey.ActionKeyValue; } }
جهت بازیابی کلید هر View میباشد. در متد GetActionKey ابتدا بدنبال کلید View درخواستی در منبعی از ActionKeyها میگردیم. اگر این کلید یافت نشد کلیدی برای آن ایجاد میکنیم و نیازی به مقدار دهی آن نمیباشد.
کلاس MvcHtmlHelperExtentions :
public static class MvcHtmlHelperExtentions { public static string GetActionKey(this System.Web.Routing.RequestContext requestContext) { IActionKeyService actionKeyService = new ActionKeyService(); var action = requestContext.RouteData.Values["Action"].ToString(); var controller = requestContext.RouteData.Values["Controller"].ToString(); var area = requestContext.RouteData.Values["Area"]; var actionKeyValue = actionKeyService.GetActionKey( action, controller, area != null ? area.ToString() : null); return actionKeyValue; } public static string GetActionKey(this HtmlHelper helper) { IActionKeyService actionKeyService = new ActionKeyService(); var action = helper.ViewContext.RouteData.Values["Action"].ToString(); var controller = helper.ViewContext.RouteData.Values["Controller"].ToString(); var area = helper.ViewContext.RouteData.Values["Area"]; var actionKeyValue = actionKeyService.GetActionKey( action, controller, area != null ? area.ToString() : null); return actionKeyValue; } }
public static string GetActionKey(this System.Web.Routing.RequestContext requestContext)
public static string GetActionKey(this HtmlHelper helper)
کلاس InputExtensions :
public static class InputExtensions { public static MvcHtmlString EncryptedHidden(this HtmlHelper helper, string name, object value) { if (value == null) { value = string.Empty; } var strValue = value.ToString(); IEncryptSettingsProvider settings = new EncryptSettingsProvider(); var encrypter = new RijndaelStringEncrypter(settings, helper.GetActionKey()); var encryptedValue = encrypter.Encrypt(strValue); encrypter.Dispose(); var encodedValue = helper.Encode(encryptedValue); var newName = string.Concat(settings.EncryptionPrefix, name); return helper.Hidden(newName, encodedValue); } public static MvcHtmlString EncryptedHiddenFor<TModel, TProperty>(this HtmlHelper<TModel> htmlHelper, Expression<Func<TModel, TProperty>> expression) { var name = ExpressionHelper.GetExpressionText(expression); var metadata = ModelMetadata.FromLambdaExpression(expression, htmlHelper.ViewData); return EncryptedHidden(htmlHelper, name, metadata.Model); } }
@Html.EncryptedHiddenFor(model => model.Id) @Html.EncryptedHidden("Id2","2")
public class DecryptingControllerFactory : DefaultControllerFactory { private readonly IEncryptSettingsProvider _settings; public DecryptingControllerFactory() { _settings = new EncryptSettingsProvider(); } public override IController CreateController(System.Web.Routing.RequestContext requestContext, string controllerName) { var parameters = requestContext.HttpContext.Request.Params; var encryptedParamKeys = parameters.AllKeys.Where(x => x.StartsWith(_settings.EncryptionPrefix)).ToList(); IRijndaelStringEncrypter decrypter = null; foreach (var key in encryptedParamKeys) { if (decrypter == null) { decrypter = GetDecrypter(requestContext); } var oldKey = key.Replace(_settings.EncryptionPrefix, string.Empty); var oldValue = decrypter.Decrypt(parameters[key]); if (requestContext.RouteData.Values[oldKey] != null) { if (requestContext.RouteData.Values[oldKey].ToString() != oldValue) throw new ApplicationException("Form values is modified!"); } requestContext.RouteData.Values[oldKey] = oldValue; } if (decrypter != null) { decrypter.Dispose(); } return base.CreateController(requestContext, controllerName); } private IRijndaelStringEncrypter GetDecrypter(System.Web.Routing.RequestContext requestContext) { var decrypter = new RijndaelStringEncrypter(_settings, requestContext.GetActionKey()); return decrypter; } }
این قسمت از کد
if (requestContext.RouteData.Values[oldKey] != null) { if (requestContext.RouteData.Values[oldKey].ToString() != oldValue) throw new ApplicationException("Form values is modified!"); }
همچنین بایستی این Controller Factory را در Application_Start فایل global.asax.cs برنامه اضافه نماییم.
protected void Application_Start() { .... ControllerBuilder.Current.SetControllerFactory(typeof(DecryptingControllerFactory)); }
کدهای پروژهی جاری
TestHiddenEncrypt.7z
*در تکمیل این مقاله میتوان SessionId کاربر یا AntyForgeryToken تولیدی در View را نیز در کلید دخالت داد و در هربار Post شدن اطلاعات این ActionKeyValue مربوط به کاربر جاری را تغییر داد و کلیدها را در بانکهای اطلاعاتی ذخیره نمود.
مراجع:
Automatic Encryption of Secure Form Field Data
Encrypted Hidden Redux : Let's Get Salty