Привет Хабр! При реализации Angular проекта, остро встал вопрос о безопасности graphql запросов в Angular 4. Выбор пал на JSON Web Tokens. Это открытый стандарт по RFC 7519.

Работает JWT по следующей схеме:
image

Я начал изучать программирование и Angular относительно недавно, полгода назад, и являюсь наивным чукотским малъчиком. Поэтому любую критику относительно кода и логики, приму как дружеский совет.

Клиентом graphql мы использовали apollo-angular (docs, github), и токены JWT нужны были в хедере каждого запроса к GraphQL API.

Создаем наш сервис авторизации AuthService. Первичное получение токена реализовано через REST:

  login(username: string, password: string){
  let headers = new Headers({ "content-type": "application/json;charset=utf-8"});
  let options = new RequestOptions({ headers: headers });
  return this.http.post('http://localhost:8080/login', ({ username: username, password: password }), options)
    .map((res : any) => {
            if (res.status === 200) {
              this.commonToken = res.json();
              let data = this.commonToken;
              this.accessToken = JSON.stringify(data.accessToken);
              this.refreshToken = JSON.stringify(data.refreshToken);

              sessionStorage.setItem('accessToken', this.accessToken);
              sessionStorage.setItem('refreshToken', this.refreshToken);

              return true;
            }
    })
  };

Получаем accessToken и пишем его в sessionStorage браузера.

Здесь стоит сделать отступление и заметить, что sessionStorage живет до закрытия вкладки/браузера, и если пользователь закрыл его, то сбрасывается все содержимое, и как следствие, теряется токен. Альтернатива: localStorage или cookies. В этом случае, токен будет находиться у пользователя до ручного удаления.
Однако тут есть свои подводные камни. Какие именно камни, можно прочитать в этой статье.
Есть еще refreshToken. О нем чуть позже.

Далее, нам необходим клиент, для работы с API. Используем apollo-client:


import ApolloClient, {
  createNetworkInterface
} from 'apollo-client';

const networkInterface = createNetworkInterface({
  uri: 'http://localhost:8080/graphql',
  opts: {
    mode: 'cors'
  }
});
networkInterface.use([
  {
    applyMiddleware(req, next) {
      if (!req.options.headers) {
        req.options.headers = {};
      }
      if (sessionStorage.getItem('accessToken')) {
        req.options.headers['authorization'] = `${JSON.parse(sessionStorage.getItem('accessToken'))}`;
      }
      next();
    }
  }
]);

const apolloClient = new ApolloClient({
  networkInterface
});
export function provideClient(): ApolloClient {
  return apolloClient;
}

export class GraphqlClient{}

В этом куске кода, мы берем наш токен из sessionStorage, и пишем его в хедер authorization.
У apollo-client есть пара методов для networkInterface: Middleware и Afterware. В нашем случае, использовался Middleware, его целью является применение тех или иных параметров, перед отправкой запроса к API.

И еще один немаловажный момент. В параметре opts, указан mode: 'cors'. Это сделано для Spring Security, на котором крутится мой бэк, в случае если на бекэнде нет cross-origin HTTP request фильтра, мод можно переключить на 'no-cors'.

Теперь, все запросы или мутации, уходящие посредством apollo-client, будут иметь в хедере наш jwt-токен. На бэкенде, реализована проверка этого токена на валидность и жизнеспособность по времени. Код не мой.

    private TokenAuthentication processAuthentication(TokenAuthentication authentication) throws AuthenticationException {
        String token = authentication.getToken();
        DefaultClaims claims;
        try {
            claims = (DefaultClaims) Jwts.parser().setSigningKey(DefaultTokenService.KEY).parse(token).getBody();
        } catch (UnsupportedJwtException | MalformedJwtException | IllegalArgumentException | SignatureException ex) {
            throw new AuthenticationServiceException("Invalid JWT token:", ex);
        } catch (ExpiredJwtException expiredEx) {
            throw new AuthenticationServiceException("JWT Token expired", expiredEx);
        }

        return buildFullTokenAuthentication(authentication, claims);
        if (claims.get("TOKEN_EXPIRATION_DATE", Long.class) == null)
            throw new AuthenticationServiceException("Invalid tokens");
        Date expiredDate = new Date(claims.get("TOKEN_EXPIRATION_DATE", Long.class));
        if (expiredDate.after(new Date()))
            return buildFullTokenAuthentication(authentication, claims);
        else
            throw new AuthenticationServiceException("Token expired date error");
    }

    private TokenAuthentication buildFullTokenAuthentication(TokenAuthentication authentication, DefaultClaims claims) {
        String username = claims.get("username", String.class);
        Long userId = Long.valueOf(claims.get("userId", String.class));
        String auth = claims.get("authorities", String.class);

        if(Roles.REFRESH_TOKEN == auth) {
            throw new AuthenticationServiceException("Refresh token can't be used for authorization!!!");
        }

        List<SimpleGrantedAuthority> authorities = new ArrayList<>();
        authorities.add(new SimpleGrantedAuthority(auth));
        TokenAuthentication fullTokenAuthentication  = new TokenAuthentication(authentication.getToken(), true,
                authorities, username, userId);

        return fullTokenAuthentication;
    }

