令牌刷新后Angular 4拦截器重试请求

Posted

技术标签:

【中文标题】令牌刷新后Angular 4拦截器重试请求【英文标题】:Angular 4 Interceptor retry requests after token refresh 【发布时间】:2017-12-25 09:54:29 【问题描述】:

您好,我正在尝试通过刷新令牌并重试请求来了解如何实现新的角度拦截器并处理 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

【问题讨论】:

我也有同样的问题,好像没有答案。 【参考方案1】:

我的最终解决方案。适用于并行请求。

更新: 使用 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
;

【讨论】:

@Andreiostrovski,您能否用imports 和AuthService 的代码更新答案? 我有一种感觉,如果由于某种原因 this.authService.refreshToken() 失败,所有等待刷新的并行查询将永远等待。 刷新令牌的捕获永远不需要我。它击中了 Observable .throw。 伙计们,它适用于并行和顺序请求。您发送 5 个请求,它们返回 401,然后执行 1 个 refreshToken,并再次发出 5 个请求。如果您的 5 个请求是连续的,则在第一个 401 之后我们发送 refreshToken,然后再次发送第一个请求和其他 4 个请求。 如果你用 @Injectable() 装饰它,你为什么要手动注入一个服务?还有一个 catchError 不返回任何东西。至少返回EMPTY【参考方案2】:

使用最新版本的 Angular (7.0.0) 和 rxjs (6.3.3),这就是我创建功能齐全的自动会话恢复拦截器的方式,以确保如果并发请求因 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 的评论,我认为在文章中解释这段代码的功能是个好主意。您可以阅读我的文章 here 以了解对这段代码的解释和理解(它是如何工作的以及为什么工作?)。希望对您有所帮助。

【讨论】:

干得好,intercept 函数中的第二个return 应该如下所示:return next.handle(this.updateHeader(req)).pipe(。目前您只在刷新后发送身份验证令牌... 我想我是通过 switchmap 来做的。请再检查一次。如果我误解了你的观点,请告诉我。 是的,它基本上可以工作,但你总是发送请求两次 - 一次没有标头,然后在标头失败后...... @SamarpanBhattacharya 这行得通。我认为这个答案可以为像我这样不了解 Observable 工作原理的人提供语义解释。 @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=&gt;const actualToken:string=resp['data'];if(actualToken)this.setLocalStorage('authToken',actualToken);return resp;));【参考方案3】:

我必须解决以下要求:

✅ 多次请求只刷新一次令牌 ✅ 如果 refreshToken 失败则注销用户 ✅如果用户在第一次刷新后出现错误,请退出 ✅ 在刷新令牌时将所有请求排队

因此,我收集了不同的选项以在 Angular 中刷新令牌:

Brute force solution 与 tokenRefreshed$ BehaviorSubject 作为信号量 在catchError RxJS 运算符中使用caught parameter 重试请求失败的请求
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);
      )
    );

使用retryWhen RxJS operator
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
      )),
    );

所有这些选项都经过了彻底的测试,可以在angular-refresh-token github repo找到

另见:

catchError - RxJS Reference

【讨论】:

【参考方案4】:

我也遇到了类似的问题,我认为收集/重试逻辑过于复杂。相反,我们可以只使用 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;

【讨论】:

我喜欢使用自定义状态码 498 来识别过期令牌,而 401 也可能表示权限不足 嗨,我正在尝试使用 return next.handle(reqClode) 并且什么都不做,我的代码与您的 abit 不同,但不工作的部分是返回部分。 authService.createToken(authToken, refreshToken); this.inflightAuthRequest = null; return next.handle(req.clone( headers: req.headers.set(appGlobals.AUTH_TOKEN_KEY, authToken) )); 收集/重试逻辑并不过分复杂,如果您不想在令牌过期时向 refreshToken 端点发出多个请求,则必须这样做。假设您的令牌已过期,您几乎同时发出 5 个请求。使用此评论中的逻辑,将在服务器端生成 5 个新的刷新令牌。 @JosephCarroll 通常没有足够的权限是 403【参考方案5】:

Andrei Ostrovski 的最终解决方案非常有效,但如果刷新令牌也已过期(假设您正在调用 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成员布尔值显示你在哪里玩吗? 你可以在上面 Andrei Ostrovski 的解决方案中找到它,我基本上已经使用了,但是添加了 if 语句来处理刷新端点被拦截时的处理。 这没有意义,为什么刷新会返回401?关键是它在身份验证失败后调用刷新,因此您的刷新 API 根本不应该进行身份验证,也不应该返回 401。 刷新令牌可以有到期日期。在我们的用例中,它设置为 4 小时后过期,如果用户在一天结束时关闭浏览器并在第二天早上返回,那么刷新令牌将在此时过期,因此我们要求他们登录再回来。如果您的刷新令牌没有过期,那么您当然不需要应用此逻辑【参考方案6】:

基于this example,这是我的作品

@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 以使用刷新令牌进行重试或只是重定向到注销页面。

仅供参考,LoginService 有以下方法: - getAccessToken(): string - 返回当前的access_token - isRememberMe(): boolean - 检查用户是否有refresh_token - refreshToken(): Observable / Promise - 使用refresh_token向oauth服务器请求新的access_token - invalidateSession(): void - 删除所有用户信息并重定向到注销页面

【讨论】:

您是否有多个请求发送多个刷新请求的问题? 这个版本我最喜欢,但我有一个问题,我的请求,当返回 401 时尝试刷新,当返回错误时它不断尝试再次发送请求,从不停止。我做错了吗? 对不起,我之前没有仔细测试过。刚刚用我正在使用的经过测试的帖子编辑了我的帖子(也迁移到 rxjs6 并刷新令牌,检查 url)。【参考方案7】:

理想情况下,您希望在发送请求之前检查isTokenExpired。如果过期刷新令牌并在标题中添加刷新。

除此之外,retry operator 可能有助于您在 401 响应中刷新令牌的逻辑。

在您提出请求的服务中使用RxJS retry operator。它接受 retryCount 参数。 如果未提供,它将无限期地重试序列。

在您的响应拦截器中刷新令牌并返回错误。当您的服务返回错误但现在正在使用重试运算符时,它将重试请求,这次使用刷新的令牌(拦截器使用刷新的令牌添加到标头中。)

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

【讨论】:

【参考方案8】:

在 Andrei Ostrovski 最接受的answer 中,人们评论了令牌刷新请求由于某种原因失败时的内存泄漏。可以通过使用 RxJS 超时运算符来缓解这种情况,如下所示:

//...

 tokenRefreshTimeout = 60000;

//...

    // Invalid token error
            else if (error.status === 401) 
                return this.refreshToken().pipe(
                    timeout(this.tokenRefreshTimeout), //added timeout here
                    switchMap(() => 
                        request = this.addAuthHeader(request);
                        return next.handle(request);
                    ),
//...

(对不起,我没有足够的代表发表评论,我也无法建议编辑,因为编辑队列总是满的)

【讨论】:

【参考方案9】:
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();
    

【讨论】:

能否添加 authenticationService 类【参考方案10】:

我得到这个基于失败请求的 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();        

【讨论】:

【参考方案11】:

在你的 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

以上是关于令牌刷新后Angular 4拦截器重试请求的主要内容,如果未能解决你的问题,请参考以下文章

如何让 axios 拦截器重试原始请求?

Angular 4.3 - HTTP 拦截器 - 刷新 JWT 令牌

Vue 资源拦截器刷新 JWT 令牌

使用多个请求刷新访问令牌

多个请求的 axios 拦截器刷新令牌

Angular JWT 拦截器切换承载令牌以进行刷新