Pull to refresh

React Apollo, Gqlgen – авторизация. Часть 1

Reading time7 min
Views4.3K

Это базовое руководство описывающее схему работы "отложенной" авторизации. При которой права пользователю выдаются с задержкой по времени. Здесь мы разберем только базовый принцип и авторизацию.

Задача

Разработать сервис позволяющий авторизовываться "нестандартным" образом: код в СМС либо код в URL. Пароль у user отсутствует.

Кейс

Пользователь в форме входа вводит свой Username, и в зависимости от настроек метода авторизации получает:

  • Письмо с кнопкой на емайл, при ее активации – происходит авторизация. Почтовый клиент может находится на другом устройстве

  • Открывается форма ввода кода из СМС, при успехе пользователь авторизовывается

Проблема

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

Пользователь получает токен сессии по предьявлению – идентификатора клиента ClientID.

Как решать?

  1. При первом запросе браузер Клиент получает cookie c идентификатором ClientID

  2. При отправке Пользователем формы авторизации, клиенту возвращается токен авторизации Auth_token – обменивается на токен Пользователя

  3. Создается авторизационная сессия, хранит Auth_token , ClientID и UserID

  4. Пользователь подтверждает авторизацию. Ищем клиента по ClientID в соединениях websocket – отправляем сигнал об авторизации

  5. Клиент получил сигнал о наличии авторизации. Выполняется GET-запрос с токеном Auth_token в HTTP-заголовке Authorization. Ищем Auth_token в сессиях, в случае успеха авторизовываем Пользователя

Статья разбита на несколько частей:
- часть 2
- часть 3 - в работе
- исходники этой части

Подготовка

  1. Развернем Gqlgen

  2. Напишем схемы GraphQL, сгенерируем модели и методы АПИ

  3. Настроим CORS

  4. GraphQL сервер

  5. Реализуем websocket соединение

  6. Store

  7. Первый запуск

1. Развернем Gqlgen

Способов развернуть проект на Gqlgen существует не мало. Мы выбрали тот, который нам кажется наиболее простым.

Структура:

  • /backoffice/cmd – основные файлы

  • /backoffice/graph – файлы GraphQL

  • /backoffice/schema – GraphQL схемы

  • /backoffice/models – сгенерированные модели GraphQL

  • /backoffice/pkg – библиотека

Создадим структуру, в директории backoffice, выполним:

go mod init react-apollo-gqlgen-tutorial/backoffice
go get github.com/99designs/gqlgen
go run github.com/99designs/gqlgen init

Отредактируем файл gqlgen.yml:

