Angular 4 Interceptor retry requests after token r

2020-01-24 19:34发布

问题:

Hi I am trying to figure out how implement the new angular interceptors and handle 401 unauthorized errors by refreshing the token and retrying the request. This is the guide I have been following: https://ryanchenkie.com/angular-authentication-using-the-http-client-and-http-interceptors

I am successfully caching the failed requests and can refresh the token but I cannot figure out how to resend the requests that previously failed. I also want to get this to work with the resolvers I am currently using.

token.interceptor.ts

return next.handle( request ).do(( event: HttpEvent<any> ) => {
        if ( event instanceof HttpResponse ) {
            // do stuff with response if you want
        }
    }, ( err: any ) => {
        if ( err instanceof HttpErrorResponse ) {
            if ( err.status === 401 ) {
                console.log( err );
                this.auth.collectFailedRequest( request );
                this.auth.refreshToken().subscribe( resp => {
                    if ( !resp ) {
                        console.log( "Invalid" );
                    } else {
                        this.auth.retryFailedRequests();
                    }
                } );

            }
        }
    } );

authentication.service.ts

cachedRequests: Array<HttpRequest<any>> = [];

public collectFailedRequest ( request ): void {
    this.cachedRequests.push( request );
}

public retryFailedRequests (): void {
    // retry the requests. this method can
    // be called after the token is refreshed
    this.cachedRequests.forEach( request => {
        request = request.clone( {
            setHeaders: {
                Accept: 'application/json',
                'Content-Type': 'application/json',
                Authorization: `Bearer ${ this.getToken() }`
            }
        } );
        //??What to do here
    } );
}

The above retryFailedRequests() file is what I can't figure out. How do I resend the requests and make them available to the route through the resolver after retrying?

This is all the relevant code if that helps: https://gist.github.com/joshharms/00d8159900897dc5bed45757e30405f9

回答1:

My final solution. Works with parallel requests.

export class AuthInterceptor implements HttpInterceptor {

    authService;
    refreshTokenInProgress = false;

    tokenRefreshedSource = new Subject();
    tokenRefreshed$ = this.tokenRefreshedSource.asObservable();

    constructor(private injector: Injector, private router: Router, private snackBar: MdSnackBar) {}

    addAuthHeader(request) {
        const authHeader = this.authService.getAuthorizationHeader();
        if (authHeader) {
            return request.clone({
                setHeaders: {
                    "Authorization": authHeader
                }
            });
        }
        return request;
    }

    refreshToken() {
        if (this.refreshTokenInProgress) {
            return new Observable(observer => {
                this.tokenRefreshed$.subscribe(() => {
                    observer.next();
                    observer.complete();
                });
            });
        } else {
            this.refreshTokenInProgress = true;

            return this.authService.refreshToken()
               .do(() => {
                    this.refreshTokenInProgress = false;
                    this.tokenRefreshedSource.next();
                });
        }
    }

    logout() {
        this.authService.logout();
        this.router.navigate(["login"]);
    }

    intercept(request: HttpRequest<any>, next: HttpHandler): Observable<any> {
        this.authService = this.injector.get(AuthService);

        // Handle request
        request = this.addAuthHeader(request);

        // Handle response
        return next.handle(request).catch(error => {

            if (error.status === 401) {
                return this.refreshToken()
                    .switchMap(() => {
                        request = this.addAuthHeader(request);
                        return next.handle(request);
                    })
                    .catch(() => {
                        this.logout();
                        return Observable.empty();
                    });
            }

            return Observable.throw(error);
        });
    }
}


回答2:

With the latest version of Angular (7.0.0) and rxjs (6.3.3), this is how I created a fully functional Auto Session recovery interceptor ensuring, if concurrent requests fail with 401, then also, it should only hit token refresh API once and pipe the failed requests to the response of that using switchMap and Subject. Below is how my interceptor code looks like. I have omitted the code for my auth service and store service as they are pretty standard service classes.

import {
  HttpErrorResponse,
  HttpEvent,
  HttpHandler,
  HttpInterceptor,
  HttpRequest
} from "@angular/common/http";
import { Injectable } from "@angular/core";
import { Observable, Subject, throwError } from "rxjs";
import { catchError, switchMap } from "rxjs/operators";

import { AuthService } from "../auth/auth.service";
import { STATUS_CODE } from "../error-code";
import { UserSessionStoreService as StoreService } from "../store/user-session-store.service";

@Injectable()
export class SessionRecoveryInterceptor implements HttpInterceptor {
  constructor(
    private readonly store: StoreService,
    private readonly sessionService: AuthService
  ) {}

  private _refreshSubject: Subject<any> = new Subject<any>();

  private _ifTokenExpired() {
    this._refreshSubject.subscribe({
      complete: () => {
        this._refreshSubject = new Subject<any>();
      }
    });
    if (this._refreshSubject.observers.length === 1) {
      this.sessionService.refreshToken().subscribe(this._refreshSubject);
    }
    return this._refreshSubject;
  }

