React Apollo, Gqlgen – authorization. Part 2


The article is divided into several parts:
– part 1
– part 3 – in progress
sources of this part

In the last part, we deployed business logic methods, protected against CSRF, and set up a GraphQL server.

In this part, you need to authorize the Client and create listeners ready to accept user authorization. It is necessary to be able to remotely terminate the Client session.

Implementation

Simplified diagram:

  1. When creating a connection, the Client receives ClientID

  2. The server creates a websocket session bound to ClientID

  3. Messages are sent to all listeners attached to ClientID

  4. Listeners ClientID can be at least 2, before transferring the connection to a new tab

Tasks

  1. Session manager

  2. How Gqlgen handles websocket

  3. Creation of the Client and his observers

  4. Save the Client session to a JWT token

  5. Creating websocket listeners

  6. Sending a websocket message

1. Session manager

We already have a session model /models/models_gen.go generated from schema session.graphqls… Create in the same directory a file with methods for secure work with the session /models/session.go

// Создает новую сессию
func NewSession() *Session {
	return &Session{

		// Идентификатором будет UUID
		// go get github.com/google/uuid
		// или go mod vendor при указании импорта
		Sid: uuid.New().String(),
	}
}

// Создаст сессию с существующим идентификатором
func NewSessionWithSid(sid string) *Session {
	return &Session{
		Sid: sid,
	}
}

// Получить идентификатор сессии
func (s *Session) GetSid() (sid string, err error) {
	if s.Sid == "" {
		return "", fmt.Errorf("session: not found")
	}
	return s.Sid, nil
}

// Подтверждает активность клиента
func (s *Session) SetOnline() {
	s.Online = true
	return
}

// Сохраняет сессию в контекст
func (s *Session) WithContext(ctx context.Context) context.Context {
	return context.WithValue(ctx, sessionCtxKey{"session"}, s)
}

// Ключ контекста
type sessionCtxKey struct {
	name string
}

Create a new file session.go in the directory pkg/store

// Обрабатывает сессию клиента
func (s *Store) SessionHandleClient(w http.ResponseWriter, r *http.Request) *http.Request {

	// Получим контекст
	ctx := r.Context()

	// Создадим сессию
	var sess *model.Session

	// Проверим наличие токена c ClientID
	cookie, err := r.Cookie("_sid")
	if err != nil {

		// Нет ClientID, создадим сессию
		sess = model.NewSession()

	} else {

		// Тут должна быть логика валидации
		// Но нам сейчас удобно видеть действительную запись
		sess = model.NewSessionWithSid(cookie.Value)

		// Клиент имеет ID
    // Вебсокет не может устанавливать cookie, 
    // значит если идентификатор отсутствует, 
    // то возможные соединения websocket 
    // являются не авторизованными и должны быть отклонены
    // 
    // В данный кейс попадает Клиент с ранее имеющимся 
    // ClientId, в этом случае соединение 
    // по websocket возможно настроим это
		sess.SetOnline()
	}

	// Если есть ошибка – устанавливаем новые cookie
	if err != nil {

		// Получим ID клиента
		sid, err2 := sess.GetSid()
		if err2 != nil {
			fmt.Printf(err.Error())
			return r
		}

		// Создадим cookie
		cookie = &http.Cookie{
			Name: "_sid",
			// Сid следует завернуть в токен, например JWT
			Value: sid,
			HttpOnly: true,
			//Secure: true,
		}

		// Установим cookie
		http.SetCookie(w, cookie)
	}

	// Сохраним сессию в контекст и вернем *http.Request
	return r.WithContext(sess.WithContext(ctx))
}

Now we are able to handle the Client session.

Let’s create a method for handling HTTP requests in a file pkg/store/auth.go and in it we connect the newly created method:

// Вызывается в AuthMiddleware
// Обрабатывает HTTP заголовки
// Проводит авторизации клиента и пользователя
func (s *Store) HandleAuthHTTP(w http.ResponseWriter, r *http.Request) *http.Request {

	// Обработаем сессию клиента
	r = s.SessionHandleClient(w, r)

	return r
}

Must publish HandleAuthHTTP as an HTTP router middleware. To do this, create a file auth.go in the directory pkg/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.HandleAuthHTTP(w, r)
			next.ServeHTTP(w, r)
		})
	}
}

Let’s connect it in main.go:

