Pull to refresh

Angular: авторизация, рефрешим токен и HttpInterceptor

Reading time11 min
Views46K
Доброго времени суток.

Опишу процесс авторизации с использованием некоторого сервера авторизации и интерфейса HttpInterceptor, который стал доступен с версии Angular 4.3+. С помощью HttpInterceptor`a будем добавлять наш токен в Header запроса перед отправкой каждого запроса. Так же, по истечению срока действия токена, получая 401ую ошибку, будем восстанавливать токен и повторять запросы, которые не прошли авторизацию пока ждали рефреша.

Начнем с конфигурации Interceptor`ов:


Проводить конфигурацию предпочитаю с основного модуля приложения. Или если ваше приложение уже достаточно большое, советую вынести конфигурации в CoreModule.
В статье буду использовать CoreModule, но можно сделать это и в корневом (AppModule обычно) модуле приложения, отличия незначительны.

Пока писал статью ресурс на angular.io по CoreModule исчез
Коротко говоря, это такой модуль, который должен содержать глобальные сервисы. Преимущество в том, что этот модуль импортируется в модуле приложение (AppModule). Все экспортированные Core модулем сервисы гарантированно будут иметь только один инстанс на все приложение, включая lazy loaded модули.

//core.module.ts
//imports....

@NgModule({
  providers: [
    AuthService,
    {
      provide: HTTP_INTERCEPTORS,
      // Этим interceptor`ом будем добавлять auth header
      useClass: ApplyTokenInterceptor,
      multi: true
    },
    {
      provide: HTTP_INTERCEPTORS,
      // этим будем соответственно рефрешить
      useClass: RefreshTokenInterceptor,
      multi: true
    }
  ],
  exports: [HttpClientModule]
})

export class CoreModule {
  //@Optional() @SkipSelf() - если вдруг мы попытаемся импортировать CoreModule в AppModule и например UserModule - получим ошибку
  constructor(@Optional() @SkipSelf() parentModule: CoreModule,
    userService: UserService,
    inj: Injector,
    auth: AuthService,
    http: HttpClient) {

    //Получаем интерцепторы которые реализуют интерфейс AuthInterceptor
    let interceptors = inj.get<AuthInterceptor[]>(HTTP_INTERCEPTORS)
      .filter(i => { return i.init; });
    //передаем http сервис и сервис авторизации.
    interceptors.forEach(i => i.init(http, auth));

    userService.init();

    if (parentModule) {
      //если мы здесь, значит случайно включили CoreModule в двух и более местах
      throw new Error(
        'CoreModule is already loaded. Import it in the AppModule only');
    }
  }
}

Конечно использовать AuthInterceptor интерфейс не обязательно, т.к нас итнересует только метод init. Просто с интерфейсом мы видим агрументы метода в IDE и так есть гарантии, что мы не получим другие Intercoptor`ы, которые может и не реализуют метод init, или реализуют но имеют иную сигнатуру.

export interface AuthInterceptor {
    init(http: HttpClient, auth: AuthRefreshProvider);
}

Про InjectionToken и как работает inj.get(HTTP_INTERCEPTORS)
InjectionToken, кто не в курсе про эту полезную вещь — изучаем.

Если вы знакомы с принципом работы DI в строго типизированных языках то коротко говоря provide: HTTP_INTERCEPTORS в декораторе модуля связывает интерфейс с реализацией (useClass: ApplyTokenInterceptor в нашем случае) и далее мы можем получить инстансы наших реализаций указав в качестве параметра константу HTTP_INTERCEPTORS, кроме того можно указать тип интерфейса в качестве generic параметра: inj.get<AuthInterceptor[]>(HTTP_INTERCEPTORS)
Вот что есть константа HTTP_INTERCEPTORS в angular/common/http (это строка + тип на основе которых DI может вернуть нам нужные инстансы):

export const HTTP_INTERCEPTORS = new InjectionToken<HttpInterceptor[]>('HTTP_INTERCEPTORS');


Зачем вообще передавать HttpClient в Interceptor из конструктора модуля, если можно просто описать его в конструкторе Interceptor`a или получить инстанс через Injector?

Инициализация наших Interceptor`ов проходит именно в модуле только потому, что напрямую внедрить http сервис в конструктор Interceptor`a не выйдет, мы просто получим цикличную зависимость:

@Injectable()
export class ApplyTokenInterceptor implements HttpInterceptor, AuthInterceptor {

    //circular dependency Error! 
    public constructor(private http: HttpClient) {
		//....
    }
	
	//....
}

Так же вы можете совершить попытку использовать Injector и получить инстанс HttpClient сервиса через него, но тоже ничего не получится, если делать это в конструкторе:

