Angular 4 запросы на повторную попытку перехватчика после обновления токена

85

Привет, я пытаюсь понять, как реализовать новые перехватчики angular и обрабатывать 401 unauthorizedошибки, обновляя токен и повторяя запрос. Это руководство, которым я следовал: https://ryanchenkie.com/angular-authentication-using-the-http-client-and-http-interceptors

Я успешно кэширую неудавшиеся запросы и могу обновить токен, но не могу понять, как повторно отправить запросы, которые ранее были неудачными. Я также хочу, чтобы это работало с резолверами, которые я сейчас использую.

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

Вышеупомянутый файл retryFailedRequests () - это то, что я не могу понять. Как мне повторно отправить запросы и сделать их доступными для маршрута через преобразователь после повторной попытки?

Если это помогает, это весь соответствующий код: https://gist.github.com/joshharms/00d8159900897dc5bed45757e30405f9

Ковачи
источник
3
У меня такая же проблема, и ответа вроде нет.
LastTribunal

Ответы:

99

Мое окончательное решение. Работает с параллельными запросами.

ОБНОВЛЕНИЕ: код обновлен с помощью Angular 9 / RxJS 6, обработка ошибок и исправление цикла при сбое 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
};
Андрей Островский
источник
3
@AndreiOstrovski, не могли бы вы обновить ответ importsи код AuthService?
takehin 07
4
У меня такое ощущение, что если по какой-то причине this.authService.refreshToken () выйдет из строя, все параллельные запросы, ожидающие обновления, будут ждать вечно.
Максим Гумеров
2
Уловка на токене обновления меня никогда не вызывает. Он попал в наблюдаемый бросок.
jamesmpw 02
2
Ребят, работает с параллельными и последовательными запросами. Вы отправляете 5 запросов, они возвращают 401, затем выполняется 1 refreshToken и снова 5 запросов. Если ваши 5 запросов являются последовательными, после первого 401 мы отправляем refreshToken, затем снова первый запрос и другие 4 запроса.
Андрей Островский
2
Почему вы вручную вводите сервис, если Angular мог бы сделать это за вас, если бы вы его украсили @Injectable()? Также одна ошибка catchError ничего не возвращает. По крайней мере, вернись EMPTY.
Дьёри Шандор
16

С последней версией Angular (7.0.0) и rxjs (6.3.3) я создал полностью функциональный перехватчик восстановления Auto Session, гарантирующий, что если параллельные запросы завершаются с ошибкой с 401, то он также должен попадать только в API обновления токенов. один раз и направьте неудавшиеся запросы в ответ с помощью switchMap и Subject. Вот как выглядит мой код перехватчика. Я пропустил код для своей службы аутентификации и службы хранилища, поскольку это довольно стандартные классы обслуживания.

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

Согласно комментарию @ anton-toshik, я подумал, что было бы неплохо объяснить функционирование этого кода в описании. Вы можете прочитать в моей статье здесь объяснение и понимание этого кода (как и почему он работает?). Надеюсь, поможет.

Самарпан
источник
1
хорошая работа, вторая returnвнутри interceptфункции должен выглядеть следующим образом : return next.handle(this.updateHeader(req)).pipe(. В настоящее время вы отправляете токен
авторизации
Я думаю, что делаю это через switchmap. Пожалуйста, проверьте снова. Сообщите мне, если я неправильно понял вашу точку зрения.
Самарпан
да, это в основном работает, но вы всегда отправляете запрос дважды - один раз без заголовка, а затем после того, как он потерпел неудачу с заголовком ....
malimo 01
@SamarpanBhattacharya Это работает. Я думаю, что этот ответ может быть связан с объяснением семантики для кого-то вроде меня, кто не понимает, как работает Observable.
Антон Тошик 03
1
@NikaKurashvili, у меня сработало это определение метода: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;}));}
Шринивас
9

Я также столкнулся с аналогичной проблемой, и я считаю, что логика сбора / повторной попытки слишком сложна. Вместо этого мы можем просто использовать оператор catch для проверки 401, затем проследить за обновлением токена и повторно запустить запрос:

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
источник
1
Мне нравится использовать настраиваемый код состояния 498 для идентификации просроченного токена по сравнению с 401, который также может указывать на недостаточный уровень приватности,
Джозеф Кэрролл,
1
Привет, я пытаюсь использовать return next.handle (reqClode) и ничего не делает, мой код отличается от вашего abit, но не работает часть возврата. authService.createToken (authToken, refreshToken); this.inflightAuthRequest = null; return next.handle (req.clone ({заголовки: req.headers.set (appGlobals.AUTH_TOKEN_KEY, authToken)}));
6
Логика сбора / повторной попытки не слишком сложна, это способ, которым вы должны это делать, если не хотите делать несколько запросов к конечной точке refreshToken, пока срок действия вашего токена истек. Допустим, срок действия вашего токена истек, и вы делаете 5 запросов почти одновременно. С помощью логики в этом комментарии на стороне сервера будут сгенерированы 5 новых токенов обновления.
Мариус Лазар
4
@JosephCarroll обычно не хватает привилегий 403
andrea.spot.
9

