Я решил написать серию сообщений, которые покажут, как я делаю определенные вещи с graphql-ruby в серверных приложениях Rails.

Сегодня я покажу, как я обрабатываю аутентификацию. Понимание этой статьи требует некоторых знаний GraphQL, Rails и того, как использовать devise (или работает какой-либо другой гем аутентификации).

Для введения в GraphQL вы можете прочитать этот пост и эти слайды. Чтобы увидеть, как создать простой GraphQL API с Rails, вы можете проверить это репо.

Пример приложения rails со всем, что описано в этой статье, можно найти на моем гитхабе здесь.

Любые конструктивные отзывы приветствуются.

Придумать

Devise - довольно популярный, довольно мощный гем для обработки аутентификации в приложении Rails. Я не буду объяснять, как установить / настроить устройство, документация довольно пояснительная. Несколько замечаний:

  • Как обычно, мы создаем User модель. Вы можете не указывать модуль :confirmable (не забудьте отредактировать перенос, если вы это сделаете).
  • В этом руководстве нет необходимости создавать какие-либо представления.
  • Вам также потребуется установить и настроить этот гем devise-token_authenticatable. Это позволяет пользователю войти в систему с помощью токена аутентификации. Этот токен может быть предоставлен с помощью строки запроса или базовой аутентификации HTTP.

Тип пользователя

Вот простой пример GraphQL UserType, с которым мы будем работать.

module Types
  UserType = GraphQL::ObjectType.define do
    name 'User'
    description 'Example User'
    
    field :lastName, !types.String, property: :last_name
    field :firstName, !types.String, property: :first_name
    field :email, !types.String
  end
end

Регистрация пользователя

Теперь мы создаем мутацию, которая позволяет пользователю регистрироваться / регистрироваться. Однако, прежде чем мы это сделаем, давайте определим UserInputType, который будет передан в качестве аргумента Мутации.

module Types
  module Input
    UserInputType = GraphQL::InputObjectType.define do
      name 'UserInputType'
      description 'Properties for registering a new User'

      argument :lastName, !types.String
      argument :firstName, !types.String
      argument :email, !types.String
      argument :password, !types.String
    end
  end
end

А ниже у нас есть мутация:

module Mutations
  RegisterUser = GraphQL::Field.define do
    name 'RegisterUser'
    argument :registrationDetails, !Types::Input::UserInputType

    type Types::UserType

    resolve ->(_obj, args, _ctx) {
      input = Hash[args['registrationDetails'].to_h.map {|k, v| [k.to_s.underscore.to_sym, v]}]
      begin
        @user = User.create!(input)
      rescue ActiveRecord::RecordInvalid => invalid
        GraphQL::ExecutionError.new("Invalid Attributes for #{invalid.record.class.name}: #{invalid.record.errors.full_messages.join(', ')}")
      end
    }
  end
end

Следует обратить внимание на приведенный выше фрагмент кода. Давайте посмотрим на строку ниже.

input = Hash[args['registrationDetails'].to_h.map {|k, v| [k.to_s.underscore.to_sym, v]}]

По соглашению типы, поля и аргументы GraphQL должны быть записаны в camelCase. С другой стороны, в Ruby принято snake_case почти все, кроме имен классов и модулей.

Это означает, что нам нужно легко преобразовать хэш нашего camelCase аргумента в его snake_case версию. Вот что делает эта строка.

Остальная часть фрагмента довольно проста; он пытается создать пользователя с вводом, и если по какой-то причине это не удается, выдает GraphQL::ExecutionError.

Войти

module Types
  AuthType = GraphQL::ObjectType.define do
    name 'AuthType'

    field :authenticationToken, !types.String, property: :authentication_token
  end
end

Выше показан тип, который вернет мутация SignIn, а ниже - сама мутация.

module Mutations
  SignIn = GraphQL::Field.define do
    name 'SignIn'
    argument :email, !types.String
    argument :password, !types.String

    type Types::AuthType

    resolve ->(_obj, args, _ctx) {
      @user = User.find_for_database_authentication(email: args[:email])
      if @user
        if @user.valid_password?(args[:password])
          authentication_token = @user.authentication_token
          return OpenStruct.new(authentication_token: authentication_token)
        else
          GraphQL::ExecutionError.new('Incorrect Email/Password')
        end
      else
        GraphQL::ExecutionError.new('User not registered on this application')
      end
    }
  end
