Thực hiện refresh token trong Angular (v2, v4, v5+)

Nếu bạn sử dụng javascript thường xuyên, chắc bạn ko ngạc nhiên gì với đoạn code setTimeout với thời gian bằng 0. Tuy nhiên bạn đã bao giờ tự hỏi, thời gian bằng 0 mà output vẫn in sau đoạn code khác chưa?

Refresh token là một feature tuy nhỏ nhưng không thể thiếu trong ứng dụng client nói chung và single-page web app nói riêng. Trong bài viết này, mình sẽ hướng dẫn các bạn thực hiện tính năng này trong Angular. Vì từ bản 4.3 trở đi, Http module bị deprecated vì Angular cho ra đời HttpClient module, nên mình sẽ hướng dẫn các bạn các thực hiện với cả Http và HttpClient module.

Logic chung

Người dùng đăng nhập vào app, access token (AT) và refresh token (RT) được lưu vào localStorage. Khi thực hiện một request nhưng server báo AT hết hạn, bạn cần tìm một thời điểm trước khi http request hoàn tất ở phía người dùng, request một AT mới (dựa vào RT đã lưu trước đó). Khi đã request một AT mới thành công, bạn lưu lại AT (cũng như RT), và thực hiện lại request thất bại trước đó.

Cách thực hiện

Với version < 4.3: can thiệp vào Http class

Trước tiên, các bạn có thể xem qua Http class gốc:

export declare class Http {
    protected _backend: ConnectionBackend;
    protected _defaultOptions: RequestOptions;
    constructor(_backend: ConnectionBackend, _defaultOptions: RequestOptions);
    /**
     * Performs any type of http request. First argument is required, and can either be a url or
     * a {@link Request} instance. If the first argument is a url, an optional {@link RequestOptions}
     * object can be provided as the 2nd argument. The options object will be merged with the values
     * of {@link BaseRequestOptions} before performing the request.
     */
    request(url: string | Request, options?: RequestOptionsArgs): Observable<Response>;
    /**
     * Performs a request with `get` http method.
     */
    get(url: string, options?: RequestOptionsArgs): Observable<Response>;
    /**
     * Performs a request with `post` http method.
     */
    post(url: string, body: any, options?: RequestOptionsArgs): Observable<Response>;
    /**
     * Performs a request with `put` http method.
     */
    put(url: string, body: any, options?: RequestOptionsArgs): Observable<Response>;
    /**
     * Performs a request with `delete` http method.
     */
    delete(url: string, options?: RequestOptionsArgs): Observable<Response>;
    /**
     * Performs a request with `patch` http method.
     */
    patch(url: string, body: any, options?: RequestOptionsArgs): Observable<Response>;
    /**
     * Performs a request with `head` http method.
     */
    head(url: string, options?: RequestOptionsArgs): Observable<Response>;
    /**
     * Performs a request with `options` http method.
     */
    options(url: string, options?: RequestOptionsArgs): Observable<Response>;
}

Việc cần làm cụ thể lúc này là can thiệp vào tất cả các request method (như get, post, put, ...), bắt lỗi token hết hạn trước khi lỗi hiện ra trên màn hình người dùng, và thực hiện lại request.

Trước tiên chúng ta tạo một class mới đặt tên là my-http.ts extends Http class như sau:

import { Injectable } from '@angular/core';
import { XHRBackend, RequestOptions, Http } from '@angular/http';

@Injectable()
export class MyHttp extends Http {
  constructor(backend: XHRBackend,
              defaultOptions: RequestOptions) {
    super(backend, defaultOptions);
  }
}

Trong đoạn code trên:

Tiếp theo, nhìn vào class Http gốc, chúng ta có thể dễ dàng thấy được 1 method rất nổi bật là request() với chú thích "Performs any type of http request...":

c1e2cec1-c46d-4557-8706-1670c8006726.jpg

Đúng vậy, đây chính là method chúng ta sẽ can thiệp. Tiếp tục implement method request:

import { Injectable } from '@angular/core';
import { Request, XHRBackend, RequestOptions, Response, Http, RequestOptionsArgs } from '@angular/http';
import { Observable } from 'rxjs/Observable';

@Injectable()
export class MyHttp extends Http {
  constructor(backend: XHRBackend,
              defaultOptions: RequestOptions) {
    super(backend, defaultOptions);
  }
  request(url: string | Request, options?: RequestOptionsArgs): Observable<Response> {
    return super.request(url, options);
  }
}

Tại thời điểm này, MyHttp đã hoạt động, bạn có thể khai báo việc sử dụng MyHttp thay cho Http mặc định trong AppModule:

@NgModule({
  declarations: [
    AppComponent
  ],
  imports: [
    // ...
  ],
  providers: [
    { provide: Http, useClass: HttpService },
    // other service
  ],
  bootstrap: [AppComponent]
})
export class AppModule {
}

