Refresh Token OAuth Authentication Angular 4+

2019-01-18 17:16发布

问题:

I was working with the Http clase from Angular but I decide to make the migration and work with the new HttpClient, and I was trying to create a solution with Interceptors to manage the cases when I need to refresh the token and when I need to modify the header to put the Authorization Token.

回答1:

First I found these post and so many others :

  • https://medium.com/@amcdnl/the-new-http-client-in-angular-4-3-754bd3ff83a8
  • https://medium.com/@ryanchenkie_40935/angular-authentication-using-the-http-client-and-http-interceptors-2f9d1540eb8

... but those solutions are perfect if you just want to handle the action to put the Authorization Header. Then I come up with this solution

@Injectable()
export class RefreshTokenInterceptor implements HttpInterceptor {

  constructor(private injector: Injector, private authService: Auth) {
  }


  private getRequestWithAuthentication(request: HttpRequest<any>, next: HttpHandler, auth: OAuthService): Observable<HttpEvent<any>> {
    const  req = request.clone({
        headers: request.headers.set('Authorization', auth.getHeaderAuthorization())
      });
    return next.handle(req);
  }

  intercept(request: HttpRequest<any>, next: HttpHandler): Observable<HttpEvent<any>> {
    // To avoid cyclic dependency
    const auth = this.injector.get(OAuthService);

    if (auth.hasAuthorization()) {
      return this.getRequestWithAuthentication(request, next, auth);
    } else if (auth.hasAuthorizationRefresh() && request.url !== AUTHORIZE_URL) {
      return auth.refreshToken().flatMap(
        (res: any) => {
          auth.saveTokens(res);
          return this.getRequestWithAuthentication(request, next, auth);
        }
      ).catch(() => {
        return next.handle(request);
      });
    } else if (request.url === AUTHORIZE_URL) {
      return next.handle(request);
    }

    return this.getRequestWithAuthentication(request, next, auth);
  }
}

The main idea with this is simple:

  • First, I'm injecting a service where I have all the logic to determinate if I have the token or the refresh token and of course the action to save it and get it.
  • If I have the authorization (which is the token to put in the header) I am just return the request with the Authorization Header, if not I am check if I have the refresh token and I am trying to get it from the server and then I'm waitng until I have the token to pass the request.
  • The constant AUTHORIZE_URL it's a string with the route from the server that I am using to get the token or refresh it. The reason because I'm checking this is because I am making a request with HttpClient in the OAuthService so It's gonna pass from the interceptor too and It's gonna make a infinite loop if I don't check it.

This Solution work fine in some cases, but the thing is when for example the token expired and you have multiple request, every request is going to try to refresh the token.


After this I found this solution but I wanna know what do you think about code and way that I'm doing it.

Ok, First I created a Service to save the state of the refresh token request and Observable to know when the request is done.

This is my Service:

@Injectable()
export class RefreshTokenService {
  public processing: boolean = false;
  public storage: Subject<any> = new Subject<any>();

  public publish(value: any) {
    this.storage.next(value);
  }
}

I noticed that It was better if I have two Interceptors one to refresh the token and handle that and one to put the Authorization Header if exist.

This the Interceptor for Refresh the Token:

@Injectable()
  export class RefreshTokenInterceptor implements HttpInterceptor {

    constructor(private injector: Injector, private tokenService: RefreshTokenService) {
    }

    intercept(request: HttpRequest<any>, next: HttpHandler): Observable<HttpEvent<any>> {
      const auth = this.injector.get(OAuthService);
      if (!auth.hasAuthorization() && auth.hasAuthorizationRefresh() && !this.tokenService.processing && request.url !== AUTHORIZE_URL) {
        this.tokenService.processing = true;
        return auth.refreshToken().flatMap(
          (res: any) => {
            auth.saveTokens(res);
            this.tokenService.publish(res);
            this.tokenService.processing = false;
            return next.handle(request);
          }
        ).catch(() => {
          this.tokenService.publish({});
          this.tokenService.processing = false;
          return next.handle(request);
        });
      } else if (request.url === AUTHORIZE_URL) {
        return next.handle(request);
      }

      if (this.tokenService.processing) {
        return this.tokenService.storage.flatMap(
          () => {
            return next.handle(request);
          }
        );
      } else {
        return next.handle(request);
      }
    }
  }

