conf-talks

Auth with GraphQL

Сперва разберемся с самым каверзным вопросом, который звучит на собеседованиях — что такое Аутентификация, Идентификация и Авторизация.

Аутентификация — процедура проверки подлинности пользователя путём сравнения введённого им логина и пароля.

Идентификация — процедура распознания пользователя по токену или кукам.

Авторизация — процедура проверки прав доступа к ресурсам на выполнение определённых действий.

Как обычно происходит дело на практике:

1. Аутентификация — Sign In

Sign In (ввод логина и пароля) может производиться двумя способами. Первый - старый добрый ендпоинт, который принимает POST-переменные и возвращает токен. Второй - создать в GraphQL-схеме query или mutation который принимает логин и пароль через аргументы, и в ответе возвращает токен и, возможно, набор каких-то данных.

Какой подход выбрать, ендпоинт или GraphQL? Это зависит от того,

Обычно у многих уже реализована аутентификация, поэтому нет ничего зазорного использовать обычный REST для этого. Один запрос отправляется для получения токена, второй запрос отправляется для получения данных для клиентского приложения через GraphQL. Но если хотите сразу авторизоваться и получить вагон данных, то юзайте сразу GraphQL. Т.к. GraphQL статически типизирован и описание всех ваших данных, возвращаемых с сервера, сильно поможет в будущем при рефакторинге.

В мире GraphQL для генерации токенов сильно прижился JWT. JSON Web Token (JWT) — это открытый стандарт (RFC 7519) для создания токенов доступа, основанный на формате JSON. Как правило, используется для передачи данных авторизации в клиент-серверных приложениях. Токены создаются сервером, подписываются секретным ключом и передаются клиенту, который в дальнейшем использует данный токен для подтверждения своей личности. В википедии достаточно коротко и хорошо расписано про JWT.

Чем JWT полюбился народу, это тем что на стороне сервера не надо заводить сессионное хранилище. Если от клиента прилетел JWT-токен, то любая нода в вашем кластере может провалидировать его по секретному ключу.

Где хранить JWT токен на клиенте, в DOM-хранилище или куках? Лучше в куках с флагом httpOnly, т.к. тогда у злоумышленника нет возможности считать токен через JavaScript (XSS). Любой браузерный экстеншн имеет доступ к вашему локальному хранилищу и коду, будьте осторожны.

А вот для мобильных приложений токены удобнее всего передавать через дополнительные HTTP-заголовки. Там уже не так просто злоумышленнику считать переменные из приложения. Но вот работа с куками это уже отдельный ад.

Поэтому хорошим тоном будет, если ваш сервер поддерживает передачу токенов через cookie (для браузеров) и через http-заголовки (для мобильных приложений). Пусть сам клиент решает как ему безопаснее и удобнее всего передавать вам токены.

Пример JWT-токена: eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJzdWIiOjEsImlhdCI6MTU0MTI1MDE2M30.M7xYg8GuEgwbqTrta0xnN7WmNEXOCKiQGDdogt_Kduk

В первой части header, в котором содержится алгоритм шифрования подписи: { "alg": "HS256", "typ": "JWT" } Во второй части payload: { "sub": 1, "iat": 1541250163 }. Эти данные всегда можно считать на клиенте. А в третьей части подпись: HMACSHA256(base64UrlEncode(header) + "." + base64UrlEncode(payload), JWT_SECRET_KEY). Для того чтоб сверить подпись вам нужно знать серверный токен. Проверьте токен на сайте jwt.io со следующим JWT_SECRET_KEY = qwerty ;).

На стороне сервера токены могут быть сгенерены и проверены всего в пару строчек кода:

import jwt from 'jsonwebtoken';

const JWT_SECRET_KEY = 'qwerty ;)';

// Генерация токена
const token = jwt.sign({ sub: 2 }, JWT_SECRET_KEY);

// Проверка токена (iat это дата генерации токена)
const payload = jwt.verify(token, JWT_SECRET_KEY); // { "sub": 1, "iat": 1541250163 }

Но будьте аккуратны с JWT. У него есть недочеты по безопасности:

Кстати, по JWT рекомендую почитать хорошую обзорную статью от @zmts: Про токены, JSON Web Tokens (JWT), аутентификацию и авторизацию. Token-Based Authentication.

В общем для индетификации воспользоваться лучше всего старыми добрыми сессиями, такими как express-session и его аналогами. Если нашли что-то что можно посоветовать “потомкам”, обязательно добавьте ссылку в эту статью.

3. Авторизация — Прикручиваем ACL

А вот теперь самый важный и интересный момент по поводу авторизации. Ее можно и нужно настраивать на следующих трех уровнях:

Давайте поподробнее разберем эту тему.

3.1. Авторизация на уровне сервера (apollo, express, koa и пр.)

На уровне сервера вам необходимо:

