Pull to refresh

Login with MetaMask 1/2 (GO lang)

Level of difficultyMedium
Reading time5 min
Views493
Hero
Hero

Предисловие

Приветствую тебя, дорогой Разработчик! Хочу поделиться своим опытом о том, как реализовать вход с помощью кошелька MetaMask (расширение для браузера) в твой проект. В этой статье я пропускаю весь код архитектуры приложения и покажу тебе только сервисный код (нижний уровень кода. Взгляни на DDD архитектуру, также известную как "Чистый код"). Я использую GO с распространенными библиотеками для веб-разработки, такими как Gin, jwt, sqlc и другие.

Для начала давай представим, как должен работать наш процесс входа. Как мы все знаем, стратегия "разделяй и властвуй" очень эффективна для чего угодно. Поэтому здесь мы можем разделить наш процесс на 2 логических шага. Назовем их "Начало" и "Завершение". Теперь давай посмотрим на схему нашего процесса, потому что мы, как инженеры, должны максимально упростить свою работу. Итак, схема - отличный способ достичь этого.

Схема auth flow
Схема auth flow

Начало

Теперь о генерации nonce. Что такое "nonce"? Nonce — это просто случайная строка, которая может включать в себя всё, что мы захотим (в будущем мы попросим пользователя подписать этот nonce приватным ключом). Вот моя вспомогательная функция для генерации nonce:

package utils

import (
 _crypto "crypto/rand"
 "errors"
 "math/big"
 _math "math/rand"
 "strings"
 "time"
)

var wordList = []string{
 "apple", "banana", "cherry", "dog", "elephant",
 "frog", "grape", "honey", "icecream", "jungle",
 "kite", "lemon", "mango", "nap", "orange",
 "parrot", "queen", "rabbit", "strawberry", "turtle",
 "umbrella", "violet", "watermelon", "xylophone", "yak", "zebra",
}

func Contains(s []string, el string) bool {
 for _, v := range s {
  if v == el {
   return true
  }
 }

 return false
}

func GenerateSecretPhrase(numWords int) (string, error) {
 var passphraseWords []string
 _math.Seed(time.Now().UnixNano())

 if numWords > len(wordList) {
  return "", errors.New("too bit number")
 }

 for i := 0; i < numWords; i++ {
  randomIndex, err := _crypto.Int(_crypto.Reader, big.NewInt(int64(len(wordList))))
  if err != nil {
   return "", err
  }
  if Contains(passphraseWords, wordList[randomIndex.Int64()]) {
   continue
  }
  passphraseWords = append(passphraseWords, wordList[randomIndex.Int64()])
 }

 passphrase := strings.Join(passphraseWords, "-")
 return passphrase, nil
}

В результате мы получаем случайную строку, основанную на всех этих словах. Например: zebra-violet-umbrella-apple-cherry-lemon-kite-rabbit-xylophone-watermelon.

Чтобы сохранить эту часть процесса, нам нужно получить адрес кошелька MetaMask пользователя (здесь он выглядит как 0x742d35Cc6634C0532925a3b844Bc454e4438f44e). (Во второй части я расскажу, откуда мы его берем).

Также нам нужно выполнить стандартные проверки, например, существует ли уже пользователь (входил ли он в систему ранее) и т.д. Вот моя реализация этой функции (я не делюсь всей частью кода, поэтому вам нужно реализовать сервисный слой репозитория для работы с некоторыми данными пользователя):

func (a AuthUseCase) GetAuthenticatorByPublicAddress(ctx context.Context, publicAddress string) (*domain.Authenticator, error) {
 applicantAuthenticator, err := a.repository.GetUserAuthenticatorByPublicAddress(ctx, publicAddress)
 if err != nil && err != sql.ErrNoRows {
  a.logger.Error(err)
  return nil, err
 }

 nonce, err := utils.GenerateSecretPhrase(12)
 if err != nil {
  a.logger.Error(err)
  return nil, err
 }

 // If Authenticator don't exists (sign in for the first time)
 if applicantAuthenticator.ID == uuid.Nil {
  dbAuthenticator := db.CreateUserAuthenticatorParams{
   Nonce:         nonce,
   PublicAddress: publicAddress,
   AuthType:      string(domain.Metamask),
  }

  id, err := a.repository.CreateUserAuthenticator(ctx, dbAuthenticator)
  if err != nil {
   a.logger.Error(err)
   return nil, err
  }

  // gets only the "Nonce" field in our controller layer 
  // and send it to the frontend
  return &domain.Authenticator{
   ID:            id,
   AuthType:      domain.Metamask,
   Nonce:         nonce,
   PublicAddress: publicAddress,
  }, nil
 }

 // Already exists authenticator
 err = a.repository.UpdateUserAuthenticatorNonceByPublicAddress(ctx, db.UpdateUserAuthenticatorNonceByPublicAddressParams{
  Nonce:         nonce,
  PublicAddress: publicAddress,
 })
 if err != nil {
  a.logger.Error(err)
  return nil, err
 }

 // gets only the "Nonce" field in our controller layer 
 // and send it to the frontend
 return &domain.Authenticator{
  ID:            applicantAuthenticator.ID,
  AuthType:      domain.AuthType(applicantAuthenticator.AuthType),
  Nonce:         nonce,
  PublicAddress: applicantAuthenticator.PublicAddress,
 }, nil
}

