Запити повторної спроби перехоплювача Angular 4 після оновлення маркера


84

Привіт! Я намагаюся зрозуміти, як реалізувати нові кутові перехоплювачі та обробляти 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

Відповіді:


98

Моє остаточне рішення. Працює з паралельними запитами.

ОНОВЛЕННЯ: Код, оновлений за допомогою 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?
Тейхін

4
У мене відчуття, що якщо з якоїсь причини this.authService.refreshToken () не вдається, всі паралельні запити, що очікують на оновлення, будуть чекати вічно.
Максим Гумеров

2
Захоплення маркера оновлення ніколи не вимагає мене. Це потрапило в Observable .row.
jamesmpw

2
Хлопці, це працює з паралельними та послідовними запитами. Ви відправляєте 5 запитів, вони повертають 401, потім виконується 1 оновленняToken і знову 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(. Наразі ви відправляєте маркер автентифікації лише після його оновлення ...
malimo

Я думаю, що роблю це за допомогою перемикання. Перевірте ще раз. Повідомте мене, якщо я неправильно зрозумів вашу думку.
Самарпан

так, це в основному працює, але ви завжди надсилаєте запит двічі - один раз без заголовка, а потім після того, як він не вдався із заголовком ....
malimo

@SamarpanBhattacharya Це працює. Я думаю, що ця відповідь могла б дати пояснення із семантикою для когось на кшталт мене, хто не розуміє, як працює робота Observable.
Антон Тошик

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

1
Мені подобається використовувати спеціальний код стану 498 для ідентифікації простроченого маркера проти 401, що також може вказувати на недостатню приватність
Джозеф Керролл

1
Привіт, я намагаюся використовувати return next.handle (reqClode) і нічого не робить, мій код відрізняється від вашого abit, але частина, яка не працює, є частиною return. authService.createToken (authToken, refreshToken); this.inflightAuthRequest = null; повернути next.handle (req.clone ({заголовки: req.headers.set (appGlobals.AUTH_TOKEN_KEY, authToken)}));

6
Логіка збору / повторної спроби не надто складна, це спосіб, яким ви повинні це зробити, якщо ви не хочете робити кілька запитів до кінцевої точки refreshToken, поки термін дії вашого маркера закінчився. Скажімо, термін дії вашого токена закінчився, і ви робите 5 запитів майже одночасно. За логікою цього коментаря, на сервері буде створено 5 нових маркерів оновлення.
Маріус Лазар

4
@JosephCarroll зазвичай не має достатньо привілеїв - це 403
andrea.spot.

8

Остаточне рішення Андрія Островського працює дуже добре, але не працює, якщо термін дії маркера оновлення також минув (за умови, що ви робите виклик 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 для обробки при перехопленні кінцевої точки оновлення.
James Lieu,

Це не має сенсу, чому оновлення поверне 401? Справа в тому, що він викликає оновлення після невдалої автентифікації, тому ваш API оновлення взагалі не повинен бути автентифікованим і не повинен повертати 401.
MD, зберегти

Оновлення маркерів може мати термін дії. У нашому випадку використання термін дії закінчувався через 4 години, якщо користувач повинен був закрити свій браузер наприкінці дня і повернутися наступного ранку, токен оновлення закінчився б до цього моменту, і тому ми вимагали від нього входу повернутися знову. Якщо термін дії вашого маркера оновлення не закінчився, тоді, звичайно, вам не потрібно було б застосовувати цю логіку
James Lieu,

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, the LoginServiceмає наступні методи:
- getAccessToken (): string - повертає поточнийaccess_token
- isRememberMe (): boolean - перевірити, чи є у користувача refresh_token
- refreshToken (): Observable / Promise - Запит на сервер oauth для нового access_tokenвикористання refresh_token
- invalidateSession (): void - видалити всю інформацію про користувача та перенаправити на сторінку виходу


У вас проблема з кількома запитами, які надсилають кілька запитів на оновлення?
CodingGorilla

Ця версія мені подобається найбільше, але у мене виникає проблема, коли моя робить запит, коли повертає 401, намагається оновити, коли повертає помилку, вона постійно намагається відправити запит знову, ніколи не зупиняючись. Я роблю щось недобре?
jamesmpw 03.03.18

Вибачте, той, що раніше я не перевіряв ретельно. Щойно відредагував мій пост із тестованим, який я використовую (також перейти на rxjs6 та оновити маркер, перевірити url).
Тхань Нхан

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

0

Мені довелося вирішити такі вимоги:

  • ✅ Оновити маркер лише один раз для кількох запитів
  • Вийти з користувача, якщо refreshToken не вдалося
  • Вийти, якщо користувач отримав помилку після першого оновлення
  • Чергуйте всі запити, поки токен оновлюється

В результаті я зібрав різні варіанти для оновлення маркера в 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


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

Це була моя початкова думка, але http.request повертається HttpEvent.
Antoniossss,
Використовуючи наш веб-сайт, ви визнаєте, що прочитали та зрозуміли наші Політику щодо файлів cookie та Політику конфіденційності.
Licensed under cc by-sa 3.0 with attribution required.