С Apollo-server это делается так (полный пример по ссылке):

import { ApolloServer, AuthenticationError } from 'apollo-server'; // v2.1

// супер секретный токен для JWT (надеюсь я поменял его у себя в продакшене)
const JWT_SECRET_KEY = 'qwerty ;)';

// Получаем объект пользователя из http-заголовка
async function getUserFromReq(req: any) {
  const token = req?.cookies?.token || req?.headers?.authorization;
  if (token) {
    const payload = jwt.verify(token, JWT_SECRET_KEY);
    if (payload) {
      const user = users.find(u => u.id === payload?.sub);
      if (user) return user;
    }
  }
  return null;
}

const server = new ApolloServer({
  schema,
  // контекст формируется для каждого http-запроса
  // перед тем как начнется выполняться GraphQL-запрос.
  // Контекст будет содержать проперти req, user и hasRole 
  // Будут доступны во всех резолверах в третьем агрументе context
  //   на любом уровне вашей схемы:
  //   resolve(source, args, context, info)
  context: async ({ req }) => {
    let user;
    try {
      user = await getUserFromReq(req);
    } catch (e) {
      throw new AuthenticationError('You provide incorrect token!');
    }
    // Примитивный RBAC
    const hasRole = (role) => {
      if (Array.isArray(user?.roles)) return user?.roles.includes(role);
      return false;
    }
    return { req, user, hasRole };
  },
});

server.listen({ port: 5000, endpoint: '/' }).then(({ url }) => {
  console.log(`🚀  Server ready at ${url}`);
});

3.2. Авторизация на уровне GraphQL-схемы (глобально на верхних полях схемы)

“Глобально на верхних полях” означает что мы можем не сразу предоставлять список полей для получения данных. А вложить их в так называемые namespace-типы. Это когда на первом уровне в Query размещаются поля-роли viewer, me, admin и пр.

query {
  viewer { # любые пользователи имеют доступ к получению данных
    getNews
    getAds
  }
  me { # здесь отображаются данные только для текущего пользователя
    nickname
    photo
  }
  admin { # а здесь методы, которые доступны только админам
    shutdown
    exposePersonalData
  }
}

В данном примере интересно рассмотреть как сделать ограничение к админским полям/методам. У GraphQL есть интересная фича, если resolve-метод для поля вернул null, undefined или выбросил ошибку, то для вложенных полей уже не будут вызываться их resolve-методы:

const AdminNamespace = new GraphQLObjectType({
  name: 'AdminNamespace',
  fields: () => ({
    shutdown: { ... },
    exposePersonalData: { ... },
  }),
});

const Query = new GraphQLObjectType({
  name: 'Query',
  fields: () => ({
    viewer: { ... },
    me: { ... },
    admin: {
      type: AdminNamespace,
      resolve: (_, __, context) => {
        if (context.hasRole('ADMIN')) {
          // LIFEHACK: возвращаем пустой объект
          // чтоб GraphQL вызвал resolve-методы у вложенных полей
          return {};
        }

        // А теперь у нас два варианта. Либо выбросить ошибку:
        throw new Error('Hey, пошел прочь от советской власти!');

        // Либо тихо вернуть пустышку в ответе для поля `admin`
        // и не выполнять никакие вложенные резолверы
        return null;
      },
    },
  }),
});

Что-то похожее у себя использует Facebook. Я частенько таким пользуюсь для нарезки глобальных доступов. Но при этом все-равно надо быть осторожными и проверять доступы в пп 3 и пп 4.

Неймспейсы еще хороши тем, что позволяют красиво нарезать ваше API и не делать из него помойку (как например это делает Prisma). Вот пример одного из моих АПИ (к примеру только на 3-ем уровне будет вызвана мутация create):

Namespaces-types

3.3. Авторизация на уровне полей (в resolve-методах)

Когда у вас в контексте есть информация о текущем пользователе и его роли, то можно настроить уровень доступа в Типах на получение данных для конкретного поля.

К примеру у нас есть Пользователь и мы храним его последний IP-адрес в поле lastIp. Так вот, в GraphQL-схеме можно для каждого поля задать логику доступа к получению тех или иных данных. Например отображение ip-адреса можно разрешить только админу и самому пользователю.

const UserType = new GraphQLObjectType({
  name: 'User',
  fields: () => ({
    name: {
      type: new GraphQLNonNull(GraphQLString),
    },
    lastIp: {
      type: GraphQLString,
      resolve: (source, _, context) => {
        const { id, lastIp } = source;

        // return IP for ADMIN
        if (context.hasRole('ADMIN')) return lastIp;

        // return IP for current user
        if (id === context.user.id) return lastIp;

        // для всех остальных айпишник не светим
        return null;

        // либо можно выбросить ошибку
        // throw new Error('Hidden due private policy');
      },
    },
  }),
});