func main() {
  // ...
  router := mux.NewRouter()
  
  // Подключим Auth middleware и передадим store в качестве параметра
	router.Use(middleware.AuthMiddleware(store))
  
  // ...
}

We start the server, open the browser. Now Customer has its identifier in the form cookie With name _sid

Commit this stage

2. How Gqlgen handles websocket

As the project develops, we are going to subscribe not only for authorization. It is necessary to implement a common subscription logic for all project entities.

It is important to consider:

  1. ClientID – can only be assigned to the browser

  2. Different browser tabs have their own unique websocket identifiers that are not associated with ClientID

The client connecting via websocket must have a cookie with ClientIDwhich it receives after the GET request. We cut off requests that do not have authorization: the parameters can be found from model.Session which we extract from the context – it is necessary to create a method.

The frontend receives authorization status on the websocket, listening to changes in subscription.auth… For these purposes, the server has Auth method processing subscriptionResolver… Using his example, we will analyze the process of adding and removing a listener. To do this, add 2 methods v models/session:

// Получает сессию из контекста
func SessionFromContext(ctx context.Context) (*Session, error) {
	if meta := ctx.Value(sessionCtxKey{"session"}); meta != nil {
		return meta.(*Session), nil
	}
	return nil, fmt.Errorf("meta: not found")
}

// Подтверждает активность клиента
func (s *Session) CheckOnline() bool {
	return s.Online
}

Let’s edit the subscription method itself Auth, it imports AuthWebsocket – from the Store. Let’s open the file /pkg/store/auth.go and edit this method:

// Авторизовывает websocket
// Создает сессию
// Обрабатывает подключение и создает канал
//
// Каждый клиент вызывавший данный
// метод – является уникальным
func (r *Store) AuthWebsocket(ctx context.Context) (<-chan *model.Auth, error) {

	// Получим сессию из контекста
	sess, err := model.SessionFromContext(ctx)
	if err != nil {

		// Если произошла ошибка то не стоит здесь
		// ее отправлять дальше.
		//
		// Ее нужно логировать и вернуть на фронт
		// что-то более обобщенное
		fmt.Printf("Auth subscriptionResolver. %v", err)

		return nil, gqlerror.Errorf("internal error")
	}

	// Проверим инициатора запроса.
	// Если запрос поступил по вебсокет и от Клиента
	// ранее не имеющего ClientID – не обрабатываем его
	if ok := sess.CheckOnline(); !ok {

		// Если клиент не имеет авторизации
		return nil, gqlerror.Errorf("unauthorized")
	}

	// Подключившийся клиент – уникален
	// Создадим websocket ID
	wsid := uuid.New().String()

	// Создаем канал в который будем писать сообщения
	in := make(chan *model.Auth)

	// Выведем в терминал сообщение при подключении
	fmt.Printf("WS connect. ID: %vn", wsid)

	// Обработаем остановку соединения
	go func() {

		// Чтобы узнать об отключении websocket
		// достаточно слушать сигнал из контекста
		<- ctx.Done()
		fmt.Printf("WS disconnect. ID: %vn", wsid)
	}()

	// Тестовая публикация сообщения
	go func() {
		
		// Сразу опубликуем сообщение
		in <- &model.Auth{
			ClientID: time.Now().String(),
		}

		// Небольшая задержка и отправим следующее
		time.Sleep(time.Second * 2)
		in <- &model.Auth{
			ClientID: time.Now().String(),
		}
	}()

	// Вернем канал
	return in, nil
}

Let’s start the server, open http: // localhost: 2000 / in the Playground window that opens, execute the request:

subscription{
  auth{
    client_id
  }
}

We receive 2 messages with a delay:

Now let’s check the answer if Customer is not authorized. On connection, the browser received a cookie with ClientID, go to the developer tools, delete the cookie and try to connect again:

We have analyzed the subscription mechanism in Gqlgen, now you can proceed to the stage of assembling the listener.

Stage sources

3. Creation of the Client and his observers

The structure of the Client and its listener:

  1. Client: unites listeners into one ClientID… In our context, the browser

  2. Observer: is the direct recipient of messages – different browser tabs. Has its own unique identifier

An important point!

Observer-th, it can be not only a new tab. But also a new request in the current tab.

In the diagram:

Observer – browser tab

