احراز هویت و اعتبارسنجی کاربران در برنامه‌های Angular - قسمت چهارم - به روز رسانی خودکار توکن‌ها
اندازه‌ی قلم متن
تخمین مدت زمان مطالعه‌ی مطلب: پنج دقیقه

در قسمت قبل، عملیات ورود به سیستم و خروج از آن‌را تکمیل کردیم. پس از ورود شخص به سیستم، هربار انقضای توکن دسترسی او، سبب خواهد شد تا وقفه‌ای در کار جاری کاربر، جهت لاگین مجدد صورت گیرد. برای این منظور، قسمتی از مطالب «اعتبارسنجی مبتنی بر JWT در ASP.NET Core 2.0 بدون استفاده از سیستم Identity»  و یا «پیاده سازی JSON Web Token با ASP.NET Web API 2.x» به تولید refresh_token در سمت سرور اختصاص دارد که از نتیجه‌ی آن در اینجا استفاده خواهیم کرد. عملیات به روز رسانی خودکار توکن دسترسی (access_token در اینجا) سبب خواهد شد تا کاربر پس از انقضای آن، نیازی به لاگین دستی مجدد نداشته باشد. این به روز رسانی در پشت صحنه و به صورت خودکار صورت می‌گیرد. refresh_token یک guid است که به سمت سرور ارسال می‌شود و برنامه‌ی سمت سرور، پس از تائید آن (بررسی صحت وجود آن در بانک اطلاعاتی و سپس واکشی اطلاعات کاربر متناظر با آن)، یک access_token جدید را صادر می‌کند.


ایجاد یک تایمر برای مدیریت دریافت و به روز رسانی توکن دسترسی

در مطلب «ایجاد تایمرها در برنامه‌های Angular» با روش کار با تایمرهای reactive آشنا شدیم. در اینجا قصد داریم از این امکانات جهت پیاده سازی به روز کننده‌ی خودکار access_token استفاده کنیم. در مطلب «احراز هویت و اعتبارسنجی کاربران در برنامه‌های Angular - قسمت دوم - سرویس اعتبارسنجی»، زمان انقضای توکن را به کمک کتابخانه‌ی jwt-decode، از آن استخراج کردیم:
  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;
  }
اکنون از این زمان در جهت تعریف یک تایمر خود متوقف شونده استفاده می‌کنیم:
  private refreshTokenSubscription: Subscription;

  scheduleRefreshToken() {
    if (!this.isLoggedIn()) {
      return;
    }

    this.unscheduleRefreshToken();

    const expiresAtUtc = this.getAccessTokenExpirationDateUtc().valueOf();
    const nowUtc = new Date().valueOf();
    const initialDelay = Math.max(1, expiresAtUtc - nowUtc);
    console.log("Initial scheduleRefreshToken Delay(ms)", initialDelay);
    const timerSource$ = Observable.timer(initialDelay);
    this.refreshTokenSubscription = timerSource$.subscribe(() => {
      this.refreshToken();      
    });
  }

  unscheduleRefreshToken() {
    if (this.refreshTokenSubscription) {
      this.refreshTokenSubscription.unsubscribe();
    }
  }
کار متد scheduleRefreshToken، شروع تایمر درخواست توکن جدید است.
- در ابتدا بررسی می‌کنیم که آیا کاربر لاگین کرده‌است یا خیر؟ آیا اصلا دارای توکنی هست یا خیر؟ اگر خیر، نیازی به شروع این تایمر نیست.
-  سپس تایمر قبلی را در صورت وجود، خاتمه می‌دهیم.
- در مرحله‌ی بعد، کار محاسبه‌ی میزان زمان تاخیر شروع تایمر Observable.timer را انجام می‌دهیم. پیشتر زمان انقضای توکن موجود را استخراج کرده‌ایم. اکنون این زمان را از زمان جاری سیستم برحسب UTC کسر می‌کنیم. مقدار حاصل، initialDelay این تایمر خواهد بود. یعنی این تایمر به مدت initialDelay صبر خواهد کرد و سپس تنها یکبار اجرا می‌شود. پس از اجرا، ابتدا متد refreshToken ذیل را فراخوانی می‌کند تا توکن جدیدی را دریافت کند.