В данном примере мы просто вернули null для левого пользователя, а могли быть строгими и выбросить ошибку throw new Error('Hidden due private policy'). Но тогда на клиенте это будет неудобно обрабатывать. Как удобно передавать ошибки фронтендеру написано в статье про ошибки.

3.4. Авторизация на уровне связей между типами (в resolve-методах)

Это практически тоже самое что и пункт 3 (авторизация на уровне полей). Тоже самое место в полях. Просто некоторые поля могут указывать на другой тип и содержать в себе логику получения данных для этого типа. И это уже не просто поле, а так называемая связь между типами. В схеме GraphQL это никак специально не помечается. Но вот логика resolvе-метода должна быть немного другой. Т.к. вы должны проверить не просто возможность получения связанных объектов, но и сами полученные объекты, на право отображения.

const UserType = new GraphQLObjectType({
  name: 'User',
  fields: () => ({
    name: {
      type: new GraphQLNonNull(GraphQLString),
    },
    metaList: {
      type: new GraphQLList(MetaDataType),
      resolve: async (source, _, context) => {
        const { id } = source;

        // тут если надо проверяем есть ли доступ (как в пункте 3)

        // если доступ есть, то получаем данные
        let metaList = await Meta.find(o => o.userId === id);

        // проверяем доступ на отображение полученных данных (это отличие от пункта 3)
        metaList = metaList.filter(m => context.hasRole(m.forRole));

        return metaList;
      },
    },
  }),
});

Appendix A: Функции помогайки

getPathFromInfo(info: GraphQLResolveInfo)

Как вам идея авторизировать роли по путям вашего GraphQL?

mutation {
  login { ... } # GUEST
  logout { ... } # USER
}

query {
  articles { ... } # USER
  me {
    debugInfo { ... } # only for ADMIN
    profile { ... } # USER
  }
}

Ну, к примеру, запилим такую политику:

ADMIN: # имеет доступ ко всему
  *
USER: # имеет доступ только к следующим путям графа
  articles.*
  me.profile.*
  logout.*
GUEST: # может вызвать только login
  login.*

Когда выполняется код в resolve-методах, то через четвертый аргумент info вы можете получить информацию о том на каком уровне схемы сейчас выполняется код. Имея путь запроса, вы можете настроить авторизацию по wildcard’ам.

К примеру мы имеем следующий GraphQL-запрос:

query { articles { author { name } } }

То в резолвере Article.author можно провернуть следующую проверку:

const ArticleType = new GraphQLObjectType({
  name: 'Article',
  fields: () => ({
    title: { type: GraphQLString },
    authorId: { type: GraphQLString },
    author: {
      type: AuthorType,
      resolve: (source, _, context, info) => {
        // В `info.path` можно считать путь запроса. Он имеет следующий вид:
        // { prev: { prev: { prev: undefined, key: 'articles' }, key: 0 }, key: 'author' }

        // Прогоняем `info.path` чтоб получить текущий путь в виде массива
        const path = getPathFromInfo(info); // ['articles', 0, 'author']

        // ну а дальше можно этот путь прогнать через свой RBAC
        // для проверки того, имеет ли юзер доступ к текущей ветки вашего АПИ или нет
        // `checkAccess` вы объявляете на уровне сервера см пункт 3.1
        context.checkAccess(path);

        return authorModel.findById(source.authorId);
      },
    },
  }),
});

Так вот можно воспользоваться следующей функцией помогайкой getPathFromInfo(info), которая вернет вам текущий путь в графе в виде массива. Ей на входе нужен четвертый аргумент info из resolve-метода:

/**
 * Функция помогайка, которая конвертирует
 * { prev: { prev: { prev: undefined, key: 'articles' }, key: 0 }, key: 'author' }
 * в
 * ['articles', 0, 'author']
 */
function getPathFromInfo(info: GraphQLResolveInfo): Array<string | number> | false {
  if (!info || !info.path) return false;
  const res = [];
  let curPath = info.path;
  while (curPath) {
    if (curPath.key) {
      res.unshift(curPath.key);
      if (curPath.prev) curPath = curPath.prev;
      else break;
    } else break;
  }
  return res;
}

Ну а дальше дело техники, как текущий путь полученный в виде массива проверить на доступ по wildcard’ам для текущей роли пользователя. Самое главное понять идею, что можно авторизовать юзеров по путям в GraphQL-запросе.

Appendix B: Почему я использую три токена (user, account, admin)

В большинстве случаев разработчики пользуется всего одним токеном при работе с сервером. Долгим и мучительным рефакторингом я для себя вынес одно великое правило, что надо использовать 3 токена:

Самый кайф в том, что:

Вот три таких нехитрых токена позволяют хорошо запроектировать доступ к данным в вашем приложении. Пользуйтесь на здоровье.