Blue denotes connections Client-1… We see that through Observer-1 there are 2 channels. This is due to the lack of an identifier on the Observer.

How do we decide?

When creating a new listener, we create a session with our own identifier SessionID

  1. Create a session for each browser tab

  2. Create a session, only for websocket connections

  3. Client receives Session-ID in websocket response

  4. Session active – while the connection is active

  5. If Customer It has SessionID, then for any requests includes it in the HTTP header: Session-ID

Scheme after creating the identifier:

Let’s get down to implementation

Let’s open the file /pkg/store/session.go, we are interested in code in method SessionHandleClient:

func (s *Store) SessionHandleClient(w http.ResponseWriter, r *http.Request) *http.Request {
  // ...
  
  // Проверим наличие токена c ClientID
	cookie, err := r.Cookie("_cid")
	if err != nil {

		// Нет ClientID, создадим сессию
		sess = model.NewSession()
	} else {
    
    // ...
	}
  
  // ...
}

Here, in the absence of the Client’s session, we create a new session, then save it into the context. In case the client has ClientID, we create a session again with ClientID obtained from the cookie.

It would be wiser if, in the presence of a cookie, we do not create, but receive an existing session.

It is not beneficial for us to create a server-side storage of session data. So we will keep it with the client. Two options would be reasonable here:

  1. Save session to cookie as JSON

  2. Save session to JWT token

4. Save the Client session to a JWT token

In the last chapter, we discussed the need to create a session for each listener, and also came to the conclusion that it needs to be saved somewhere.

At first glance, the simplest solution is to store the session in a cookie. But we need a session tied to a specific tab. In the case of a cookie, it will become visible to the entire browser: you will have to create unique cookies. In the case of unique cookies, how do you clear them? We take into account that websocket does not work with cookies and will not be able to delete them after unsubscribing.

In order not to invent: save the session to a JWT token and pass it To the client… Next time you contact Customer: will send the token in the HTTP header Session-ID

Why Session-ID?
1. We can store the session on the side Server and upon receipt Session-ID request it from the repository
2. Technically, a JWT token is not a session. In encrypted form, it is useless for the Client

JWT validation

Token validation states:

  1. Token invalid – not working: when what came is not a token at all

  2. Claims invalid – not working: when it was not possible to extract the payload

  3. Expired – suitable for further validation: in this case, the token must be updated. Possibly after additional session validation

  4. Valid – working token

When developing authorization User, we need the parameter Expired… In this case: the token will have to contain the field: AccessToken… We will use it in the session storage at Server… And if it is valid, we will update the JWT token.

– This is how OAuth works

We will not delve into the structure of the token. Moreover, there is a good article:
Five Easy Steps to Understand JSON Web Tokens (JWT)

We are interested in how to work with JWT in Golang, for this we take the popular package jwt-goLet’s add it to the project:

go get github.com/dgrijalva/jwt-go

Let’s create a file /pkg/token/jwt.go:

package token

import (
	"fmt"
	"github.com/dgrijalva/jwt-go"
	model "react-apollo-gqlgen-tutorial/backoffice/models"
	"time"
)

// Создадим структуру Jwt
type Jwt struct {
	SecretKey  string
	Issuer     string
	Expiration int64
}

// Опции при генерации токена
type JwtClaims struct {

	// Нужен для обновления токена
	AccessToken string

	// Прикрепим сессию
	Sess *model.Session

	jwt.StandardClaims
}

// Генерация токена
func (j *Jwt) Generate(opt JwtClaims) (token string, err error) {

	// Получим Claims
	claims := &opt

	// Инициализация StandardClaims
	//
	// Здесь "подключаются" все настройки
	// Необходимые для валидации токена
	//
	// Указываются при инициализации структуры Jwt
	claims.StandardClaims = jwt.StandardClaims{
		ExpiresAt: time.Now().Local().Add(time.Second * time.Duration(j.Expiration)).Unix(),
		Issuer:    j.Issuer,
	}

	// Генерация токена
	t := jwt.NewWithClaims(jwt.SigningMethodHS256, claims)

	return t.SignedString([]byte(j.SecretKey))
}

// Опции при валидации токена
type JwtValidateOptions struct {
	Token 	string
}