در متد unscheduleRefreshToken کار لغو تایمر جاری در صورت وجود انجام می‌شود.

متد درخواست یک توکن جدید بر اساس refreshToken موجود نیز به صورت ذیل است:
  refreshToken() {
    const headers = new HttpHeaders({ "Content-Type": "application/json" });
    const model = { refreshToken: this.getRawAuthToken(AuthTokenType.RefreshToken) };
    return this.http
      .post(`${this.appConfig.apiEndpoint}/${this.appConfig.refreshTokenPath}`, model, { headers: headers })
      .finally(() => {
        this.scheduleRefreshToken();
      })
      .map(response => response || {})
      .catch((error: HttpErrorResponse) => Observable.throw(error))
      .subscribe(result => {
        console.log("RefreshToken Result", result);
        this.setLoginSession(result);
      });
  }
در اینجا هرزمانیکه تایمر اجرا شود، این متد فراخوانی شده و مقدار refreshToken فعلی را به سمت سرور ارسال می‌کند. سپس سرور این مقدار را بررسی کرده و در صورت تعیین اعتبار، یک access_token و refresh_token جدید را به سمت کلاینت ارسال می‌کند که نتیجه‌ی آن به متد setLoginSession جهت ذخیره سازی ارسال خواهد شد.
در آخر چون این تایمر، خود متوقف شونده‌است (متد Observable.timer بدون پارامتر دوم آن فراخوانی شده‌است)، یکبار دیگر کار زمانبندی دریافت توکن جدید بعدی را در متد finally انجام می‌دهیم (متد scheduleRefreshToken را مجددا فراخوانی می‌کنیم).


تغییرات مورد نیاز در سرویس Auth جهت زمانبندی دریافت توکن دسترسی جدید

تا اینجا متدهای مورد نیاز شروع زمانبندی دریافت توکن جدید، خاتمه‌ی زمانبندی و دریافت و به روز رسانی توکن جدید را پیاده سازی کردیم. محل قرارگیری و فراخوانی این متدها در سرویس Auth، به صورت ذیل هستند:
الف) در سازنده‌ی کلاس:
  constructor(
    @Inject(APP_CONFIG) private appConfig: IAppConfig,
    private browserStorageService: BrowserStorageService,
    private http: HttpClient,
    private router: Router
  ) {
    this.updateStatusOnPageRefresh();
    this.scheduleRefreshToken();
  }
این مورد برای مدیریت حالتی که کاربر صفحه را refresh کرده‌است و یا پس از مدتی مجددا از ابتدا برنامه را بارگذاری کرده‌است، مفید است.

ب) پس از لاگین موفقیت آمیز
در متد لاگین، پس از دریافت یک response موفقیت آمیز و تنظیم و ذخیره سازی توکن‌های دریافتی، کار زمانبندی دریافت توکن دسترسی بعدی بر اساس refresh_token فعلی انجام می‌شود:
this.setLoginSession(response);
this.scheduleRefreshToken();

ج) پس از خروج از سیستم
در متد logout، پس از حذف توکن‌های کاربر از کش مرورگر، کار لغو تایمر زمانبندی دریافت توکن بعدی نیز صورت خواهد گرفت:
this.deleteAuthTokens();
this.unscheduleRefreshToken();

در این حالت اگر برنامه را اجرا کنید، یک چنین خروجی را که بیانگر دریافت خودکار توکن‌های جدید است، پس از مدتی در کنسول developer مرورگر مشاهده خواهید کرد:


ابتدا متد scheduleRefreshToken اجرا شده و تاخیر آغازین تایمر محاسبه شده‌است. پس از مدتی متد refreshToken توسط تایمر فراخوانی شده‌است. در آخر مجددا متد scheduleRefreshToken جهت شروع یک زمانبندی جدید، اجرا شده‌است.

