Solicitudes de reintento de Angular 4 Interceptor después de la actualización del token

84

Hola, estoy tratando de averiguar cómo implementar los nuevos interceptores angulares y manejar los 401 unauthorizederrores actualizando el token y volviendo a intentar la solicitud. Esta es la guía que he estado siguiendo: https://ryanchenkie.com/angular-authentication-using-the-http-client-and-http-interceptors

Estoy almacenando en caché con éxito las solicitudes fallidas y puedo actualizar el token, pero no puedo averiguar cómo reenviar las solicitudes que fallaron anteriormente. También quiero que esto funcione con los solucionadores que estoy usando actualmente.

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
    } );
}

El archivo retryFailedRequests () anterior es lo que no puedo entender. ¿Cómo reenvío las solicitudes y las pongo a disposición de la ruta a través del solucionador después de volver a intentarlo?

Este es todo el código relevante si eso ayuda: https://gist.github.com/joshharms/00d8159900897dc5bed45757e30405f9

Kovaci
fuente
3
Tengo el mismo problema y parece que no hay respuesta.
Último Tribunal

Respuestas:

98

Mi solución final. Funciona con solicitudes paralelas.

ACTUALIZACIÓN: el código actualizado con Angular 9 / RxJS 6, manejo de errores y corrección de bucles cuando falla refreshToken

import { HttpRequest, HttpHandler, HttpInterceptor, HTTP_INTERCEPTORS } from "@angular/common/http";
import { Injector } from "@angular/core";
import { Router } from "@angular/router";
import { Subject, Observable, throwError } from "rxjs";
import { catchError, switchMap, tap} from "rxjs/operators";
import { AuthService } from "./auth.service";

export class AuthInterceptor implements HttpInterceptor {

    authService;
    refreshTokenInProgress = false;

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

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

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

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

            return this.authService.refreshToken().pipe(
                tap(() => {
                    this.refreshTokenInProgress = false;
                    this.tokenRefreshedSource.next();
                }),
                catchError(() => {
                    this.refreshTokenInProgress = false;
                    this.logout();
                }));
        }
    }

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

    handleResponseError(error, request?, next?) {
        // Business error
        if (error.status === 400) {
            // Show message
        }

        // Invalid token error
        else if (error.status === 401) {
            return this.refreshToken().pipe(
                switchMap(() => {
                    request = this.addAuthHeader(request);
                    return next.handle(request);
                }),
                catchError(e => {
                    if (e.status !== 401) {
                        return this.handleResponseError(e);
                    } else {
                        this.logout();
                    }
                }));
        }

        // Access denied error
        else if (error.status === 403) {
            // Show message
            // Logout
            this.logout();
        }

        // Server error
        else if (error.status === 500) {
            // Show message
        }

        // Maintenance error
        else if (error.status === 503) {
            // Show message
            // Redirect to the maintenance page
        }

        return throwError(error);
    }

    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).pipe(catchError(error => {
            return this.handleResponseError(error, request, next);
        }));
    }
}

export const AuthInterceptorProvider = {
    provide: HTTP_INTERCEPTORS,
    useClass: AuthInterceptor,
    multi: true
};
Andrei Ostrovski
fuente
3
@AndreiOstrovski, ¿podría actualizar la respuesta importsy el código de AuthService?
toma el
4
Tengo la sensación de que si por alguna razón this.authService.refreshToken () falla, todas las consultas paralelas que esperan una actualización esperarán para siempre.
Maksim Gumerov
2
La captura en el token de actualización nunca me llama. Golpeó el lanzamiento de Observable.
jamesmpw
2
Chicos, funciona con solicitudes paralelas y secuenciales. Envía 5 solicitudes, devuelven 401, luego se realiza 1 refreshToken y 5 solicitudes nuevamente. Si sus 5 solicitudes son secuenciales, después del primer 401 enviamos refreshToken, luego la primera solicitud nuevamente y otras 4 solicitudes.
Andrei Ostrovski
2
¿Por qué inyecta manualmente un servicio cuando Angular podría hacer eso por usted si lo decorara @Injectable()? Además, un catchError no devuelve nada. Al menos regresa EMPTY.
Győri Sándor
16

Con la última versión de Angular (7.0.0) y rxjs (6.3.3), así es como creé un interceptor de recuperación de sesión automática completamente funcional que garantiza que, si las solicitudes simultáneas fallan con 401, también debería presionar la API de actualización del token. una vez y canalice las solicitudes fallidas a la respuesta de eso usando switchMap y Subject. A continuación se muestra cómo se ve mi código de interceptor. He omitido el código para mi servicio de autenticación y servicio de tienda, ya que son clases de servicio bastante estándar.

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;
  }
}