// Валидация токена
func (j *Jwt) Validate(opt JwtValidateOptions) (claims *JwtClaims, err error) {

	// Попробуем получить полезную нагрузку
	token, err := jwt.ParseWithClaims(
		opt.Token,
		&JwtClaims{},
		func(token *jwt.Token) (interface{}, error) {
			return []byte(j.SecretKey), nil
		},
	)

	// Полезной нагрузки нет
	// Что-то явно не валидное – вернем ошибку
	if token == nil {
		return nil, fmt.Errorf("token invalid")
	}

	// Получим Claims
	claims, ok := token.Claims.(*JwtClaims)
	if !ok {
		return nil, fmt.Errorf("error token claims")
	}

	// Проверим срок жизни токена
	if j.Expiration > 0 && claims.ExpiresAt < time.Now().Local().Unix() {

		// Если токен протух
		// Вернем полезную нагрузку вместе с ошибкой
    // 
    // Для дальнейшей валидации:
    // claims будет содержать AccessToken
		return claims, fmt.Errorf("token is expired")
	}

	return claims, nil
}

// Опции структуры Jwt
type JwtOptions struct {
	SecretKey 	string
	Issuer 		string
	ExpSeconds 	int64
}

func NewJwt(opt JwtOptions) *Jwt {
	return &Jwt{
		SecretKey: 	opt.SecretKey,
		Issuer: 	opt.Issuer,
		Expiration: opt.ExpSeconds,
	}
}

Now we can save the session in a token, let’s go back to the file /pkg/store/session.go… Let’s add a method validating a token and a method validating a listener’s session:

// Валидирует сессию слушателя
func (s *Store) ValidateClientSession(ctx context.Context) (sessionID string, err error) {

	// Получим сессию из контекста
	sess, err := model.SessionFromContext(ctx)
	if err != nil {
		return "", fmt.Errorf("internal error")
	}

	if ok := sess.CheckOnline(); !ok {

		// Если клиент не авторизован: SessionID отсутствует
		// Создадим SessionID, и отправим клиенту
		sessionToken, err2 := s.token.SessionID.Generate(token.JwtClaims{
			Sess: sess,
		})

		if err2 != nil {
			fmt.Println(err2)
			return "", fmt.Errorf("internal error")
		}

		return sessionToken, nil
	}

	return "", nil
}

// Валидирует токен слушателя
func (s *Store) ValidateSessionToken(sid string) (*model.Session, error) {

	// Валидируем токен
	// Считаем токен не валидным если нет claims
	if claims, _ := s.token.SessionID.Validate(token.JwtValidateOptions{
		Token: sid,
	}); claims != nil {
		sess := claims.Sess

		// Сессию получили из заголовка: клиент онлайн
		sess.SetOnline()

		// Сохраним сессию в контекст
		return sess, nil
	}

	return nil, fmt.Errorf("invalid session token")
}

In the same file /pkg/store/session.go , let’s move on to the method SessionHandleClient, let’s edit it like this:

// Обрабатывает сессию клиента
func (s *Store) SessionHandleClient(w http.ResponseWriter, r *http.Request) *http.Request {

	// Получим контекст
	ctx := r.Context()

	// Сюда запишем сессию, если сработает кейс
	var sess *model.Session
	var ClientID string

	// Проверим наличие cookie c ClientID
	cookie, err := r.Cookie("_cid")
	if err == nil {
		ClientID = cookie.Value

		// У клиента есть ClientID
		// 1. Проверим наличие заголовка Session-ID
		// 2. Получаем токен и валидируем его
		// 2.1. Токен валидный: сохраним сессию из токена
		// 2.2. Токен протух: сохраним сессию из токена
		// 2.3. Токен Invalid: создадим новую сессии

		// Ищем заголовок Session-ID
		if t := r.Header.Get("Session-ID"); t != "" {

			// Нашли сессию
			if ss, err2 := s.ValidateSessionToken
				sess = ss
			}
		}

		// Этот метод теперь удален
		//sess = model.NewSessionWithSid(cookie.Value)
	}

	// Если сессии нет: создаем сессию
	if sess == nil {
		sess = model.NewSession()
		
		if ClientID != "" {
			sess.AddClientID(ClientID)
		}
	}

	// Если есть ошибка при чтении cookie
	if err != nil {

		// Получим ID клиента
		cid, err2 := sess.GetSid()
		if err2 != nil {
			fmt.Printf(err.Error())
			return r
		}

		// Создадим cookie
		cookie = &http.Cookie{
			Name: "_cid",
			Value: cid,
			HttpOnly: true,
			//Secure: true,
		}

		// Установим cookie
		http.SetCookie(w, cookie)
	}

	// Сохраним сессию в контекст и вернем *http.Request
	return r.WithContext(sess.WithContext(ctx))
}