  private _checkTokenExpiryErr(error: HttpErrorResponse): boolean {
    return (
      error.status &&
      error.status === STATUS_CODE.UNAUTHORIZED &&
      error.error.message === "TokenExpired"
    );
  }

  intercept(
    req: HttpRequest<any>,
    next: HttpHandler
  ): Observable<HttpEvent<any>> {
    if (req.url.endsWith("/logout") || req.url.endsWith("/token-refresh")) {
      return next.handle(req);
    } else {
      return next.handle(req).pipe(
        catchError((error, caught) => {
          if (error instanceof HttpErrorResponse) {
            if (this._checkTokenExpiryErr(error)) {
              return this._ifTokenExpired().pipe(
                switchMap(() => {
                  return next.handle(this.updateHeader(req));
                })
              );
            } else {
              return throwError(error);
            }
          }
          return caught;
        })
      );
    }
  }

  updateHeader(req) {
    const authToken = this.store.getAccessToken();
    req = req.clone({
      headers: req.headers.set("Authorization", `Bearer ${authToken}`)
    });
    return req;
  }
}

As per @anton-toshik comment, I thought it's a good idea to explain the functioning of this code in a write-up. You can have a read at my article here for the explanation and understanding of this code (how and why it works?). Hope it helps.



回答3:

I ran into a similar problem as well and I think the collect/retry logic is overly complicated. Instead, we can just use the catch operator to check for the 401, then watch for the token refresh, and rerun the request:

return next.handle(this.applyCredentials(req))
  .catch((error, caught) => {
    if (!this.isAuthError(error)) {
      throw error;
    }
    return this.auth.refreshToken().first().flatMap((resp) => {
      if (!resp) {
        throw error;
      }
      return next.handle(this.applyCredentials(req));
    });
  }) as any;

...

private isAuthError(error: any): boolean {
  return error instanceof HttpErrorResponse && error.status === 401;
}


回答4:

Andrei Ostrovski's final solution works really well, but does not work if the refresh token is also expired (assuming you're making an api call to refresh). After some digging, I realised that the refresh token API call was also intercepted by the interceptor. I've had to add an if statement to handle this.

 intercept( request: HttpRequest<any>, next: HttpHandler ):Observable<any> {
   this.authService = this.injector.get( AuthenticationService );
   request = this.addAuthHeader(request);

   return next.handle( request ).catch( error => {
     if ( error.status === 401 ) {

     // The refreshToken api failure is also caught so we need to handle it here
       if (error.url === environment.api_url + '/refresh') {
         this.refreshTokenHasFailed = true;
         this.authService.logout();
         return Observable.throw( error );
       }

       return this.refreshAccessToken()
         .switchMap( () => {
           request = this.addAuthHeader( request );
           return next.handle( request );
         })
         .catch((err) => {
           this.refreshTokenHasFailed = true;
           this.authService.logout();
           return Observable.throw( err );
         });
     }

     return Observable.throw( error );
   });
 }


回答5:

Based on this example, here's my piece

@Injectable({
    providedIn: 'root'
})
export class AuthInterceptor implements HttpInterceptor {

    constructor(private loginService: LoginService) { }

    /**
     * Intercept request to authorize request with oauth service.
     * @param req original request
     * @param next next
     */
    intercept(req: HttpRequest<any>, next: HttpHandler): Observable<any> {
        const self = this;

        if (self.checkUrl(req)) {
            // Authorization handler observable
            const authHandle = defer(() => {
                // Add authorization to request
                const authorizedReq = req.clone({
                    headers: req.headers.set('Authorization', self.loginService.getAccessToken()
                });
                // Execute
                return next.handle(authorizedReq);
            });

            return authHandle.pipe(
                catchError((requestError, retryRequest) => {
                    if (requestError instanceof HttpErrorResponse && requestError.status === 401) {
                        if (self.loginService.isRememberMe()) {
                            // Authrozation failed, retry if user have `refresh_token` (remember me).
                            return from(self.loginService.refreshToken()).pipe(
                                catchError((refreshTokenError) => {
                                    // Refresh token failed, logout
                                    self.loginService.invalidateSession();
                                    // Emit UserSessionExpiredError
                                    return throwError(new UserSessionExpiredError('refresh_token failed'));
                                }),
                                mergeMap(() => retryRequest)
                            );
                        } else {
                            // Access token failed, logout
                            self.loginService.invalidateSession();
                            // Emit UserSessionExpiredError
                            return throwError(new UserSessionExpiredError('refresh_token failed')); 
                        }
                    } else {
                        // Re-throw response error
                        return throwError(requestError);
                    }
                })
            );
        } else {
            return next.handle(req);
        }
    }

    /**
     * Check if request is required authentication.
     * @param req request
     */
    private checkUrl(req: HttpRequest<any>) {
        // Your logic to check if the request need authorization.
        return true;
    }
}

You may want to check if user enabled Remember Me to use refresh token for retrying or just redirect to logout page.

Fyi, the LoginService has the following methods:
- getAccessToken(): string - return the current access_token
- isRememberMe(): boolean - check if user have refresh_token
- refreshToken(): Observable / Promise - Request to oauth server for new access_token using refresh_token
- invalidateSession(): void - remove all user info and redirect to logout page



回答6:

Ideally, you want to check isTokenExpired before request sent. And if expired refresh the token and add refreshed in the header.

Other than that retry operator may help with your logic of refreshing token on 401 response.

Use the RxJS retry operator in your service where you are making a request. It accepts a retryCount argument. If not provided, it will retry the sequence indefinitely.

In your interceptor on response refresh the token and return the error. When your service gets back the error but now retry operator is being used so it will retry the request and this time with the refreshed token(Interceptor uses refreshed token to add in the header.)

import {HttpClient} from '@angular/common/http';
import { Injectable } from '@angular/core';
import { Observable } from 'rxjs/Rx';

@Injectable()
export class YourService {