Chúng ta quay lại với MyHttp class, tiếp theo chúng ta sẽ bắt lỗi token hết hạn:

import { Injectable } from '@angular/core';
import { Request, XHRBackend, RequestOptions, Response, Http, RequestOptionsArgs } from '@angular/http';
import { Observable } from 'rxjs/Observable';
import 'rxjs/add/operator/catch';
import 'rxjs/add/observable/throw';
import 'rxjs/Rx';

@Injectable()
export class HttpService extends Http {
  constructor(backend: XHRBackend,
              defaultOptions: RequestOptions) {
    super(backend, defaultOptions);
  }

  request(url: string | Request, options?: RequestOptionsArgs): Observable<Response> {
    return super.request(url, options).catch(this.catchErrors(url, options))
  }

  catchErrors(url, options) {
    return (err: Response) => {
      const errObj = err.json();
      switch (err.status) {
        case 401:
          return super
            .post(`https://example-api-endpoint/refreshtoken`, {
              refreshToken: localStorage.getItem('refresh_token')
            })
            .map(res => res.json)
            .flatMap(refreshTokenResponseResult => {
                // TODO: lưu lại AT và RT
                return this.request(url, options);
            });
        default:
          return Observable.throw(errObj);
      }
    };
  }
}

Trong method catchErrors() bên trên:

Đến đây, ứng dụng của chúng ta đã có thể refresh token tự động, bạn có thể chờ token của bạn hết hạn để test.

Với version từ 4.3 đến trước 5: sử dụng HttpInterceptor trong bộ HttpClient