We receive HTTP headers. But websocket is authorized differently. Let’s open the file /pkg/graph/resolver.go… Here you need to accept the title Session-ID, validate the token, if successful: get the session and save it to the context.

func NewServer(opt Options) *handler.Server {
	// ...
  
	srv.AddTransport(transport.Websocket{
    
    // ...
		InitFunc: transport.WebsocketInitFunc(func(ctx context.Context, initPayload transport.InitPayload) (context.Context, error) {

			// Тут обрабатываются websocket соединения
			// Получим заголовок "Session-ID"
			if sid, ok := initPayload["Session-ID"]; ok {
				if sess, err := opt.Store.ValidateSessionToken(sid.(string)); err == nil {
					
          // Сохраним сессию в контекст
          ctx = sess.WithContext(ctx)
				}
			}

			return ctx, nil
		}),
	})
  
  // ...
}

Authorization methods

We prepared everything, now we need to get somewhere token SessionID… For any request model.Auth, you need to create a session. Except when it is available.

Every request model.Auth: returns the current authorization state, the method is responsible for it Auth() in file /pkg/store/auth.go… Let’s open and edit it:

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

	// создадим модель
	auth := &model.Auth{}

	// Проверим сессию
	sid, err := s.ValidateClientSession(ctx)
	if err != nil {
		return nil, gqlerror.Errorf("internal error")
	}

	// Если есть sid – добавим его к Auth
	if sid != "" {
		auth.AddSessionId(sid)
	}

	// Отправим текущее состояние
	return auth, nil
}

We will also edit the websocket subscription method model.Auth:

func (s *Store) AuthWebsocket(ctx context.Context) (<-chan *model.Auth, error) {

	// Получим текущее состояние авторизации
	auth, err := s.Auth(ctx)
	if err != nil {
		return nil, gqlerror.Errorf("internal error")
	}

	// Создаем канал в который будем писать сообщения
	ch := make(chan *model.Auth)

	// Нужно вернуть текущее состояние
	go func() {
		ch <- auth
	}()

	// Вернем канал
	return ch, nil
}

For the purity of the experiment

Let’s add a websocket listener for User… Let’s open the file schema.graphqls and add a method:

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

  """
  Добавлен метод:
  Подписка на Auth
  """
  user: User!

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

Let’s enter the command to generate laughter in the terminal:

go run cmd/gqlgen.go

Move the generated method:
User(ctx context.Context) (<-chan *model.User, error)
v /pkg/graph/user and edit it:

func (r *subscriptionResolver) User(ctx context.Context) (<-chan *model.User, error) {
	user := make(chan *model.User)

	// Получим сессию из контекста
	sess, err := model.SessionFromContext(ctx)
	if err != nil {
		return nil, gqlerror.Errorf("internal error")
	}

	fmt.Printf("User. Session: %vn", sess.Sid)

	return user, nil
}

We will also add similar code to the subscription method for Auth:

func (s *Store) AuthWebsocket(ctx context.Context) (<-chan *model.Auth, error) {

	// Получим сессию из контекста
	sess, err := model.SessionFromContext(ctx)
	if err != nil {
		return nil, gqlerror.Errorf("internal error")
	}

	fmt.Printf("Auth. Session: %vn", sess.Sid)
  
  // ...
}

Let’s open a terminal:

go run cmd/main.go

In the browser: http: // localhost: 2000 /

We enter the request:

query{
  auth{
    sessionId
  }
}

We get the token:

Now we connect to Authusing the given token. There is a tool for this in the Playground:

We enter the request:

subscription{
  auth{
    sessionId
  }
}

Next, we enter the second:

subscription {
  user{
    uid
  }
}

We look at the terminal, and we see that Auth and User have the same identifier. This means that the session is created and read:

We have created a session for each window, now we need to create the connection handler itself.

5. Creating websocket listeners

When subscribing a new listener Auth or User necessary:

  1. Receive: ClientID and SessionID

  2. Create Client by ClientID

  3. Create a listener by SessionID