# Изменим экспорт файлов схемы
schema:
  - schema/*.graphqls

# Изменим расположение моделей
model:
  filename: models/models_gen.go
  package: model
  
# Добавим исключение
# Чтобы генератор моделей не перезатирал уже имеющееся
autobind:
  - "react-apollo-gqlgen-tutorial/backoffice/models"

Переименуем файл server.go в main.go и поместим его в /backoffice/cmd.

В директории /backoffice/cmd создадим файл gqlgen.go:

// +build tools – не просто комментарий, подробнее здесь

// +build tools

package main

import (
	"fmt"
	"github.com/99designs/gqlgen/cmd"
)

func main() {
	fmt.Println("Building Graphql schema")
	cmd.Execute()
}

Этот файл позволит генерировать модели из схемы GraphQL выполнением команды:

go run cmd/gqlgen.go

2. Напишем схемы GraphQL, сгенерируем модели и методы АПИ

Необходимо сгенерировать 3 модели данных: Auth, Session, User и методы GraphQL.

Schema schema/schema.graphqls:

"""
Запросы GET
"""
type Query {

  """
  Первый запрос. Необходим для авторизации

  Вызывается при наличии флага Auth.authorized
  
  1. Если есть Auth.auth_token и Auth.authorized
  передаем его в HTTP заголовке Authorization
  
  2. Если есть Auth.client_id
  передаем в заголовке Client-ID
  """
  auth: Auth!

  user: User!
}

"""
Запросы POST
"""
type Mutation {

  """
  Метод авторизации.
  Принимает username
  Вернет Auth.auth_token, должен быть в 
  HTTP заголовке Authorization
  при получении токена пользователя
  """
  authorization(login: String!): Auth!
  smsCode(code: String!): Auth!
}

"""
Подписки на websocket
"""
type Subscription {

  """
  Подписка на Auth
  """
  auth: Auth!
}

Session schema/session.graphqls:

type Session {

    """
    Токен авторизации, должен совпасть с тем
    что отдали клиенту при отправке формы авторизации
    """
    auth_token: String!

    """
    Идентификатор пользователя
    """
    uid:    Int!

    """
    Метод авторизации установленный пользователем
    """
    method: String!
}

Auth schema/auth.graphqls:

type Auth {

    """
    Должен быть предъявлен при запросе токена пользователя
    Получаем в ответе на GET запрос формы авторизации

    Отправляем в HTTP заголовке Client-ID
    """
    client_id:      String!

    """
    Должен быть предъявлен при запросе токена пользователя
    Получаем в ответе на GET запрос формы авторизации

    Отправляем в HTTP заголовке Authorization
    """
    token:      String!

    """
    Указывает на наличие авторизации
    """
    authorized: Boolean!

    """
    Метод авторизации
    """
    method: 	String!
}

На User можно посмотреть здесь

Генерация моделей и методов

После того как изменения в файлах схем GraphQL были сохранены, выполним команду:

go run cmd/gqlgen.go

Эта команда создаст /backoffice/models но нам интересна директория /backoffice/graph.

backoffice/graph:

  1. Удаляем папку models в /backoffice/graph

  2. Перемещаем файлы: resolver.go и schema.resolvers.go в /backoffice/pkg/graph

backoffice/pkg/qraph:

Оредактируем файл schema.resolvers.go заберем методы относящиеся к *model.Auth и *model.User . Поместим их в auth.go и user.go текущего каталога

Затянем зависимости:

go mod vendor

Получившаяся структура:

Коммит Github

3. Настроим CORS

Создадим базовую защиту от CSRF атак

func CorsMiddleware() func(http.Handler) http.Handler {
	return func(next http.Handler) http.Handler {
		return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {

			// Разрешаем подключаться только конкретному хосту
      // Заголовко всегда должен возвращать адрес действительного хоста
      // Устанавливая звездочку "*" - создаем уязвимость в безопасности
			w.Header().Set("Access-Control-Allow-Origin", "http://localhost:3000")

			// Разрешаем принимать файлы cookie только от http://localhost:3000
			//
			// Подробнее про сочетания
			// Access-Control-Allow-Origin и Access-Control-Allow-Credentials
			// В этой таблице:
			// https://fetch.spec.whatwg.org/#cors-protocol-and-credentials
			w.Header().Set("Access-Control-Allow-Credentials", "true")

			// Разрешаем использовать методы
			w.Header().Set("Access-Control-Allow-Methods", "POST, GET, OPTIONS, PUT, DELETE")

			// Блокируем возможность CSRF
			// Access-Control-Allow-Headers = Content-Type
			// Подробнее здесь:
			// https://fetch.spec.whatwg.org/#concept-header
			w.Header().Set("Access-Control-Allow-Headers",
				"Accept, Content-Type, Content-Length, Accept-Encoding, Authorization")

			if r.Method == "OPTIONS" {
				return
			}

			next.ServeHTTP(w, r)
		})
	}
}

Для комплексной защиты необходимо устранить XSS-уязвимости.
Интересная статья на эту тему англ.

4. GraphQL сервер

Мы уже переместили и структурировали файлы в /pkg/qraph. Теперь необходимо поправить resolver.go:

var (
	mb int64 = 1 << 20
)

type Resolver struct{}

// Создадим функцию NewServer
func NewServer(opt Options) *handler.Server {

	// Переместим создание сервера из cmd/main.go
	srv := handler.New(
		generated.NewExecutableSchema(
			generated.Config{
				Resolvers: &Resolver{},
			},
		),
	)
	srv.AddTransport(transport.MultipartForm{
		MaxMemory:     32 * mb,
		MaxUploadSize: 50 * mb,
	})
	srv.AddTransport(transport.POST{})
	srv.AddTransport(transport.GET{})
	srv.AddTransport(transport.Websocket{
		KeepAlivePingInterval: 10 * time.Second,
		Upgrader: websocket.Upgrader{
			CheckOrigin: func(r *http.Request) bool {
				return true
			},
			ReadBufferSize:  1024,
			WriteBufferSize: 1024,
		},
		InitFunc: transport.WebsocketInitFunc(func(ctx context.Context, initPayload transport.InitPayload) (context.Context, error) {
			return ctx, nil
		}),
	})
	srv.Use(extension.Introspection{})

	return srv
}

type Options struct {}

5. Websocket соединение

Для реализации websocket возьмем пакет Gorilla websocket и для работы с HTTP – Gorilla mux

Изменим cmd/main.go:

import (
  //...
  
	"react-apollo-gqlgen-tutorial/backoffice/pkg/graph"
	"github.com/gorilla/mux"
)

var (
	defaultPort = "2000"
)

func main() {
	port := os.Getenv("PORT")
	if port == "" {
		port = defaultPort
	}
  
	// Создадим GraphQL сервер
	srv := graph.NewServer(graph.Options{})

	// Создадим роутер
	router := mux.NewRouter()

	// Подключим CORS middleware
	router.Use(middleware.CorsMiddleware())

	router.Handle("/", playground.Handler("GraphQL playground", "/graphql"))
	router.Handle("/graphql", srv)

	log.Printf("connect to http://localhost:%s/ for GraphQL playground", port)
	log.Fatal(http.ListenAndServe(":"+port, router))
}

Коммит данного этапа

6. Store

В нашей логике взаимодействует несколько сущностей:

  1. Session: хранит данные об отложенной и пользовательской сессиях, работает с клиентом

  2. Auth: осуществляет авторизацию клиента и пользователя

  3. User: работает с пользователем

Store по факту является менеджером, управляющим различными сущностями проекта выполняющие общую логику.

Опишем Store /backoffice/pkg/store:

package store

type Store struct {
	token TokenOptions
}

func NewStore(opt Options) *Store {
	return &Store{
		token: opt.Token,
	}
}

type Options struct {
	Token TokenOptions
}

type TokenOptions struct {}

Создадим методы необходимые для работы сервера GraphQL.

Методы Auth:

// pkg/store/auth.go

// Возвращает состояние Auth исходя из текущего контекста
func (s *Store) Auth(ctx context.Context) (auth *model.Auth, err error) {
	// ...
	return
}

// Авторизовывает websocket, обрабатывает подключение и создает канал
func (s *Store) AuthCreateWebsocket(ctx context.Context) (out <-chan *model.Auth, err error) {
	// ...
	return
}

// Авторизация по Username
func (s *Store) AuthorizeForUsername(ctx context.Context, login string) (auth *model.Auth, err error) {
	// ...
	return
}

// Подтверждение авторизации из СМС сообщения
func (s *Store) AuthSMSApprove(ctx context.Context, code string) (auth *model.Auth, err error) {
	// ...
	return
}

Методы User:

// pkg/store/user.go

// Вернет User согласно текущего состояния авторизации
func (s *Store) User(ctx context.Context) (user *model.User, err error) {
	// ...
	return
}

Подключим Store в pkg/graph/resolver.go:

type Resolver struct{
	store *store.Store
}

func NewServer(opt Options) *handler.Server {
	srv := handler.New(
		generated.NewExecutableSchema(
			generated.Config{
				Resolvers: &Resolver{
					store: opt.Store,
				},
			},
		),
	)
  // ...
}

type Options struct {
	Store *store.Store
}

Отредактируем pkg/graph/auth.go и pkg/graph/user.go

// pkg/graph/auth.go
func (r *queryResolver) Auth(ctx context.Context) (*model.Auth, error) {
	return r.store.Auth(ctx)
}

func (r *subscriptionResolver) Auth(ctx context.Context) (<-chan *model.Auth, error) {
	return r.store.AuthCreateWebsocket(ctx)
}

func (r *mutationResolver) Authorization(ctx context.Context, login string) (*model.Auth, error) {
	return r.store.AuthorizeForUsername(ctx, login)
}

func (r *mutationResolver) SmsCode(ctx context.Context, code string) (*model.Auth, error) {
	return r.store.AuthSMSApprove(ctx, code)
}

// pkg/graph/user.go
func (r *queryResolver) User(ctx context.Context) (*model.User, error) {
	return r.store.User(ctx)
}

Исходники этапа

7. Первый запуск

В терминале набираем команду:

go run cmd/main.go

В адресной строке: http://localhost:2000/

Открывается GraphQL playground:

На этом, подготовительную часть можно считать завершенной.
Продолжение в следующей части

Tags:
Hubs:
Total votes 4: ↑1 and ↓3-2
Comments3

Articles