تعریف تزریق وابستگی تنظیمات برنامه
در مطلب «تزریق وابستگیها فراتر از کلاسها در برنامههای 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 را اجرا کنید، تا توکن سرور برنامه نیز فعال شود.
پر کردن مقادیر Enum در کلاینت
در مورد کار با jQuery Ajax و نحوه فراخوانی یک متد وب سرویس توسط آن، چند مطلب پیشتر ارائه شدند:
بررسی وجود نام کاربر با استفاده از jQuery Ajax در ASP.Net
و ...
تمام این مقالات یک ایراد مهم دارند که امروز با آن مواجه شدم و خلاصه آن به شرح زیر است:
پارامتر data متد Ajax جیکوئری را به صورت زیر در نظر بگیرید:
data: "{'username': '" + $('#<%= TextBox1.ClientID %>').val() + "'}",
\b Backspace (ascii code 08)
\f Form feed (ascii code 0C)
\n New line
\r Carriage return
\t Tab
\v Vertical tab
' Apostrophe or single quote
" Double quote
\ Backslash caracter
ابتدا ارجاعی از این اسکریپت باید به صفحه اضافه شود:
<script src="js/json2.js" type="text/javascript"></script>
var jsonText = JSON.stringify({ username: $('#<%= TextBox1.ClientID %>').val() });
...
data: jsonText,
به عنوان مثالی دیگر، اگر متد وب سرویس ما دو پارامتر داشت، jsonText به شکل زیر در خواهد آمد:
var jsonText = JSON.stringify({ param1: val1, param2: val2 });
مدل و کنترلر برنامه
namespace jQueryMvcSample07.Models { public class BlogPost { public int Id { set; get; } public string Title { set; get; } public string Body { set; get; } } }
using System.Web.Mvc; using System.Web.UI; using jQueryMvcSample07.Models; using jQueryMvcSample07.Security; namespace jQueryMvcSample07.Controllers { public class HomeController : Controller { [HttpGet] public ActionResult Index() { return View(); //نمایش یک منوی ساده در ابتدای کار } [HttpGet] public ActionResult ShowSynchronous() { var model = getModel(); return View(model); //نمایش همزمان } private static BlogPost getModel() { //شبیه سازی یک عملیات طولانی System.Threading.Thread.Sleep(3000); var model = new BlogPost { Title = "عنوان ... ", Body = "مطلب... " }; return model; } [HttpGet] public ActionResult ShowAsynchronous() { return View(); //نمایش ابتدایی صفحه } [HttpPost] [AjaxOnly] [OutputCache(Location = OutputCacheLocation.None, NoStore = true)] public ActionResult RenderAsynchronous() { //دریافت اطلاعات به صورت غیرهمزمان var model = getModel(); return PartialView(viewName: "_Post", model: model); } } }
در کنترلر Home، ابتدا اکشن متد Index آن فراخوانی شده و در این حالت دو لینک زیر نمایش داده میشوند:
@{ ViewBag.Title = "Index"; } <h2> نمایش اطلاعات به صورت همزمان و غیرهمزمان</h2> <ul> <li> @Html.ActionLink(linkText: "نمایش همزمان", actionName: "ShowSynchronous", controllerName: "Home") </li> <li> @Html.ActionLink(linkText: "نمایش غیر همزمان", actionName: "ShowAsynchronous", controllerName: "Home") </li> </ul>
در هر دو صفحه نهایتا از یک Partial View به نام _Post.cshtml برای نمایش اطلاعات استفاده خواهد شد:
@model jQueryMvcSample07.Models.BlogPost <fieldset> <legend>@Model.Title</legend> @Model.Body </fieldset>
@model jQueryMvcSample07.Models.BlogPost @{ ViewBag.Title = "ShowSynchronous"; } <h2>نمایش همزمان</h2> @{ Html.RenderPartial("_Post", Model); }
در حالتیکه کاربر بر روی لینک نمایش غیرهمزمان کلیک میکند، صفحه زیر را مشاهده خواهد کرد:
@{ ViewBag.Title = "ShowAsynchronous"; var loadInfoUrl = Url.Action(actionName: "RenderAsynchronous", controllerName: "Home"); } <h2> نمایش غیر همزمان</h2> <div id="info" align="center"> </div> <div id="progress" align="center" style="display: none"> <br /> <img src="@Url.Content("~/Content/images/loadingAnimation.gif")" alt="loading..." /> </div> @section JavaScript { <script type="text/javascript"> $(function () { $("#progress").css("display", "block"); $.ajax({ type: "POST", url: '@loadInfoUrl', complete: function (xhr, status) { var data = xhr.responseText; if (xhr.status == 403) { window.location = "/login"; } else if (status === 'error' || !data || data == "nok") { alert('خطایی رخ داده است'); } else { $("#progress").css("display", "none"); $("#info").html(data); } } }); }); </script> }
به این ترتیب میتوان حس سریع بودن سایت را زمانیکه قسمتی از صفحه نیاز به زمان بیشتری برای نمایش اطلاعات دارد، به کاربر منتقل کرد.
دریافت پروژه کامل این قسمت
jQueryMvcSample07.zip
تاریخ شمسی با Extension Method برای DateTime
قسمت بالاشم که اسم ماه و سال هست، نوشته مثلا مرداد 2013. در کل سال رو به همون صورت میلادی نشون میده و بقیه رو به شمسی. این CultureInfo شما چیزی دیگهای کم نداره که اونم رو هم مقدار دهی کنیم، تکمیل بشه؟ من احساس میکنم همه توابع و خصوصیتهاش مقدار دهی نشده. در ضمن با اینکه تقویمشو Persian Calender دادیم پس چرا هنوز داره با همون میلادی روزها و بقیه رو حساب میکنه. تازه خلاصه اسم روزها رو هم نشون نمیده. جالب اینجاست که وقتی من تاریخ سیستم خودمو فارسی میکنم مثلا این چیزاشو نمایش میده. چیزایی مثله خلاصه اسم روز. اگه بشه اینو یه جورایی فارسی کرد خیلی خوب میشه. هم کلی تمپلیت داره. هم کلی افکت خوب داره. هم خود تلریک یه نوع Project template برای MVC4 درست کرده که وقتی باهاش پروژتونو ایجاد میکنید دیگه خودش اتوماتیک هرجا فیلدتونو تاریخ تعریف کنید خیلی باحال همه چیز رو میاره. ببینین میتونین یه کاریش بکنین. باز ممنون.
»اگر با #F آشنایی ندارید میتوانید از اینجا شروع کنید.
به صورت کلی برای استفاده گسترده از #F در پروژههای وب نیاز به یک سری templateهای آماده داریم در غیر این صورت کار کمی سخت خواهد شد. به تصویر زیر دقت نمایید:
واضح است که با توجه به تصویر بالا کنترلرها و البته مدلهای app و هر آنچه که سمت سرور به آن نیاز است باید با استفاده از #F پیاده سازی شوند. اما هنگامی که کنترلرها با استفاده از #F نوشته شوند سیستم مسیر یابی نیز تحت تاثیر قرار خواهد گرفت. علاوه بر آن باید فکری برای بخش Bundling و همچنین فیلترهاو... نمود. در نتیجه با توجه به template پروژه مورد نظر بر خلاف حالت پیش فرض #C آن که در قالب یک پروژه ارائه میشود در این جا حداقل به دو پروژه نیاز داریم. خوشبختانه همانند پروژه FSharpX که برای WPF مناسب است برای MVC نیز template آماده موجود است که در ادامه با آن آشنا خواهیم شد.
شروع به کار
ابتدا در VS.Net یک پروژه جدید ایجاد نمایید. از بخش Online Template گزینه FSharp MVC 4 را جستجو کنید.
بعد از انتخاب نام پروژه و کلیک بر روی Ok ( و البته دانلود حدود ده MB اطلاعات) صفحه زیر نمایان میشود. در این قسمت تنظیمات مربوط به انتخاب View Engine و نوع قالب پروژه را وجود دارد. در صورتی که قصد استفاده از Web Api را دارید گزینه Web Api Project را انتخاب کنید در غیر این صورت گزینه Empty Project.
البته از Visual Studio 2012 به بعد این بخش به صورت زیر خواهد بود که قسمت Single Page App به آن اضافه شده است:
بعد از کلیک بر روی Ok یک پروژه بر اساس Template مورد نظر ساخته میشود. همانند تصویر زیر:
بررسی تغییرات
در یک نگاه به راحتی میتوان تغییرات زیر را در پروژه Web تشخیص داد:
»پوشه Controller وجود ندارد؛
»پوشه مدل وجود ندارد؛
»فایل Global.asax دیگر فایلی به نام Global.asax.cs را همراه با خود ندارد.
دلیل اصلی عدم وجود موارد بالا این است که تمام این موارد باید به صورت #F پیاده سازی شوند در نتیجه به پروژه #F ساخته شده منتقل شده اند. فایل Global.asax را باز نمایید. سورس زیر قابل مشاهده است:
<%@ Application Inherits="FsWeb.Global" Language="C#" %> <script Language="C#" RunAt="server">
// Defines the Application_Start method and calls Start in // System.Web.HttpApplication from which Global inherits. protected void Application_Start(Object sender, EventArgs e) { base.Start(); } </script>
حال به بررسی پروژه #F ساخته شده خواهیم پرداخت. در این پروژه یک فایل Global.fs وجود دارد که سورس آن به صورت زیر است:
namespace FsWeb open System open System.Web open System.Web.Mvc open System.Web.Routing type Route = { controller : string action : string id : UrlParameter } type Global() = inherit System.Web.HttpApplication() static member RegisterRoutes(routes:RouteCollection) = routes.IgnoreRoute("{resource}.axd/{*pathInfo}") routes.MapRoute("Default", "{controller}/{action}/{id}", { controller = "Home"; action = "Index" id = UrlParameter.Optional } ) member this.Start() = AreaRegistration.RegisterAllAreas() Global.RegisterRoutes(RouteTable.Routes)
RouteConfig.RegisterRoutes(RouteTable.Routes);
public class RouteConfig { public static void RegisterRoutes(RouteCollection routes) { routes.IgnoreRoute("{resource}.axd/{*pathInfo}"); routes.MapRoute( name: "Default", url: "{controller}/{action}/{id}", defaults: new { controller = "Home", action = "Index", id = UrlParameter.Optional } ); } }
//تعریف الکوی مسیر یابی type Route = { controller : string action : string id : UrlParameter } type Global() = inherit System.Web.HttpApplication() static member RegisterRoutes(routes:RouteCollection) = //فراخوانی و انتساب الگوی مسیر یابی به مسیرهای تعریف شده routes.IgnoreRoute("{resource}.axd/{*pathInfo}") routes.MapRoute("Default", "{controller}/{action}/{id}", { controller = "Home"; action = "Index" id = UrlParameter.Optional } )
Global.RegisterRoutes(RouteTable.Routes)
در این پست با Template پروژههای F# MVC 4 اشنا شدیم و از طرفی مشخص شد که برای پیاده سازی این گونه پروژهها حداقل نیاز به دو پروژه داریم. یک پروژه که از نوع #C است ولی در آن فقط View ها و فایل جاوااسکریپتی و البته Css وجود دارد. از طرف دیگر کنترلرها و مدلها و هر چیز دیگر که مربوط به سمت سرور است در قالب یک پروژه #F پیاده سازی میشود.
نصب پیشنیازهای کار با Gulp و TypeScript
فایل package.json در قسمت اول این سری معرفی شد. دراینجا قسمت devDependencies آنرا به نحو ذیل تکمیل کنید:
"devDependencies": { "typescript": "^1.8.10", "gulp": "^3.9.1", "path": "^0.12.7", "gulp-clean": "^0.3.2", "fs": "^0.0.2", "gulp-concat": "^2.6.0", "gulp-typescript": "^2.13.1", "gulp-tsc": "^1.1.5", "del": "^2.2.0", "gulp-autoprefixer": "^3.1.0", "gulp-cssnano": "^2.0.0", "gulp-html-replace": "^1.5.4", "gulp-htmlmin": "^1.0.5", "gulp-uglify": "^1.5.3", "merge-stream": "^1.0.0", "systemjs-builder": "^0.15.16", "typings": "^0.8.1" },
نکتهی مهم آن systemjs-builder است. این کتابخانه کار کامپایل systemjs.config.js را به یک تک اسکریپت انجام میدهد. به این ترتیب مشکل صدها بار رفت و برگشت به سرور، برای دریافت وابستگیهای AngularJS 2.0، به طور کامل برطرف میشود.
افزودن فایل gulpfile.js به پروژه
یا یک فایل جدید جاوا اسکریپتی را به نام gulpfile.js به ریشهی پروژه اضافه کنید و یا از منوی project -> add new item نیز میتوانید گزینهی gulp configuration file را در VS 2015 انتخاب نمائید. محتوای این فایل را به نحو ذیل تغییر دهید:
var gulp = require("gulp"), concat = require("gulp-concat"), tsc = require("gulp-typescript"), jsMinify = require("gulp-uglify"), cssPrefixer = require("gulp-autoprefixer"), cssMinify = require("gulp-cssnano"), del = require("del"), merge = require("merge-stream"), minifyHTML = require('gulp-htmlmin'), SystemBuilder = require("systemjs-builder"); var appFolder = "./app"; var outFolder = "wwwroot"; gulp.task("clean", () => { return del(outFolder); }); gulp.task("shims", () => { return gulp.src([ "node_modules/es6-shim/es6-shim.js", "node_modules/zone.js/dist/zone.js", "node_modules/reflect-metadata/Reflect.js" ]) .pipe(concat("shims.js")) .pipe(jsMinify()) .pipe(gulp.dest(outFolder + "/js/")); }); gulp.task("tsc", () => { var tsProject = tsc.createProject("./tsconfig.json"); var tsResult = gulp.src([ appFolder + "/**/*.ts" ]) .pipe(tsc(tsProject), undefined, tsc.reporter.fullReporter()); return tsResult.js.pipe(gulp.dest("build/")); }); gulp.task("system-build", ["tsc"], () => { var builder = new SystemBuilder(); return builder.loadConfig("systemjs.config.js") .then(() => builder.buildStatic(appFolder, outFolder + "/js/bundle.js")) .then(() => del("build")); }); gulp.task("buildAndMinify", ["system-build"], () => { var bundle = gulp.src(outFolder + "/js/bundle.js") .pipe(jsMinify()) .pipe(gulp.dest(outFolder + "/js/")); var css = gulp.src(outFolder + "/css/styles.css") .pipe(cssMinify()) .pipe(gulp.dest(outFolder + "/css/")); return merge(bundle, css); }); gulp.task("favicon", function () { return gulp.src("./app/favicon.ico") .pipe(gulp.dest(outFolder)); }); gulp.task("css", function () { return gulp.src(appFolder + "/**/*.css") .pipe(cssPrefixer()) .pipe(cssMinify()) .pipe(gulp.dest(outFolder)); }); gulp.task("templates", function () { return gulp.src(appFolder + "/**/*.html") .pipe(minifyHTML()) .pipe(gulp.dest(outFolder)); }); gulp.task("assets", ["templates", "css", "favicon"], function () { return gulp.src(appFolder + "/**/*.png") .pipe(gulp.dest(outFolder)); }); gulp.task("otherScriptsAndStyles", () => { gulp.src([ "jquery/dist/jquery.*js", "bootstrap/dist/js/bootstrap*.js" ], { cwd: "node_modules/**" }) .pipe(gulp.dest(outFolder + "/js/")); gulp.src([ "node_modules/bootstrap/dist/css/bootstrap.css" ]).pipe(cssMinify()).pipe(gulp.dest(outFolder + "/css/")); gulp.src([ "node_modules/bootstrap/fonts/*.*" ]).pipe(gulp.dest(outFolder + "/fonts/")); }); //gulp.task("watch.tsc", ["tsc"], function () { // return gulp.watch(appFolder + "/**/*.ts", ["tsc"]); //}); //gulp.task("watch", ["watch.tsc"]); gulp.task("default", [ "shims", "buildAndMinify", "assets", "otherScriptsAndStyles" //,"watch" ]);
در این فایل فرض شدهاست که خروجی نهایی برنامه قرار است در پوشهای به نام wwwroot کپی شود و پوشهی اصلی برنامه، همان پوشهای به نام app، در ریشهی پروژه است.
var appFolder = "./app"; var outFolder = "wwwroot";
1) وظیفهی clean، کار تمیز کردن پوشهی نهایی خروجی برنامه را انجام میدهد (حذف تمام فایلهای آن).
2) وظیفهی shims، کار بسته بندی، یکی کردن و فشرده کردن سه اسکریپت es6-shim.js، zone.js و Reflect.js را انجام میدهد. سپس تک فایل حاصل را به نام shims.js، در پوشهی wwwroot/js کپی میکند.
3) وظیفهی tsc، یکبار دیگر کامپایلر TypeScript را اجرا میکند تا مطمئن شویم با آخرین نگارش فایلهای js برنامه کار میکنیم.
4) وظیفهی system-build، کار پردازش خودکار مداخل فایل systemjs.config.js را انجام میدهد. در آخرین نگارش ارائه شدهی AngularJS 2.0، بجای ذکر مداخل مورد نیاز آن، این تک فایل systemjs.config.js را به صفحه پیوست میکنیم تا اسکریپتهای لازم را (چند صد عدد)، به صورت خودکار بارگذاری کند. برای یکی کردن این چند صد عدد اسکریپت، از کتابخانهی SystemBuilder آن کمک گرفته و کار کامپایل نهایی را انجام میدهیم. خروجی تمام این فایلها، به همراه کلیه فایلهای js حاصل از کامپایل فایلهای TypeScript برنامه، در فایلی به نام bundle.js کپی شدهی در پوشهی wwwroot/js نوشته میشود. بنابراین دیگر نیازی نیست تا فایلهای js پوشهی app و همچنین فایلهای js وابستگیهای AngularJS 2.0 را توزیع کنیم. تک فایل bundle.js، حاوی تمام اینها است.
5) وظیفهی buildAndMinify کار اجرای وظیفهی system-bulder را به همراه فشرده سازی تک فایل bundle.js، به عهده دارد. به علاوه اگر در پوشهی css آن نیز فایل styles.css موجود باشد، آن را فشرده میکند.
6) در ادامه یک سری وظیفهی کپی کردن منابع برنامه را مشاهده میکنید. مانند favicon که کار کپی کردن این آیکن را به پوشهی wwwroot انجام میدهد. وظیفهی css، فایلهای css موجود در پوشههای برنامه را به wwwroot و زیر پوشههای آن کپی میکند. وظیفهی templates، کار کپی کردن فایلهای html قالبهای کامپوننتها را بر عهده دارد. وظیفهی assets، کار کپی کردن فایلهای png را انجام میدهد.
7) وظیفهی otherScriptsAndStyles یک سری css و js ثالث را به پوشهی wwwroot کپی میکند؛ مانند فایلهای بوت استرپ و جیکوئری.
8) وظیفهی default، کار اجرای تمام این وظایف را با هم به عهده دارد.
اکنون اگر بر روی gulpfile.js کلیک راست کنید، گزینهی task runner explorer ظاهر خواهد شد. آنرا انتخاب کنید:
بر روی وظیفهی default کلیک راست کرده و آنرا اجرا کنید. پس از مدتی پوشهی جدید wwwroot ساخته شده و فایلهای نهایی برنامه به آن کپی میشوند.
اصلاح فایل index.html و یا Views\Shared\_Layout.cshtml
اکنون که تمام فایلهای مورد نیاز پروژه در پوشهی wwwroot کپی شدهاند، نیاز است فایل index.html را به نحو ذیل تغییر داد:
<!DOCTYPE html> <html> <head> <base href="/"> <meta charset="utf-8" /> <meta name="viewport" content="width=device-width, initial-scale=1.0"> <title>@ViewBag.Title - My ASP.NET Application</title> <link href="~/wwwroot/css/bootstrap.css" rel="stylesheet" /> <link href="~/wwwroot/app.component.css" rel="stylesheet" /> <link href="~/Content/Site.css" rel="stylesheet" type="text/css" /> <script src="~/wwwroot/js/shims.js"></script> </head> <body> <div> @RenderBody() <pm-app>Loading App...</pm-app> </div> <script src="~/wwwroot/js/jquery/dist/jquery.min.js"></script> <script src="~/wwwroot/js/bootstrap/dist/js/bootstrap.min.js"></script> <script src="~/wwwroot/js/bundle.js"></script> @RenderSection("Scripts", required: false) </body> </html>
اسکریپتهای shims که برای مرورگرهای قدیمیتر درنظر گرفته شدهاند، به تک فایل wwwroot/js/shims.js منتقل شدهاند.
تمام اسکریپتهای AngularJS 2.0 و وابستگیهای آن به همراه تمام اسکریپتهای برنامهی خودمان، به تک فایل wwwroot/js/bundle.js منتقل شدهاند.
اکنون اگر برنامه را اجرا کنید، سرعت آن با قبل قابل مقایسه نیست! اینبار دیگر نه نیازی به بارگذاری تمام وابستگیهای AngularJS 2.0 به صورت مجزا توسط systemjs.config.js وجود دارد و نه به ازای مشاهدهی هر صفحهای، یکبار قرار است فایل js کامپوننت آن بارگذاری شود. تمام اینها داخل فایل wwwroot/js/bundle.js قرار گرفتهاند و تنها یکبار بارگذاری میشوند.
کدهای کامل این قسمت را از اینجا میتوانید دریافت کنید: MVC5Angular2.part12.zip
خلاصهی بحث
با نوشتن یک Gulp Task جدید میتوان بر اساس فایل systemjs.config.js، تمام اسکریپتهای دخیل در اجرای برنامه را به صورت خودکار یافته و به صورت یک تک فایل نهایی، بسته بندی و توزیع کرد.
بررسی طول عمر توکنها
اگر مرورگر خود را پس از لاگین به سیستم، برای مدتی به حال خود رها کنید، پس از شروع به کار مجدد، مشاهده خواهید کرد که دیگر نمیتوانید به API دسترسی پیدا کنید. علت اینجا است که Access token صادر شده، منقضی شدهاست. تمام توکنها، دارای طول عمر مشخصی هستند و پس از سپری شدن این زمان، دیگر اعتبارسنجی نخواهند شد. زمان انقضای توکن، در خاصیت یا claim ویژهای به نام exp ذخیره میشود.
در اینجا ما دو نوع توکن را داریم: Identity token و Access token
از Identity token برای ورود به سیستم کلاینت استفاده میشود و به صورت پیشفرض طول عمر کوتاه آن به 5 دقیقه تنظیم شدهاست. علت کوتاه بودن این زمان این است که این توکنها تنها یکبار مورد استفاده قرار میگیرد و پس از ارائهی آن به کلاینت، از طریق آن Claim Identity تولید میشود. پس از آن طول عمر Claim Identity تولید شده صرفا به تنظیمات برنامهی کلاینت مرتبط است و میتواند از تنظیمات IDP کاملا مجزا باشد؛ مانند پیاده سازی sliding expiration. در این حالت تا زمانیکه کاربر در برنامه فعال است، در حالت logged in باقی خواهد ماند.
Access tokenها متفاوت هستند. طول عمر پیشفرض آنها به یک ساعت تنظیم شدهاست و نسبت به Identity token طول عمر بیشتری دارند. پس از اینکه این زمان سپری شد، تنها با داشتن یک Access token جدید است که دسترسی ما مجددا به Web API برقرار خواهد شد. بنابراین در اینجا ممکن است هنوز در برنامهی کلاینت در حالت logged in قرار داشته باشیم، چون هنوز طول عمر Claim Identity آن به پایان نرسیدهاست، اما نتوانیم با قسمتهای مختلف برنامه کار کنیم، چون نمیتوانیم از یک Access token منقضی شده جهت دسترسی به منابع محافظت شدهی سمت Web API استفاده نمائیم. در اینجا دیگر برنامهی کلاینت هیچ نقشی بر روی تعیین طول عمر یک Access token ندارد و این طول عمر صرفا توسط IDP به تمام کلاینتهای آن دیکته میشود.
در اینجا برای دریافت یک Access token جدید، نیاز به یک Refresh token داریم که صرفا برای «کلاینتهای محرمانه» که در قسمت سوم این سری آنها را بررسی کردیم، توصیه میشود.
چگونه میتوان زمان انقضای توکنها را صریحا تنظیم کرد؟
برای تنظیم زمان انقضای توکنها، از کلاس src\IDP\DNT.IDP\Config.cs سمت IDP شروع میکنیم.
namespace DNT.IDP { public static class Config { public static IEnumerable<Client> GetClients() { return new List<Client> { new Client { ClientName = "Image Gallery", // IdentityTokenLifetime = ... // defaults to 300 seconds / 5 minutes // AuthorizationCodeLifetime = ... // defaults to 300 seconds / 5 minutes // AccessTokenLifetime = ... // defaults to 3600 seconds / 1 hour } }; } } }
- مقدار خاصیت AuthorizationCodeLifetime تنظیمات یک کلاینت، عدد صحیحی است با مقدار پیشفرض 300 ثانیه یا معادل 5 دقیقه که طول عمر AuthorizationCode را تعیین میکند. این مورد، طول عمر توکن خاصی نیست و در حین فراخوانی Token Endpoint مبادله میشود و در طی Hybrid flow رخ میدهد. بنابراین مقدار پیشفرض آن بسیار مناسب بوده و نیازی به تغییر آن نیست.
- مقدار خاصیت AccessTokenLifetime تنظیمات یک کلاینت، عدد صحیحی است با مقدار پیشفرض 3600 ثانیه و یا معادل 1 ساعت و طول عمر Access token تولید شدهی توسط این IDP را مشخص میکند.
البته باید درنظر داشت اگر طول عمر این توکن دسترسی را برای مثال به 120 یا 2 دقیقه تنظیم کنید، پس از سپری شدن این 2 دقیقه ... هنوز هم برنامهی کلاینت قادر است به Web API دسترسی داشته باشد. علت آن وجود بازهی 5 دقیقهای است که در طی آن، انجام این عملیات مجاز شمرده میشود و برای کلاینتهایی درنظر گرفته شدهاست که ساعت سیستم آنها ممکن است اندکی با ساعت سرور IDP تفاوت داشته باشند.
درخواست تولید یک Access Token جدید با استفاده از Refresh Tokens
زمانیکه توکنی منقضی میشود، کاربر باید مجددا به سیستم لاگین کند تا توکن جدیدی برای او صادر گردد. برای بهبود این تجربهی کاربری، میتوان در کلاینتهای محرمانه با استفاده از Refresh token، در پشت صحنه عملیات دریافت توکن جدید را انجام داد و در این حالت دیگر کاربر نیازی به لاگین مجدد ندارد. در این حالت برنامهی کلاینت یک درخواست از نوع POST را به سمت IDP ارسال میکند. در این حالت عملیات Client Authentication نیز صورت میگیرد. یعنی باید مشخصات کامل کلاینت را به سمت IDP ارسال کرد. در اینجا اطلاعات هویت کلاینت در هدر درخواست و Refresh token در بدنهی درخواست به سمت سرور IDP ارسال خواهند شد. پس از آن IDP اطلاعات رسیده را تعیین اعتبار کرده و در صورت موفقیت آمیز بودن عملیات، یک Access token جدید را به همراه Identity token و همچنین یک Refresh token جدید دیگر، صادر میکند.
برای صدور مجوز درخواست یک Refresh token، نیاز است scope جدیدی را به نام offline_access معرفی کنیم. به این معنا که امکان دسترسی به برنامه حتی در زمانیکه offline است، وجود داشته باشد. بنابراین offline در اینجا به معنای عدم لاگین بودن شخص در سطح IDP است.
بنابراین اولین قدم پیاده سازی کار با Refresh token، مراجعهی به کلاس src\IDP\DNT.IDP\Config.cs و افزودن خاصیت AllowOfflineAccess با مقدار true به خواص یک کلاینت است:
namespace DNT.IDP { public static class Config { public static IEnumerable<Client> GetClients() { return new List<Client> { new Client { ClientName = "Image Gallery", // IdentityTokenLifetime = ... // defaults to 300 seconds / 5 minutes // AuthorizationCodeLifetime = ... // defaults to 300 seconds / 5 minutes // AccessTokenLifetime = ... // defaults to 3600 seconds / 1 hour AllowOfflineAccess = true, // AbsoluteRefreshTokenLifetime = ... // Defaults to 2592000 seconds / 30 days // RefreshTokenExpiration = TokenExpiration.Sliding UpdateAccessTokenClaimsOnRefresh = true, // ... } }; } } }
- البته RefreshToken ضرورتی ندارد که طول عمر Absolute و یا کاملا تعیین شدهای را داشته باشد. این رفتار را توسط خاصیت RefreshTokenExpiration میتوان به TokenExpiration.Sliding نیز تنظیم کرد. البته حالت پیشفرض آن بسیار مناسب است.
- در اینجا میتوان خاصیت UpdateAccessTokenClaimsOnRefresh را نیز به true تنظیم کرد. فرض کنید یکی از Claims کاربر مانند آدرس او تغییر کردهاست. به صورت پیشفرض با درخواست مجدد توکن توسط RefreshToken، این Claims به روز رسانی نمیشوند. با تنظیم این خاصیت به true این مشکل برطرف خواهد شد.
پس از تنظیم IDP جهت صدور RefreshToken، اکنون کلاس ImageGallery.MvcClient.WebApp\Startup.cs برنامهی MVC Client را به صورت زیر تکمیل میکنیم:
ابتدا در متد تنظیمات AddOpenIdConnect، نیاز است صدور درخواست scope جدید offline_access را صادر کنیم:
options.Scope.Add("offline_access");
در ادامه نیاز است به سرویس ImageGalleryHttpClient مراجعه کرده و کدهای آنرا به صورت زیر تغییر داد:
using System; using System.Collections.Generic; using System.Globalization; using System.Net.Http; using System.Net.Http.Headers; using System.Threading.Tasks; using IdentityModel.Client; using Microsoft.AspNetCore.Authentication; using Microsoft.AspNetCore.Http; using Microsoft.Extensions.Configuration; using Microsoft.Extensions.Logging; using Microsoft.IdentityModel.Protocols.OpenIdConnect; namespace ImageGallery.MvcClient.Services { public interface IImageGalleryHttpClient { Task<HttpClient> GetHttpClientAsync(); } /// <summary> /// A typed HttpClient. /// </summary> public class ImageGalleryHttpClient : IImageGalleryHttpClient { private readonly HttpClient _httpClient; private readonly IConfiguration _configuration; private readonly IHttpContextAccessor _httpContextAccessor; private readonly ILogger<ImageGalleryHttpClient> _logger; public ImageGalleryHttpClient( HttpClient httpClient, IConfiguration configuration, IHttpContextAccessor httpContextAccessor, ILogger<ImageGalleryHttpClient> logger) { _httpClient = httpClient; _configuration = configuration; _httpContextAccessor = httpContextAccessor; _logger = logger; } public async Task<HttpClient> GetHttpClientAsync() { var accessToken = string.Empty; var currentContext = _httpContextAccessor.HttpContext; var expires_at = await currentContext.GetTokenAsync("expires_at"); if (string.IsNullOrWhiteSpace(expires_at) || ((DateTime.Parse(expires_at).AddSeconds(-60)).ToUniversalTime() < DateTime.UtcNow)) { accessToken = await RenewTokens(); } else { accessToken = await currentContext.GetTokenAsync(OpenIdConnectParameterNames.AccessToken); } if (!string.IsNullOrWhiteSpace(accessToken)) { _logger.LogInformation($"Using Access Token: {accessToken}"); _httpClient.SetBearerToken(accessToken); } _httpClient.BaseAddress = new Uri(_configuration["WebApiBaseAddress"]); _httpClient.DefaultRequestHeaders.Accept.Clear(); _httpClient.DefaultRequestHeaders.Accept.Add(new MediaTypeWithQualityHeaderValue("application/json")); return _httpClient; } private async Task<string> RenewTokens() { // get the current HttpContext to access the tokens var currentContext = _httpContextAccessor.HttpContext; // get the metadata var discoveryClient = new DiscoveryClient(_configuration["IDPBaseAddress"]); var metaDataResponse = await discoveryClient.GetAsync(); // create a new token client to get new tokens var tokenClient = new TokenClient( metaDataResponse.TokenEndpoint, _configuration["ClientId"], _configuration["ClientSecret"]); // get the saved refresh token var currentRefreshToken = await currentContext.GetTokenAsync(OpenIdConnectParameterNames.RefreshToken); // refresh the tokens var tokenResult = await tokenClient.RequestRefreshTokenAsync(currentRefreshToken); if (tokenResult.IsError) { throw new Exception("Problem encountered while refreshing tokens.", tokenResult.Exception); } // update the tokens & expiration value var updatedTokens = new List<AuthenticationToken>(); updatedTokens.Add(new AuthenticationToken { Name = OpenIdConnectParameterNames.IdToken, Value = tokenResult.IdentityToken }); updatedTokens.Add(new AuthenticationToken { Name = OpenIdConnectParameterNames.AccessToken, Value = tokenResult.AccessToken }); updatedTokens.Add(new AuthenticationToken { Name = OpenIdConnectParameterNames.RefreshToken, Value = tokenResult.RefreshToken }); var expiresAt = DateTime.UtcNow + TimeSpan.FromSeconds(tokenResult.ExpiresIn); updatedTokens.Add(new AuthenticationToken { Name = "expires_at", Value = expiresAt.ToString("o", CultureInfo.InvariantCulture) }); // get authenticate result, containing the current principal & properties var currentAuthenticateResult = await currentContext.AuthenticateAsync("Cookies"); // store the updated tokens currentAuthenticateResult.Properties.StoreTokens(updatedTokens); // sign in await currentContext.SignInAsync("Cookies", currentAuthenticateResult.Principal, currentAuthenticateResult.Properties); // return the new access token return tokenResult.AccessToken; } } }
پیشتر در قسمت ششم، روش کار مستقیم با DiscoveryClient و TokenClient را در حین کار با UserInfo Endpoint جهت دریافت دستی اطلاعات claims از IDP بررسی کردیم. در اینجا به همین ترتیب با TokenEndpoint کار میکنیم. به همین جهت توسط DiscoveryClient، متادیتای IDP را که شامل آدرس TokenEndpoint است، استخراج کرده و توسط آن TokenClient را به همراه اطلاعات کلاینت تشکیل میدهیم.
سپس مقدار refresh token فعلی را نیاز داریم. زیرا توسط آن است که میتوانیم درخواست دریافت یکسری توکن جدید را ارائه دهیم. پس از آن با فراخوانی tokenClient.RequestRefreshTokenAsync(currentRefreshToken)، تعدادی توکن جدید را از سمت IDP دریافت میکنیم. لیست آنها را تهیه کرده و توسط آن کوکی جاری را به روز رسانی میکنیم. در این حالت نیاز است مجددا SignInAsync فراخوانی شود تا کار به روز رسانی کوکی نهایی گردد.
خروجی این متد، مقدار access token جدید است.
پس از آن در متد GetHttpClientAsync بررسی میکنیم که آیا نیاز است کار refresh token صورت گیرد یا خیر؟ برای این منظور مقدار expires_at را دریافت و با زمان جاری با فرمت UTC مقایسه میکنیم. 60 ثانیه پیش از انقضای توکن، متد RenewTokens فراخوانی شده و توسط آن access token جدیدی برای استفادهی در برنامه صادر میشود. مابقی این متد مانند قبل است و این توکن دسترسی را به همراه درخواست از Web API به سمت آن ارسال میکنیم.
معرفی Reference Tokens
تا اینجا با توکنهایی از نوع JWT کار کردیم. این نوع توکنها، به همراه تمام اطلاعات مورد نیاز جهت اعتبارسنجی آنها در سمت کلاینت، بدون نیاز به فراخوانی مجدد IDP به ازای هر درخواست هستند. اما این نوع توکنها به همراه یک مشکل نیز هستند. زمانیکه صادر شدند، دیگر نمیتوان طول عمر آنها را کنترل کرد. اگر طول عمر یک Access token به مدت 20 دقیقه تنظیم شده باشد، میتوان مطمئن بود که در طی این 20 دقیقه حتما میتوان از آن استفاده کرد و دیگر نمیتوان در طی این بازهی زمانی دسترسی آنرا بست و یا آنرا برگشت زد. اینجاست که Reference Tokens معرفی میشوند. بجای قرار دادن تمام اطلاعات در یک JWT متکی به خود، این نوع توکنهای مرجع، فقط یک Id هستند که به توکن اصلی ذخیره شدهی در سطح IDP لینک میشوند و به آن اشاره میکنند. در این حالت هربار که نیاز به دسترسی منابع محافظت شدهی سمت API را با یک چنین توکن دسترسی لینک شدهای داشته باشیم، Reference Token در پشت صحنه (back channel) به IDP ارسال شده و اعتبارسنجی میشود. سپس محتوای اصلی آن به سمت API ارسال میشود. این عملیات از طریق endpoint ویژهای در IDP به نام token introspection endpoint انجام میشود. به این ترتیب میتوان طول عمر توکن صادر شده را کاملا کنترل کرد؛ چون تنها تا زمانیکه در data store مربوط به IDP وجود خارجی داشته باشند، قابل استفاده خواهند بود. بنابراین نسبت به حالت استفادهی از JWTهای متکی به خود، تنها عیب آن زیاد شدن ترافیک به سمت IDP جهت اعتبارسنجی Reference Tokenها به ازای هر درخواست به سمت Web API است.
چگونه از Reference Tokenها بجای JWTهای متکی به خود استفاده کنیم؟
برای استفادهی از Reference Tokenها بجای JWTها، ابتدا نیاز به مراجعهی به کلاس src\IDP\DNT.IDP\Config.cs و تغییر مقدار خاصیت AccessTokenType هر کلاینت است:
namespace DNT.IDP { public static class Config { public static IEnumerable<Client> GetClients() { return new List<Client> { new Client { ClientName = "Image Gallery", // ... AccessTokenType = AccessTokenType.Reference } }; } } }
اینبار اگر برنامه را اجرا کنید و در کلاس ImageGalleryHttpClient برنامهی کلاینت، بر روی سطر httpClient.SetBearerToken یک break-point قرار دهید، مشاهده خواهید کرد فرمت این توکن ارسالی به سمت Web API تغییر یافته و اینبار تنها یک Id سادهاست که دیگر قابل decode شدن و استخراج اطلاعات دیگری از آن نیست. با ادامه جریان برنامه و رسیدن این توکن به سمت Web API، درخواست رسیده برگشت خواهد خورد و اجرا نمیشود.
علت اینجا است که هنوز تنظیمات کار با token introspection endpoint انجام نشده و این توکن رسیدهی در سمت Web API قابل اعتبارسنجی و استفاده نیست. برای تنظیم آن نیاز است یک ApiSecret را در سطح Api Resource مربوط به IDP تنظیم کنیم:
namespace DNT.IDP { public static class Config { // api-related resources (scopes) public static IEnumerable<ApiResource> GetApiResources() { return new List<ApiResource> { new ApiResource( name: "imagegalleryapi", displayName: "Image Gallery API", claimTypes: new List<string> {"role" }) { ApiSecrets = { new Secret("apisecret".Sha256()) } } }; }
namespace ImageGallery.WebApi.WebApp { public class Startup { public void ConfigureServices(IServiceCollection services) { services.AddAuthentication(defaultScheme: IdentityServerAuthenticationDefaults.AuthenticationScheme) .AddIdentityServerAuthentication(options => { options.Authority = Configuration["IDPBaseAddress"]; options.ApiName = "imagegalleryapi"; options.ApiSecret = "apisecret"; });
اکنون اگر برنامه را اجرا کنید، ارتباط با token introspection endpoint به صورت خودکار برقرار شده، توکن رسیده اعتبارسنجی گردیده و برنامه بدون مشکل اجرا خواهد شد.
چگونه میتوان Reference Tokenها را از IDP حذف کرد؟
هدف اصلی استفادهی از Reference Tokenها به دست آوردن کنترل بیشتری بر روی طول عمر آنها است و حذف کردن آنها میتواند به روشهای مختلفی رخ دهد. برای مثال یک روش آن تدارک یک صفحهی Admin و ارائهی رابط کاربری برای حذف توکنها از منبع دادهی IDP است. روش دیگر آن حذف این توکنها از طریق برنامهی کلاینت با برنامه نویسی است؛ برای مثال در زمان logout شخص. برای این منظور، endpoint ویژهای به نام token revocation endpoint در نظر گرفته شدهاست. فراخوانی آن از سمت برنامهی کلاینت، امکان حذف توکنهای ذخیره شدهی در سمت IDP را میسر میکند.
به همین جهت به کنترلر ImageGallery.MvcClient.WebApp\Controllers\GalleryController.cs مراجعه کرده و متد Logout آنرا تکمیل میکنیم:
namespace ImageGallery.MvcClient.WebApp.Controllers { [Authorize] public class GalleryController : Controller { public async Task Logout() { await revokeTokens(); // Clears the local cookie ("Cookies" must match the name of the scheme) await HttpContext.SignOutAsync("Cookies"); await HttpContext.SignOutAsync("oidc"); } private async Task revokeTokens() { var discoveryClient = new DiscoveryClient(_configuration["IDPBaseAddress"]); var metaDataResponse = await discoveryClient.GetAsync(); var tokenRevocationClient = new TokenRevocationClient( metaDataResponse.RevocationEndpoint, _configuration["ClientId"], _configuration["ClientSecret"] ); var accessToken = await HttpContext.GetTokenAsync(OpenIdConnectParameterNames.AccessToken); if (!string.IsNullOrWhiteSpace(accessToken)) { var response = await tokenRevocationClient.RevokeAccessTokenAsync(accessToken); if (response.IsError) { throw new Exception("Problem accessing the TokenRevocation endpoint.", response.Exception); } } var refreshToken = await HttpContext.GetTokenAsync(OpenIdConnectParameterNames.RefreshToken); if (!string.IsNullOrWhiteSpace(refreshToken)) { var response = await tokenRevocationClient.RevokeRefreshTokenAsync(refreshToken); if (response.IsError) { throw new Exception("Problem accessing the TokenRevocation endpoint.", response.Exception); } } }
کدهای کامل این قسمت را از اینجا میتوانید دریافت کنید.
برای اجرای برنامه:
- ابتدا به پوشهی src\WebApi\ImageGallery.WebApi.WebApp وارد شده و dotnet_run.bat آنرا اجرا کنید تا WebAPI برنامه راه اندازی شود.
- سپس به پوشهی src\IDP\DNT.IDP مراجعه کرده و و dotnet_run.bat آنرا اجرا کنید تا برنامهی IDP راه اندازی شود.
- در آخر به پوشهی src\MvcClient\ImageGallery.MvcClient.WebApp وارد شده و dotnet_run.bat آنرا اجرا کنید تا MVC Client راه اندازی شود.
اکنون که هر سه برنامه در حال اجرا هستند، مرورگر را گشوده و مسیر https://localhost:5001 را درخواست کنید. در صفحهی login نام کاربری را User 1 و کلمهی عبور آنرا password وارد کنید.
EF Code First #7
تکرار داده در جداول مختلفی که به آن ارتباط پیدا میکنند ??
تکرار چه داده ای؟
سعید جان مطمئنا کلید شما تو جداول برای رابطه استفاده میشه حالا چه طبیعی باشه چه جانشین.
با فرض اینکه با 10 جدول ارتباط داشته باشه با n تعداد رکورد . حالا 6 ماهی یک بار این اتفاق چه ایرادی داره؟
بگذریم . موفق باشید