When unsubscribing:

  1. Remove listener Auth or User

  2. Check for the presence of other listeners, in case of absence: completely delete the record with the Client’s session

We want to have a unified handler for Auth and User or for other entities of the project. Therefore, we cannot know the type of the received channel or message. To solve this problem, we will take an empty interface as a type and extract a channel according to its type.

Let’s create a file /pkg/websocket/observer.go channel processing type:

type observer struct {
	auth chan 	*model.Auth
	user chan 	*model.User
}

func (o *observer) Add(ch interface{}) error {

	// Получим тип из интерфейса
	switch ch.(type) {
	case chan *model.Auth:
		o.auth = ch.(chan *model.Auth)
		return nil
	case chan *model.User:
		o.user = ch.(chan *model.User)
		return nil
	default:
    
    // Тип не обнаружен
		return fmt.Errorf("observer: unknown type")
	}
}

// Удаляет наблюдателя,
// если вернет true - можно удалить слушатель
func (o *observer) Delete(ch interface{}) bool {

	// Получим тип из интерфейса
	switch ch.(type) {
	case chan *model.Auth:
		o.auth = nil
	case chan *model.User:
		o.user = nil
	}

	return o.checkEmpty()
}

// Вернет истину если нет слушателей
func (o *observer) checkEmpty() bool {
	switch {
	case o.auth != nil:
		return false
	case o.user != nil:
		return false
	}
	return true
}

The listeners are united by the Client, let’s create the /pkg/websocket/client.go file:

type client struct {
	observers 	map[string]*observer
	mu 			sync.Mutex
}

// Добавляет слушателя Клиента
func (c *client) Add(sid string, ch interface{}) error {

	// Заблокируем мапу слушателей
	// чтобы безопасно с ней работать работать
	c.mu.Lock()

	// Разблокируем мапу после выхода из функции
	defer c.mu.Unlock()

	// Поищем слушателя
	obs, ok := c.observers[sid]
	if !ok {

		// Слушатель не найден, создадим
		obs = &observer{}

		// Добавим в мапу
		c.observers[sid] = obs
	}

	err := obs.Add(ch)
	if err != nil {
		return err
	}

	return nil
}

// Удаляет слушателя
// Возвращает признак наличия других слушателей
func (c *client) Delete(sid string, ch interface{}) bool {
	c.mu.Lock()
	defer c.mu.Unlock()

	obs, ok := c.observers[sid]
	if !ok {
		// Обсервер не найден?
		fmt.Println("panic")
	}

	// Удаляем канал
	if ok = obs.Delete(ch); ok {

		// Если вернулся признак пустоты
		// Удалим слушатель
		delete(c.observers, sid)
	}

	// Посчитаем количество слушателей
	// и вернем результат
	return len(c.observers) == 0
}

func newClient() *client {
	return &client{
		observers: make(map[string]*observer),
	}
}

There is a manager file with a single method that creates a new listener /pkg/websocket/manager.go:

type Websocket struct {
	clients map[string]*client

	// Защищаем мапу
	mu sync.Mutex
}

// Создает Клиента
func (w *Websocket) NewObserver(ctx context.Context, ch interface{}) error {

	// Заблокируем мапу clients
	// чтобы безопасно с ней работать работать
	w.mu.Lock()

	// Разблокируем мапу после выхода из функции
	defer w.mu.Unlock()

	// Получим сессию из контекста
	sess, err := model.SessionFromContext(ctx)
	if err != nil {
		return err
	}

	cid := sess.ClientID
	sid := sess.Sid

	// Найдем, или создадим клиента
	cli, ok := w.clients[cid]
	if !ok {

		// Клиент не найден, создадим
		cli = newClient()

		// Добавим в мапу
		w.clients[cid] = cli
	}

	// Добавим слушателя клиента
	err = cli.Add(sid, ch)
	if err != nil {
		return err
	}

	// Клиент отписывается – удаляем слушатель
	go func() {
		<- ctx.Done()

		cli.Delete(sid, ch)
	}()

	return nil
}

func New() *Websocket {
	return &Websocket{
		clients: make(map[string]*client),
	}
}

We have created a websocket listener manager, it remains to connect it.

Let’s open the file /pkg/store/auth.go, and add the created manager to the method AuthWebsocket implementing a websocket connection for Auth:

