Pull to refresh

Angular — Имплементация безопасных запросов к GraphQL API посредством JWT-токенов

Website development *API *Angular *
Sandbox
Привет Хабр! При реализации 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?

Рад критике и хорошим советам.
Tags:
Hubs:
Total votes 5: ↑4 and ↓1 +3
Views 8.2K
Comments Comments 24