Теперь о refreshToken. Задача refreshToken'a обновить устаревший accessToken.

Реализации могут быть разные, начиная от примитивной проверки в AuthGuard сервисе Angular'a, заканчивая scheduler сервисом, который будет обновлять токен по заданному интервалу времени. В моем случае, был сделан первый вариант. Когда додумаюсь до более умного варианта, реализую. Пока что смог только так.

Итак, создаем метод в нашем сервисе AuthService, который будет вызываться, если наш сервис проверки AuthGuard заметит, что истек срок действия токена:

  
refresh() {
    let token = sessionStorage.getItem('accessToken');
    let refToken = sessionStorage.getItem('refreshToken');
    let headers = new Headers({ "content-type": "application/x-www-form-urlencoded"});
    let options = new RequestOptions({headers: headers});
    let body = new URLSearchParams();
    body.set('RefreshToken', refToken);
    if (token != null && refToken != null) {
      return this.http.post('http://localhost:8080/login/refresh', body, options)
        .subscribe((res : any) => {
          if (res) {
            this.commonToken = res.json();
            let data = this.commonToken;
            this.accessToken = JSON.stringify(data.accessToken);
            sessionStorage.setItem('accessToken', this.accessToken);
          }
        })
    } else {
      console.error('An error occurred');
    }
  }

Далее, создаем собственно сервис проверки AuthGuard:


import { Injectable } from '@angular/core';
import {Router, CanActivate, RouterStateSnapshot, ActivatedRouteSnapshot} from '@angular/router';
import {JwtHelper} from "angular2-jwt";
import {AuthService} from "./auth.service";

@Injectable()
export class AuthGuard implements CanActivate {
  jwtHelper: JwtHelper = new JwtHelper();
  constructor(private authService: AuthService, private router: Router) { }

  canActivate(route: ActivatedRouteSnapshot, state: RouterStateSnapshot) {
    let accessToken = sessionStorage.getItem('accessToken');
    let refreshToken = sessionStorage.getItem('refreshToken');
    if (accessToken && refreshToken) {
      if (this.jwtHelper.isTokenExpired(accessToken)){
        this.authService.refresh()
      } else {
        return true
      }
    }
    this.router.navigateByUrl('/unauthorized');
  }
}

Здесь используется библиотека angular2-jwt и ее метод isTokenExpired(). Если метод возвращает true, вызываем созданный ранее метод refresh() и обновляем токен.

Если кому то будет интересно почитать насчет JWT, то вот хороший обзор на английском What is a JSON Web Token?

Рад критике и хорошим советам.