اعداد initialDelay محاسبه شده‌ای را هم که ملاحظه می‌کنید، نزدیک به همان 2 دقیقه‌ی تنظیمات سمت سرور در فایل appsettings.json هستند:
  "BearerTokens": {
    "Key": "This is my shared key, not so secret, secret!",
    "Issuer": "http://localhost/",
    "Audience": "Any",
    "AccessTokenExpirationMinutes": 2,
    "RefreshTokenExpirationMinutes": 60
  }


کدهای کامل این سری را از اینجا می‌توانید دریافت کنید.
برای اجرای آن فرض بر این است که پیشتر Angular CLI را نصب کرده‌اید. سپس از طریق خط فرمان به ریشه‌ی پروژه‌ی ASPNETCore2JwtAuthentication.AngularClient وارد شده و دستور npm install را صادر کنید تا وابستگی‌های آن دریافت و نصب شوند. در آخر با اجرای دستور ng serve -o، برنامه ساخته شده و در مرورگر پیش فرض سیستم نمایش داده خواهد شد (و یا همان اجرای فایل ng-serve.bat). همچنین باید به پوشه‌ی ASPNETCore2JwtAuthentication.WebApp نیز مراجعه کرده و فایل dotnet_run.bat را اجرا کنید، تا توکن سرور برنامه نیز فعال شود.
  • #
    ‫۶ سال و ۸ ماه قبل، دوشنبه ۱۱ دی ۱۳۹۶، ساعت ۱۳:۵۳
    در هنگام فراخوانی متد refreshToken اگر درخواست به هر علتی با شکست مواجه شود در نهایت متد scheduleRefreshToken فراخوانی میشود. 
    ...
    .post(`${this.appConfig.apiEndpoint}/${this.appConfig.refreshTokenPath}`, model, { headers: headers })
          .finally(() => {
            this.scheduleRefreshToken();
          })
    ...

    مثلا در صورت مواجه با خطای 401 مجددا متد scheduleRefreshToken  فرخوانی شده و چون از سمت سرور مقداری ارسال نمیشود در متد  scheduleRefreshToken  در خط:
    ...
    const initialDelay = Math.max(1, expiresAtUtc - nowUtc); //return 1
    ...

    همواره مقدار 1 برگردانده میشود و این چرخه مدام تکرار میشود!
    • #
      ‫۶ سال و ۸ ماه قبل، دوشنبه ۱۱ دی ۱۳۹۶، ساعت ۱۵:۲۲
      ابتدای متد scheduleRefreshToken بررسی isLoggedIn است:
      scheduleRefreshToken() {
          if (!this.isLoggedIn()) {
            return;
          }
      در قسمتی از isLoggedIn کار بررسی منقضی شدن طول عمر توکن هم انجام می‌شود. بنابراین پس از انقضای توکن، درخواستی به سمت سرور ارسال نخواهد شد.
      initialDelay هم همواره اندکی کمتر است از زمان انقضای توکن.
  • #
    ‫۶ سال و ۷ ماه قبل، سه‌شنبه ۲۴ بهمن ۱۳۹۶، ساعت ۱۷:۳۵
    به روز رسانی
    جهت کاهش مسئولیت‌های کلاس سرویس Auth، دو سرویس جدید token-store.service.ts  (برای ذخیره و بازیابی توکن‌های دریافتی از سرور) و refresh-token.service.ts (مدیریت به روز رسانی خودکار توکن) اضافه و در اصل از auth.service.ts استخراج شدند.
  • #
    ‫۶ سال و ۶ ماه قبل، یکشنبه ۱۳ اسفند ۱۳۹۶، ساعت ۱۹:۵۰
    در صورتی که نرم افزار در بیش از یک tab/window باز باشد ممکن است race condition به وجود بیاید. البته متوجه هستم این مقاله صرفا یک مثال آموزشی هست. برای جلوگیری از ارسال درخواست همزمان جهت بروزرسانی token چه راه حلی پیشنهاد میشه؟ 
    این راه حل خوبه؟ استفاده از localStorage و آگاه سازی دیگر نسخه‌های در حال اجرا یک درخواست پیش از این برای بروزرسانی ارسال شده/درحال ارسال است.
    • #
      ‫۶ سال و ۶ ماه قبل، یکشنبه ۱۳ اسفند ۱۳۹۶، ساعت ۲۳:۳۷
      این بررسی در حال اجرا بودن تایمر در یک Tab دیگر اضافه شد.
  • #
    ‫۶ سال و ۴ ماه قبل، سه‌شنبه ۲۵ اردیبهشت ۱۳۹۷، ساعت ۲۱:۲۹
    اگر در حین این که درخواست توکن جدید زده شود، درخواست دیگری توسط کاربر با توکن فعلی زده شود و درخواست توکن به سرعت اجرا شده و توکن فعلی را نامعتبر کند، چه اتفاقی برای درخواست کاربر که به صورت همزمان و با توکن فعلی(که در حال حاضر بی اعتبار است) می‌افتد؟ آیا این مسئله‌ی همزمانی هم مدیریت شده است؟
    • #
      ‫۶ سال و ۴ ماه قبل، سه‌شنبه ۲۵ اردیبهشت ۱۳۹۷، ساعت ۲۳:۱۹
      «... و توکن فعلی را نامعتبر کند ...»
      تنها جایی که tokenهای موجود حذف می‌شوند، در قسمت logout است. در سایر موارد نتیجه‌ی استفاده‌ی از token غیرمعتبر، توسط interceptor نوشته شده به صورت خودکار مدیریت شده و کاربر به صفحه‌ی عدم دسترسی هدایت می‌شود.
      • #
        ‫۶ سال و ۴ ماه قبل، چهارشنبه ۲۶ اردیبهشت ۱۳۹۷، ساعت ۰۳:۰۱
        ببخشید منظور بنده سمت back-end است؛ موقعی که برای درخواست رفرش توکن متد CreateJwtTokens  صدا زده می‌شود و در نتیجه‌ی آن متد InvalidateUserTokensAsync فراخوانی می‌شود و تمامی توکن‌های شخص حذف می‌شوند. این در زمانی که بین دو درخواست همزمان رقابت ایجاد شود طبق تست هایی که انجام دادم به نظر مشکل ایجاد می‌کند. 
        • #
          ‫۶ سال و ۴ ماه قبل، چهارشنبه ۲۶ اردیبهشت ۱۳۹۷، ساعت ۰۳:۳۹
          «... تمامی توکن‌های شخص حذف می‌شوند ...»
          تمام توکن‌های قبلی و نه جدید. شخص در این حالت فقط یک درخواستش برگشت خواهد خورد. اگر مجددا سعی کند، چون توکن جدیدی به کش اضافه شده و جای توکن قبلی را گرفته، درخواست دوم او پذیرفته می‌شود.
          من قصد هیچگونه تغییری را در این مورد ندارم چون اهمیتی ندارد. چون کل این بحث refresh token فقط یک «لطف» هست به کاربر و نه یک الزام. چیزی است شبیه به sliding expiration در مورد کوکی‌ها که خیلی‌ها اساسا از آن استفاده نمی‌کنند و Absolute Expiration و وادار کردن کاربر به لاگین مجدد را ترجیح می‌دهند.
          • #
            ‫۶ سال و ۱ ماه قبل، دوشنبه ۸ مرداد ۱۳۹۷، ساعت ۰۲:۰۴
            دریافت خطای 401 به دلیل استفاده نکردن از توکن بروزرسانی شده حتی در زمان سعی مجدد
            در تصویر زیر که  Api مربوط به بروز رسانی توکن فراخوانی می‌شود ، هدر Authorization  با مقدار H2M به پایان میپذیرد 

            نتیجه پاسخ دریافتی از فراخوانی بالا توکن جدید می‌باشد که با مقدار KnI به پایان میپذیرد 

            در زمان فراوانی در خواست بعدی هدر Authorization باید مقدار توکن بروزرسانی شده را داشته باشد این در حالی است که مقدار توکن قبلی (H2M...)را دارد نه توکن بروزرسانی شده(KnI...)
            تصویر زیر گویا است 

            دلیل اینکه از توکن جدید استفاده نمی‌کند چیست ؟ (در سرویس AuthInterceptor در زمان سعی مجدد هم دوباره از همان توکن قبلی استفاده می‌کند نه توکن جدید. )
  • #
    ‫۴ سال و ۱۰ ماه قبل، شنبه ۱۱ آبان ۱۳۹۸، ساعت ۲۰:۲۹
    نکاتی کوچک و پراکنده در سایت که به حل خطای 400 (Bad Request) در اجرای متد refreshToken کمک کرده و می‌تواند یافتن آن‌ها زمانبر باشد.
    نکته:  "سرور و کلاینت در دو دامنه جدا از هم اجرا می‌شوند."

    در این حالت می‌توان در سمت سرور در قسمت تنظیمات فایل Startup.cs  مقدار ClockSkew را تغییر داد
    cfg.TokenValidationParameters = new TokenValidationParameters
    {  
       //... 
       //ClockSkew = TimeSpan.Zero            
       ClockSkew = TimeSpan.FromMinutes(5)
    }

    برای ارسال کوکی XSRF-TOKEN  در هدر درخواست با عنوان  X-XSRF-TOKEN  بین دامنه‌ها باید در سمت کلاینت withCredentials: true تنظیم شود
    this.http
        .post<Xyz>(`${this.apiUrl}`, data, { withCredentials: true /* For CORS */ })
        .map(response => response || {})
        .catch(this.handleError);
    و  یا از یک HTTP Interceptor استفاده کرد:
    @Injectable()
    export class CORSInterceptor implements HttpInterceptor {
    
        constructor() {}
    
        intercept(request: HttpRequest<any>, next: HttpHandler): Observable<HttpEvent<any>> {
            request = request.clone({
                withCredentials: true
            });
            return next.handle(request);
        }
    }

    3- تنظیمات CORS در ASP.NET Core 2.2
    عدم استفاده از AllowAnyOrigin و AllowCredential با هم در تنظیمات سمت سرور در فایل Startup.cs:
    app.UseCors(builder => builder
                    .AllowAnyHeader()
                    .AllowAnyMethod()
                    //.AllowAnyOrigin()
                    .SetIsOriginAllowed((host) => true)
                    .AllowCredentials()
                );
    و یا
    services.AddCors(options =>
            {
                options.AddPolicy("CorsPolicy",
                    builder => builder
                      .AllowAnyMethod()
                      .AllowAnyHeader()
                      .WithOrigins("http://localhost:4200")
                      .AllowCredentials()
                .Build());
            });
    app.UseCors("CorsPolicy");
  • #
    ‫۳ سال و ۵ ماه قبل، چهارشنبه ۲۰ اسفند ۱۳۹۹، ساعت ۱۴:۱۱
    سلام؛ در خصوص اینکه مثلا کاربری غیرفعال شده ( قبل از اینکه غیرفعال بشه توکن را دریافت کرده) و در درخواست‌های جدید بررسی بشه که اگر غیر فعاله توکن حذف بشه و به صفحه‌ی 403 انتقال داده بشه هم میشه راهنمایی کنین؟
    • #
      ‫۳ سال و ۵ ماه قبل، چهارشنبه ۲۰ اسفند ۱۳۹۹، ساعت ۱۴:۳۱
      ITokenValidatorService دقیقا همین کار را انجام می‌دهد و درخواست غیرمعتبر (ناشی از منطق‌های سفارشی) را برگشت می‌زند که نتیجه‌ی نهایی آن در سمت کاربر با بررسی status-code دریافتی، قابل ردیابی است. برای مدیریت سراسری این مورد در این سری، یک interceptor مخصوص نوشته شده.