В предыдущей части мы подготовили сервис GraphQL, к работе со слоем бизнес-логики.

Предстоящие задачи:

  1. Подключиться из React + Apollo

  2. Создать CID – идентификатор клиента (браузера) и записать его в Cookie

  3. Достоверно связать websocket с клиентом CID

Обработка авторизации

Сам по себе пакет Gqlgen не предоставляет инструментов для работы с HTTP. Это не минус, ему это не нужно.
Конечно, наше приложение должно уметь ставить cookie и читать заголовки.

Все это решается с помощью middleware, в Gorilla mux это делается так:

// Инициализируем стор
st := store.NewStore(store.Options{})

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

// Инициализируем middleware
// Передадим Store в качестве параметра
router.Use(middleware.AuthMiddleware(st))
router.Use(middleware.CorsMiddleware(st))

Код AuthMiddleware, стандартная middleware:

func AuthMiddleware(store *store.Store) func(http.Handler) http.Handler {
	return func(next http.Handler) http.Handler {
		return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
			
      // Метод из Store, обрабатывает логику авторизации
      r = store.AuthorizationHTTP(w, r)
			next.ServeHTTP(w, r)
		})
	}
}

Код CorsMiddleware, ни какой логики выполнять не будем, все "захардкодим":

func CorsMiddleware(store *store.Store) 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")
			w.Header().Set("Access-Control-Allow-Credentials", "true")
			w.Header().Set("Access-Control-Allow-Methods", "POST, GET, OPTIONS, PUT, DELETE")
			w.Header().Set("Access-Control-Allow-Headers",
				"Accept, Content-Type, Content-Length, Accept-Encoding")
			if r.Method == "OPTIONS" {
				return
			}

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

Метод AuthorizationHTTP

Тот самый метод что вызывается в AuthMiddleware. Здесь мы проверяем токены, выполняем авторизацию, работаем с cookie. Официальный пример здесь.

func (s *Store) AuthorizationHTTP(w http.ResponseWriter, r *http.Request) *http.Request {
	ctx := r.Context()
  
	return r.WithContext(ctx)
}

Метаданные: UID, CID и тд. будем сохранять в контекст. Напишем немного логики, чтобы в дальнейшем к ним можно было легко обратиться

package model

// Структура мета данных
type Meta struct {
	Uid 		int
	Cid 		string
	Role 		string
	Reconnect 	bool
	Authorized 	bool
}

// Приватный ключ контекста
type key struct {
	name string
}

// Читаем Meta из контекста
func (m *Meta) Value(ctx context.Context) *Meta {
	meta := ctx.Value(key{"meta"})
	if meta == nil {
		return m
	}
	return meta.(*Meta)
}

// Пишем Meta в контекст
func (m *Meta) WithContext(ctx context.Context) context.Context {
	ctx = context.WithValue(ctx, key{"meta"}, m)
	return ctx
}

Теперь у нас есть все инструменты для авторизации дальнейших действий пользователя. Доработаем AuthorizationHTTP:

func (s *Store) AuthorizationHTTP(w http.ResponseWriter, r *http.Request) *http.Request {
	ctx := r.Context()

	// Создадим мету
	meta := &model.Meta{}

	// Проверим наличие токена Cid
	cidCookie, err := r.Cookie("_cid")
	if err != nil {
		
		// Токена нет
		cid := uuid.New().String()
		cidCookie = &http.Cookie{
			Name: "_cid",
			Value: cid,
			HttpOnly: true,
		}
		http.SetCookie(w, cidCookie)
	}

	// Прочитаем Cid и запишем в Meta
	if cidCookie != nil {
		meta.Cid = cidCookie.Value
	}
	
	return r.WithContext(
    
		// Запишем Meta в контекст
		meta.WithContext(ctx),
	)
}

Напомню, про методы авторизации которые клиент обязан дернуть вызвать, в первую очередь.

Auth(ctx context.Context) (*model.Auth, error)