@Injectable()
export class ApplyTokenInterceptor implements HttpInterceptor, AuthInterceptor {
    public constructor(private injector: Injector) {
		//circular dependency Error! 
		injector.get(HttpClient);
    }
	
	//....
}

Кроме того, нельзя получить инстанс других сервисов которые так де инжектят себе HttpClient — как AuthService например.

//auth.service.ts
export class AuthService implements AuthRefreshProvider {
    constructor(client: HttpClient) { }
}

//apply.interceptor.ts
export class ApplyTokenInterceptor implements HttpInterceptor, AuthInterceptor {
    //circular dependency Error! потому, что AuthService имеет зависимость с HttpClient
    public constructor(private auth: AuthService ) { }
}


Кстати не за горами релиз 6ой версии Angular и в этой версии мы наконец-то сможем внедрить HttpClient в Interceptor!

откуда же взялся этот circular dependency Error!
  • Previously, an interceptor attempting to inject HttpClient directly would receive a circular dependency error, as HttpClient was constructed via a factory which injected the interceptor instances. Users want to inject HttpClient into interceptors to make supporting;
  • Either HttpClient or the user has to deal specially with the circular Dependency. This change moves that responsibility into HttpClient itself. By utilizing a new class HttpInterceptingHandler which lazily Loads the set of interceptors at request time, it's possible to inject HttpClient directly into interceptors as construction of HttpClient no longer requires the interceptor chain to be constructed.



Если идея с инициализацией внутри модуля не впечатляет, но впечатляет «лишний if», то можно сделать это прямо в методе intercept и вовсе не использовать интерфейс AuthInterceptor и Injector в констукторе модуля CoreModule.

@Injectable()
export class ApplyTokenInterceptor implements HttpInterceptor, AuthInterceptor {

  private http: HttpClient;

  constructor(private injector: Injector) { }

  intercept(req: HttpRequest<any>, next: HttpHandler): Observable<HttpEvent<any>> {
    //забираем HttpClient в при каждом перехвате запроса
    if (!http) {
      this.http = injector.get(HttpClient);
    }
  }
}

Инициализация наших ApplyTokenInterceptor и RefreshTokenInterceptor на этом почти все!

Добавим еще одну инициализацию в CoreModule, будем рефрешить токен при инициализации приложение, если токен почти истек (осталось к примеру 60 секунд) или совсем истек, тогда почему бы не обновить его сразу.:

@NgModule({
  providers: [
    AuthService,
    UserService,
        export class CoreModule {
  {
    provide: APP_INITIALIZER,
    // можно сразу инициировать рефреш: (a) => a.renewAuth()
    // важно! renewAuth должен вернуть Promise, а не Observable т.к инициализация работает 
    // только с промисами
    //но очень важно делать это через экспортную функцию, дабы не сломать AOT сборку
    useFactory: refreshToken,
    deps: [AuthService],
    multi: true
  },
  {
    provide: HTTP_INTERCEPTORS,
    useClass: ApplyTokenInterceptor,
    multi: true
  },
  {
    provide: HTTP_INTERCEPTORS,
    useClass: RefreshTokenInterceptor,
    multi: true
  }
  ],
  exports: [HttpClientModule]
})
export class CoreModule {
  // some code
}

APP_INITIALIZER
APP_INITIALIZER — это наш InjectionToken, в недрах Angular (если нет желания колупать исходники, вот статья)есть код который выдергивает из DI все APP_INITIALIZER`ы и выполняет их на этапе загрузки (пока ми видим надпись loading… например).


Опишем нашу фабрику рефреша. В идеале renewAuth() должен вернуть Promise, а не Observable. С Observable тоже будет работать (но не корректно, сразу можно и не заметить), но я проводил тест, в котором использовался Observable, он резолвился по таймеру (10с) и в итоге модуль был инициализирован до того как истекло 10 секунд (значит наш рефреш токен может прийти уже после того как пропадет прелоадер, что не есть корректно). Я предпочитаю приводить Observable в Promise в фабрике (`export function refreshToken() {… return o.toPromise(); }`), таким образом остальной мой код все еще будет работать с Observable, что явно удобнее чем Promise.
export function refreshToken(auth: AuthService) {
    return () => {
        // return own subject to complete this initialization step in any case
        // otherwise app will stay on preloader if any error while token refreshing occurred
        const subj = new Subject();
        auth.renewAuth()
        .finally(() => {
            subj.complete();
        })
        .catch((err, caught: Observable<any>) => {
            // do logout, redirect to login will occurs at UserService with onLoggedOut event
            auth.logout();
            return ErrorObservable.create(err);
        })
        .subscribe();
        // need to return Promise!!
        return subj.toPromise();
    };
  }


Еще один важный момент! Конечно есть вероятность, что Refresh token может истечь, или же он может быть отозван администратором. В этом случае запрос вернет ошибку, и процесс инициализации прервется и юзер зависнет на прелоадере. Потому кроме того, что нужно вернуть Promise, мы также вернем собственный Subject который будет completed в любом случае, но если произошла ошибка мы сделаем логаут и если нужно редирект на логин. Подход с комплитом может быть полезен и в многих других процессах инициализации.

Проверять токен на «свежесть» в renewAuth() нужно обязательно, что бы не обновлять его по каждому нажатию на F5 в браузере и при этом у нас есть возможность обновить его до того как приложение начнет слать запросы на наш API и столкнется с истекшим токеном (если он все таки истек конечно).

//auth.service.ts

public renewAuth(): Observable < string > {
  if(!this.isNeedRefreshToken()) {
  return Observable.empty<string>();
}
return this._http.post<string>(`https://${authConfig.domain}/oauth/token`,
  {
    client_id: authConfig.clientID,
    grant_type: 'refresh_token',
    refresh_token: localStorage.getItem('refresh_token')
  }).do(res => this.onAuthRenew(res));
}