  constructor(private http: HttpClient) {}

  search(params: any) {
    let tryCount = 0;
    return this.http.post('https://abcdYourApiUrl.com/search', params)
      .retry(2);
  }
}


回答7:

To support ES6 syntax the solution needs to be bit modify and that is as following also included te loader handler on multiple request


        private refreshTokenInProgress = false;
        private activeRequests = 0;
        private tokenRefreshedSource = new Subject();
        private tokenRefreshed$ = this.tokenRefreshedSource.asObservable();
        private subscribedObservable$: Subscription = new Subscription();



 intercept(request: HttpRequest<any>, next: HttpHandler): Observable<HttpEvent<any>> {
        if (this.activeRequests === 0) {
            this.loaderService.loadLoader.next(true);
        }
        this.activeRequests++;

        // Handle request
        request = this.addAuthHeader(request);

        // NOTE: if the flag is true it will execute retry auth token mechanism ie. by using refresh token it will fetch new auth token and will retry failed api with new token
        if (environment.retryAuthTokenMechanism) {
            // Handle response
            return next.handle(request).pipe(
                catchError(error => {
                    if (this.authenticationService.refreshShouldHappen(error)) {
                        return this.refreshToken().pipe(
                            switchMap(() => {
                                request = this.addAuthHeader(request);
                                return next.handle(request);
                            }),
                            catchError(() => {
                                this.authenticationService.setInterruptedUrl(this.router.url);
                                this.logout();
                                return EMPTY;
                            })
                        );
                    }

                    return EMPTY;
                }),
                finalize(() => {
                    this.hideLoader();
                })
            );
        } else {
            return next.handle(request).pipe(
                catchError(() => {
                    this.logout();
                    return EMPTY;
                }),
                finalize(() => {
                    this.hideLoader();
                })
            );
        }
    }

    ngOnDestroy(): void {
        this.subscribedObservable$.unsubscribe();
    }

    /**
     * @description Hides loader when all request gets complete
     */
    private hideLoader() {
        this.activeRequests--;
        if (this.activeRequests === 0) {
            this.loaderService.loadLoader.next(false);
        }
    }

    /**
     * @description set new auth token by existing refresh token
     */
    private refreshToken() {
        if (this.refreshTokenInProgress) {
            return new Observable(observer => {
                this.subscribedObservable$.add(
                    this.tokenRefreshed$.subscribe(() => {
                        observer.next();
                        observer.complete();
                    })
                );
            });
        } else {
            this.refreshTokenInProgress = true;

            return this.authenticationService.getNewAccessTokenByRefreshToken().pipe(tap(newAuthToken => {
            this.authenticationService.updateAccessToken(newAuthToken.access_token);
            this.refreshTokenInProgress = false;
            this.tokenRefreshedSource.next();
        }));
        }
    }

    private addAuthHeader(request: HttpRequest<any>) {
        const accessToken = this.authenticationService.getAccessTokenOnly();
        return request.clone({
            setHeaders: {
                Authorization: `Bearer ${accessToken}`
            }
        });
    }

    /**
     * @todo move in common service or auth service once tested
     * logout and redirect to login
     */
    private logout() {
        this.authenticationService.removeSavedUserDetailsAndLogout();
    }


回答8:

I got this creating a new request based on the url of the failed request and sending the same body of the failed request.

 retryFailedRequests() {

this.auth.cachedRequests.forEach(request => {

  // get failed request body
  var payload = (request as any).payload;

  if (request.method == "POST") {
    this.service.post(request.url, payload).subscribe(
      then => {
        // request ok
      },
      error => {
        // error
      });

  }
  else if (request.method == "PUT") {

    this.service.put(request.url, payload).subscribe(
      then => {
       // request ok
      },
      error => {
        // error
      });
  }

  else if (request.method == "DELETE")

    this.service.delete(request.url, payload).subscribe(
      then => {
        // request ok
      },
      error => {
        // error
      });
});

this.auth.clearFailedRequests();        

}



回答9:

In your authentication.service.ts, you should have a HttpClient injected as a dependency

constructor(private http: HttpClient) { }

You can then re-submit the request (inside retryFailedRequests) as follow:

this.http.request(request).subscribe((response) => {
    // You need to subscribe to observer in order to "retry" your request
});