Комментарии (24)


  1. alexs0ff
    21.08.2017 18:06
    +2

    В своих приложениях на Angularе я остановился на OAuth access token. Принципы авторизации примерно похожи с описанным выше. Правда работу по обслуживанию токена обернул в другой класс, чтобы код в гварде был более читаем, без «внутренней кухни».


  1. VolCh
    21.08.2017 19:39

    Поработав с JWT-токенами на фронте корпоративной системы (авторизация сотрудников и т. п.) пришёл к выводу, что они неоправданно усложняют и фронт, и бэк без ощутимых плюсов перед привычными сессиями. Вот хранить токен в сессии на фронт-шлюзе для передачи его бэкенд- сервисам, упрощает многое.


    1. xeofus Автор
      21.08.2017 20:55

      Наверное многое зависит от применимости, оправданности или необходимости технологии в конкретной ситуации.
      В случае с корпоративными системами, пару лет назад делал вещи просто LDAP с последующим сохранением сессии в куках и с SSL сертификатом от локального ЦС, внутри компании. И на тот момент посчитал достаточным секьюрность, и дальнейшее усугубление уже лишним в корпоративной среде.
      Хотя среда среде рознь, может и есть компании где этого мало для отдела ИБ.


      1. VolCh
        22.08.2017 09:50

        Вот как раз применение JWT в браузерах мне кажется неоправданным в большинстве случаев. Плюсы есть, но больше для серверной части и при условии, что авторизационная информация последнее, что осталось у пользователя в состоянии сессии. Для нас основным минусом (кроме усложнения фронта) стала невозможность оперативно изменяь права или вооюще блокировать пользователя.


    1. x07
      22.08.2017 08:46

      В чем сложность?


      1. VolCh
        22.08.2017 09:56

        На фронте — организовывать хранение токена при переходе между страницами (в случае SPA — при обновлении страницы) и добавлять его в каждый запрос. На бэке — оперативно изменять права и отзывать токены в целом ("банить").


        Ну и для сложных систем информации в токене о пользователе может быть очень много, многократно превышая размеры всех остальных заголовков и данных запроса — тут не сложность скорее, а эффективность.


        1. iosiff
          22.08.2017 11:36

          На фронте — организовывать хранение токена при переходе между страницами (в случае SPA — при обновлении страницы) и добавлять его в каждый запрос. На бэке — оперативно изменять права и отзывать токены в целом («банить»).

          Засунуть токен в куку, локалсторадж это разве проблема?


          1. VolCh
            23.08.2017 10:49

            Это сложнее, чем никуда не засовывать ничего и довериться механизму браузера. Ещё забыл про рефреш токена.


            1. r-moiseev
              23.08.2017 23:02

              С хранением токена в куке вам придется цеплять к запросу csrf токен


              хрен редьки не слаще


  1. nomoreload
    21.08.2017 20:51

    У нас backend на ASP.NET Core, а вот фронт как раз на Angular 4. На бэке — IdentityServer4, на фронте oidc-client-js. Те же яйца, только в профиль. Тоже JWT юзаем. На бэке включены CORS, а фронт по ImplicitFlow работает с silent renew. Таким образом, отпадает необходимость в refresh-token+можно относительно быстро отреагировать на изменение прав пользователя. Если на IdentityServer'е пользователя заблокируют — его просто выпрет из фронта. Или если новых фич добавят — они у него просто сами в интерфейсе появятся. Всё сертифицировано OpenIdConnect. По мне, так на фронте лучше не хранить refresh_token без возможности инвалидации.


    1. Gorniv
      22.08.2017 14:27

      по ImplicitFlow вы как повторно token получаете? Мы перешли на CodeFlow(чтобы не хранить локально логин пароль) и храним только access & refresh, access протухaет через 15 минут, refresh обновляется при каждом запросе access.


      1. nomoreload
        23.08.2017 08:20

        Повторно токены получаем через silent renew. Для пользователя всё прозрачно — он авторизуется на IdentityServer, а далее, до тех пор пока активна сессия на стороне IdentityServer, токены скрытно обновляются. Никаких попапов, редиректов или чего-то там ещё. Если в промежутке между обновлениями были внесены изменения в учётную запись пользователя (добавили новую роль, например), то они отражаются в токене при следующем его обновлении, и на основе новых данных реактивно перестраивается фронт. Типа не было кнопочек каких-то, оппа- появились :-) Пользователи довольны, самое главное) Но чтоб такой трюк провернуть, нужно при каждой валидация кук на IdentityServer'е, актуализировать их. Тайм-аут обновления на фронте — 5 минут, срок жизни access_token — 60 минут. Как-то так


    1. VolCh
      23.08.2017 10:57

      А можно поподробнее эту схему описать на уровне фронта и HTTP, асбстрагируясь от каки-то техналогий типа IdentityServer4 и ImplicitFlow ?


      1. nomoreload
        24.08.2017 07:30
        +1

        Ну IdentityServer — это OAuth2.0/OpenIdConnect сервер, реализованный по спецификациям.
        ImplicitFlow — это стандартный flow из спецификации OAuth2.0 и OpenIdConnect.
        Работает следующим образом:


        1. Пользователь открывает https://example.com (FrontEnd)
        2. Фронт в процессе инициализации залазит в localStorage в поисках токена. Если токен найден, пытается получить информацию о пользователе, обращаясь к UserInfoEndpoint. Если ответ не 200, то стираем всю информацию об авторизации и считаем пользователя неавторизованным, если же 200ка, то сохраняем информацию о пользователе в TypeScript-сервисе (который Angular потом инжектит куда нужно) и работаем как обычно.
        3. Если пользователь не авторизован, открываем ему страницу входа с кнопкой "Войти". По нажатию на кнопку, юзер отправляется на сервер авторизации (он же IdentityServer) https://identity.example.com через Implicit flow.
          Это будет запрос вида:
          GET https://identity.example.com/connect/authorize?response_type=id_token token&client_id=frontend-app&redirect_uri=https://example.com/login-callback&scope=openid profile api_v1&nonce=чего-то
        4. Попадая на IdentityServer, пользователь авторизуется через логин-пароль/вк/google/facebook/whatever you want. После этого его редиректит на https://example.com/login-callback, где его ожидает сервис, отвечающий за авторизацию. Т.е. фронт в данном случае у нас перезагружается полностью (ну или нет, если кэширование настроили). На колбэке сидит сервис, который записывает информацию о токенах в localstorage, и, который после этого опять же дёргает метод для получения информации о пользователе (который был в п.2), только после успешного выполнения которого мы продолжаем работу.
        5. По ходу работы у нас в фоне периодически выполняются обращения к IdentityServer за новыми токенами. И вот тут самое важное. До тех пор, пока сессия на IdentityServer открыта, повторная авторизация в нём не требуется и он, глядя, что с куками всё ок — выдаёт новые токены, попутно обновляя информацию о юзере и устанавливая новые куки, тем самым продлевая время жизни сессии.

        Резюмируя вышеописанное. До тех пор пока открыта сессия на IdentityServer, пользователь без проблем будет пользоваться приложением. Может возникнуть вопрос по п.2 — а что если юзер ушёл на 2 часа, закрыв приложение, а сессия валидна 8 часов? Что будет при повторном посещении? А будет вот что — фронт увидит, что токен протух, сотрёт его и редиректнет пользователя на страницу входа. Он нажмёт кнопку "Войти" и дальше просто последует череда редиректов с попутным обновлением сессии на IdentityServer.


        А дальше приложение просто делает запросы, цепляя в заголовок access_token, который backend уже сам проверяет, периодически обращаясь к IdentityServer и кэшируя ответы, чтобы сильно его запросами не спамить.


        Как-то так)


        1. VolCh
          24.08.2017 12:03

          Спасибо. Попробую реализовать.


  1. r-moiseev
    21.08.2017 22:53

    Все таки не понятно, какие преимущества у session storage перед local?


    1. xeofus Автор
      22.08.2017 06:30

      Я бы не назвал это преимуществом, скорее отличием.
      sessionStorage — хранилище, который живет только в оперативной памяти. Как только завершается работа вкладки/браузера, удаляется все содержимое.
      localStorage — хранилище, который пишется на HDD, и актуален даже после закрытия браузера или перезагрузки устройства.

      Тут мы видим то самое отличие sessionStorage перед localStorage — токен необходимо получать заново, если закрыл вкладку. На мой взгляд, это добавляет секьюрности, но создает дополнительные телодвижения для юзера.


      1. r-moiseev
        22.08.2017 08:59
        +1

        И логин каждый раз по новой.


        Можно сделать срок жизни токена поменьше. Иначе вы зарастёте в этих токенах.


        1. xeofus Автор
          22.08.2017 10:11
          +1

          Есть еще вариант реализации, поместить access токен в sessionStorage и сделать срок жизни покроче. А refresh токен в localStorage, в этом случае, каждый раз логин/пароль вбивать не придется, и секьюрность присутствует.


          1. r-moiseev
            22.08.2017 13:04

            Да, хороший вариант


  1. Poccomaxa_zt
    22.08.2017 20:13

    Заранее прошу прощения, если заметки будут не к теме самой архитектуры, но может Вам будет интересны мысли (исходя из фразы «Поэтому любую критику относительно кода и логики, приму как дружеский совет.»).

    this.http.post('http://localhost:8080/login', ({ username: username, password: password }), options)

    Думаю здесь (и в других подобных кусочках Вашего кода) стоит использовать новый сахар касающийся свойств с идентичными значениями — ({ username, password}). Значение хоста «localhost:8080» вынести в отдельную константу, если она Вам вообще нужна. Перед map сделать манипуляции .filter(res => res.status === 200).map(res => res.json()).map(data => ...).

    Вещи связанные с получением самого токена и проверкой на то, является ли пользователь авторизованным…
    sessionStorage.getItem('accessToken')

    … стоило бы вынести в Ваш AuthService как отдельные методы isAuthorised и getAccessToken… Это позволяет не привязываться к месту хранения (вдруг Вы заходите переехать на ngrx?..)

    Постарайтесь избегать использование сравнений с приведением типов…
    if (token != null && refToken != null)

    … это не есть хорошей практикой и часто приводит к ошибкам… В данной ситуации у Вас токены undefinedЫ.

    Ещё хочу сказать, что возможно Вам стоит взглянуть на практику связанную с созданием класса-обёртки над ангуляровским http. В нём Вы можете единоразово настроить обратку ошибку, парсинг ответа и ту же работу с добавлением хедеров в Ваши запросы (кое что будет неактуально с новыми релизами фреймворка)…


    1. xeofus Автор
      22.08.2017 20:19

      Спасибо большое за труд, потраченный на анализ и советы, обязательно пройдусь по коду.


    1. nomoreload
      23.08.2017 09:10

      К счастью, больше не нужно делать такой велосипед) В Angular 4.3 завезли Http клиент новый и HttpInterceptor'ы к нему. По сути middleware. Можно все запросы обрабатывать до, во время отправки, во время и после получения ответа. В частности типичный юзкейс — установка заголовков авторизации.


      1. Poccomaxa_zt
        23.08.2017 10:06

        Как я и сказал — «кое что будет неактуально с новыми релизами фреймворка» :)