Según el comentario de @ anton-toshik, pensé que es una buena idea explicar el funcionamiento de este código en un artículo. Puede leer mi artículo aquí para obtener una explicación y comprensión de este código (¿cómo y por qué funciona?). Espero eso ayude.

Samarpan
fuente
1
buen trabajo, el segundo returndentro de la interceptfunción debería tener este aspecto: return next.handle(this.updateHeader(req)).pipe(. Actualmente, solo envía el token de autenticación después de actualizarlo ...
malimo
Creo que lo estoy haciendo a través de switchmap. Por favor revise de nuevo. Avísame si entendí mal tu punto.
Samarpan
sí, básicamente funciona, pero siempre envía la solicitud dos veces, una vez sin el encabezado y luego, después de que falló con el encabezado ...
malimo
@SamarpanBhattacharya Esto funciona. Creo que a esta respuesta le vendría bien una explicación con semántica para alguien como yo que no entiende cómo funciona Observable.
Anton Toshik
1
@NikaKurashvili, esta definición de método funcionó para mí:public refreshToken(){const url:string=environment.apiUrl+API_ENDPOINTS.REFRESH_TOKEN;const req:any={token:this.getAuthToken()};const head={};const header={headers:newHttpHeaders(head)};return this.http.post(url,req,header).pipe(map(resp=>{const actualToken:string=resp['data'];if(actualToken){this.setLocalStorage('authToken',actualToken);}return resp;}));}
Shrinivas
9

También me encontré con un problema similar y creo que la lógica de recopilación / reintento es demasiado complicada. En su lugar, podemos usar el operador de captura para verificar el 401, luego observar la actualización del token y volver a ejecutar la solicitud:

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;
}
rdukeshier
fuente
1
Me gusta usar un código de estado personalizado de 498 para identificar un token caducado en comparación con el 401 que también puede indicar que no hay suficiente priv
Joseph Carroll
1
Hola, estoy intentando usar return next.handle (reqClode) y no hace nada, mi código es diferente de su abit pero la parte que no funciona es la parte de retorno. authService.createToken (authToken, refreshToken); this.inflightAuthRequest = null; return next.handle (req.clone ({encabezados: req.headers.set (appGlobals.AUTH_TOKEN_KEY, authToken)}));
6
La lógica de recopilación / reintento no es demasiado complicada, es la forma en que debe hacerlo si no desea realizar múltiples solicitudes al punto final refreshToken mientras su token está vencido. Digamos que su token expiró y realiza 5 solicitudes casi al mismo tiempo. Con la lógica de este comentario, se generarán 5 nuevos tokens de actualización en el lado del servidor.
Marius Lazar
4
@JosephCarroll generalmente no hay suficientes privilegios es 403
andrea.spot.
8

La solución final de Andrei Ostrovski funciona muy bien, pero no funciona si el token de actualización también está vencido (asumiendo que estás haciendo una llamada a la API para actualizar). Después de investigar un poco, me di cuenta de que el interceptor también interceptó la llamada a la API del token de actualización. Tuve que agregar una declaración if para manejar esto.

 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 );
   });
 }
James Lieu
fuente
¿Podrías mostrar dónde más juegas con el refreshTokenHasFailedmiembro booleano?
Stephane
1
Puede encontrarlo en la solución de Andrei Ostrovski anterior, básicamente lo he usado, pero agregué la declaración if para manejar cuando se intercepta el punto final de actualización.
James Lieu
Esto no tiene sentido, ¿por qué la actualización devolvería un 401? El punto es que está llamando a la actualización después de que falla la autenticación, por lo que su API de actualización no debería autenticarse en absoluto y no debería devolver un 401.
MDave
Los tokens de actualización pueden tener fechas de caducidad. En nuestro caso de uso, se configuró para caducar después de 4 horas, si el usuario cerrara su navegador al final del día y regresara a la mañana siguiente, el token de actualización habría caducado en ese punto y, por lo tanto, le solicitamos que inicie sesión. de nuevo adentro. Si su token de actualización no expira, por supuesto, no necesitaría aplicar esta lógica
James Lieu
4

Basado en este ejemplo , aquí está mi pieza

@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;
    }
}

Es posible que desee verificar si el usuario está habilitado Remember Mepara usar el token de actualización para reintentar o simplemente redirigir a la página de cierre de sesión.