public isNeedRefreshToken(): boolean {
  //expires_at - время когда токен должен истечь, записано при логине или после очередного рефреша 
  let expiresAtString = localStorage.getItem('expires_at');
  if (!expiresAtString) {
    return false;
  }

  const expiresAt = JSON.parse(expiresAtString);
  //считаем, что токен нужно рефрешить не когда он уже истек, а за минуту до его невалидности
  let isExpireInMinute = new Date().getTime() > (expiresAt - 60000);
  return isExpireInMinute;
}


Почему не стоит хранить токен на клиенте, как это сделал я.
Теперь добавим Authorization заголовок в запросы к нашему API.

export class ApplyTokenInterceptor implements HttpInterceptor, AuthInterceptor {

  intercept(req: HttpRequest<any>, next: HttpHandler): Observable<HttpEvent<any>> {

    //т.к HttpInterceptor перехватывает абсолютно все запросы мы должны гарантировать, что Authorization заголовок будет
    //добавлен только к запросам на наш API
    if (!req.url.includes('api/')) {
      return next.handle(req);
    }

    //клонироуем запрос, что бы добавить новый заголовок 
    const authReq = req.clone({
      headers: req.headers.set('Authorization', this.auth.authHeader)
    });
    //передаем клонированный запрос место ориганального
    return next.handle(authReq);
  }
}