Завершение

После того, как пользователь подпишет наш nonce своим приватным ключом (смотрите вторую часть, которая скоро появится), нам нужно проверить подпись, создать профиль пользователя и выпустить JWT-токен. Для проверки подписи мы используем библиотеку Ethereum (пакет). Вам нужно установить её следующим образом:

go get github.com/ethereum/go-ethereum

От клиента (frontend) мы ожидаем следующий DTO (Data Transfer Object):

type VerifySignatureDto struct {
 // same user's MetaMask wallet address as before
 PublicAddress string
 // signed nonce hash
 Signature     string
}

Метод верификации подписанного nonce :
Тут так же нету реализации сервиса, который генерит и проверят JWT токен, но это не сложно сделать (Можете написать в комментах, если нужно, я сделаю статью на этот счет)

func (a AuthUseCase) VerifySignedNonce(ctx context.Context, dto domain.VerifySignatureDto) (string, error) {
 // for consistent data in DB we need to transaction flow here
 tx, err := a.connect.Begin()
 if err != nil {
  a.logger.Error(err)
  return "", err
 }
 defer tx.Rollback()
 qtx := a.repository.WithTx(tx)

 applicantAuthenticator, err := qtx.GetUserAuthenticatorByPublicAddress(ctx, dto.PublicAddress)
 if err != nil {
  a.logger.Error(err)
  return "", err
 }

 // checkout the etherium's library docs and issues
 sig := hexutil.MustDecode(dto.Signature)
 nonceAsByte := []byte(applicantAuthenticator.Nonce)
 msg := accounts.TextHash(nonceAsByte)
 sig[crypto.RecoveryIDOffset] -= 27 // Transform yellow paper V from 27/28 to 0/1

 recovered, err := crypto.SigToPub(msg, sig)
 if err != nil {
  a.logger.Error(err)
  return "", err
 }

 recoveredAddr := crypto.PubkeyToAddress(*recovered)

 if recoveredAddr.Hex() != applicantAuthenticator.PublicAddress {
  errMsg := errors.New("invalid signature")
  a.logger.Error(errMsg)
  return "", errMsg
 }

 // To security we need to update our nonce in DB
 nonce, err := utils.GenerateSecretPhrase(12)
 if err != nil {
  a.logger.Error(err)
  return "", err
 }

 err = qtx.UpdateUserAuthenticatorNonceByPublicAddress(ctx, db.UpdateUserAuthenticatorNonceByPublicAddressParams{
  Nonce:         nonce,
  PublicAddress: dto.PublicAddress,
 })
 if err != nil {
  a.logger.Error(err)
  return "", err
 }

 userId := applicantAuthenticator.UserID.UUID

 // Create user if not exists
 if userId == uuid.Nil {
  profileId, err := qtx.CreateUserProfile(ctx, int32(domain.Unknown))
  if err != nil {
   a.logger.Error(err)
   return "", err
  }
  userId, err = qtx.CreateUser(ctx, db.CreateUserParams{
   Status:    string(domain.NotVerified),
   ProfileID: profileId,
  })
  if err != nil {
   a.logger.Error(err)
   return "", err
  }
  err = qtx.SetUserIdToAuthenticator(ctx, db.SetUserIdToAuthenticatorParams{
   UserID:        uuid.NullUUID{Valid: true, UUID: userId},
   PublicAddress: dto.PublicAddress,
  })
  if err != nil {
   a.logger.Error(err)
   return "", err
  }
 }

 token, err := a.tokenMaker.CreateToken(go_toolkit.Payload{
  UserId: userId,
 }, time.Hour*168) // 7 days
 if err != nil {
  a.logger.Error(err)
  return "", err
 }

 tx.Commit()
 return token, nil
}

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

Увидимся во второй части (скоро)!

Tags:
Hubs:
+3
Comments2

Articles