Para su información, LoginServicetiene los siguientes métodos:
- getAccessToken (): cadena - devolver el actual access_token
- isRememberMe (): boolean - verificar si el usuario tiene refresh_token
- refreshToken (): Observable / Promise - Solicitud al servidor oauth para un nuevo access_tokenuso refresh_token
- invalidateSession (): void: elimina toda la información del usuario y redirige a la página de cierre de sesión

Thanh Nhan
fuente
¿Tiene algún problema con varias solicitudes que envían varias solicitudes de actualización?
CodingGorilla
Esta versión es la que más me gusta, pero tengo un problema en el que el mío hace una solicitud, cuando devuelve 401 intenta actualizarse, cuando devuelve un error, intenta continuamente enviar la solicitud nuevamente, sin detenerse nunca. ¿Estoy haciendo algo mal?
jamesmpw
Lo siento, el anterior no lo probé con cuidado. Acabo de editar mi publicación con la probada que estoy usando (también migrar a rxjs6 y actualizar el token, verificar la URL).
Thanh Nhan
1

Idealmente, desea verificar isTokenExpiredantes de enviar la solicitud. Y si expiró, actualice el token y agregue actualizado en el encabezado.

Aparte de eso, retry operatorpuede ayudar con su lógica de actualizar el token en la respuesta 401.

Utilice el RxJS retry operatoren su servicio donde realiza una solicitud. Acepta un retryCountargumento. Si no se proporciona, volverá a intentar la secuencia de forma indefinida.

En su interceptor en respuesta, actualice el token y devuelva el error. Cuando su servicio recupere el error, pero ahora se está utilizando el operador de reintento, por lo que volverá a intentar la solicitud y esta vez con el token actualizado (Interceptor usa el token actualizado para agregar en el encabezado).

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);
  }
}
Lahar Shah
fuente
0
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();
    }
Saurabh Deshmukh
fuente
0

Tuve que resolver los siguientes requisitos:

  • ✅ Actualizar el token solo una vez para múltiples solicitudes
  • ✅ Cerrar sesión de usuario si falla refreshToken
  • ✅ Cerrar sesión si el usuario recibe un error después de la primera actualización
  • ✅ Ponga en cola todas las solicitudes mientras se actualiza el token

Como resultado, he recopilado diferentes opciones para actualizar el token en Angular:

intercept(req: HttpRequest<any>, next: HttpHandler): Observable<HttpEvent<any>> {
    let retries = 0;
    return this.authService.token$.pipe(
      map(token => req.clone({ setHeaders: { Authorization: `Bearer ${token}` } })),
      concatMap(authReq => next.handle(authReq)),
      // Catch the 401 and handle it by refreshing the token and restarting the chain
      // (where a new subscription to this.auth.token will get the latest token).
      catchError((err, restart) => {
        // If the request is unauthorized, try refreshing the token before restarting.
        if (err.status === 401 && retries === 0) {
          retries++;
    
          return concat(this.authService.refreshToken$, restart);
        }
    
        if (retries > 0) {
          this.authService.logout();
        }
    
        return throwError(err);
      })
    );
}
intercept(req: HttpRequest<any>, next: HttpHandler): Observable<HttpEvent<any>> {
    return this.authService.token$.pipe(
      map(token => req.clone({ setHeaders: { Authorization: `Bearer ${token}` } })),
      concatMap(authReq => next.handle(authReq)),
      retryWhen((errors: Observable<any>) => errors.pipe(
        mergeMap((error, index) => {
          // any other error than 401 with {error: 'invalid_grant'} should be ignored by this retryWhen
          if (error.status !== 401) {
            return throwError(error);
          }
    
          if (index === 0) {
            // first time execute refresh token logic...
            return this.authService.refreshToken$;
          }
    
          this.authService.logout();
          return throwError(error);
        }),
        take(2)
        // first request should refresh token and retry,
        // if there's still an error the second time is the last time and should navigate to login
      )),
    );
}

Todas estas opciones se han probado exhaustivamente y se pueden encontrar en el repositorio de github de token de actualización angular

yurzui
fuente
-3

Obtuve esto creando una nueva solicitud basada en la URL de la solicitud fallida y enviando el mismo cuerpo de la solicitud fallida.

 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();        

}

Johseffer Chepli
fuente
-4

En su authentication.service.ts, debe tener un HttpClient inyectado como dependencia

constructor(private http: HttpClient) { }

Luego puede volver a enviar la solicitud (dentro de retryFailedRequests) de la siguiente manera:

this.http.request(request).subscribe((response) => {
    // You need to subscribe to observer in order to "retry" your request
});
Attrox_
fuente
Este fue mi pensamiento inicial, pero devuelve http.request HttpEvent.
Antoniossss