Добавить заголовок Authorization достаточно просто (на просторах уже есть статьи). Единственный момент, который хотелось бы прояснить — почему HttpRequest сделан как immutable? Скорее всего это сделано, что бы проще было тестировать и в принципе работать с цепочкой Interceptor`ов.

Но если представить HttpRequest как mutable объект, что станет хуже (кто знает)?

Mutable\Immutable
Кто не знает но хочет знать про mutable и immutable объекты — гуглим. Коротко говоря immutable объект мы не можем изменять после того как он создан, а mutable — можем.Вызов req.headers.set('Authorization', this.auth.authHeader) — на первый взгял должен добавить на заголовок в запрос, но так как он immutable, он просто создаст копию заголовков + добавить новый который на нужен и вернет новый HttpHeader объект с нашим новым заголовком. Кроме того на основе идемпотентности объектов можно улучшить производительнось Angular приложения (ссылка)

Наконец переходим непосредственно к рефрешу токена.

Наша задача написать еще один Interceptor, который будет перехватывать все запросы к нашему API, и в случае если в ответе на запрос приходит 401 ошибка, нам нужно рефрешнуть токен (authService.renewAuth()) и:

  • Если renewAuth() по каким то причинам не смог обновить токен, можем, к примеру перенаправить юзера на страницу логина;
  • В случае если с запросом на рефреш пока все хорошо — тобишь пока он в процессе, будем запоминать все запросы которые вернули 401ую пока токен рефрешился, т.к сервер авторизации может быть на другом хосте и может задержать ответ по каким то причинам, а наш сервер будет сыпать 401ые. При этом код инициирующий запрос (код компонента к примеру) не должен получать 401ые ошибки и при этом подписка инициирующего запрос кода не должна слететь и при этом мы не должны нарушить стандратное поведение RxJs, тобишь запрос должен быть отправлен только после того как вызывающий компонент сделает подписку (subscribe()).

            let first = service.getData();
    	//далее мы можем к примеру добавить еще один запрос и выполнить их паралельно
    	let second = service.getData();
    	let concatenated = first.concat(second);
    	concatenated.subscribe(); //и только после вызова subscribe должен уйти запрос.
    

  • После того как токен успешно рефрешнулся — повторяем запросы которые были зафейлены пока был рефреш.

Сначала процесс перехвата:

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

//для удобства объявим тип который содержит инфу о запросе который вернул 401ую и наш "внутренний" подписчик
//"внутренний" - потому что мы будем обворачивать Observable который зашел к нам из инициатора
type CallerRequest = {
    subscriber: Subscriber<any>;
    failedRequest: HttpRequest<any>;
};
 
@Injectable()
export class RefreshTokenInterceptor implements HttpInterceptor, AuthInterceptor {

   private auth: AuthRefreshProvider;
private http: HttpClient;
private refreshInProgress: boolean;
private requests: CallerRequest[] = [];
	
init(http: HttpClient, auth: AuthRefreshProvider) { /*some init;*/ }

intercept(req: HttpRequest<any>, next: HttpHandler): Observable<HttpEvent<any>> {
  //перехватываем только "наши" запросы
  if(!req.url.includes('api/')) {
  return next.handle(req);
}

//оборачиваем Observable из вызывающего кода своим, внутренним Observable
// далее вернем вызывающему коду Observable, который под нашим контролем здесь
let observable = new Observable<HttpEvent<any>>((subscriber) => {
  //как только вызывающий код сделает подписку мы попадаем сюда и подписываемся на наш HttpRequest
  //тобишь выполняем оригинальный запрос
  let originalRequestSubscription = next.handle(req)
    .subscribe((response) => {
      //оповещаем в инициатор (success) ответ от сервера 
      subscriber.next(response);
    },
    (err) => {
      if (err.status === 401) {
        //если споймали 401ую - обрабатываем далее по нашему алгоритму
        this.handleUnauthorizedError(subscriber, req);
      } else {
        //оповещаем об ошибке
        subscriber.error(err);
      }
    },
    () => {
      //комплит запроса, отрабатывает finally() инициатора
      subscriber.complete();
    });

    return () => {
      // на случай если в вызывающем коде мы сделали отписку от запроса
      // если не сделать отписку и здесь, в dev tools браузера не увидим отмены запросов, т.к инициатор (например Controller) делает отписку от нашего враппера, а не от исходного запроса
      originalRequestSubscription.unsubscribe();
    };
  });

//вернем вызывающему коду Observable, пусть сам решает когда делать подписку.
return observable;
}

//private handleUnauthorizedError
//private repeatFailedRequests
//private repeatRequest
}

Рассмотрим как мы будем запоминать «401ые» и повторять их:

private handleUnauthorizedError(subscriber: Subscriber < any >, request: HttpRequest<any>) {

  //запоминаем "401ый" запрос
  this.requests.push({ subscriber, failedRequest: request });
  if(!this.refreshInProgress) {
    //делаем запрос на восстанавливение токена, и установим флаг, дабы следующие "401ые"
    //просто запоминались но не инициировали refresh
    this.refreshInProgress = true;
    this.auth.renewAuth()
      .finally(() => {
        this.refreshInProgress = false;
      })
      .subscribe((authHeader) =>
        //если токен рефрешнут успешно, повторим запросы которые накопились пока мы ждали ответ от рефреша
        this.repeatFailedRequests(authHeader),
        () => {
          //если по каким - то причинам запрос на рефреш не отработал, то делаем логаут
          this.auth.logout();
        });
  }
}

private repeatFailedRequests(authHeader) {

    this.requests.forEach((c) => {
      //клонируем наш "старый" запрос, с добавлением новенького токена
      const requestWithNewToken = c.failedRequest.clone({
        headers: c.failedRequest.headers.set('Authorization', authHeader)
      });
      //и повторяем (помним с.subscriber - subscriber вызывающего кода)
      this.repeatRequest(requestWithNewToken, c.subscriber);
    });
    this.requests = [];
  }

private repeatRequest(requestWithNewToken: HttpRequest < any >, subscriber: Subscriber<any>) {

    //и собственно сам процесс переотправки
    this.http.request(requestWithNewToken).subscribe((res) => {
      subscriber.next(res);
    },
      (err) => {
        if (err.status === 401) {
          // if just refreshed, but for unknown reasons we got 401 again - logout user
          this.auth.logout();
        }
        subscriber.error(err);
      },
      () => {
        subscriber.complete();
      });
  }

На этом все. Research and improve!
Tags:
Hubs:
+11
Comments9

Articles

Change theme settings