Окончательное решение Андрея Островского работает очень хорошо, но не работает, если срок действия токена обновления также истек (при условии, что вы выполняете вызов api для обновления). Покопавшись, я понял, что вызов API токена обновления также был перехвачен перехватчиком. Мне пришлось добавить оператор if, чтобы справиться с этим.

 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 );
   });
 }
Джеймс Лиу
источник
Не могли бы вы показать, где еще вы играете с refreshTokenHasFailedлогическим членом?
Стефан
1
Вы можете найти его в решении Андрея Островского выше, я в основном использовал его, но добавил оператор if для обработки при перехвате конечной точки обновления.
Джеймс Лью
Это не имеет смысла, почему обновление вернет 401? Дело в том, что он вызывает обновление после сбоя аутентификации, поэтому ваш API обновления вообще не должен проходить аутентификацию и не должен возвращать 401.
MDave
У токенов обновления могут быть даты истечения срока действия. В нашем случае он был настроен на истечение через 4 часа, если бы пользователь закрывал свой браузер в конце дня и возвращался на следующее утро, токен обновления истек бы к этому моменту, и поэтому мы потребовали, чтобы они регистрировали снова. Если срок действия вашего токена обновления не истек, тогда, конечно, вам не нужно было бы применять эту логику
Джеймс Лью
4

На основе этого примера вот моя работа

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

Вы можете проверить, разрешено ли пользователю Remember Meиспользовать токен обновления для повторной попытки или просто перенаправить на страницу выхода.

Fyi, у LoginServiceнего есть следующие методы:
- getAccessToken (): string - вернуть текущий access_token
- isRememberMe (): boolean - проверить, есть ли у пользователя refresh_token
- refreshToken (): Observable / Promise - запрос к серверу oauth для нового access_tokenиспользования refresh_token
- invalidateSession (): void - удалить всю информацию о пользователе и перенаправить на страницу выхода

Тхань Нхан
источник
У вас есть проблема с несколькими запросами, отправляющими несколько запросов на обновление?
CodingGorilla
Эта версия мне нравится больше всего, но у меня проблема, когда моя делает запрос, когда он возвращает 401, пытается обновить, когда это возвращает ошибку, он постоянно пытается отправить запрос снова, никогда не останавливаясь. Я что-то не так делаю?
jamesmpw
Извините, тот, что я раньше не тестировал. Только что отредактировал мой пост с проверенным, который я использую (также перейдите на rxjs6 и обновите токен, проверьте URL-адрес).
Thanh Nhan
2

Мне нужно было решить следующие требования:

  • ✅ Обновить токен только один раз для нескольких запросов
  • ✅ Выйти из системы, если обновить токен не удалось
  • ✅ Выйти из системы, если после первого обновления пользователь получает сообщение об ошибке
  • ✅ Ставить в очередь все запросы, пока токен обновляется

В результате я собрал разные варианты для обновления токена в 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
      )),
    );
}

Все эти параметры тщательно протестированы и их можно найти в репозитории github angular-refresh-token.

Юрзуй
источник
1

В идеале вы хотите проверить isTokenExpiredперед отправкой запроса. А если истекло, обновите токен и добавьте в заголовок обновленное.

Помимо этого, retry operatorможет помочь логика обновления токена при ответе 401.

Используйте RxJS retry operatorв своем сервисе, где вы делаете запрос. Он принимает retryCountаргумент. Если не указан, последовательность будет повторяться бесконечно.

В вашем перехватчике при ответе обновите токен и верните ошибку. Когда ваша служба возвращает ошибку, но теперь используется оператор повтора, поэтому он повторит запрос, и на этот раз с обновленным токеном (Interceptor использует обновленный токен для добавления в заголовок).

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);
  }
}
Лахар Шах
источник
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();
    }
Саураб Дешмук
источник
Можете ли
-3

Я получил это, создав новый запрос на основе URL-адреса неудавшегося запроса и отправив тот же текст неудавшегося запроса.

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

}

Джосеффер Чепли
источник
-4

В вашем authentication.service.ts у вас должен быть HttpClient, введенный как зависимость

constructor(private http: HttpClient) { }

Затем вы можете повторно отправить запрос (внутри retryFailedRequests) следующим образом:

this.http.request(request).subscribe((response) => {
    // You need to subscribe to observer in order to "retry" your request
});
Attrox_
источник
Это была моя первоначальная мысль, но http.request возвращается HttpEvent.
Антониосс