Если вернет reconnect, это значит что React Apollo должен перезапустить соединение.
- Зачем так сложно?
- Иногда не получается достоверно определить клиента websocket.
Он нам сейчас не очень интересен.

Метод подписки на авторизацию по websocket.

Кейс: пользователь в форме вводит свой username после чего получает сообщение с "Кнопкой входа" на емайл.

Условия "нажатия кнопки входа" на фронте не известны, тем не менее React должен понять, что там где-то – нажали эту кнопку. Когда React получит сообщение об имеющейся авторизации все что нужно, это вызвать любой HTTP запрос, и произойдет установка cookie с токеном пользователя.

Метод:

AuthSubscription(ctx context.Context) (<-chan *model.Auth, error)

Возвращает канал, все что в него записанно будет отправлено по websocket.
Следует учесть что каждая новая вкладка браузера создает дополнительный канал chan *model.Auth. По факту создаются разные клиенты, но связанные единым CID.

Отписка канала (закрытие вкладки), об этом можно узнать в методе ctx.Done:

func AuthSubscription(ctx context.Context) (<-chan *model.Auth, error) {
	// Получим мета из контекста
	meta := &model.Meta{}
	meta = meta.Value(ctx)

	// Создадим websocket id
	wsid := uuid.New().String()

	// Создадим канал
	ch := make(chan *model.Auth)

	// Логика по добавлению слушателя
	fmt.Printf("Connect CID: %v, WSID: %v\n", meta.Cid, wsid)

	// Логика по удалению слушателя
	go func() {
		<- ctx.Done()
		fmt.Printf("Disconnect CID: %v, WSID: %v\n", meta.Cid, wsid)
	}()

	return ch, nil
}

Создадим фронт

Установим React

npx create-react-app front --template typescript

Установим Apollo Client и зависимости

npm install @apollo/client graphql subscriptions-transport-ws

Для упрощения процесса создания интерфейсов установим MUI этот момент не будем разбирать, так как он не входит в основную задачу.

Соединение Apollo Client

Соберем функцию соединения с АПИ, документация:

const connect = () => {
  const httpLink = new HttpLink({
    uri: 'http://localhost:2000/graphql',
    credentials: 'include',
  });

  const wsLink = new WebSocketLink({
    uri: 'ws://localhost:2000/graphql',
    options: {
      reconnect: true,
    }
  });

  const splitLink = split(
    ({ query }) => {
      const definition = getMainDefinition(query);
      return (
        definition.kind === 'OperationDefinition' &&
        definition.operation === 'subscription'
      );
    },
    wsLink,
    httpLink,
  );

  return new ApolloClient({
    link: splitLink,
    cache: new InMemoryCache(),
  });
}

Мы хотим иметь возможность управлять соединением, переподключаться в случае необходимости. Для этой задачи лучше всего подходит контекст документация:

interface ContextTypes {}

export const ConnectContext = createContext<Partial<ContextTypes>>({})

export const ConnectProvider: FC = ({ children }) => {
  const [state, setState] = useState<ApolloClient<NormalizedCacheObject>>(connect)

  return(
    <ConnectContext.Provider value={{
      
    }}>
      <ApolloProvider client={state}>
        { children }
      </ApolloProvider>
    </ConnectContext.Provider>
  )
}

Будем делать запрос авторизации по GET и websocket:

type TAuth = {
  authorized: boolean
  method: string
  reconnect: boolean
}

interface ContextTypes {
  auth: TAuth
  logout: () => void
}

export const AuthorizationContext = createContext<Partial<ContextTypes>>({})

export const AuthorizationProvider: FC = ({ children }) => {
  // GET запрос Auth
  const { loading, error, data } = useQuery(QUERY);
  useEffect(() => {
    console.log("GET Auth", loading, error, data)
  }, [loading, error, data])

  // Слушаем Auth websocket
  const { loading: wsLoading, error: wsError, data: wsData } = useSubscription(
    SUBSCR
  );
  useEffect(() => {
    console.log("Websocket Auth", wsLoading, wsError, wsData)
  }, [wsLoading, wsError, wsData])

  return (
    <AuthorizationContext.Provider value={{

    }}>
      {children}
    </AuthorizationContext.Provider>
  )
}