func (s *Store) AuthWebsocket(ctx context.Context) (<-chan *model.Auth, error) {

	// Получим текущее состояние авторизации
	auth, err := s.Auth(ctx)
	if err != nil {
		fmt.Println(err)
		return nil, gqlerror.Errorf("internal error")
	}

	// Создаем канал в который будем писать сообщения
	ch := make(chan *model.Auth)

	// Подключим канал к менеджеру websocket
	err = s.websocket.NewObserver(ctx, ch)
	if err != nil {
		fmt.Println(err)
		return nil, gqlerror.Errorf("internal error")
	}

	// Нужно вернуть текущее состояние
	go func() {
		ch <- auth
	}()

	// Вернем канал
	return ch, nil
}

Everything is ready, now our connections are bound to ClientID and SessionID

Stage sources

6. Sending a message via websocket

We have created listeners, it remains to implement methods for sending messages to listeners according to their destination.

Let’s open the file /pkg/websocket/observer.go and add the method Send:

func (o *observer) Send(ch interface{}) {

	// Получим тип из интерфейса
	switch ch.(type) {
	case *model.Auth:

		// Валидируем канал
		if o.auth == nil {
			fmt.Println("Auth sending error")
			return
		}

		// Отправляем сообщение
		o.auth <- ch.(*model.Auth)
	case *model.User:

		// Валидируем канал
		if o.user == nil {
			fmt.Println("User sending error")
			return
		}

		// Отправляем сообщение
		o.user <- ch.(*model.User)
	default:
		fmt.Println("unknown message type")
	}
}

Next, let’s create a method in /pkg/websocket/client.go:

func (c *client) Send(ch interface{}) {
	c.mu.Lock()
	defer c.mu.Unlock()

	// Необходимо отправить сообщение всем слушателям
	// Пройдемся в цикле и запустим отправку
	//
	// Лучше всего это сделать в отдельной горутине

	// Создадим WaitGroup
	// Про применение описано в этой статье:
	// https://habr.com/ru/company/otus/blog/557312/
	wg := sync.WaitGroup{}
	wg.Add(1)
	go func() {
		for _, obs := range c.observers {
			obs.Send(ch)
		}
		wg.Done()
	}()
	wg.Wait()
}

Now let’s add Send to the manager /pkg/websoket/manager.go:

func (w *Websocket) Send(ctx context.Context, ch interface{}) error {
	w.mu.Lock()
	defer w.mu.Unlock()

	// Получим сессию из контекста
	sess, err := model.SessionFromContext(ctx)
	if err != nil {
		return err
	}

	// Получим ClientID
	cid := sess.ClientID

	// Найдем клиента
	cli, ok := w.clients[cid]
	if !ok {
		return fmt.Errorf("client not found")
	}

	// Отправляем сообщение
	cli.Send(ch)

	return nil
}

Sending Auth over websocket

We are now ready to send messages on the websocket. Implementing the method for sending the current state Auth To the client:

func (s *Store) SendAuth(ctx context.Context) error {
	
	// Получим текущее состояние
	auth, err := s.Auth(ctx)
	if err != nil {
		return err
	}

	// Todo: удалить!!!
	// Чтобы увидеть результат изменений
	// Нужно что нибудь рандомное
	auth.Method = time.Now().String()

	if err = s.websocket.Send(ctx, auth); err != nil {
		return err
	}

	return nil
}

Testing the connection

For the test, let’s change the method User requesting user, in file /pkg/store/user.go:

func (s *Store) User(ctx context.Context) (*model.User, error) {

	err := s.SendAuth(ctx)
	fmt.Println("Запрашиваем Auth из метода User")
	fmt.Printf("Ошибка: %vn", err)

	// ...
	return &model.User{
		Username: "LOLO",
	}, nil
}

We start the server:

go run cmd/main.go

We open: http: // localhost: 2000 /

We execute the request:

subscription{
  auth{
    sessionId,
    method
  }
}

Open a new tab in the Playground, place the resulting token in the header:

{
  "Session-ID": "TOKEN"
}

We execute the request:

query{
  user{
    username
  }
}

We get the result in the auth tab:

Browser tabs, and other browsers: test it yourself.

This concludes this part. In the next part, we will develop a mechanism for delivering authorization to the User and deploy the frontend on React + Apollo.

Similar Posts

Leave a Reply

Your email address will not be published. Required fields are marked *