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

IT技术 javascript angular rxjs
2021-02-06 12:16:52

嗨,我想弄清楚如何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();
                    }
                } );

            }
        }
    } );

身份验证.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

6个回答

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

更新:使用 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
};
如果 Angular 用 装饰它,为什么要手动注入服务@Injectable()还有一个 catchError 不返回任何内容。至少回报EMPTY
2021-04-02 12:16:52
我有一种感觉,如果由于某种原因 this.authService.refreshToken() 失败,所有等待刷新的并行查询将永远等待。
2021-04-04 12:16:52
@AndreiOstrovski,您能否用importsAuthService 的代码更新答案
2021-04-07 12:16:52
伙计们,它适用于并行和顺序请求。您发送 5 个请求,它们返回 401,然后执行 1 个 refreshToken,然后再次发送 5 个请求。如果您的 5 个请求是连续的,则在第一个 401 之后我们发送 refreshToken,然后再次发送第一个请求和其他 4 个请求。
2021-04-07 12:16:52
刷新令牌上的捕获从不要求我。它击中了 Observable .throw。
2021-04-08 12:16:52

使用最新版本的 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 的评论,我认为在一篇文章中解释这段代码的功能是个好主意。您可以在这里阅读我的文章,以解释和理解这段代码(它是如何工作的以及为什么工作?)。希望能帮助到你。

@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;}));}
2021-03-16 12:16:52
良好的工作,第二个return内部intercept函数应该是这样的:return next.handle(this.updateHeader(req)).pipe(目前您只在刷新后发送身份验证令牌......
2021-03-25 12:16:52
我想我是通过 switchmap 做到的。请再次检查。如果我误解了你的意思,请告诉我。
2021-03-27 12:16:52
@SamarpanBhattacharya 这有效。我认为这个答案可以为像我这样不了解 Observable 如何工作的人提供语义解释。
2021-04-05 12:16:52
是的,它基本上有效,但您总是发送请求两次 - 一次没有标头,然后在标头失败后......
2021-04-07 12:16:52

我必须解决以下要求:

  • ✅ 多个请求只刷新一次令牌
  • ✅ 如果 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
      )),
    );
}

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

也可以看看:

我也遇到了类似的问题,我认为收集/重试逻辑过于复杂。相反,我们可以只使用 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;
}
收集/重试逻辑并不过分复杂,如果您不想在令牌过期时向 refreshToken 端点发出多个请求,则必须这样做。假设您的令牌已过期,并且您几乎同时发出 5 个请求。使用此注释中的逻辑,将在服务器端生成 5 个新的刷新令牌。
2021-03-26 12:16:52
@JosephCarroll 通常没有足够的权限是 403
2021-03-28 12:16:52
我喜欢使用 498 的自定义状态代码来识别过期的令牌,而 401 也可以表示没有足够的 priv
2021-03-31 12:16:52
嗨,我正在尝试使用 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) }));
2021-04-09 12:16:52

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 );
   });
 }
这没有意义,为什么刷新会返回 401?关键是它在身份验证失败后调用刷新,因此您的刷新 API 根本不应该进行身份验证,也不应该返回 401。
2021-03-13 12:16:52
刷新令牌可以有到期日期。在我们的用例中,它被设置为在 4 小时后过期,如果用户在一天结束时关闭浏览器并在第二天早上返回,刷新令牌将在那时过期,因此我们要求他们登录再回来。如果您的刷新令牌没有过期,那么您当然不需要应用此逻辑
2021-03-18 12:16:52
您可以在上面的 Andrei Ostrovski 的解决方案中找到它,我基本上已经使用了它,但是添加了 if 语句来处理拦截刷新端点时的情况。
2021-03-30 12:16:52
你能展示你在其他地方玩过refreshTokenHasFailed成员 boolean 吗?
2021-04-05 12:16:52