const SUBSCR = gql`
    subscription{
        authSubscription{
            authorized,
            method,
            reconnect
        }
    }
`;

const QUERY = gql`
    query {
        auth{
            authorized,
            method,
            reconnect
        }
    }
`;

Эксперименты

При подключении клиента, терминал с запущенным сервисом на Go сообщает нам примерно следующее:

# При подключении
Connect CID: UUID_1, WSID: UUID_2
# При закрытии соединения
Disconnect CID: UUID_1, WSID: UUID_2

UUID_1 должен совпадать с cookie CID, иначе доставки не состоится.

Кейс отличающегося cookie CID с websocket ID
Шаги воспроизведения:

  1. Удаляем токен CID,

  2. обновляем вкладку

  3. проверяем запись CID в Cookie

  4. смотрим запись CID клиента, подключившегося по websocket (терминал Go)

Записи различаются, связано с Apollo, двигаться в сторону ping-pong

Что произошло?

Вебсокет соединение устанавливается прежде чем клиент (браузер) получает CID cookie.

ЗЫ: Почти уверен что это костыль и решение есть в Apollo. Если кто то знает как решать, буду рад в комментариях

Как решать?

Нужно сообщить на фронт о том что CID только что был установлен, для этого передадим флаг Reconnect.

Допишем логику AuthorizationHTTP:

// ...

cidCookie, err := r.Cookie("_cid")
if err != nil {
  meta.Reconnect = true
  
  // ...
}

Поправим метод AuthQuery:

func AuthQuery(ctx context.Context) (*model.Auth, error) {
	// Получим мета из контекста
	meta := &model.Meta{}
	meta = meta.Value(ctx)

	auth := &model.Auth{}
  
  // Отправим флаг Reconnect, если он есть
	auth.Reconnect = meta.Reconnect

	return auth, nil
}

React ConnectProvider.
Свесим метод позволяющий выполнять перезапуск соединения

interface ContextTypes {
  reconnect: () => void
}

export const ConnectContext = createContext<Partial<ContextTypes>>({})

export const ConnectProvider: FC = ({ children }) => {
  const [state, setState] = useState<ApolloClient<NormalizedCacheObject>>(connect)
  const reconnect = () => setState(connect)
  return(
    <ConnectContext.Provider value={{
      reconnect
    }}>
      <ApolloProvider client={state}>
        { children }
      </ApolloProvider>
    </ConnectContext.Provider>
  )
}

AuthorizationProvider вытащим из контекста метод reconnect

export const AuthorizationProvider: FC = ({ children }) => {
  const { reconnect } = useContext(ConnectContext)

  // GET запрос Auth
  const { loading, error, data } = useQuery(QUERY);
  useEffect(() => {
    if (!loading && !error && data) {
      const { reconnect: rc } = data.auth
      if (rc === true && reconnect) {
        
      	// Если получен флаг reconnect
        reconnect()
      }
    }
  }, [loading, error, data])

  // Слушаем Auth websocket
  const { loading: wsLoading, error: wsError, data: wsData } = useSubscription(
    SUBSCR
  );
  useEffect(() => {
    console.log("Websocket Auth", wsLoading, wsError, wsData)
  }, [wsLoading, wsError, wsData])

  return (
    <AuthorizationContext.Provider value={{

    }}>
      {children}
    </AuthorizationContext.Provider>
  )
}

Готово

Теперь мы всегда получаем одиноковые идентификаторы клиента в HTTP и WS запросах.

Закончим данный этап. Исходники здесь

Далее необходимо собрать слушателей websocket, и прикрутить авторизацию через СМС.

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