end

Это также довольно просто и использует методы, которые Devise автоматически добавляет в модель User.

Это простой шаг за шагом, как это работает:

  1. Убедитесь, что пользователь существует.
  2. Если пользователя не существует, выдать GraphQL::ExecutionError.
  3. В противном случае проверьте, действителен ли пароль.
  4. Если пароль недействителен, вывести GraphQL::ExecutionError.
  5. В противном случае верните authentication_token.

Авторизация

Все, что осталось сделать, это защитить определенные запросы или изменения от доступа, когда пользователь не вошел в систему.

Когда клиентское приложение вызывает мутацию SignIn и получает взамен токен, теперь оно может делать дальнейшие запросы как входящие в систему, добавляя токен в заголовок запроса (Authorization: Bearer <token-is-here>).

Вы можете протестировать передачу заголовка в свой GraphQL запрос с помощью Альтаира.

Поскольку devise & devise-token_authenticatable справляется со стрессом, связанным с проверкой заголовков для проверки токена и определения того, какой пользователь вошел в систему, остальное довольно легко сделать.

Во-первых, вы убедитесь, что передали current_user (который является вспомогательным методом, предоставляемым разработчиком для пользователя, который в настоящее время вошел в систему) в контекст всех преобразователей запросов / мутаций GraphQL. Это сделано в вашем GraphqlController

class GraphqlController < ApplicationController
  def execute
    variables = ensure_hash(params[:variables])
    query = params[:query]
    operation_name = params[:operationName]
    context = {
      current_user: current_user
    }
    result = TixSchema.execute(query, variables: variables, context: context, operation_name: operation_name)
    render json: result
  end
...

Во-вторых, мы создадим собственный простой помощник, который будет называться AuthorizeUser.

AuthorizeUser - это простой класс Ruby с методом call. Он также должен иметь простой пользовательский initializer.

module Resolvers
  module Helpers
    class AuthorizeUser
      def initialize(resolve_func)
        @resolve_func = resolve_func
      end

      def call(obj, args, ctx)
        if ctx[:current_user].blank?
          GraphQL::ExecutionError.new('User not signed in')
        else
          @resolve_func.call(obj, args, ctx)
        end
      end
    end
  end
end

Когда используется AuthorizeUser.new(...., он ожидает метод / лямбда-преобразователь GraphQL в качестве аргумента, который затем устанавливает как переменную экземпляра.

В этом методе call он принимает те же самые аргументы, что и метод преобразователя / лямбда.

Сначала он проверяет, существует ли current_user, который мы передали в контексте. Как обычно, если его не существует, выдает GraphQL::ExecutionError. Однако, если current_user существует, он передает его аргументы нетронутыми в call метод @resolve_func, который был передан во время инициализации.

Думайте об этом AuthorizeUser как о функции преобразователя, которая оборачивается вокруг другой функции преобразователя.

Скажем, например, у нас была мутация под названием CreateTicket, которая была примерно такой.

module Mutations
  CreateTicket = GraphQL::Field.define do
    name 'CreateTicket'
    argument :ticket, Types::Input::TicketInputType
    type Types::TicketType

    resolve ->(_obj, args, _ctx) {
      # perform ticket creation
    }
  end
end

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

module Mutations
  CreateTicket = GraphQL::Field.define do
    name 'CreateTicket'
    argument :ticket, Types::Input::TicketInputType
    type Types::TicketType

    resolve Resolvers::Helpers::AuthorizeUser.new(->(_obj, args, _ctx) {
      # perform ticket creation
    })
  end
end

Мы просто передали преобразователь CreateTicket в качестве аргумента AuthorizeUser.new, который затем выполнит содержимое преобразователя, только если current_user присутствует.

Вот и все!

Помните, вы можете найти образец кода со всем, через что мы прошли здесь.