So here I'm waiting to the refresh token to be available or fails and then I release the request that needs the Authorization Header.

This is the Interceptor to put the Authorization Header:

@Injectable()
  export class TokenInterceptor implements HttpInterceptor {
    constructor(private injector: Injector) {}

    intercept(request: HttpRequest<any>, next: HttpHandler): Observable<HttpEvent<any>> {
      const auth = this.injector.get(OAuthService);
      let req = request;
      if (auth.hasAuthorization()) {
        req = request.clone({
          headers: request.headers.set('Authorization', auth.getHeaderAuthorization())
        });
      }

      return next.handle(req).do(
        () => {},
        (error: any) => {
          if (error instanceof HttpErrorResponse) {
            if (error.status === 401) {
              auth.logOut();
            }
          }
        });
    }
  }

And my main module is something like this:

@NgModule({
  imports: [
    ...,
    HttpClientModule
  ],
  declarations: [
    ...
  ],
  providers: [
    ...
    OAuthService,
    AuthService,
    RefreshTokenService,
    {
      provide: HTTP_INTERCEPTORS,
      useClass: RefreshTokenInterceptor,
      multi: true
    },
    {
      provide: HTTP_INTERCEPTORS,
      useClass: TokenInterceptor,
      multi: true
    }
  ],
  bootstrap: [AppComponent]
})
export class AppModule {
}

Please any feedback will be welcome and if I'm doning something wrong tell me. I'm testing with Angular 4.4.6 but I don't know if it work on angular 5, I think should work.



回答2:

For anyone else looking for a solution to this in Angular 4 (maybe slight changes needed for Angular 5+), I came up with the following solution:

@Injectable()
export class AuthInterceptorService implements HttpInterceptor {
    private _refreshRequest: Observable<ApiResult<any>> | null = null;

    constructor(
        private _router: Router, 
        private _tokenStorage: TokenStorageService,
        private _injector: Injector) {
    }

    private _addTokenHeader(request: HttpRequest<any>) {
        const authToken = this._tokenStorage.authToken;

        if (!authToken) {
            return request;
        }

        return request.clone({setHeaders: {'Authorization': 'Bearer ' + authToken.value}});
    }

    private _fail() {
        this._tokenStorage.clearTokens();
        this._router.navigate(['/login']);
        return Observable.of(new HttpResponse({status: 401}));
    }

    private _refreshAuthToken(request: HttpRequest<any>, next: HttpHandler) {
        // AuthService has the following dependency chain:
        // ApiService -> HttpClient -> HTTP_INTERCEPTORS
        // If injected at the constructor this causes a circular dependency error. 
        const authService = <AuthService>this._injector.get(AuthService);

        if (this._refreshRequest === null) {
            // Send the auth token refresh request
            this._refreshRequest = authService.refreshAuthToken();
            this._refreshRequest.subscribe(() => this._refreshRequest = null);
        }

        // Wait for the auth token refresh request to finish before sending the pending request
        return this._refreshRequest
            .flatMap(result => {
                if (result.success) {
                    // Auth token was refreshed, continue with pending request
                    return this._sendRequest(this._addTokenHeader(request), next);
                }

                // Refreshing the auth token failed, fail the pending request
                return this._fail();
            });
    }

    private _sendRequest(request: HttpRequest<any>, next: HttpHandler) {
        return next.handle(request).catch((err: HttpErrorResponse, caught) => {
            // Send the user to the login page If there are any 'Unauthorized' responses
            if (err.status === 401) {
                this._router.navigate(['/login']);
            }

            return Observable.throw(err);
        });
    }