Từ version 4.3 trở lên, Http module bị khai tử và Angular khuyến nghị chúng ta chuyển sang HttpClient module. Trong HttpClient Angular cung cấp cho chúng ta một khái niệm rõ ràng hơn trong việc can thiệp vào http request, đó là Interceptor. (tham khảo thêm tại https://angular.io/guide/http#intercepting-requests-and-responses).

Để thực hiện, thay vì extends Http class như lúc trước, chúng ta sẽ implements HttpInterceptor interface, một interface được thiết kế riêng biệt cho việc can thiệp vào request/response. Trước tiên chúng ta tạo một class với tên MyRefreshTokenInterceptor - và tất nhiên - implements HttpInterceptor.

import { Injectable } from '@angular/core';
import {
  HttpEvent, HttpInterceptor, HttpHandler, HttpRequest
} from '@angular/common/http';
import { Observable } from 'rxjs/Observable';

@Injectable()
export class MyRefreshTokenInterceptor implements HttpInterceptor {
  intercept(req: HttpRequest<any>, next: HttpHandler): Observable<HttpEvent<any>> {
    return next.handle(req);
  }
}

Class trên chưa thực hiện gì, mới chỉ chuyển tiếp request sang Interceptor tiếp theo (nếu có). Tại đây, bạn đã có thể sử dụng Interceptor này bằng cách khai báo trong app.module.ts:

// ...
@NgModule({
  declarations: [
    AppComponent
  ],
  imports: [
    // ...
  ],
  providers: [
      {
        provide: HTTP_INTERCEPTORS,
        useClass: MyRefreshTokenInterceptor,
        multi: true
      }
    // ...
  ],
  bootstrap: [AppComponent]
})
export class AppModule {
}

Lúc này Interceptor của bạn đã có thể can thiệp vào request response (mặc dù nó chả làm gì cả :D). Trước khi code phần refresh token, chúng ta sẽ thêm một chút code giúp cho interceptor này có thể đính kèm luôn cả access token cho mỗi request:

import { Injectable } from '@angular/core';
import {
  HttpEvent, HttpInterceptor, HttpHandler, HttpRequest,
} from '@angular/common/http';
import { Observable } from 'rxjs/Observable';
import 'rxjs/add/operator/mergeMap';

@Injectable()
export class MyRefreshTokenInterceptor implements HttpInterceptor {
  intercept(req: HttpRequest<any>, next: HttpHandler): Observable<HttpEvent<any>> {
    if (localStorage.getItem('access_token')) {
      return next.handle(this.modifyRequest(req));
    } else {
      // TODO: đăng xuất
      return next.handle(req);
    }
  }

  private modifyRequest(req) {
    return req.clone({setHeaders: {'authorization': localStorage.getItem('access_token')}});
  }
}

Công việc tiếp theo là refresh token nếu có lỗi token hết hạn trả về. Giống với code bên HttpModule, chúng ta cũng sẽ dựa vào Http status code để bắt lỗi 401. Vì next.handle() là 1 Observable, các bạn sử dụng method catch của RxJS:

import { Injectable } from '@angular/core';
import {
  HttpEvent, HttpInterceptor, HttpHandler, HttpRequest,
  HttpErrorResponse
} from '@angular/common/http';
import { Observable } from 'rxjs/Observable';

@Injectable()
export class MyRefreshTokenInterceptor implements HttpInterceptor {
  intercept(req: HttpRequest<any>, next: HttpHandler): Observable<HttpEvent<any>> {
    if (localStorage.getItem('access_token')) {
      return next.handle(this.modifyRequest(req)).catch(error => {
        if (error instanceof HttpErrorResponse) {
          switch (error.status) {
            case 401:
              // TODO: thực hiện refresh token
          }
        } else {
          return Observable.throw(error);
        }
      });
    } else {
      // TODO: đăng xuất
      return next.handle(req);
    }
  }

  private modifyRequest(req) {
    return req.clone({setHeaders: {'authorization': localStorage.getItem('access_token')}});
  }
}

Đến đây thì mọi thứ lại giống như code ở bản 2 rồi đúng ko, chúng ta chỉ cần thực hiện request lên server để lấy token mới, lưu token, và thực hiện lại request nữa là xong:

import { Injectable, Injector } from '@angular/core';
import {
  HttpEvent, HttpInterceptor, HttpHandler, HttpRequest,
  HttpErrorResponse, HttpClient
} from '@angular/common/http';
import { Observable } from 'rxjs/Observable';
import 'rxjs/add/operator/mergeMap';

@Injectable()
export class MyRefreshTokenInterceptor implements HttpInterceptor {
  constructor(private injector: Injector) {
  }

  intercept(req: HttpRequest<any>, next: HttpHandler): Observable<HttpEvent<any>> {
    if (localStorage.getItem('access_token')) {
      return next.handle(this.modifyRequest(req)).catch(error => {
        if (error instanceof HttpErrorResponse) {
          switch (error.status) {
            case 401:
              return this.injector.get(HttpClient)
                .post(
                  `https://example-api-endpoint/refreshtoken`,
                  {
                    refreshToken: localStorage.getItem('refresh_token')
                  }
                )
                .flatMap(res => {
                  // TODO: lưu AT và RT vào localStorage
                  return next.handle(this.modifyRequest(req));
                })
          }
        } else {
          return Observable.throw(error);
        }
      });
    } else {
      // TODO: đăng xuất
      return next.handle(req);
    }
  }

  private modifyRequest(req) {
    return req.clone({setHeaders: {'authorization': localStorage.getItem('access_token')}});
  }
}

Trong đoạn code trên:

Đối với phiên bản 5 trở lên

Từ version 5, các đối tượng sử dụng Observale được Angular tích hợp tính năng Lettable Operators (Đã được đổi tên thành Pipeable Operators) của rxjs bản 5.5. Do đó, mặc dù vẫn dùng HttpInterceptor để kiểm soát luồng refresh token, chúng ta sẽ cần cải thiện cú pháp code theo cách viết mới:

import { Injectable, Injector } from '@angular/core';
import {
  HttpEvent, HttpInterceptor, HttpHandler, HttpRequest,
  HttpErrorResponse, HttpClient
} from '@angular/common/http';
import { Observable } from 'rxjs/Observable';
import { catchError, mergeMap } from 'rxjs/operators';

@Injectable()
export class MyRefreshTokenInterceptor implements HttpInterceptor {
  constructor(private injector: Injector) {
  }

  intercept(req: HttpRequest<any>, next: HttpHandler): Observable<HttpEvent<any>> {
    if (localStorage.getItem('access_token')) {
      return next.handle(this.modifyRequest(req)).pipe(
          catchError(error => {
            if (error instanceof HttpErrorResponse) {
              switch (error.status) {
                case 401:
                  return this.injector.get(HttpClient)
                    .post(
                      `https://example-api-endpoint/refreshtoken`,
                      {
                        refreshToken: localStorage.getItem('refresh_token')
                      }
                    )
                    .pipe(
                        mergeMap(res => {
                            // TODO: lưu AT và RT vào localStorage
                            return next.handle(this.modifyRequest(req));
                        })
                    )
              }
            } else {
              return Observable.throw(error);
            }
          })
      );
    } else {
      // TODO: đăng xuất
      return next.handle(req);
    }
  }

  private modifyRequest(req) {
    return req.clone({setHeaders: {'authorization': localStorage.getItem('access_token')}});
  }
}

Trong đoạn code trên:

Kết

Trên đây là 2 ví dụ cơ bản về việc refresh token trong Angular. Trong các ví dụ trên mình chỉ tập trung vào việc refresh token, khi implement trong dự án thực, các bạn cần bắt thêm exception như refresh token bị lỗi, implement thêm các thao tác như đăng xuất nếu refresh token thất bại, cũng như tổ chức code gọn gàng.

Previous Post Next Post