امکان مفهوم بخشیدن به رشتهها در NET 7.
حال مواقعی پیش میآید که نیاز داریم پیغام هایی با گرافیک و عبارات متفاوت نمایش دهیم . برای رفع این نیاز میتوانیم از پلاگین jQuery Impromptu استفاده کنیم . البته این پلاگین قابلیتهای دیگری هم دارد که در این مقاله با آنها آشنا میشویم .
از ویژگیهای این پلاگین میتوان حجم کم (حدود 11 کیلوبایت) و قدرت شخصی سازی بالا اشاره کرد .
طرز استفاده به این شکل است :
$.prompt( msg , options )
msg میتواند یک string یا یک شئ از states باشد . string ارسال شده میتواند شامل کدهای html باشد .
یک شئ states هم شامل مجموعه ای از وضعیتهای prompt است . برای مثال میتوان یک prompt ایجاد کرد که مثل یک Wizard شامل چند مرحله باشد .
options هم تنظیماتی است که میتوان مشخص کرد . تنظیماتی مثل : prefix,classes,persistent,timeout,...
msg :
گفتیم شئ states شامل وضعیتهای مختلف prompt است . هر وضعیت ( state ) میتواند شامل بخشهای زیر باشد :
- html : مقدار Html وضعیت
- buttons : یک شئ شامل متن و مقدار دکمه هایی که کاربر میتواند کلیک کند
- focus : ایندکس دکمهی focus شده در وضعیت
- submit : تابعی که زمانی که یکی از دکمههای وضعیت انتخاب شود فراخوانی میشود .
اگر در این تابع false بازگشت داده شود یا متد preventDefault از event فراخوانی شود ، prompt باز میماند . ( روشی برای جلوگیری از بسته شدن prompt هنگام تغییر state یا اعتبار سنجی فرم )
همچنین شئ event شامل state ( المنت state ) و stateName ( نام state ) میباشد .
پیشفرض :
function(event, value, message, formVals){}
- position : مشخص کنندهی موقعیت state که شامل موارد زیر است :
position: { container: '#container', x: 0, y: 0, width: 0, arrow: 'lm' }
container : selector المنتی است که state باید در آن مکان قرار بگیرد .
x/y : موقعیت نسبی prompt نسبت به container
arrow : جهت نمایش فلش prompt است که میتواند یکی از این مقادیر باشد : tl, tc, tr, rt, tm, tb, br, bc, bl, lb, lm, lt.
var tourSubmitFunc = function (e, v, m, f) { if (v === -1) { $.prompt.prevState(); return false; } else if (v === 1) { $.prompt.nextState(); return false; } }; var states = { state0: { html: "State1", buttons: { Next: 1 }, //position: { container: '#container', x: 10, y: 0, width: 350, arrow: 'lm' }, submit: tourSubmitFunc }, state1: { html: "State2", buttons: { Prev: -1, Next: 1 }, submit: tourSubmitFunc }, state2: { html: "State3", buttons: { Prev: -1, Done: 0 }, submit: tourSubmitFunc } }; $.prompt(states);
تا به اینجا با پارامتر اول prompt آشنا شدیم و فهمیدیم که میتوانیم یک رشته یا یک شئ states به عنوان message به prompt ارسال کنیم .
options :
اکنون با optionهای prompt ( پارامتر دوم ) آشنا خواهیم شد .
توجه کنید که زمانی که یک رشته به prompt ارسال کنید ، مقادیر buttons,focus,submit از این تنظیمات دریافت میشود .
به عبارت دیگر ، زمانی که یک شئ states به prompt ارسال کنید ، از مقادیر فوق که در تنظیمات است ، استفاده نمیشود .
- loaded
یک تابع که زمانی که prompt کامل بارگزاری شده فراخوانی میشود .
$.prompt("Message", { loaded: function() { alert("Prompt Loaded !"); } });
- submit
یک تابع که زمانی که یکی از دکمههای state کلیک شود ، فراخوانی میشود .
( زمانی اتفاق میوفتد که یک رشته به عنوان متن به prompt ارسال کرده باشید و زمانی که یک شئ از states ارسال میکنید ، هنگام کلیک دکمههای آنها ، این تابع فراخوانی نمیشود . )
پیشفرض :
function(event){}
- statechanging
یک تابع که زمانی که یک state در حال تعویض شدن هست فراخوانی میشود .
پیشفرض :
function(event, fromStateName, toStateName){}
- statechanged
یک تابع که زمانی که یک state در حال تعویض شدن هست فراخوانی میشود .
پیشفرض :
function(event, toStateName){}
- callback
یک تابع که زمانی که ( یکی از دکمههای prompt کلیک شود و ) prompt بسته شود ، فراخوانی میشود .
پیشفرض :
function(event[, value, message, formVals]){}
- buttons
یک شئ شامل مجموعه ای از دکمهها .
پیشفرض :
{ Ok : true }
[ {title: 'Hello World',value:true}, {title: 'Good Bye',value:false} ]
- prefix
یک پیشوند برای همه css classها و idهای المنتهای html که توسط prompt ایجاد میشود .
پیشفرض : jqi
- classes
یک css class که به بالاترین سطح prompt داده میشود .
در حالت پیشفرض مقداری ندارد .
- focus
ایندکس دکمهی focus شده
پیشفرض : 0
- zIndex
zIndex اعمال شده بروی prompt .
پیشفرض : 999
- useiframe
استفاده از یک iframe برای overlay در IE6
پیشفرض : false
- top
فاصلهی prompt از بالای صفحه
پیشفرض : 15%
- opacity
میزان شفافیت لایهی ای که صفحه را پوشانده است .
پیشفرض : 0.6
- overlayspeed
سرعت نمایش افکت fadeIn , fadeOut لایهی پوشاننده .
مقادیر قابل قبول : "slow", "fast", number(milliseconds)
پیشفرض : "slow"
- promptspeed
سرعت نمایش prompt .
مقادیر قابل قبول : "slow", "fast", number(milliseconds)
پیشفرض : "fast"
- show
نام یک متد jQuery برای animate کردن نمایش prompt .
مقادیر قابل قبول : "show","fadeIn","slideDown", ...
پیشفرض : "promptDropIn"
- persistent
بسته شدن prompt زمانی که بروی لایهی fade کلیک شود .
false : بسته شدن
پیشفرض : true
- timeout
مدت زمانی که پس از آن ، prompt بصورت خودکار بسته میشود . ( milliseconds )
پیشفرض : 0
returns :
مقدار بازگشتی متد prompt ، یک شئ jQuery ، شامل همهی محتویات تولید شده توسط prompt است .
Events using Bind :
- promptloaded
معادل loaded در optionها .
- promptsubmit
هنگام submit شدن ( کلیک شدن یکی از دکمههای ) state فعال میشود .
- promptclose
معادل callback در optionها .
- promptstatechanging
معادل statechanging در optionها .
- promptstatechanged
معادل statechanged در optionها .
ظرز بایند کردن یک event به شئ prompt :
var myPrompt = $.prompt( msg , options ); myPrompt.bind('promptloaded', function(e){});
- jQuery.prompt.setDefaults(options)
تعیین مقادیر پیشفرض برای همهی promptها .
jQuery.prompt.setDefaults({ prefix: 'myPrompt', show: 'slideDown' });
- jQuery.prompt.setStateDefaults(options)
تعیین مقادیر پیشفرض برای stateها .
jQuery.prompt.setStateDefaults({ buttons: { Ok:true, Cancel:false }, focus: 1 });
- jQuery.prompt.getCurrentState()
یک شئ jQuery از state جاری برمیگرداند .
- jQuery.prompt.getCurrentStateName()
نام state جاری را برمیگرداند .
- jQuery.prompt.getStateContent(stateName)
یک شئ jQuery از state مشخص شده برمیگرداند .
- jQuery.prompt.goToState(stateName, callback)
state مشخص شده را در prompt نمایش میدهد .
callback تابعی است که بعد از تغییر state فراخوانی میشود .
- jQuery.prompt.nextState(callback)
prompt را به state بعدی منتقل میکند .
- jQuery.prompt.prevState(callback)
prompt را به state قبلی منتقل میکند .
- jQuery.prompt.close()
prompt را میبندد .
حال که با این پلاگین آشنا شدیم ، سه دستور جاوا اسکریپتی که در ابتدای مقاله ذکر کردیم را با این پلاگین پیاده سازی میکنیم .
- alert
$.prompt("یک پیام تستی", { prefix: 'dnt', buttons: { 'تایید': true } });
نتیجه :
- confirm
$.prompt("درخواست تایید - موافقید ؟", { prefix: 'dnt', buttons: { 'تایید': true, 'انصراف': false } });
نتیجه :
- prompt
یک فرم html مخفی برای نمایش در prompt :
<div class="prompt-content" style="display: none;"> <span>نام خود را وارد نمایید : </span> <span> <input type="text" name="name" /> </span> </div>
نمایش prompt :
$.prompt( $(".prompt-content").html(), { prefix: 'dnt', buttons: { 'تایید': true, 'انصراف': false } });
نتیجه :
در این سه مثال آخر ، از یک css متفاوت استفاده کردیم . این پلاگین یک سری کلاس دارد که نام این کلاسها از ترکیب مقدار prefix که در option مشخص کردیم حاصل میشود .
div.dnt .dntmessage { color: #444444; line-height: 20px; padding: 10px; /* Edited */ direction:rtl; text-align:right; }
البته راه بهتری هم هست که نیاز به آشنایی با فایرباگ دارد . در این روش ابتدا کل قالب jqi ( موجود در قالب پیشفرض ) را در برنامه خود کپی میکنیم ، مقادیر jqi را با نام قالب جایگزین میکنیم ، مقدار prefix را در prompt برابر با نام قالب قرار میهیم .
اکنون در FireFox یک prompt ایجاد میکنیم و توسط فایرباگ استایل هایی که با نام قالب بروی prompt اعمال شدهاند را مطابق سلیقه تغییر میدهیم . در مرحله آخر به تب CSS در فایرباگ میرویم و کل استایلهای مربوط به قالب را کپی و جایگزین استایل قبلی در برنامه میکنیم .
/* Start : DotNetTips Theme */ .dntfade { background-color: #AAAAAA; position: absolute; } div.dnt { background-color: #FFFFFF; border-radius: 10px 10px 10px 10px; border: 1px solid #FFFFFF; box-shadow: 0px 0px 10px 1px #6D6D6C; font-family: tahoma; font-size: 11px; padding: 7px; position: absolute; text-align: left; width: 400px; } div.dnt .dntcontainer { font-size: small; } div.dnt .dntclose { color: #BBBBBB; cursor: pointer; font-weight: bold; position: absolute; top: 4px; width: 18px; } div.dnt .dntmessage { color: #444444; line-height: 20px; padding: 10px; } div.dnt .dntbuttons { background-color: #F4F4F4; border: 1px solid #EEEEEE; padding: 5px 0px; text-align: right; } div.dnt button { background-color: #2F6073; border: 1px solid #F4F4F4; color: #FFFFFF; font-size: 12px; font-weight: bold; margin: 0px 10px; padding: 3px 10px; } div.dnt button:hover { background-color: #728A8C; } div.dnt button.dntdefaultbutton { background-color: #0099CC; } .dnt_state { direction: rtl; text-align: right; } .dnt_state button { font-family: tahoma; } .dntwarning .dnt .dntbuttons { background-color: #CCDDFF; } .dnt .dntarrow { border: 10px solid transparent; font-size: 0px; height: 0px; line-height: 0; position: absolute; width: 0px; } .dnt .dntarrowtl { border-bottom-color: #FFFFFF; left: 10px; top: -20px; } .dnt .dntarrowtc { border-bottom-color: #FFFFFF; left: 50%; margin-left: -10px; top: -20px; } .dnt .dntarrowtr { border-bottom-color: #FFFFFF; right: 10px; top: -20px; } .dnt .dntarrowbl { border-top-color: #FFFFFF; bottom: -20px; left: 10px; } .dnt .dntarrowbc { border-top-color: #FFFFFF; bottom: -20px; left: 50%; margin-left: -10px; } .dnt .dntarrowbr { border-top-color: #FFFFFF; bottom: -20px; right: 10px; } .dnt .dntarrowlt { border-right-color: #FFFFFF; left: -20px; top: 10px; } .dnt .dntarrowlm { border-right-color: #FFFFFF; left: -20px; margin-top: -10px; top: 50%; } .dnt .dntarrowlb { border-right-color: #FFFFFF; bottom: 10px; left: -20px; } .dnt .dntarrowrt { border-left-color: #FFFFFF; right: -20px; top: 10px; } .dnt .dntarrowrm { border-left-color: #FFFFFF; margin-top: -10px; right: -20px; top: 50%; } .dnt .dntarrowrb { border-left-color: #FFFFFF; bottom: 10px; right: -20px; } /* End : DotNetTips Theme */
در قسمت قبل، در حین بررسی رفتار جزیرههای تعاملی Blazor Server، نکتهی زیر را هم دربارهی راهبری صفحات SSR مرور کردیم:
« اگر دقت کنید، جابجایی بین صفحات، با استفاده از fetch انجام شده؛ یعنی با اینکه این صفحات در اصل static HTML خالص هستند، اما ... کار full reload صفحه مانند ASP.NET Web forms قدیمی انجام نمیشود (و یا حتی برنامههای MVC و Razor pages) و نمایش صفحات، Ajax ای است و با fetch استاندارد آن صورت میگیرد تا هنوز هم حس و حال SPA بودن برنامه حفظ شود. همچنین اطلاعات DOM کل صفحه را هم بهروز رسانی نمیکند؛ فقط موارد تغییر یافته در اینجا به روز رسانی خواهند شد.»
در این قسمت، نکات تکمیلی این قابلیت را که به آن enhanced navigation هم گفته میشود، بررسی میکنیم.
روش غیرفعال کردن راهبری بهبودیافته برای بعضی از لینکها
ویژگی راهبری بهبودیافته فقط در حین هدایت بین صفحات مختلف یک برنامهی Blazor 8x SSR، فعال است. اگر در این بین، کاربری به یک صفحهی غیر بلیزری هدایت شود، راهبری بهبود یافته شکست خورده و سعی میکند حالت full document load را پیاده سازی و اجرا کند. مشکل اینجاست که در این حالت دو درخواست ارسال میشود: ابتدا حالت راهبری بهبودیافته فعال میشود و در ادامه پس از شکست این راهبری، هدایت مستقیم صورت میگیرد. برای رفع این مشکل میتوان ویژگی جدید data-enhance-nav را با مقدار false، به لینکهای خارجی مدنظر اضافه کرد تا برای این حالتها دیگر ویژگی راهبری بهبودیافته فعال نشود:
<a href="/not-blazor" data-enhance-nav="false">A non-Blazor page</a>
فعالسازی مدیریت بهبودیافتهی فرمهای SSR
در قسمت چهارم این سری با فرمهای جدید SSR مخصوص Blazor 8x آشنا شدیم. این فرمها هم میتوانند از امکانات راهبری بهبود یافته استفاده کنند (یعنی مدیریت ارسال آن، توسط fetch API انجام شده و به روز رسانی قسمتهای تغییریافتهی صفحه را Ajax ای انجام دهند)؛ برای نمونه اینبار همانند تصویر زیر، از fetch استاندارد برای ارسال اطلاعات به سمت سرور کمک گرفته میشود (یعنی عملیات Ajax ای شده؛ بجای یک post-back معمولی):
اما ... این قابلیت به صورت پیشفرض در فرمهای تعاملی SSR غیرفعال است. چون همانطور که عنوان شد، اگر مقصد این فرم، یک آدرس غیربلیزری باشد، دوبار ارسال فرم صورت خواهد گرفت؛ یکبار با استفاده از fetch API و بار دیگر پس از شکست، به صورت معمولی. اما اگر مطمئن هستید که endpoint این فرم، قطعا یک کامپوننت بلیزری است، بهتر است این قابلیت را در یک چنین فرمهایی نیز به صورت زیر فعال کنید:
<form method="post" @onsubmit="() => submitted = true" @formname="name" data-enhance> <AntiforgeryToken /> <InputText @bind-Value="Name" /> <button>Submit</button> </form> @if (submitted) { <p>Hello @Name!</p> } @code { bool submitted; [SupplyParameterFromForm] public string Name { get; set; } = ""; }
<EditForm method="post" Model="NewCustomer" OnValidSubmit="() => submitted = true" FormName="customer" Enhance> <DataAnnotationsValidator /> <ValidationSummary/> <p> <label> Name: <InputText @bind-Value="NewCustomer.Name" /> </label> </p> <button>Submit</button> </EditForm> @if (submitted) { <p id="pass">Hello @NewCustomer.Name!</p> } @code { bool submitted = false; [SupplyParameterFromForm] public Customer? NewCustomer { get; set; } protected override void OnInitialized() { NewCustomer ??= new(); } public class Customer { [StringLength(3, ErrorMessage = "Name is too long")] public string? Name { get; set; } } }
نکتهی مهم: در این حالت فرض بر این است که هیچگونه هدایتی به یک Non-Blazor endpoint صورت نمیگیرید؛ وگرنه با یک خطا مواجه خواهید شد.
غیرفعال کردن راهبری بهبودیافته برای قسمتی از صفحه
اگر با استفاده از جاواسکریپت و خارج از کدهای بیلزر، اطلاعات DOM را بهروز رسانی میکنید، ویژگی راهبری بهبودیافته، از آن آگاهی نداشته و به صورت خودکار تمام تغییرات شما را بازنویسی میکند. به همین جهت اگر نیاز است قسمتی از صفحه را که مستقیما توسط کدهای جاواسکریپتی تغییر میدهید، از بهروز رسانیهای این قابلیت مصون نگهدارید، میتوانید ویژگی جدید data-permanent را به آن قسمت اضافه کنید:
<div data-permanent> Leave me alone! I've been modified dynamically. </div>
امکان آگاه شدن از بروز راهبری بهبودیافته در کدهای جاواسکریپتی
اگر به هردلیلی در کدهای جاواسکریپتی خودنیاز به آگاه شدن از وقوع یک هدایت بهبودیافته را دارید (برای مثال جهت بازنویسی تغییرات ایجاد شدهی توسط آن)، میتوانید به نحو زیر، مشترک رخدادهای آن شوید:
<script> Blazor.addEventListener('enhancedload', () => { console.log('enhanced load event occurred'); }); </script>
ویژگی جدید Named Element Routing در Blazor 8x
Blazor 8x از ویژگی مسیریابی سمت کلاینت به کمک تعریف URL fragments پشتیبانی میکند. به این صورت رسیدن (اسکرول) به یک قسمت از صفحهای طولانی، بسیار ساده میشود.
برای مثال المان h2 با id مساوی targetElement را درنظر بگیرید:
<div class="border border-info rounded bg-info" style="height:500px"></div> <h2 id="targetElement">Target H2 heading</h2> <p>Content!</p>
<a href="/counter#targetElement"> <NavLink href="/counter#targetElement"> Navigation.NavigateTo("/counter#targetElement");
معرفی متد جدید Refresh در Blazor 8x
در Blazor 8x، امکان بارگذاری مجدد صفحه با فراخوانی متد جدید NavigationManager.Refresh(bool forceLoad = false) میسر شدهاست. این متد در حالت پیشفرض از قابلیت راهبری بهبودیافته برای به روز رسانی صفحه استفاده میکند؛ مگر اینکه اینکار میسر نباشد. اگر آنرا با پارامتر true فراخوانی کنید، full-page reload رخ خواهد داد.
همین اتفاق در مورد متد Navigation.NavigateTo نیز رخدادهاست. این متد نیز در Blazor 8x به صورت پیشفرض بر اساس قابلیت راهبری بهبود یافته کار میکند؛ مگر اینکه اینکار میسر نباشد و یا پارامتر forceLoad آنرا به true مقدار دهی کنید.
AngularJS #4
<div ng-app="myApp"> <div ng-controller="CommentCtrl"> <div ng-repeat="comment in comments"> <div style="float:right;cursor:pointer;" ng-click="remove(comment.Id,$index);">X</div> <a href="#"> <img style="width:32px;" ng-src="/Content/user.gif" alt="{{comment.Name}}"> </a> <div> <h4>{{comment.Name}}</h4> {{comment.CommentBody}} </div> </div> <div> <form action="/Comment/Add" method="post"> <div> <label for="Name">Name</label> <input id="Name" type="text" name="Name" ng-model="comment.Name" placeholder="Your Name" /> </div> <div> <label for="Email">Email</label> <input id="Email" type="text" name="Email" ng-model="comment.Email" placeholder="Your Email" /> </div> <div> <label for="CommentBody">Comment</label> <textarea id="CommentBody" name="CommentBody" ng-model="comment.CommentBody" placeholder="Your Comment"></textarea> </div> <button type="button" ng-click="addComment()">Send</button> </form> </div> </div> </div>
var app = angular.module('myApp', []); app.controller('CommentCtrl', function ($scope, $http) { $scope.comment = {}; $http.get('/Comment/GetAll').success(function (data) { $scope.comments = data; }) $scope.addComment = function () { $http.post("/Comment/Add", $scope.comment).success(function () { $scope.comments.push({ Name: $scope.comment.Name, CommentBody: $scope.comment.CommentBody }); $scope.comment = {}; }); }; $scope.remove = function (id, index) { $http.post("/Comment/Remove", { id: id }).success(function () { $scope.comments.splice(index, 1); }); }; });
چرا 8 مورد دیگر هم هستند:
http://www.webdesignbooth.com/9-useful-javascript-syntax-highlighting-scripts/
بعلاوه لایور رایتر مایکروسافت به همراه پلاگین copy as html برای VS.Net
البته اگر منظور سایت جاری باشد این مشکل فکر نکنم به علت این syntax HL باشد، مشکل از اتمام پهنای باند سایت گوگل پیج است (خصوصا هنگام نمایش تصاویر صفحه از آن و یک سری بررسی جاوا اسکریپتی).
import { decorate } from "mobx"; class Count { value = 0; } decorate(Count, { value: observable }); const count = new Count();
بازنویسی مثال ورود متن و نمایش آن با Mobx decorators
در اینجا یک text-box، به همراه دو div در صفحه رندر خواهند شد که قرار است با ورود اطلاعاتی در text-box، یکی از آنها (text-display) این اطلاعات را به صورت معمولی و دیگری (text-display-uppercase) آنرا به صورت uppercase نمایش دهد. روش کار انجام شده هم مستقل از React است و به صورت مستقیم با استفاده از DOM API عمل شدهاست. این مثال را پیشتر در اولین قسمت بررسی MobX، ملاحظه کردید. اکنون اگر بخواهیم بجای شیءای که توسط متد observable کتابخانهی MobX محصور شدهاست:
const text = observable({ value: "Hello world!", get uppercase() { return this.value.toUpperCase(); } });
ابتدا یک پروژهی جدید React را ایجاد میکنیم:
> create-react-app state-management-with-mobx-part3 > cd state-management-with-mobx-part3 > npm start
> npm install --save mobx
<!DOCTYPE html> <html lang="en"> <head> <title>MobX Basics, part 3</title> <meta charset="UTF-8" /> <link href="src/styles.css" /> </head> <body> <main> <input id="text-input" /> <p id="text-display"></p> <p id="text-display-uppercase"></p> </main> <script src="src/index.js"></script> </body> </html>
import { autorun, computed, observable } from "mobx"; const input = document.getElementById("text-input"); const textDisplay = document.getElementById("text-display"); const loudDisplay = document.getElementById("text-display-uppercase"); class Text { @observable value = "Hello World"; @computed get uppercase() { return this.value.toUpperCase(); } } const text = new Text(); input.addEventListener("keyup", event => { text.value = event.target.value; }); autorun(() => { input.value = text.value; textDisplay.textContent = text.value; loudDisplay.textContent = text.uppercase; });
اکنون اگر در همین حال، برنامه را با دستور npm start اجرا کنیم، با خطای زیر متوقف خواهیم شد:
./src/index.js SyntaxError: \src\index.js: Support for the experimental syntax 'decorators-legacy' isn't currently enabled (8:3): 6 | 7 | class Text { > 8 | @observable value = "Hello World"; | ^ 9 | @computed get uppercase() { 10 | return this.value.toUpperCase(); 11 | }
راه حل اول: از Decorators استفاده نکنیم!
یک راه حل مشکل فوق این است که بدون هیچ تغییری در ساختار پروژهی React خود، اصلا از decorator syntax استفاده نکنیم. برای مثال اگر یک کلاس متداول MobX ای چنین شکلی را دارد:
import { observable, computed, action } from "mobx"; class Timer { @observable start = Date.now(); @observable current = Date.now(); @computed get elapsedTime() { return this.current - this.start + "milliseconds"; } @action tick() { this.current = Date.now(); } }
import { observable, computed, action, decorate } from "mobx"; class Timer { start = Date.now(); current = Date.now(); get elapsedTime() { return this.current - this.start + "milliseconds"; } tick() { this.current = Date.now(); } } decorate(Timer, { start: observable, current: observable, elapsedTime: computed, tick: action });
همچنین در این حالت بجای استفاده از کامپوننتهای کلاسی، باید از روش بکارگیری متد observer برای محصور کردن کامپوننت تابعی تعریف شده استفاده کرد (تا دیگر نیازی به ذکر observer class@ نباشد):
const Counter = observer(({ count }) => { return ( // ... ); });
راه حل دوم: از تایپاسکریپت استفاده کنید!
create-react-app امکان ایجاد پروژههای React تایپاسکریپتی را با ذکر سوئیچ typescript نیز دارد:
> create-react-app my-proj1 --typescript
{ "compilerOptions": { // ... "experimentalDecorators": true } }
فعالسازی MobX Decorators در پروژههای استاندارد React مبتنی بر ES6
MobX از legacy" decorators spec" پشتیبانی میکند. یعنی اگر پروژهای از spec جدید استفاده کند، دیگر نخواهد توانست با MobX فعلی کار کند. این هم مشکل MobX نیست. مشکل اینجا است که باید دانست کلا decorators در زبان جاوااسکریپت هنوز در مرحلهی آزمایشی قرار دارند و تکلیف spec نهایی و تائید شدهی آن مشخص نیست.
برای فعالسازی decorators در یک پروژهی React استاندارد مبتنی بر ES6، شاید کمی جستجو کنید و به نتایجی مانند افزودن فایل babelrc. به ریشهی پروژه و نصب افزونههایی مانند babel/plugin-proposal-decorators @babel/plugin-proposal-class-properties@ برسید. اما ... اینها بدون اجرای دستور npm run eject کار نمیکنند و اگر این دستور را اجرا کنیم، در نهایت به یک فایل package.json بسیار شلوغ خواهیم رسید (اینبار ارجاعات به Babel، Webpack و تمام ابزارهای دیگر نیز ظاهر میشوند). همچنین این عملیات نیز یک طرفهاست. یعنی از این پس قرار است کنترل تمام این پشت صحنه، در اختیار ما باشد و به روز رسانیهای بعدی create-react-app را با مشکل مواجه میکند. این گزینه صرفا مختص توسعه دهندگان پیشرفتهی React است. به همین جهت نیاز به روشی را داریم تا بتوانیم تنظیمات Webpack و کامپایلر Babel را بدون اجرای دستور npm run eject، تغییر دهیم تا در نتیجه، decorators را در آن فعال کنیم و خوشبختانه پروژهی react-app-rewired دقیقا برای همین منظور طراحی شدهاست.
بنابراین ابتدا بستههای زیر را نصب میکنیم:
> npm i --save-dev customize-cra react-app-rewired
پس از نصب این پیشنیازها، فایل جدید config-overrides.js را به ریشهی پروژه، جائیکه فایل package.json قرار گرفتهاست، با محتوای زیر اضافه کنید تا پشتیبانی ازlegacy" decorators spec" فعال شوند:
const { override, addDecoratorsLegacy, disableEsLint } = require("customize-cra"); module.exports = override( // enable legacy decorators babel plugin addDecoratorsLegacy(), // disable eslint in webpack disableEsLint() );
"scripts": { "start": "react-app-rewired start", "build": "react-app-rewired build", "test": "react-app-rewired test", "eject": "react-app-rewired eject" },
تنظیمات ESLint مخصوص کار با decorators
فایل ویژهی eslintrc.json. که در ریشهی پروژه قرار میگیرد (این فایل بدون نام است و فقط از پسوند تشکیل شده)، برای پروژههای MobX، باید حداقل تنظیم زیر را داشته باشد تا ESLint بتواند legacyDecorators را نیز پردازش کند:
{ "extends": "react-app", "parserOptions": { "ecmaFeatures": { "legacyDecorators": true } } }
{ "env": { "node": true, "commonjs": true, "browser": true, "es6": true, "mocha": true }, "settings": { "react": { "version": "detect" } }, "parserOptions": { "ecmaFeatures": { "jsx": true, "legacyDecorators": true }, "ecmaVersion": 2018, "sourceType": "module" }, "plugins": [ "babel", "react", "react-hooks", "react-redux", "no-async-without-await", "css-modules", "filenames", "simple-import-sort" ], "rules": { "no-const-assign": "warn", "no-this-before-super": "warn", "constructor-super": "warn", "strict": [ "error", "safe" ], "no-debugger": "error", "brace-style": [ "error", "1tbs", { "allowSingleLine": true } ], "no-trailing-spaces": "error", "keyword-spacing": "error", "space-before-function-paren": [ "error", "never" ], "spaced-comment": [ "error", "always" ], "vars-on-top": "error", "no-undef": "error", "no-undefined": "warn", "comma-dangle": [ "error", "never" ], "quotes": [ "error", "double" ], "semi": [ "error", "always" ], "guard-for-in": "error", "no-eval": "error", "no-with": "error", "valid-typeof": "error", "no-unused-vars": "error", "no-continue": "warn", "no-extra-semi": "warn", "no-unreachable": "warn", "no-unused-expressions": "warn", "max-len": [ "warn", 80, 4 ], "react/prefer-es6-class": "warn", "react/jsx-boolean-value": "warn", "react-hooks/rules-of-hooks": "error", "react-hooks/exhaustive-deps": "warn", "react/prop-types": "off", "react-redux/mapDispatchToProps-returns-object": "off", "react-redux/prefer-separate-component-file": "off", "no-async-without-await/no-async-without-await": "warn", "css-modules/no-undef-class": "off", "filenames/match-regex": [ "off", "^[a-zA-Z]+\\.*\\b(typescript|module|locale|validate|test|action|api|reducer|saga)?\\b$", true ], "filenames/match-exported": "off", "filenames/no-index": "off", "simple-import-sort/sort": "error" }, "extends": [ "react-app", "eslint:recommended", "plugin:react/recommended", "plugin:react-redux/recommended", "plugin:css-modules/recommended" ], "globals": { "Atomics": "readonly", "SharedArrayBuffer": "readonly", "process": true } }
>npm i --save-dev eslint babel-eslint eslint-config-react-app eslint-loader eslint-plugin-babel eslint-plugin-react eslint-plugin-css-modules eslint-plugin-filenames eslint-plugin-flowtype eslint-plugin-import eslint-plugin-no-async-without-await eslint-plugin-react-hooks eslint-plugin-react-redux eslint-plugin-redux-saga eslint-plugin-simple-import-sort eslint-loader typescript
const { override, addDecoratorsLegacy, useEslintRc } = require("customize-cra"); module.exports = override( addDecoratorsLegacy(), useEslintRc(".eslintrc.json") );
رفع اخطار مرتبط با decorators در VSCode
تا اینجا کار تنظیم کامپایلر babel، جهت پردازش decorators انجام شد. اما خود VSCode نیز چنین اخطاری را در پروژههایی که از decorates استفاده میکنند، نمایش میدهد:
Experimental support for decorators is a feature that is subject to change in a future release. Set the 'experimentalDecorators' option in your 'tsconfig' or 'jsconfig' to remove this warning.ts(1219)
{ "compilerOptions": { "experimentalDecorators": true, "allowJs": true } }
کدهای کامل این قسمت را میتوانید از اینجا دریافت کنید: state-management-with-mobx-part3.zip
پیاده سازیهای زیادی را در مورد JSON Web Token با ASP.NET Web API، با کمی جستجو میتوانید پیدا کنید. اما مشکلی که تمام آنها دارند، شامل این موارد هستند:
- چون توکنهای JWT، خودشمول هستند (در پیشنیاز بحث مطرح شدهاست)، تا زمانیکه این توکن منقضی نشود، کاربر با همان سطح دسترسی قبلی میتواند به سیستم، بدون هیچگونه مانعی لاگین کند. در این حالت اگر این کاربر غیرفعال شود، کلمهی عبور او تغییر کند و یا سطح دسترسیهای او کاهش یابند ... مهم نیست! باز هم میتواند با همان توکن قبلی لاگین کند.
- در روش JSON Web Token، عملیات Logout سمت سرور بیمعنا است. یعنی اگر برنامهای در سمت کاربر، قسمت logout را تدارک دیده باشد، چون در سمت سرور این توکنها جایی ذخیره نمیشوند، عملا این logout بیمفهوم است و مجددا میتوان از همان توکن قبلی، برای لاگین به سرور استفاده کرد. چون این توکن شامل تمام اطلاعات لازم برای لاگین است و همچنین جایی هم در سرور ثبت نشدهاست که این توکن در اثر logout، باید غیرمعتبر شود.
- با یک توکن از مکانهای مختلفی میتوان دسترسی لازم را جهت استفادهی از قسمتهای محافظت شدهی برنامه یافت (در صورت دسترسی، چندین نفر میتوانند از آن استفاده کنند).
به همین جهت راه حلی عمومی برای ذخیره سازی توکنهای صادر شده از سمت سرور، در بانک اطلاعاتی تدارک دیده شد که در ادامه به بررسی آن خواهیم پرداخت و این روشی است که میتواند به عنوان پایه مباحث Authentication و Authorization برنامههای تک صفحهای وب استفاده شود. البته سمت کلاینت این راه حل با jQuery پیاده سازی شدهاست (عمومی است؛ برای طرح مفاهیم پایه) و سمت سرور آن به عمد از هیچ نوع بانک اطلاعات و یا ORM خاصی استفاده نمیکند. سرویسهای آن برای بکارگیری انواع و اقسام روشهای ذخیره سازی اطلاعات قابل تغییر هستند و الزامی نیست که حتما از EF استفاده کنید یا از ASP.NET Identity یا هر روش خاص دیگری.
نگاهی به برنامه
در اینجا تمام قابلیتهای این پروژه را مشاهده میکنید.
- امکان لاگین
- امکان دسترسی به یک کنترلر مزین شدهی با فلیتر Authorize
- امکان دسترسی به یک کنترلر مزین شدهی با فلیتر Authorize جهت کاربری با نقش Admin
- پیاده سازی مفهوم ویژهای به نام refresh token که نیاز به لاگین مجدد را پس از منقضی شدن زمان توکن اولیهی لاگین، برطرف میکند.
- پیاده سازی logout
بستههای پیشنیاز برنامه
پروژهای که در اینجا بررسی شدهاست، یک پروژهی خالی ASP.NET Web API 2.x است و برای شروع به کار با JSON Web Tokenها، تنها نیاز به نصب 4 بستهی زیر را دارد:
PM> Install-Package Microsoft.Owin.Host.SystemWeb PM> Install-Package Microsoft.Owin.Security.Jwt PM> Install-Package structuremap PM> Install-Package structuremap.web
از structuremap هم برای تنظیمات تزریق وابستگیهای برنامه استفاده شدهاست. به این صورت قسمت تنظیمات اولیهی JWT ثابت باقی خواهد ماند و صرفا نیاز خواهید داشت تا کمی قسمت سرویسهای برنامه را بر اساس بانک اطلاعاتی و روش ذخیره سازی خودتان سفارشی سازی کنید.
دریافت کدهای کامل برنامه
کدهای کامل این برنامه را از اینجا میتوانید دریافت کنید. در ادامه صرفا قسمتهای مهم این کدها را بررسی خواهیم کرد.
بررسی کلاس AppJwtConfiguration
کلاس AppJwtConfiguration، جهت نظم بخشیدن به تعاریف ابتدایی توکنهای برنامه در فایل web.config، ایجاد شدهاست. اگر به فایل web.config برنامه مراجعه کنید، یک چنین تعریفی را مشاهده خواهید کرد:
<appJwtConfiguration tokenPath="/login" expirationMinutes="2" refreshTokenExpirationMinutes="60" jwtKey="This is my shared key, not so secret, secret!" jwtIssuer="http://localhost/" jwtAudience="Any" />
<configSections> <section name="appJwtConfiguration" type="JwtWithWebAPI.JsonWebTokenConfig.AppJwtConfiguration" /> </configSections>
- در این تنظیمات، دو زمان منقضی شدن را مشاهده میکنید؛ یکی مرتبط است به access tokenها و دیگری مرتبط است به refresh tokenها که در مورد اینها، در ادامه بیشتر توضیح داده خواهد شد.
- jwtKey، یک کلید قوی است که از آن برای امضاء کردن توکنها در سمت سرور استفاده میشود.
- تنظیمات Issuer و Audience هم در اینجا اختیاری هستند.
یک نکته
جهت سهولت کار تزریق وابستگیها، برای کلاس AppJwtConfiguration، اینترفیس IAppJwtConfiguration نیز تدارک دیده شدهاست و در تمام تنظیمات ابتدایی JWT، از این اینترفیس بجای استفادهی مستقیم از کلاس AppJwtConfiguration استفاده شدهاست.
بررسی کلاس OwinStartup
شروع به کار تنظیمات JWT و ورود آنها به چرخهی حیات Owin از کلاس OwinStartup آغاز میشود. در اینجا علت استفادهی از SmObjectFactory.Container.GetInstance انجام تزریق وابستگیهای لازم جهت کار با دو کلاس AppOAuthOptions و AppJwtOptions است.
- در کلاس AppOAuthOptions تنظیماتی مانند نحوهی تهیهی access token و همچنین refresh token ذکر میشوند.
- در کلاس AppJwtOptions تنظیمات فایل وب کانفیگ، مانند کلید مورد استفادهی جهت امضای توکنهای صادر شده، ذکر میشوند.
حداقلهای بانک اطلاعاتی مورد نیاز جهت ذخیره سازی وضعیت کاربران و توکنهای آنها
همانطور که در ابتدای بحث عنوان شد، میخواهیم اگر سطوح دسترسی کاربر تغییر کرد و یا اگر کاربر logout کرد، توکن فعلی او صرفنظر از زمان انقضای آن، بلافاصله غیرقابل استفاده شود. به همین جهت نیاز است حداقل دو جدول زیر را در بانک اطلاعاتی تدارک ببینید:
الف) کلاس User
در کلاس User، بر مبنای اطلاعات خاصیت Roles آن است که ویژگی Authorize با ذکر نقش مثلا Admin کار میکند. بنابراین حداقل نقشی را که برای کاربران، در ابتدای کار نیاز است مشخص کنید، نقش user است.
همچنین خاصیت اضافهتری به نام SerialNumber نیز در اینجا درنظر گرفته شدهاست. این مورد را باید به صورت دستی مدیریت کنید. اگر کاربری کلمهی عبورش را تغییر داد، اگر مدیری نقشی را به او انتساب داد یا از او گرفت و یا اگر کاربری غیرفعال شد، مقدار خاصیت و فیلد SerialNumber را با یک Guid جدید به روز رسانی کنید. این Guid در برنامه با Guid موجود در توکن مقایسه شده و بلافاصله سبب عدم دسترسی او خواهد شد (در صورت عدم تطابق).
ب) کلاس UserToken
در کلاس UserToken کار نگهداری ریز اطلاعات توکنهای صادر شده صورت میگیرد. توکنهای صادر شده دارای access token و refresh token هستند؛ به همراه زمان انقضای آنها. به این ترتیب زمانیکه کاربری درخواستی را به سرور ارسال میکند، ابتدا token او را دریافت کرده و سپس بررسی میکنیم که آیا اصلا چنین توکنی در بانک اطلاعاتی ما وجود خارجی دارد یا خیر؟ آیا توسط ما صادر شدهاست یا خیر؟ اگر خیر، بلافاصله دسترسی او قطع خواهد شد. برای مثال عملیات logout را طوری طراحی میکنیم که تمام توکنهای یک شخص را در بانک اطلاعاتی حذف کند. به این ترتیب توکن قبلی او دیگر قابلیت استفادهی مجدد را نخواهد داشت.
مدیریت بانک اطلاعاتی و کلاسهای سرویس برنامه
در لایه سرویس برنامه، شما سه سرویس را مشاهده خواهید کرد که قابلیت جایگزین شدن با کدهای یک ORM را دارند (نوع آن ORM مهم نیست):
الف) سرویس TokenStoreService
public interface ITokenStoreService { void CreateUserToken(UserToken userToken); bool IsValidToken(string accessToken, int userId); void DeleteExpiredTokens(); UserToken FindToken(string refreshTokenIdHash); void DeleteToken(string refreshTokenIdHash); void InvalidateUserTokens(int userId); void UpdateUserToken(int userId, string accessTokenHash); }
پیاده سازی این کلاس بسیار شبیه است به پیاده سازی ORMهای موجود و فقط یک SaveChanges را کم دارد.
یک نکته:
در سرویس ذخیره سازی توکنها، یک چنین متدی قابل مشاهده است:
public void CreateUserToken(UserToken userToken) { InvalidateUserTokens(userToken.OwnerUserId); _tokens.Add(userToken); }
ب) سرویس UsersService
public interface IUsersService { string GetSerialNumber(int userId); IEnumerable<string> GetUserRoles(int userId); User FindUser(string username, string password); User FindUser(int userId); void UpdateUserLastActivityDate(int userId); }
همچنین متدهای دیگری برای یافتن یک کاربر بر اساس نام کاربری و کلمهی عبور او (جهت مدیریت مرحلهی لاگین)، یافتن کاربر بر اساس Id او (جهت استخراج اطلاعات کاربر) و همچنین یک متد اختیاری نیز برای به روز رسانی فیلد آخرین تاریخ فعالیت کاربر در اینجا پیش بینی شدهاند.
ج) سرویس SecurityService
public interface ISecurityService { string GetSha256Hash(string input); }
پیاده سازی قسمت لاگین و صدور access token
در کلاس AppOAuthProvider کار پیاده سازی قسمت لاگین برنامه انجام شدهاست. این کلاسی است که توسط کلاس AppOAuthOptions به OwinStartup معرفی میشود. قسمتهای مهم کلاس AppOAuthProvider به شرح زیر هستند:
برای درک عملکرد این کلاس، در ابتدای متدهای مختلف آن، یک break point قرار دهید. برنامه را اجرا کرده و سپس بر روی دکمهی login کلیک کنید. به این ترتیب جریان کاری این کلاس را بهتر میتوانید درک کنید. کار آن با فراخوانی متد ValidateClientAuthentication شروع میشود. چون با یک برنامهی وب در حال کار هستیم، ClientId آنرا نال درنظر میگیریم و برای ما مهم نیست. اگر کلاینت ویندوزی خاصی را تدارک دیدید، این کلاینت میتواند ClientId ویژهای را به سمت سرور ارسال کند که در اینجا مدنظر ما نیست.
مهمترین قسمت این کلاس، متد GrantResourceOwnerCredentials است که پس از ValidateClientAuthentication بلافاصله فراخوانی میشود. اگر به کدهای آن دقت کنید، خود owin دارای خاصیتهای user name و password نیز هست.
این اطلاعات را به نحو ذیل از کلاینت خود دریافت میکند. اگر به فایل index.html مراجعه کنید، یک چنین تعریفی را برای متد login میتوانید مشاهده کنید:
function doLogin() { $.ajax({ url: "/login", // web.config --> appConfiguration -> tokenPath data: { username: "Vahid", password: "1234", grant_type: "password" }, type: 'POST', // POST `form encoded` data contentType: 'application/x-www-form-urlencoded'
در متد GrantResourceOwnerCredentials کار بررسی نام کاربری و کلمهی عبور کاربر صورت گرفته و در صورت یافت شدن کاربر (صحیح بودن اطلاعات)، نقشهای او نیز به عنوان Claim جدید به توکن اضافه میشوند.
در اینجا یک Claim سفارشی هم اضافه شدهاست:
identity.AddClaim(new Claim(ClaimTypes.UserData, user.UserId.ToString()));
در انتهای این کلاس، از متد TokenEndpointResponse جهت دسترسی به access token نهایی صادر شدهی برای کاربر، استفاده کردهایم. هش این access token را در بانک اطلاعاتی ذخیره میکنیم (جستجوی هشها سریعتر هستند از جستجوی یک رشتهی طولانی؛ به علاوه در صورت دسترسی به بانک اطلاعاتی، اطلاعات هشها برای مهاجم قابل استفاده نیست).
اگر بخواهیم اطلاعات ارسالی به کاربر را پس از لاگین، نمایش دهیم، به شکل زیر خواهیم رسید:
در اینجا access_token همان JSON Web Token صادر شدهاست که برنامهی کلاینت از آن برای اعتبارسنجی استفاده خواهد کرد.
بنابراین خلاصهی مراحل لاگین در اینجا به ترتیب ذیل است:
- فراخوانی متد ValidateClientAuthenticationدر کلاس AppOAuthProvider . طبق معمول چون ClientID نداریم، این مرحله را قبول میکنیم.
- فراخوانی متد GrantResourceOwnerCredentials در کلاس AppOAuthProvider . در اینجا کار اصلی لاگین به همراه تنظیم Claims کاربر انجام میشود. برای مثال نقشهای او به توکن صادر شده اضافه میشوند.
- فراخوانی متد Protect در کلاس AppJwtWriterFormat که کار امضای دیجیتال access token را انجام میدهد.
- فراخوانی متد CreateAsync در کلاس RefreshTokenProvider. کار این متد صدور توکن ویژهای به نام refresh است. این توکن را در بانک اطلاعاتی ذخیره خواهیم کرد. در اینجا چیزی که به سمت کلاینت ارسال میشود صرفا یک guid است و نه اصل refresh token.
- فرخوانی متد TokenEndpointResponse در کلاس AppOAuthProvider . از این متد جهت یافتن access token نهایی تولید شده و ثبت هش آن در بانک اطلاعاتی استفاده میکنیم.
پیاده سازی قسمت صدور Refresh token
در تصویر فوق، خاصیت refresh_token را هم در شیء JSON ارسالی به سمت کاربر مشاهده میکنید. هدف از refresh_token، تمدید یک توکن است؛ بدون ارسال کلمهی عبور و نام کاربری به سرور. در اینجا access token صادر شده، مطابق تنظیم expirationMinutes در فایل وب کانفیگ، منقضی خواهد شد. اما طول عمر refresh token را بیشتر از طول عمر access token در نظر میگیریم. بنابراین طول عمر یک access token کوتاه است. زمانیکه access token منقضی شد، نیازی نیست تا حتما کاربر را به صفحهی لاگین هدایت کنیم. میتوانیم refresh_token را به سمت سرور ارسال کرده و به این ترتیب درخواست صدور یک access token جدید را ارائه دهیم. این روش هم سریعتر است (کاربر متوجه این retry نخواهد شد) و هم امنتر است چون نیازی به ارسال کلمهی عبور و نام کاربری به سمت سرور وجود ندارند.
سمت کاربر، برای درخواست صدور یک access token جدید بر اساس refresh token صادر شدهی در زمان لاگین، به صورت زیر عمل میشود:
function doRefreshToken() { // obtaining new tokens using the refresh_token should happen only if the id_token has expired. // it is a bad practice to call the endpoint to get a new token every time you do an API call. $.ajax({ url: "/login", // web.config --> appConfiguration -> tokenPath data: { grant_type: "refresh_token", refresh_token: refreshToken }, type: 'POST', // POST `form encoded` data contentType: 'application/x-www-form-urlencoded'
- فرخوانی متد ValidateClientAuthentication در کلاس AppOAuthProvider . طبق معمول چون ClientID نداریم، این مرحله را قبول میکنیم.
- فراخوانی متد ReceiveAsync در کلاس RefreshTokenProvider. در قسمت توضیح مراحل لاگین، عنوان شد که پس از فراخوانی متد GrantResourceOwnerCredentials جهت لاگین، متد CreateAsync در کلاس RefreshTokenProvider فراخوانی میشود. اکنون در متد ReceiveAsync این refresh token ذخیره شدهی در بانک اطلاعاتی را یافته (بر اساس Guid ارسالی از طرف کلاینت) و سپس Deserialize میکنیم. به این ترتیب است که کار درخواست یک access token جدید بر مبنای refresh token موجود آغاز میشود.
- فراخوانی GrantRefreshToken در کلاس AppOAuthProvider . در اینجا اگر نیاز به تنظیم Claim اضافهتری وجود داشت، میتوان اینکار را انجام داد.
- فراخوانی متد Protect در کلاس AppJwtWriterFormat که کار امضای دیجیتال access token جدید را انجام میدهد.
- فراخوانی CreateAsync در کلاس RefreshTokenProvider . پس از اینکه context.DeserializeTicket در متد ReceiveAsync بر مبنای refresh token قبلی انجام شد، مجددا کار تولید یک توکن جدید در متد CreateAsync شروع میشود و زمان انقضاءها تنظیم خواهند شد.
- فراخوانی TokenEndpointResponse در کلاس AppOAuthProvider . مجددا از این متد برای دسترسی به access token جدید و ذخیرهی هش آن در بانک اطلاعاتی استفاده میکنیم.
پیاده سازی فیلتر سفارشی JwtAuthorizeAttribute
در ابتدای بحث عنوان کردیم که اگر مشخصات کاربر تغییر کردند یا کاربر logout کرد، امکان غیرفعال کردن یک توکن را نداریم و این توکن تا زمان انقضای آن معتبر است. این نقیصه را با طراحی یک AuthorizeAttribute سفارشی جدید به نام JwtAuthorizeAttribute برطرف میکنیم. نکات مهم این فیلتر به شرح زیر هستند:
- در اینجا در ابتدا بررسی میشود که آیا درخواست رسیدهی به سرور، حاوی access token هست یا خیر؟ اگر خیر، کار همینجا به پایان میرسد و دسترسی کاربر قطع خواهد شد.
- سپس بررسی میکنیم که آیا درخواست رسیده پس از مدیریت توسط Owin، دارای Claims است یا خیر؟ اگر خیر، یعنی این توکن توسط ما صادر نشدهاست.
- در ادامه شماره سریال موجود در access token را استخراج کرده و آنرا با نمونهی موجود در دیتابیس مقایسه میکنیم. اگر این دو یکی نبودند، دسترسی کاربر قطع میشود.
- همچنین در آخر بررسی میکنیم که آیا هش این توکن رسیده، در بانک اطلاعاتی ما موجود است یا خیر؟ اگر خیر باز هم یعنی این توکن توسط ما صادر نشدهاست.
بنابراین به ازای هر درخواست به سرور، دو بار بررسی بانک اطلاعاتی را خواهیم داشت:
- یکبار بررسی جدول کاربران جهت واکشی مقدار فیلد شماره سریال مربوط به کاربر.
- یکبار هم جهت بررسی جدول توکنها برای اطمینان از صدور توکن رسیده توسط برنامهی ما.
و نکتهی مهم اینجا است که از این پس بجای فیلتر معمولی Authorize، از فیلتر جدید JwtAuthorize در برنامه استفاده خواهیم کرد:
[JwtAuthorize(Roles = "Admin")] public class MyProtectedAdminApiController : ApiController
نحوهی ارسال درخواستهای Ajax ایی به همراه توکن صادر شده
تا اینجا کار صدور توکنهای برنامه به پایان میرسد. برای استفادهی از این توکنها در سمت کاربر، به فایل index.html دقت کنید. در متد doLogin، پس از موفقیت عملیات دو متغیر جدید مقدار دهی میشوند:
var jwtToken; var refreshToken; function doLogin() { $.ajax({ // same as before }).then(function (response) { jwtToken = response.access_token; refreshToken = response.refresh_token; }
function doCallApi() { $.ajax({ headers: { 'Authorization': 'Bearer ' + jwtToken }, url: "/api/MyProtectedApi", type: 'GET' }).then(function (response) {
پیاده سازی logout سمت سرور و کلاینت
پیاده سازی سمت سرور logout را در کنترلر UserController مشاهده میکنید. در اینجا در اکشن متد Logout، کار حذف توکنهای کاربر از بانک اطلاعاتی انجام میشود. به این ترتیب دیگر مهم نیست که توکن او هنوز منقضی شدهاست یا خیر. چون هش آن دیگر در جدول توکنها وجود ندارد، از فیلتر JwtAuthorizeAttribute رد نخواهد شد.
سمت کلاینت آن نیز در فایل index.html ذکر شدهاست:
function doLogout() { $.ajax({ headers: { 'Authorization': 'Bearer ' + jwtToken }, url: "/api/user/logout", type: 'GET'
بررسی تنظیمات IoC Container برنامه
تنظیمات IoC Container برنامه را در پوشهی IoCConfig میتوانید ملاحظه کنید. از کلاس SmWebApiControllerActivator برای فعال سازی تزریق وابستگیها در کنترلرهای Web API استفاده میشود و از کلاس SmWebApiFilterProvider برای فعال سازی تزریق وابستگیها در فیلتر سفارشی که ایجاد کردیم، کمک گرفته خواهد شد.
هر دوی این تنظیمات نیز در کلاس WebApiConfig ثبت و معرفی شدهاند.
به علاوه در کلاس SmObjectFactory، کار معرفی وهلههای مورد استفاده و تنظیم طول عمر آنها انجام شدهاست. برای مثال طول عمر IOAuthAuthorizationServerProvider از نوع Singleton است؛ چون تنها یک وهله از AppOAuthProvider در طول عمر برنامه توسط Owin استفاده میشود و Owin هربار آنرا وهله سازی نمیکند. همین مساله سبب شدهاست که معرفی وابستگیها در سازندهی کلاس AppOAuthProvider کمی با حالات متداول، متفاوت باشند:
public AppOAuthProvider( Func<IUsersService> usersService, Func<ITokenStoreService> tokenStoreService, ISecurityService securityService, IAppJwtConfiguration configuration)
در اینجا سرویس IAppJwtConfiguration با Func معرفی نشدهاست؛ چون طول عمر تنظیمات خوانده شدهی از Web.config نیز Singleton هستند و معرفی آن به همین نحو صحیح است.
اگر علاقمند بودید که بررسی کنید یک سرویس چندبار وهله سازی میشود، یک سازندهی خالی را به آن اضافه کنید و سپس یک break point را بر روی آن قرار دهید و برنامه را اجرا و در این حالت چندبار Login کنید.
تیم ASP.NET Identity پروژه نمونه ای را فراهم کرده است که میتواند بعنوان نقطه شروعی برای اپلیکیشنهای MVC استفاده شود. پیکربندیهای لازم در این پروژه انجام شدهاند و برای استفاده از فریم ورک جدید آماده است.
شروع به کار : پروژه نمونه را توسط NuGet ایجاد کنید
برای شروع یک پروژه ASP.NET خالی ایجاد کنید (در دیالوگ قالبها گزینه Empty را انتخاب کنید). سپس کنسول Package Manager را باز کرده و دستور زیر را اجرا کنید.
PM> Install-Package Microsoft.AspNet.Identity.Samples -Pre
پس از اینکه NuGet کارش را به اتمام رساند باید پروژه ای با ساختار متداول پروژههای ASP.NET MVC داشته باشید. به تصویر زیر دقت کنید.
همانطور که میبینید ساختار پروژه بسیار مشابه پروژههای معمول MVC است، اما آیتمهای جدیدی نیز وجود دارند. فعلا تمرکز اصلی ما روی فایل IdentityConfig.cs است که در پوشه App_Start قرار دارد.
اگر فایل مذکور را باز کنید و کمی اسکرول کنید تعاریف دو کلاس سرویس را مشاهده میکنید: EmailService و SmsService.
public class EmailService : IIdentityMessageService { public Task SendAsync(IdentityMessage message) { // Plug in your email service here to send an email. return Task.FromResult(0); } } public class SmsService : IIdentityMessageService { public Task SendAsync(IdentityMessage message) { // Plug in your sms service here to send a text message. return Task.FromResult(0); } }
اگر دقت کنید هر دو کلاس قرارداد IIdentityMessageService را پیاده سازی میکنند. میتوانید از این قرارداد برای پیاده سازی سرویسهای اطلاع رسانی ایمیلی، پیامکی و غیره استفاده کنید. در ادامه خواهیم دید چگونه این دو سرویس را بسط دهیم.
یک حساب کاربری مدیریتی پیش فرض ایجاد کنید
پیش از آنکه بیشتر جلو رویم نیاز به یک حساب کاربری در نقش مدیریتی داریم تا با اجرای اولیه اپلیکیشن در دسترس باشد. کلاسی بنام ApplicationDbInitializer در همین فایل وجود دارد که هنگام اجرای اولیه و یا تشخیص تغییرات در مدل دیتابیس، اطلاعاتی را Seed میکند.
public class ApplicationDbInitializer : DropCreateDatabaseIfModelChanges<ApplicationDbContext> { protected override void Seed(ApplicationDbContext context) { InitializeIdentityForEF(context); base.Seed(context); } //Create User=Admin@Admin.com with password=Admin@123456 in the Admin role public static void InitializeIdentityForEF(ApplicationDbContext db) { var userManager = HttpContext.Current.GetOwinContext().GetUserManager<ApplicationUserManager>(); var roleManager = HttpContext.Current.GetOwinContext().Get<ApplicationRoleManager>(); const string name = "admin@admin.com"; const string password = "Admin@123456"; const string roleName = "Admin"; //Create Role Admin if it does not exist var role = roleManager.FindByName(roleName); if (role == null) { role = new IdentityRole(roleName); var roleresult = roleManager.Create(role); } var user = userManager.FindByName(name); if (user == null) { user = new ApplicationUser { UserName = name, Email = name }; var result = userManager.Create(user, password); result = userManager.SetLockoutEnabled(user.Id, false); } // Add user admin to Role Admin if not already added var rolesForUser = userManager.GetRoles(user.Id); if (!rolesForUser.Contains(role.Name)) { var result = userManager.AddToRole(user.Id, role.Name); } } }
تایید حسابهای کاربری : چگونه کار میکند
بدون شک با تایید حسابهای کاربری توسط ایمیل آشنا هستید. حساب کاربری ای ایجاد میکنید و ایمیلی به آدرس شما ارسال میشود که حاوی لینک فعالسازی است. با کلیک کردن این لینک حساب کاربری شما تایید شده و میتوانید به سایت وارد شوید.
اگر به کنترلر AccountController در این پروژه نمونه مراجعه کنید متد Register را مانند لیست زیر مییابید.
[HttpPost] [AllowAnonymous] [ValidateAntiForgeryToken] public async Task<ActionResult> Register(RegisterViewModel model) { if (ModelState.IsValid) { var user = new ApplicationUser { UserName = model.Email, Email = model.Email }; var result = await UserManager.CreateAsync(user, model.Password); if (result.Succeeded) { var code = await UserManager.GenerateEmailConfirmationTokenAsync(user.Id); var callbackUrl = Url.Action( "ConfirmEmail", "Account", new { userId = user.Id, code = code }, protocol: Request.Url.Scheme); await UserManager.SendEmailAsync( user.Id, "Confirm your account", "Please confirm your account by clicking this link: <a href=\"" + callbackUrl + "\">link</a>"); ViewBag.Link = callbackUrl; return View("DisplayEmail"); } AddErrors(result); } // If we got this far, something failed, redisplay form return View(model); }
public static ApplicationUserManager Create( IdentityFactoryOptions<ApplicationUserManager> options, IOwinContext context) { var manager = new ApplicationUserManager( new UserStore<ApplicationUser>( context.Get<ApplicationDbContext>())); // Configure validation logic for usernames manager.UserValidator = new UserValidator<ApplicationUser>(manager) { AllowOnlyAlphanumericUserNames = false, RequireUniqueEmail = true }; // Configure validation logic for passwords manager.PasswordValidator = new PasswordValidator { RequiredLength = 6, RequireNonLetterOrDigit = true, RequireDigit = true, RequireLowercase = true, RequireUppercase = true, }; // Configure user lockout defaults manager.UserLockoutEnabledByDefault = true; manager.DefaultAccountLockoutTimeSpan = TimeSpan.FromMinutes(5); manager.MaxFailedAccessAttemptsBeforeLockout = 5; // Register two factor authentication providers. This application // uses Phone and Emails as a step of receiving a code for verifying the user // You can write your own provider and plug in here. manager.RegisterTwoFactorProvider( "PhoneCode", new PhoneNumberTokenProvider<ApplicationUser> { MessageFormat = "Your security code is: {0}" }); manager.RegisterTwoFactorProvider( "EmailCode", new EmailTokenProvider<ApplicationUser> { Subject = "SecurityCode", BodyFormat = "Your security code is {0}" }); manager.EmailService = new EmailService(); manager.SmsService = new SmsService(); var dataProtectionProvider = options.DataProtectionProvider; if (dataProtectionProvider != null) { manager.UserTokenProvider = new DataProtectorTokenProvider<ApplicationUser>( dataProtectionProvider.Create("ASP.NET Identity")); } return manager; }
در قطعه کد بالا کلاسهای EmailService و SmsService روی وهله ApplicationUserManager تنظیم میشوند.
manager.EmailService = new EmailService(); manager.SmsService = new SmsService();
درست در بالای این کدها میبینید که چگونه تامین کنندگان احراز هویت دو مرحله ای (مبتنی بر ایمیل و پیامک) رجیستر میشوند.
// Register two factor authentication providers. This application // uses Phone and Emails as a step of receiving a code for verifying the user // You can write your own provider and plug in here. manager.RegisterTwoFactorProvider( "PhoneCode", new PhoneNumberTokenProvider<ApplicationUser> { MessageFormat = "Your security code is: {0}" }); manager.RegisterTwoFactorProvider( "EmailCode", new EmailTokenProvider<ApplicationUser> { Subject = "SecurityCode", BodyFormat = "Your security code is {0}" });
تایید حسابهای کاربری توسط ایمیل و احراز هویت دو مرحله ای توسط ایمیل و/یا پیامک نیاز به پیاده سازی هایی معتبر از قراردارد IIdentityMessageService دارند.
پیاده سازی سرویس ایمیل توسط ایمیل خودتان
پیاده سازی سرویس ایمیل نسبتا کار ساده ای است. برای ارسال ایمیلها میتوانید از اکانت ایمیل خود و یا سرویس هایی مانند SendGrid استفاده کنید. بعنوان مثال اگر بخواهیم سرویس ایمیل را طوری پیکربندی کنیم که از یک حساب کاربری Outlook استفاده کند، مانند زیر عمل خواهیم کرد.
public class EmailService : IIdentityMessageService { public Task SendAsync(IdentityMessage message) { // Credentials: var credentialUserName = "yourAccount@outlook.com"; var sentFrom = "yourAccount@outlook.com"; var pwd = "yourApssword"; // Configure the client: System.Net.Mail.SmtpClient client = new System.Net.Mail.SmtpClient("smtp-mail.outlook.com"); client.Port = 587; client.DeliveryMethod = System.Net.Mail.SmtpDeliveryMethod.Network; client.UseDefaultCredentials = false; // Creatte the credentials: System.Net.NetworkCredential credentials = new System.Net.NetworkCredential(credentialUserName, pwd); client.EnableSsl = true; client.Credentials = credentials; // Create the message: var mail = new System.Net.Mail.MailMessage(sentFrom, message.Destination); mail.Subject = message.Subject; mail.Body = message.Body; // Send: return client.SendMailAsync(mail); } }
پیاده سازی سرویس ایمیل با استفاده از SendGrid
سرویسهای ایمیل متعددی وجود دارند اما یکی از گزینههای محبوب در جامعه دات نت SendGrid است. این سرویس API قدرتمندی برای زبانهای برنامه نویسی مختلف فراهم کرده است. همچنین یک Web API مبتنی بر HTTP نیز در دسترس است. قابلیت دیگر اینکه این سرویس مستقیما با Windows Azure یکپارچه میشود.
می توانید در سایت SendGrid یک حساب کاربری رایگان بعنوان توسعه دهنده بسازید. پس از آن پیکربندی سرویس ایمیل با مرحله قبل تفاوت چندانی نخواهد داشت. پس از ایجاد حساب کاربری توسط تیم پشتیبانی SendGrid با شما تماس گرفته خواهد شد تا از صحت اطلاعات شما اطمینان حاصل شود. برای اینکار چند گزینه در اختیار دارید که بهترین آنها ایجاد یک اکانت ایمیل در دامنه وب سایتتان است. مثلا اگر هنگام ثبت نام آدرس وب سایت خود را www.yourwebsite.com وارد کرده باشید، باید ایمیلی مانند info@yourwebsite.com ایجاد کنید و توسط ایمیل فعالسازی آن را تایید کند تا تیم پشتیبانی مطمئن شود صاحب امتیاز این دامنه خودتان هستید.
تنها چیزی که در قطعه کد بالا باید تغییر کند اطلاعات حساب کاربری و تنظیمات SMTP است. توجه داشته باشید که نام کاربری و آدرس فرستنده در اینجا متفاوت هستند. در واقع میتوانید از هر آدرسی بعنوان آدرس فرستنده استفاده کنید.
public class EmailService : IIdentityMessageService { public Task SendAsync(IdentityMessage message) { // Credentials: var sendGridUserName = "yourSendGridUserName"; var sentFrom = "whateverEmailAdressYouWant"; var sendGridPassword = "YourSendGridPassword"; // Configure the client: var client = new System.Net.Mail.SmtpClient("smtp.sendgrid.net", Convert.ToInt32(587)); client.Port = 587; client.DeliveryMethod = System.Net.Mail.SmtpDeliveryMethod.Network; client.UseDefaultCredentials = false; // Creatte the credentials: System.Net.NetworkCredential credentials = new System.Net.NetworkCredential(credentialUserName, pwd); client.EnableSsl = true; client.Credentials = credentials; // Create the message: var mail = new System.Net.Mail.MailMessage(sentFrom, message.Destination); mail.Subject = message.Subject; mail.Body = message.Body; // Send: return client.SendMailAsync(mail); } }
آزمایش تایید حسابهای کاربری توسط سرویس ایمیل
ابتدا اپلیکیشن را اجرا کنید و سعی کنید یک حساب کاربری جدید ثبت کنید. دقت کنید که از آدرس ایمیلی زنده که به آن دسترسی دارید استفاده کنید. اگر همه چیز بدرستی کار کند باید به صفحه ای مانند تصویر زیر هدایت شوید.
همانطور که مشاهده میکنید پاراگرافی در این صفحه وجود دارد که شامل لینک فعالسازی است. این لینک صرفا جهت تسهیل کار توسعه دهندگان درج میشود و هنگام توزیع اپلیکیشن باید آن را حذف کنید. در ادامه به این قسمت باز میگردیم. در این مرحله ایمیلی حاوی لینک فعالسازی باید برای شما ارسال شده باشد.
پیاده سازی سرویس SMS
برای استفاده از احراز هویت دو مرحله ای پیامکی نیاز به یک فراهم کننده SMS دارید، مانند Twilio . مانند SendGrid این سرویس نیز در جامعه دات نت بسیار محبوب است و یک C# API قدرتمند ارائه میکند. میتوانید حساب کاربری رایگانی بسازید و شروع به کار کنید.
پس از ایجاد حساب کاربری یک شماره SMS، یک شناسه SID و یک شناسه Auth Token به شما داده میشود. شماره پیامکی خود را میتوانید پس از ورود به سایت و پیمایش به صفحه Numbers مشاهده کنید.
شناسههای SID و Auth Token نیز در صفحه Dashboard قابل مشاهده هستند.
اگر دقت کنید کنار شناسه Auth Token یک آیکون قفل وجود دارد که با کلیک کردن روی آن شناسه مورد نظر نمایان میشود.
حال میتوانید از سرویس Twilio در اپلیکیشن خود استفاده کنید. ابتدا بسته NuGet مورد نیاز را نصب کنید.
PM> Install-Package Twilio
public class SmsService : IIdentityMessageService { public Task SendAsync(IdentityMessage message) { string AccountSid = "YourTwilioAccountSID"; string AuthToken = "YourTwilioAuthToken"; string twilioPhoneNumber = "YourTwilioPhoneNumber"; var twilio = new TwilioRestClient(AccountSid, AuthToken); twilio.SendSmsMessage(twilioPhoneNumber, message.Destination, message.Body); // Twilio does not return an async Task, so we need this: return Task.FromResult(0); } }
حال که سرویسهای ایمیل و پیامک را در اختیار داریم میتوانیم احراز هویت دو مرحله ای را تست کنیم.
آزمایش احراز هویت دو مرحله ای
پروژه نمونه جاری طوری پیکربندی شده است که احراز هویت دو مرحله ای اختیاری است و در صورت لزوم میتواند برای هر کاربر بصورت جداگانه فعال شود. ابتدا توسط حساب کاربری مدیر، یا حساب کاربری ای که در قسمت تست تایید حساب کاربری ایجاد کرده اید وارد سایت شوید. سپس در سمت راست بالای صفحه روی نام کاربری خود کلیک کنید. باید صفحه ای مانند تصویر زیر را مشاهده کنید.
در این قسمت باید احراز هویت دو مرحله ای را فعال کنید و شماره تلفن خود را ثبت نمایید. پس از آن یک پیام SMS برای شما ارسال خواهد شد که توسط آن میتوانید پروسه را تایید کنید. اگر همه چیز بدرستی کار کند این مراحل چند ثانیه بیشتر نباید زمان بگیرد، اما اگر مثلا بیش از 30 ثانیه زمان برد احتمالا اشکالی در کار است.
حال که احراز هویت دو مرحله ای فعال شده از سایت خارج شوید و مجددا سعی کنید به سایت وارد شوید. در این مرحله یک انتخاب به شما داده میشود. میتوانید کد احراز هویت دو مرحله ای خود را توسط ایمیل یا پیامک دریافت کنید.
پس از اینکه گزینه خود را انتخاب کردید، کد احراز هویت دو مرحله ای برای شما ارسال میشود که توسط آن میتوانید پروسه ورود به سایت را تکمیل کنید.
حذف میانبرهای آزمایشی
همانطور که گفته شد پروژه نمونه شامل میانبرهایی برای تسهیل کار توسعه دهندگان است. در واقع اصلا نیازی به پیاده سازی سرویسهای ایمیل و پیامک ندارید و میتوانید با استفاده از این میانبرها حسابهای کاربری را تایید کنید و کدهای احراز هویت دو مرحله ای را نیز مشاهده کنید. اما قطعا این میانبرها پیش از توزیع اپلیکیشن باید حذف شوند.
بدین منظور باید نماها و کدهای مربوطه را ویرایش کنیم تا اینگونه اطلاعات به کلاینت ارسال نشوند. اگر کنترلر AccountController را باز کنید و به متد ()Register بروید با کد زیر مواجه خواهید شد.
if (result.Succeeded) { var code = await UserManager.GenerateEmailConfirmationTokenAsync(user.Id); var callbackUrl = Url.Action("ConfirmEmail", "Account", new { userId = user.Id, code = code }, protocol: Request.Url.Scheme); await UserManager.SendEmailAsync(user.Id, "Confirm your account", "Please confirm your account by clicking this link: <a href=\"" + callbackUrl + "\">link</a>"); // This should not be deployed in production: ViewBag.Link = callbackUrl; return View("DisplayEmail"); } AddErrors(result);
نمایی که این متد باز میگرداند یعنی DisplayEmail.cshtml نیز باید ویرایش شود.
@{ ViewBag.Title = "DEMO purpose Email Link"; } <h2>@ViewBag.Title.</h2> <p class="text-info"> Please check your email and confirm your email address. </p> <p class="text-danger"> For DEMO only: You can click this link to confirm the email: <a href="@ViewBag.Link">link</a> Please change this code to register an email service in IdentityConfig to send an email. </p>
متد دیگری که در این کنترلر باید ویرایش شود ()VerifyCode است که کد احراز هویت دو مرحله ای را به صفحه مربوطه پاس میدهد.
[AllowAnonymous] public async Task<ActionResult> VerifyCode(string provider, string returnUrl) { // Require that the user has already logged in via username/password or external login if (!await SignInHelper.HasBeenVerified()) { return View("Error"); } var user = await UserManager.FindByIdAsync(await SignInHelper.GetVerifiedUserIdAsync()); if (user != null) { ViewBag.Status = "For DEMO purposes the current " + provider + " code is: " + await UserManager.GenerateTwoFactorTokenAsync(user.Id, provider); } return View(new VerifyCodeViewModel { Provider = provider, ReturnUrl = returnUrl }); }
همانطور که میبینید متغیری بنام Status به ViewBag اضافه میشود که باید حذف شود.
نمای این متد یعنی VerifyCode.cshtml نیز باید ویرایش شود.
@model IdentitySample.Models.VerifyCodeViewModel @{ ViewBag.Title = "Enter Verification Code"; } <h2>@ViewBag.Title.</h2> @using (Html.BeginForm("VerifyCode", "Account", new { ReturnUrl = Model.ReturnUrl }, FormMethod.Post, new { @class = "form-horizontal", role = "form" })) { @Html.AntiForgeryToken() @Html.ValidationSummary("", new { @class = "text-danger" }) @Html.Hidden("provider", @Model.Provider) <h4>@ViewBag.Status</h4> <hr /> <div class="form-group"> @Html.LabelFor(m => m.Code, new { @class = "col-md-2 control-label" }) <div class="col-md-10"> @Html.TextBoxFor(m => m.Code, new { @class = "form-control" }) </div> </div> <div class="form-group"> <div class="col-md-offset-2 col-md-10"> <div class="checkbox"> @Html.CheckBoxFor(m => m.RememberBrowser) @Html.LabelFor(m => m.RememberBrowser) </div> </div> </div> <div class="form-group"> <div class="col-md-offset-2 col-md-10"> <input type="submit" class="btn btn-default" value="Submit" /> </div> </div> }
در این فایل کافی است ViewBag.Status را حذف کنید.
از تنظیمات ایمیل و SMS محافظت کنید
در مثال جاری اطلاعاتی مانند نام کاربری و کلمه عبور، شناسههای SID و Auth Token همگی در کد برنامه نوشته شده اند. بهتر است چنین مقادیری را بیرون از کد اپلیکیشن نگاه دارید، مخصوصا هنگامی که پروژه را به سرویس کنترل ارسال میکند (مثلا مخازن عمومی مثل GitHub). بدین منظور میتوانید یکی از پستهای اخیر را مطالعه کنید.
فعال سازی پردازش فایلهای استاتیک در برنامههای ASP.NET Core 1.0
در مورد پوشهی جدید wwwroot در «قسمت 2 - بررسی ساختار جدید Solution» مطالبی عنوان شدند. جهت یادآوری:
اگر فایل Program.cs را بررسی کنید، یک چنین تعاریفی را مشاهده خواهید کرد:
public class Program { public static void Main(string[] args) { var host = new WebHostBuilder() .UseKestrel() .UseContentRoot(Directory.GetCurrentDirectory()) .UseIISIntegration() .UseStartup<Startup>() .Build(); host.Run(); } }
یک مثال: زمانیکه فایل استاتیک images/banner3.svg در پوشهی wwwroot قرار میگیرد، با آدرس http://localhost:9189/images/banner3.svg توسط عموم قابل دسترسی خواهد بود.
یک نکتهی امنیتی مهم
در برنامههای ASP.NET Core، هنوز فایل web.config را نیز مشاهده میکنید. این فایل تنها کاربردی که در اینجا دارد، تنظیم ماژول AspNetCoreModule برای IIS است تا IIS static file handler آن، راسا اقدام به توزیع فایلهای یک برنامهی ASP.NET Core نکند. بنابراین توزیع این فایل را بر روی سرورهای IIS فراموش نکنید. همچنین بهتر است در ویندوزهای سرور، به قسمت Modules feature مراجعه کرده و StaticFileModule را از لیست ویژگیهای موجود حذف کرد.
نصب Middleware مخصوص پردازش فایلهای استاتیک
در قسمت قبل با نحوهی نصب و فعال سازی middleware مخصوص WelcomePage آشنا شدیم. روال کار در اینجا نیز دقیقا به همان صورت است:
الف) نصب بستهی نیوگت Microsoft.AspNetCore.StaticFiles
برای اینکار میتوان بر روی گرهی references کلیک راست کرده و سپس از منوی ظاهر شده،گزینهی manage nuget packages را انتخاب کرد. سپس ابتدا برگهی browse را انتخاب کنید و در اینجا نام Microsoft.AspNetCore.StaticFiles را جستجو کرده و سپس نصب کنید.
انجام این کارها معادل افزودن یک سطر ذیل به فایل project.json است و سپس ذخیرهی آن که کار بازیابی بستهها را به صورت خودکار آغاز میکند:
"dependencies": { // same as before "Microsoft.AspNetCore.StaticFiles": "1.0.0" },
برای اینکار به فایل Startup.cs مراجعه کرده و سطر UseStaticFiles را به متد Configure اضافه کنید (به UseWelcomePage هم دیگر نیازی نداریم):
public class Startup { public void ConfigureServices(IServiceCollection services) { } public void Configure(IApplicationBuilder app) { app.UseStaticFiles(); //app.UseWelcomePage(); app.Run(async context => { await context.Response.WriteAsync("Hello DNT!"); }); } }
یک مثال: بر روی پوشهی wwwroot کلیک راست کرده و گزینهی add->new item را انتخاب کنید. سپس یک HTML page جدید را به نام index.html به این پوشه اضافه کنید.
با این محتوا:
<!DOCTYPE html> <html> <head> <meta charset="utf-8" /> <title>Hello World</title> </head> <body> Hello World! </body> </html>
که این خروجی دقیقا خروجی app.Run برنامه است و نه محتوای فایل index.html ایی که اضافه کردیم.
در ادامه اگر مسیر کامل این فایل را (http://localhost:7742/index.html) درخواست دهیم، آنگاه میتوان خروجی این فایل استاتیک را مشاهده کرد:
این رفتار اندکی متفاوت است نسبت به نگارشهای قبلی ASP.NET که فایل index.html را به عنوان فایل پیش فرض، درنظر میگرفت و محتوای آنرا نمایش میداد. منظور از فایل پیش فرض، فایلی است که با درخواست ریشهی یک مسیر، به کاربر ارائه داده میشود و index.html یکی از آنها است.
برای رفع این مشکل، نیاز است Middleware مخصوص آنرا به نام Default Files نیز به برنامه معرفی کرد:
public void Configure(IApplicationBuilder app) { app.UseDefaultFiles(); app.UseStaticFiles();
فعال سازی Default Files، سبب جستجوی یکی از 4 فایل ذیل به صورت پیش فرض میشود (اگر تنها ریشهی پوشهای درخواست شود):
default.htm
default.html
index.htm
index.html
اگر خواستید فایل سفارشی خاص دیگری را معرفی کنید، نیاز است پارامتر DefaultFilesOptions آنرا مقدار دهی نمائید:
// Serve my app-specific default file, if present. DefaultFilesOptions options = new DefaultFilesOptions(); options.DefaultFileNames.Clear(); options.DefaultFileNames.Add("mydefault.html"); app.UseDefaultFiles(options);
ترتیب معرفی Middlewares مهم است
در قسمت قبل، در حین معرفی تفاوتهای Middlewareها با HTTP Modules، عنوان شد که اینبار برنامه نویس میتواند بر روی ترتیب اجرای Middlewareها کنترل کاملی داشته باشد و این ترتیب معادل است با ترتیب معرفی آنها در متد Configure، به نحوی که مشاهده میکنید. برای آزمایش این مطلب، متد معرفی middleware فایلهای پیش فرض را پس از متد معرفی فایلهای استاتیک قرار دهید:
public void Configure(IApplicationBuilder app) { app.UseStaticFiles(); app.UseDefaultFiles();
بله. اینبار تعریف فایلهای پیش فرض، هیچ تاثیری نداشته و درخواست ریشهی سایت، بدون ذکر صریح نام فایلی، مجددا به app.Run ختم شدهاست.
توزیع فایلهای استاتیک خارج از wwwroot
همانطور که در ابتدای بحث عنوان شد، با فعال سازی UseStaticFiles به صورت پیش فرض مسیر content root/wwwroot در معرض دید دنیای خارج قرار میگیرد و توسط وب سرور قابل توزیع خواهد شد:
○ wwwroot § css § images § ... ○ MyStaticFiles § test.png
برای این منظور میتوان از پارامتر StaticFileOptions متد UseStaticFiles به نحو ذیل جهت معرفی پوشهی MyStaticFiles استفاده کرد:
app.UseStaticFiles(new StaticFileOptions { FileProvider = new PhysicalFileProvider(root: Path.Combine(Directory.GetCurrentDirectory(), @"MyStaticFiles")), RequestPath = new PathString("/StaticFiles") });
فعال سازی مشاهدهی مرور فایلهای استاتیک بر روی سرور
فرض کنید پوشهی تصاویر را به پوشهی عمومی wwwroot اضافه کردهایم. برای فعال سازی مرور محتوای این پوشه میتوان از Middleware دیگری به نام DirectoryBrowser استفاده کرد:
app.UseDirectoryBrowser(new DirectoryBrowserOptions { FileProvider = new PhysicalFileProvider(root: Path.Combine(Directory.GetCurrentDirectory(), @"wwwroot\images")), RequestPath = new PathString("/MyImages") });
Unable to resolve service for type 'System.Text.Encodings.Web.HtmlEncoder' while attempting to activate 'Microsoft.AspNetCore.StaticFiles.DirectoryBrowserMiddleware'.
public void ConfigureServices(IServiceCollection services) { services.AddDirectoryBrowser(); }
مشکل! در این حالت که DirectoryBrowser را فعال کردهایم، اگر بر روی لینک فایل تصویر نمایش داده شده کلیک کنیم، باز پیام Hello DNT یا اجرای app.Run را شاهد خواهیم بود.
به این دلیل که UseStaticFiles پیش فرض، مسیر درخواستی MyImages را که بر روی file system وجود ندارد، نمیشناسد. برای رفع این مشکل تنها کافی است مسیریابی این Request Path خاص را نیز فعال کنیم:
app.UseStaticFiles(new StaticFileOptions { FileProvider = new PhysicalFileProvider(root: Path.Combine(Directory.GetCurrentDirectory(), @"wwwroot\images")), RequestPath = new PathString("/MyImages") });
بررسی خلاصهی تنظیماتی که به فایل آغازین برنامه اضافه شدند
تا اینجا اگر توضیحات را قدم به قدم دنبال و اجرا کرده باشید، یک چنین تنظیماتی را خواهید داشت:
using System.IO; using Microsoft.AspNetCore.Builder; using Microsoft.AspNetCore.Http; using Microsoft.Extensions.DependencyInjection; using Microsoft.Extensions.FileProviders; namespace Core1RtmEmptyTest { public class Startup { public void ConfigureServices(IServiceCollection services) { services.AddDirectoryBrowser(); } public void Configure(IApplicationBuilder app) { app.UseDefaultFiles(); app.UseStaticFiles(); // For the wwwroot folder // For the files outside of the wwwroot app.UseStaticFiles(new StaticFileOptions { FileProvider = new PhysicalFileProvider(root: Path.Combine(Directory.GetCurrentDirectory(), @"MyStaticFiles")), RequestPath = new PathString("/StaticFiles") }); // For DirectoryBrowser app.UseStaticFiles(new StaticFileOptions { FileProvider = new PhysicalFileProvider(root: Path.Combine(Directory.GetCurrentDirectory(), @"wwwroot\images")), RequestPath = new PathString("/MyImages") }); app.UseDirectoryBrowser(new DirectoryBrowserOptions { FileProvider = new PhysicalFileProvider(root: Path.Combine(Directory.GetCurrentDirectory(), @"wwwroot\images")), RequestPath = new PathString("/MyImages") }); //app.UseWelcomePage(); app.Run(async context => { await context.Response.WriteAsync("Hello DNT!"); }); } } }
UseDefaultFiles کار فعال سازی شناسایی فایلهای پیش فرضی مانند index.html را در صورت ذکر نام ریشهی یک پوشه، انجام میدهد.
اولین UseStaticFiles تعریف شده، تمام مسیرهای فیزیکی ذیل wwwroot را عمومی میکند.
دومین UseStaticFiles تعریف شده، پوشهی MyStaticFiles واقع در خارج از wwwroot را عمومی میکند.
سومین UseStaticFiles تعریف شده، پوشهی فیزیکی wwwroot\images را به مسیر درخواستهای MyImages نگاشت میکند (http://localhost:7742/myimages) تا توسط DirectoryBrowser تعریف شده، قابل استفاده شود.
در آخر هم DirectoryBrowser تعریف شدهاست.
یک نکتهی امنیتی مهم
یک چنین قابلیتی (مرور فایلهای درون یک پوشه) به صورت پیش فرض بر روی تمام IISها به دلایل امنیتی غیرفعال است. به همین جهت بهتر است Middleware فوق را هیچگاه استفاده نکنید و به این قسمت صرفا از دیدگاه اطلاعات عمومی نگاه کنید.
ساده سازی تعاریف توزیع فایلهای استاتیک
Middleware دیگری به نام FileServer کار تعریف توزیع فایلهای استاتیک را ساده میکند. اگر آنرا تعریف کنید:
app.UseFileServer();
اگر خواستید DirectoryBrowsing آنرا نیز فعال کنید، پارامتر ورودی آنرا به true مقدار دهی کنید (که به صورت پیش فرض غیرفعال است):
app.UseFileServer(enableDirectoryBrowsing: true);
app.UseFileServer(new FileServerOptions { FileProvider = new PhysicalFileProvider( Path.Combine(Directory.GetCurrentDirectory(), @"MyStaticFiles")), RequestPath = new PathString("/StaticFiles"), EnableDirectoryBrowsing = false });
توزیع فایلهای ناشناخته
اگر به سورس ASP.NET Core 1.0 دقت کنید، کلاسی را به نام FileExtensionContentTypeProvider خواهید یافت. اینها پسوندها و mime typeهای متناظری هستند که توسط ASP.NET Core شناخته شده و توزیع میشوند. برای مثال اگر فایلی را به نام test.xyz به پوشهی wwwroot اضافه کنید، درخواست آن توسط کاربر، به Hello DNT ختم میشود؛ چون در این کلاس پایه، پسوند xyz تعریف نشدهاست.
برای رفع این مشکل و تکمیل این لیست میتوان به نحو ذیل عمل کرد:
// Set up custom content types -associating file extension to MIME type var provider = new FileExtensionContentTypeProvider(); provider.Mappings[".xyz"] = "text/html"; app.UseStaticFiles(new StaticFileOptions { ContentTypeProvider = provider }) ; // For the wwwroot folder
و یا اگر خواستید کمی تمیزتر کار کنید، بهتر است از کلاس پایه FileExtensionContentTypeProvider ارث بری کرده و سپس در سازندهی این کلاس، خاصیت Mappings را ویرایش نمود:
public class XyzContentTypeProvider : FileExtensionContentTypeProvider { public XyzContentTypeProvider() { this.Mappings.Add(".xyz", "text/html"); } }
app.UseStaticFiles(new StaticFileOptions { ContentTypeProvider = new XyzContentTypeProvider() }) ; // For the wwwroot folder
روش دیگر مدیریت این مساله، تنظیم مقدار خاصیت ServeUnknownFileTypes به true است:
app.UseStaticFiles(new StaticFileOptions { ServeUnknownFileTypes = true, DefaultContentType = "image/png" });