    intercept(request: HttpRequest<any>, next: HttpHandler): Observable<HttpEvent<any>> {
        if (request.url.indexOf('/api/auth') !== -1) {
            // This interceptor should not be applied to auth requests
            return this._sendRequest(request, next);
        }

        const authToken = this._tokenStorage.authToken;
        const refreshToken = this._tokenStorage.refreshToken;

        // Attempt to refresh the auth token if it is expired or about to expire
        if (authToken && authToken.expiresWithinSeconds(60)) {
            if (refreshToken && !refreshToken.isExpired) {
                return this._refreshAuthToken(request, next);
            }
            else {
                // Auth token has expired and cannot be refreshed
                return this._fail();
            }
        }

        return this._sendRequest(this._addTokenHeader(request), next);
    }
}

This will issue an auth token refresh request to the server if the current auth token is expired, but there is a valid refresh token. Further requests are buffered until the pending refresh request completes.

Not shown is the source to:
- TokenStorageService which just uses localStorage
- Jwt class that wraps a token and makes the token claims like expiry date easy to access
- ApiResult which is just a simple wrapper around HttpResponse for my application, and not particularly relevant to anything here

Edit: Angular 6/7

import { Injectable, Inject, Injector } from '@angular/core';
import { Router } from '@angular/router';
import { 
    HttpEvent, 
    HttpInterceptor, 
    HttpHandler, 
    HttpRequest, 
    HttpResponse, 
} from '@angular/common/http';
import { Observable, of, throwError } from 'rxjs';
import { catchError, flatMap } from 'rxjs/operators';

import { ApiResult } from '../../api';

import { TokenStorageService } from './token-storage.service';
import { AuthService } from './auth.service';


@Injectable()
export class AuthInterceptorService implements HttpInterceptor {
    private _refreshRequest: Observable<ApiResult> | null = null;

    constructor(
        private _router: Router, 
        private _tokenStorage: TokenStorageService,
        @Inject('BASE_URL') private _baseUrl: string,
        private _injector: Injector) {
    }

    private _addTokenHeader(request: HttpRequest<any>) {
        const authToken = this._tokenStorage.authToken;

        if (!authToken) {
            return request;
        }

        return request.clone({setHeaders: {'Authorization': 'Bearer ' + authToken.value}});
    }

    private _fail() {
        this._tokenStorage.clearTokens();
        this._router.navigate(['/login']);
        return of(new HttpResponse({status: 401}));
    }

    private _refreshAuthToken(request: HttpRequest<any>, next: HttpHandler) {
        // AuthService has the following dependency chain:
        // ApiService -> HttpClient -> HTTP_INTERCEPTORS
        // If injected at the constructor this causes a circular dependency error. 
        const authService = <AuthService>this._injector.get(AuthService);

        if (this._refreshRequest === null) {
            // Send the auth token refresh request
            this._refreshRequest = authService.refreshAuthToken();
            this._refreshRequest.subscribe(() => this._refreshRequest = null);
        }

        // Wait for the auth token refresh request to finish before sending the pending request
        return this._refreshRequest.pipe(flatMap(result => {
            if (result.success) {
                // Auth token was refreshed, continue with pending request
                return this._sendRequest(this._addTokenHeader(request), next);
            }

            // Refreshing the auth token failed, fail the pending request
            return this._fail();
        }));
    }

    private _sendRequest(request: HttpRequest<any>, next: HttpHandler) {
        return next.handle(request).pipe(catchError(err => {
            // Send the user to the login page If there are any 'Unauthorized' responses
            if (err.status === 401) {
                this._router.navigate(['/login']);
            }

            return throwError(err);
        }));
    }

    intercept(request: HttpRequest<any>, next: HttpHandler): Observable<HttpEvent<any>> {
        if (request.url.indexOf(this._baseUrl) === -1  || request.url.indexOf('/api/auth') !== -1) {
            // This interceptor should not be applied to non-api requests or auth requests
            return this._sendRequest(request, next);
        }

        const authToken = this._tokenStorage.authToken;
        const refreshToken = this._tokenStorage.refreshToken;

        // Attempt to refresh the auth token if it is expired or about to expire
        if (authToken && authToken.expiresWithinSeconds(60)) {
            if (refreshToken && !refreshToken.isExpired) {
                return this._refreshAuthToken(request, next);
            }
            else {
                // Auth token has expired and cannot be refreshed
                return this._fail();
            }
        }

        return this._sendRequest(this._addTokenHeader(request), next);
    }
}