MetaMask Login using Golang and VueJS

step by step guide to build a REST API that uses MetaMask for authentication.

ยท

25 min read

Introduction

In this tutorial we are going to build step by step a REST API in Go that supports authentication using public key cryptography.

The idea is that we will utilize MetaMask's cryptographic sign capabilities to perform authentication in our system.

We will try to keep things simple for a reference implementation.

For the backend we are going to use Golang and for the frontend VueJS. The concept is the same for other Languages/Frameworks so you can continue reading even if you code in PHP and React.

Prerequisites

  • Golang 1.18+ installed
  • VueJs and dependencies installed

Note: I am developing on Ubuntu 22.04 LTS, but the code should work on most operating systems.

Install MetaMask

Note:: In case you already have metamask you can skip this step

visit metamask.io and install the metamask extension for your browser.

Then follow the instructions and either import or create a wallet. If you do not have metamask installed the chances are that you want to create a new wallet.

Important: Note down your password and most importantly the secret recovery phrase!

You should have an Ethereum Address of the form:

0x3110752149AF23Ee65968C2019b7c86D12B32229

this is your public Address and you can share with anyone.

After completing the above steps we can continue to the next steps.

Login Flow

Using your MetaMask extension you created a public Ethereum Address. In my case above this was 0x3110752149AF23Ee65968C2019b7c86D12B32229

This address is constructed from a Public Key that it is stored in your MetaMask. You can share this public key or your address with anybody. We will use this as an equivalent of a unique user identifer. In a username/password authentication scheme this would have been the username.

Metamask gives us the ability to cryptographically sign some data using Digital Signatures.

We sign the data using our private key. This private key is stored in your MetaMask and nobody has access to it (except you). In a username/password authentication scheme this would be our password. We never share passwords so same here. We never share a public key.

When we sign some data we sign them with our Private Key (it only happens locally and we never share the Private Key with anybody). This signing operation produces some bytes that from now on we will call signature.

One of the signature's properties is that anybody can verify if the one who signed it is the one who claims he is. The verification does not need the Private Key.

Very simply let's say that we have 2 functions:

signature = Sign(PrivateKey, hashedData)
valid = Verify(PublicKey,  hashedData, signature)

Classic username/password Login Flow

When using username and password we perform the Authentication usually like that:

POST request to /register with body

{
"username": "bob",
"password": "1234"
}

then the server hashes the password using a password hashing function like bcrypt and saves it in the database.

and then to signin

POST request to /signin with body

{
 "username": "bob", 
"password": "1234"
}
`

When the server receives such a request it usually performs the following:

  • loads the user from the database with username bob
  • hashes the password he received using the password hashing function and it and compares it with the one it has in the database for that user
  • if all ok then we can return him a JWT token Of course our service is run over TLS and the password is not transmitted in plain text.

The server in any case receives your password. Do you trust the server owner? Are you sure that uses a proper password hashing function or even using one? Maybe, he logs the payloads he receives (and with that your password). In any case you need to trust the server/application which you probably do for using it. But what if the database with the passwords is compromised some hackers? The hackers then have access to the properly hashed (or not) passwords.

Let's see an alternative

Digital signatures authentication

POST request to /register with body

{
"publicAddress": "0x3110752149AF23Ee65968C2019b7c86D12B32229"
}

This will register a user in the database (notice there is no password).

Then let's see the login

GET /users/0x3110752149AF23Ee65968C2019b7c86D12B32229/nonce this will return a body containing a random number (if the user with address exists)

{
"nonce": "63210018627757926317526024290391413217358890723641523832149966690207267728843150165831744512767436400627528585164026452344678510"
}
`

the server will store this number in the database along with the user. We call this a nonce from the phrase number once, this means that it is supposed to used only one time

Now let's see how we login:

  • In frontend when we obtain this nonce we use our Private Key to sign a hash of that nonce. We call the Sign function that returns a signature. Then frontend makes a POST request to /signin endpoint with body:
    {
    "publicAddress": "0x3110752149AF23Ee65968C2019b7c86D12B32229",
    "signature": "LHB/Efh/BB4JyCUGDIFYp46nutMLyHvwENwd2sss",
    "nonce": "63210018627757926317526024290391413217358890723641523832149966690207267728843150165831744512767436400627528585164026452344678510"
    }
    
    (signature is base64 encoded - or however you like) .

When the server receives such a request

  • it loads the User from db with that public key (User)
  • nonce = User.nonce & userPublicKey = User.public_key
  • hashes the nonce with the same hash function of the frontend and gets hashedData
  • Uses Verify(UserPublicKey, hashedData, signature)
  • if the function returns true then we know that the owner of this public key (so our user) signed the nonce and we can authenticate him and return a valid JWT

As you see here the client/user never sents his private key and the authentication is performed using Digital Signatures "magic". We don't need to store the user's password in any form which is cool.

Let's build the above step by step

Project initialization

We are going to create a folder that will contains the files for our demo project

mkdir blog-metamask-login-tutorial
cd blog-metamask-login-tutorial

we are going to store the files required for frontend in the folder frontend and for the backend in the folder backend.

Backend

create the backend directory

mkdir backend

Let's first create a Go Module

cd backend
go mod init rest-api-metamask

create a main.go file with the following contents

package main

import (
    "log"
)

func main() {
    log.Println("Hello metamask")
}

Run it via go run main.go.

As you see nothing happens yet, we just log a message. We will come to the backend later.

Frontend

From the main directory ( blog-metamask-login-tutorial)

run vue create frontend .

Use Vue version 3 (the default) and wait until the project creation finishes.

On success you should see in your terminal something similar to:

๐ŸŽ‰  Successfully created project frontend.
๐Ÿ‘‰  Get started with the following commands:

 $ cd frontend
 $ yarn serve

Follow the above commands and make sure than afteryarn serveyou
visit localhost:8080 and that you can see Vue's welcome page


You can find the code up to here in the branch ProjectInitialization

Backend - Web server & Endpoints

In this section we are going to create the web server and the REST endpoints.

We are going to use JSON web token (JWT) authentication.

Endpoints Definition

We will define the following endpoints:

Method: POST Path: /register

Example body:

{
"publicAddress": "0x3110752149AF23Ee65968C2019b7c86D12B32229"
}

Returns StatusCode:

   - 201 Created when the user successfully created
   - 409 When the user already exists
   - 400 when the request body is invalid
   - 500 if there is a server error

Returns Body:

   - Empty

Method: GET Path: /users/:publicAddress/nonce

Returns StatusCode:

- 200 when a nonce is fetched
- 404 when the publicAddress is not registered
- 500 if there is a server error

Returns Example Body:

{
"nonce": "63210018627757926317526024290391413217358890723641523832149966690207267728843150165831744512767436400627528585164026452344678510"
}

Method: POST Path:/signin

Example body:

{
"publicAddress": "0x3110752149AF23Ee65968C2019b7c86D12B32229",
"signature": "LHB/Efh/BB4JyCUGDIFYp46nutMLyHvwENwd2sss",
"nonce": "63210018627757926317526024290391413217358890723641523832149966690207267728843150165831744512767436400627528585164026452344678510"
}

Returns StatusCode:

  • 200 when ok
  • 401 when the user is not authenticate
  • 500 if there is a server error Returns Example Body:

    Define me
    

Method: GET Path: /welcome

Returns StatusCode:

- 200 when ok
- 401 when the user is not authenticate
- 500 if there is a server error

Returns Example Body:

```
{
 "publicAddress": "0x3110752149AF23Ee65968C2019b7c86D12B32229"
}
```

Implementation

Now, let's go and build a webserver in Go with these endpoints. Since, this is a reference implementation we are going to keep all the code in the main.go file for simplicity.

We are going to use Chi as a router.

cd backend
go get go get github.com/go-chi/chi

Copy the following code in your main.go

package main

import (
    "log"
    "net/http"

    "github.com/go-chi/chi"
)

func RegisterHandler() http.HandlerFunc {
    return func(w http.ResponseWriter, r *http.Request) {
    }
}

func UserNonceHandler() http.HandlerFunc {
    return func(w http.ResponseWriter, r *http.Request) {
    }
}

func SigninHandler() http.HandlerFunc {
    return func(w http.ResponseWriter, r *http.Request) {
    }
}

func WelcomeHandler() http.HandlerFunc {
    return func(w http.ResponseWriter, r *http.Request) {
    }
}

func run() error {
    // setup the endpoints
    r := chi.NewRouter()
    r.Post("/register", RegisterHandler())
    r.Get("/users/{address:^0x[a-fA-F0-9]{40}$}/nonce", UserNonceHandler())
    r.Post("/signin", SigninHandler())
    r.Get("/welcome", WelcomeHandler())

    // start the server on port 8001
    err := http.ListenAndServe("localhost:8001", r)
    return err
}

func main() {
    if err := run(); err != nil {
        log.Fatalln(err.Error())
    }
}

Our handlers not doing anything at the moment. They are enough although to test that the web server works.

Start the program:

go run main.go

Try the following

curl -i -XPOST http://localhost:8001/register --data {}
curl -i http://localhost:8001/users/0x3110752149AF23Ee65968C2019b7c86D12B32229/nonce
curl -i -XPOST http://localhost:8001/signin --data {}
curl -i  http://localhost:8001/welcome
`

You should get a 200 status code for the above requests

You can find the code up to here on Github in branch EndpointsWebServer

Let's add some functionality in our endpoints.

Register endpoint

The user will submit his public address in order to register. If the address is already registered we will return status code 409 othewise will save this address in the database and return 201.

For the purposes of the reference implementation we are going to store everything in a memory storage. Let's first define that

Add the following in your main.go file.

var ErrUserExists = errors.New("user already exists")

type User struct {
    Address string
}

type MemStorage struct {
    lock  sync.Mutex
    users map[string]User
}

func (m *MemStorage) CreateIfNotExists(u User) error {
    m.lock.Lock()
    defer m.lock.Unlock()
    if _, exists := m.users[u.Address]; exists {
        return ErrUserExists
    }
    m.users[u.Address] = u
    return nil
}

func NewMemStorage() *MemStorage {
    ans := MemStorage{
        users: make(map[string]User),
    }
    return &ans
}

We created a User struct that will hold the user's data. For now we only have his Address. We store our users in a map with key the address of the user and value the User struct. Notice, that I am using value semantics in map[string]User.

Method CreateIfNotExists returns an ErrUserExists when the user already exists otherwise we add him to the map.

These should be sufficient for the register endpoint, so we continue with it's implementation.

Let's see the main.go after implementing the above.

package main

import (
    "encoding/json"
    "errors"
    "log"
    "net/http"
    "regexp"
    "strings"
    "sync"

    "github.com/go-chi/chi"
)

var (
    ErrUserExists     = errors.New("user already exists")
    ErrInvalidAddress = errors.New("invalid address")
)

type User struct {
    Address string
}

type MemStorage struct {
    lock  sync.Mutex
    users map[string]User
}

func (m *MemStorage) CreateIfNotExists(u User) error {
    m.lock.Lock()
    defer m.lock.Unlock()
    if _, exists := m.users[u.Address]; exists {
        return ErrUserExists
    }
    m.users[u.Address] = u
    return nil
}

func NewMemStorage() *MemStorage {
    ans := MemStorage{
        users: make(map[string]User),
    }
    return &ans
}

// ============================================================================

var hexRegex *regexp.Regexp = regexp.MustCompile(`^0x[a-fA-F0-9]{40}$`)

type RegisterPayload struct {
    Address string `json:"address"`
}

func (p RegisterPayload) Validate() error {
    if !hexRegex.MatchString(p.Address) {
        return ErrInvalidAddress
    }
    return nil
}

func RegisterHandler(storage *MemStorage) http.HandlerFunc {
    return func(w http.ResponseWriter, r *http.Request) {
        var p RegisterPayload
        if err := bindReqBody(r, &p); err != nil {
            w.WriteHeader(http.StatusBadRequest)
            return
        }
        if err := p.Validate(); err != nil {
            w.WriteHeader(http.StatusBadRequest)
            return
        }
        u := User{
            Address: strings.ToLower(p.Address), // let's only store lower case
        }
        if err := storage.CreateIfNotExists(u); err != nil {
            switch errors.Is(err, ErrUserExists) {
            case true:
                w.WriteHeader(http.StatusConflict)
            default:
                w.WriteHeader(http.StatusInternalServerError)
            }
            return
        }
        w.WriteHeader(http.StatusCreated)
    }
}

func UserNonceHandler() http.HandlerFunc {
    return func(w http.ResponseWriter, r *http.Request) {
    }
}

func SigninHandler() http.HandlerFunc {
    return func(w http.ResponseWriter, r *http.Request) {
    }
}

func WelcomeHandler() http.HandlerFunc {
    return func(w http.ResponseWriter, r *http.Request) {
    }
}

// ============================================================================

func bindReqBody(r *http.Request, obj any) error {
    return json.NewDecoder(r.Body).Decode(obj)
}

// ============================================================================

func run() error {
    // initialization of storage
    storage := NewMemStorage()

    // setup the endpoints
    r := chi.NewRouter()
    r.Post("/register", RegisterHandler(storage))
    r.Get("/users/{address:^0x[a-fA-F0-9]{40}$}/nonce", UserNonceHandler())
    r.Post("/signin", SigninHandler())
    r.Get("/welcome", WelcomeHandler())

    // start the server on port 8001
    err := http.ListenAndServe("localhost:8001", r)
    return err
}

func main() {
    if err := run(); err != nil {
        log.Fatalln(err.Error())
    }
}

Our register endpoint looks good now let's do some curl requests to verify functionality. Normally, we should write some unit tests but for the purpose of the reference implementation we skip.

Here we should see the 400 status codes

giorgos@gtp:~/Development/github.com/gosom/blog-metamask-login-tutorial/backend$ curl -i -XPOST http://localhost:8001/register 
HTTP/1.1 400 Bad Request
Date: Sun, 29 May 2022 10:47:10 GMT
Content-Length: 0

giorgos@gtp:~/Development/github.com/gosom/blog-metamask-login-tutorial/backend$ curl -i -XPOST http://localhost:8001/register --data '{}'
HTTP/1.1 400 Bad Request
Date: Sun, 29 May 2022 10:48:02 GMT
Content-Length: 0

giorgos@gtp:~/Development/github.com/gosom/blog-metamask-login-tutorial/backend$ curl -i -XPOST http://localhost:8001/register --data '{"address": "test"}'
HTTP/1.1 400 Bad Request
Date: Sun, 29 May 2022 10:49:23 GMT
Content-Length: 0

this should be 201

giorgos@gtp:~/Development/github.com/gosom/blog-metamask-login-tutorial/backend$ curl -i -XPOST http://localhost:8001/register --data '{"address": "0x3110752149AF23Ee65968C2019b7c86D12B32229"}'
HTTP/1.1 201 Created
Date: Sun, 29 May 2022 10:50:25 GMT
Content-Length: 0

And this should be 409

giorgos@gtp:~/Development/github.com/gosom/blog-metamask-login-tutorial/backend$ curl -i -XPOST http://localhost:8001/register --data '{"address": "0x3110752149AF23Ee65968C2019b7c86D12B32229"}'
HTTP/1.1 409 Conflict
Date: Sun, 29 May 2022 10:50:29 GMT
Content-Length: 0

It would be nice if we could return why the requests failed in the body. Additionally, since we would like to return JSON we should set the correct HTTP headers (same applies for the requests). We won't deal with this at the moment, maybe later we will revisit.

you can find the implementation up to here on branch RegisterEndpointImpl(github.com/gosom/blog-metamask-login-tutori..)

/users/:address/nonce endpoint

In order to login the user should get a nonce to sign. This nonce will be fetched using the /users/:address/nonce endpoint.

This endpoint will fetch the user with address from the database and then it will returns his nonce. But wait, we don't have a nonce in the database yet. We should create that when the user registers.

Let's add a Nonce field in our User struct and store this upon registration.

Add the following


var (
    max  *big.Int
    once sync.Once
)

func GetNonce() (string, error) {
    once.Do(func() {
        max = new(big.Int)
        max.Exp(big.NewInt(2), big.NewInt(130), nil).Sub(max, big.NewInt(1))
    })
    n, err := rand.Int(rand.Reader, max)
    if err != nil {
        return "", err
    }
    return n.Text(10), nil
}

This is a utility function to return a random nonce. We put a limit to the number we return. See the documentation of Exp to see how this works in detail.

The add the Nonce to the User struct.

type User struct {
    Address string
    Nonce   string
}

and modify the RegisterHandler accordingly

...
u := User{
            Address: strings.ToLower(p.Address), // let's only store lower case
            Nonce:   nonce,
        }
        if err := storage.CreateIfNotExists(u); err != nil {
...

Now we can implement our endpoint. See the updated main.go

package main

import (
    "crypto/rand"
    "encoding/json"
    "errors"
    "log"
    "math/big"
    "net/http"
    "regexp"
    "strings"
    "sync"

    "github.com/go-chi/chi"
)

var (
    ErrUserNotExists  = errors.New("user does not exist")
    ErrUserExists     = errors.New("user already exists")
    ErrInvalidAddress = errors.New("invalid address")
)

type User struct {
    Address string
    Nonce   string
}

type MemStorage struct {
    lock  sync.Mutex
    users map[string]User
}

func (m *MemStorage) CreateIfNotExists(u User) error {
    m.lock.Lock()
    defer m.lock.Unlock()
    if _, exists := m.users[u.Address]; exists {
        return ErrUserExists
    }
    m.users[u.Address] = u
    return nil
}

func (m *MemStorage) Get(address string) (User, error) {
    m.lock.Lock()
    defer m.lock.Unlock()
    u, exists := m.users[address]
    if !exists {
        return u, ErrUserNotExists
    }
    return u, nil
}

func NewMemStorage() *MemStorage {
    ans := MemStorage{
        users: make(map[string]User),
    }
    return &ans
}

// ============================================================================

var hexRegex *regexp.Regexp = regexp.MustCompile(`^0x[a-fA-F0-9]{40}$`)

type RegisterPayload struct {
    Address string `json:"address"`
}

func (p RegisterPayload) Validate() error {
    if !hexRegex.MatchString(p.Address) {
        return ErrInvalidAddress
    }
    return nil
}

func RegisterHandler(storage *MemStorage) http.HandlerFunc {
    return func(w http.ResponseWriter, r *http.Request) {
        var p RegisterPayload
        if err := bindReqBody(r, &p); err != nil {
            w.WriteHeader(http.StatusBadRequest)
            return
        }
        if err := p.Validate(); err != nil {
            w.WriteHeader(http.StatusBadRequest)
            return
        }
        nonce, err := GetNonce()
        if err != nil {
            w.WriteHeader(http.StatusInternalServerError)
            return
        }
        u := User{
            Address: strings.ToLower(p.Address), // let's only store lower case
            Nonce:   nonce,
        }
        if err := storage.CreateIfNotExists(u); err != nil {
            switch errors.Is(err, ErrUserExists) {
            case true:
                w.WriteHeader(http.StatusConflict)
            default:
                w.WriteHeader(http.StatusInternalServerError)
            }
            return
        }
        w.WriteHeader(http.StatusCreated)
    }
}

func UserNonceHandler(storage *MemStorage) http.HandlerFunc {
    return func(w http.ResponseWriter, r *http.Request) {
        address := chi.URLParam(r, "address")
        if !hexRegex.MatchString(address) {
            w.WriteHeader(http.StatusBadRequest)
            return
        }
        user, err := storage.Get(strings.ToLower(address))
        if err != nil {
            switch errors.Is(err, ErrUserNotExists) {
            case true:
                w.WriteHeader(http.StatusNotFound)
            default:
                w.WriteHeader(http.StatusInternalServerError)
            }
            return
        }
        resp := struct {
            Nonce string
        }{
            Nonce: user.Nonce,
        }
        renderJson(r, w, http.StatusOK, resp)
    }
}

func SigninHandler() http.HandlerFunc {
    return func(w http.ResponseWriter, r *http.Request) {
    }
}

func WelcomeHandler() http.HandlerFunc {
    return func(w http.ResponseWriter, r *http.Request) {
    }
}

// ============================================================================

var (
    max  *big.Int
    once sync.Once
)

func GetNonce() (string, error) {
    once.Do(func() {
        max = new(big.Int)
        max.Exp(big.NewInt(2), big.NewInt(130), nil).Sub(max, big.NewInt(1))
    })
    n, err := rand.Int(rand.Reader, max)
    if err != nil {
        return "", err
    }
    return n.Text(10), nil
}

func bindReqBody(r *http.Request, obj any) error {
    return json.NewDecoder(r.Body).Decode(obj)
}

func renderJson(r *http.Request, w http.ResponseWriter, statusCode int, res interface{}) {
    w.Header().Set("Content-Type", "application/json; charset=utf-8 ")
    var body []byte
    if res != nil {
        var err error
        body, err = json.Marshal(res)
        if err != nil { // TODO handle me better
            w.WriteHeader(http.StatusInternalServerError)
        }
    }
    w.WriteHeader(statusCode)
    if len(body) > 0 {
        w.Write(body)
    }
}

// ============================================================================

func run() error {
    // initialization of storage
    storage := NewMemStorage()

    // setup the endpoints
    r := chi.NewRouter()
    r.Post("/register", RegisterHandler(storage))
    r.Get("/users/{address:^0x[a-fA-F0-9]{40}$}/nonce", UserNonceHandler(storage))
    r.Post("/signin", SigninHandler())
    r.Get("/welcome", WelcomeHandler())

    // start the server on port 8001
    err := http.ListenAndServe("localhost:8001", r)
    return err
}

func main() {
    if err := run(); err != nil {
        log.Fatalln(err.Error())
    }
}

We added a Get method in storage and we invoke it in the handler. Additionally, we added a render function to return the response in json.

I noticed something here, we are using a Mutex which locks the critical section for read and writes. We are going to replace the Mutex with an RWMutex so we allow concurrent reads. Change the following:

type MemStorage struct {
    lock  sync.RWMutex
    users map[string]User
}

and

func (m *MemStorage) Get(address string) (User, error) {
    m.lock.RLock()
    defer m.lock.RUnlock()
    u, exists := m.users[address]
    if !exists {
        return u, ErrUserNotExists
    }
    return u, nil
}

Let's test the endpoint with curl

Register the user

giorgos@gtp:~/Development/github.com/gosom/blog-metamask-login-tutorial/backend$ curl -i -XPOST http://localhost:8001/register --data '{"address": "0x3110752149AF23Ee65968C2019b7c86D12B32229"}'
HTTP/1.1 201 Created
Date: Sun, 29 May 2022 11:41:16 GMT
Content-Length: 0

Get the nonce

giorgos@gtp:~/Development/github.com/gosom/blog-metamask-login-tutorial/backend$ curl -i -XGET http://localhost:8001/users/0x3110752149AF23Ee65968C2019b7c86D12B32229/nonce
HTTP/1.1 200 OK
Content-Type: application/json; charset=utf-8
Date: Sun, 29 May 2022 11:41:39 GMT
Content-Length: 51

{"Nonce":"684408294788786939221596093416816730231"}

Code up to here can be found on GetNonceEndpointImpl branch

We still need to implement the /signin and /welcome endpoints. I prefer although we do a little bit of frontend implementation to verify what we have so far.

Frontend - basic metamask functionality

We need to add a metamask button first that once we click the following our performed:

  • connect to metamask and fetch the user's public address
  • make a GET request to fetch the nonce
  • if we get 404 in the previous request we need to register the user and fetch the nonce again
  • Once we have the nonce we should sign in and make a POST request to the /signin endpoint.

Our server has an issue at the moment, it does not accept the OPTIONS request that the browser is doing to check if the website is allowed to do such a re Let's modify our server to accept OPTIONS requests and return the proper headers.

In our backed folder

go get github.com/go-chi/cors

Then modify the main.go and add CORS support. For this reference implementation we are very permissive and allow everything. Of course, we should not use this for a production system

In the run method add one line:

...
// setup the endpoints
    r := chi.NewRouter()

    //  Just allow all for the reference implementation
    r.Use(cors.AllowAll().Handler)

    r.Post("/register", RegisterHandler(storage))
    ...

Let's now write a basic frontend that perform the above actions.

Since we have a lot of changes from the default vue code see the changes here

Let's focus only on App.vue

In the template section

<template >
  <button @click="login">
    <img src="../assets/metamask-logo.svg"  
    width="60" height="60"
    @click="login"
    />
    <span>Login with Metamask</span>
  </button>
</template>

we just add a button which when we click it invokes the login function.

In the setup section we declare some variables that are going to be populated upon a succesfull connect to metamask (account & address)

const ethereum = window.ethereum
var account = null
var address = ethereum.selectedAddress
 var token = null

The login function

 async function login() {
    if (address === null || account === null){
      const accounts = await ethereum.request({ method: 'eth_requestAccounts' })
      account = accounts[0]
      address = ethereum.selectedAddress
    }
    const [status_code, nonce] = await get_nonce()
    if (status_code === 404) {
      const registered = await register()
      if (!registered) {
        return
      }
      await login()
    }else if (status_code != 200) {
      return
    }
    const signature = await sign(nonce)
    token = await perform_signin(signature, nonce)
  }

Here we try to fetch the nonce. if the nonce is not found then the get_nonce will give as status code of 404 as defined in our specification. In such case we register the user and try to fetch the nonce again.

When we have the nonce then we can sign the nonce and then invoke the POST /signin endpoint .

See the other functions implementation on github

(we need to implement that in our backend in order to work).

Now, let's try that. Make sure that your backend server is running and then:

cd frontend
npm i
yarn serve

Visit (localhost:8080) and click the Login with Metamask button. MetaMask's will present a popup asking you first to allow this website to access your Metamask account. Once you accept that then the website is connected to your Metamask account. We now can fetch the public address from the user in order to use it in our next requests.

Once we perform the above we make a request to get the nonce. The request will return 404 (since we don't have this address in our database) so we are going to invoke /register endpoint. Upon successful registration we then can try to login again and this time we will fetch the user's nonce. Now we can sign the user nonce and submit it to the server. If the request is successful the server would give us back our access token.

Notice, that in the backend we don't have an implementation of the Signin process. This is the next step we are going to do.

The code up to here is on branch BasicFrontend

Backend - Signin endpoint & JWT tokens

In the section above I forgot to add the signature to the POST body, let's add it on frontend/src/components/MetaMaskLogin.vue .

Additionally in frontend I have a bug in the recursion in login. I am also coding it while writing so expect some mistakes.

Let's do the changes in our frontend first.

--- a/frontend/src/components/MetaMaskLogin.vue
+++ b/frontend/src/components/MetaMaskLogin.vue
@@ -13,13 +13,10 @@
   import { Buffer } from 'buffer'
   const ethereum = window.ethereum
   var account = null
-  var address = ethereum.selectedAddress
+  var address = null
   var token = null

-  console.log(token)
-
   async function login() {
-    console.log("EDW")
     if (address === null || account === null){
       const accounts = await ethereum.request({ method: 'eth_requestAccounts' })
       account = accounts[0]
@@ -32,6 +29,7 @@
         return
       }
       await login()
+      return
     }else if (status_code != 200) {
       return
     }
@@ -84,7 +82,8 @@
       headers: {"Content-Type": "application/json"},
       body: JSON.stringify({ 
         address: address, 
-        nonce: nonce
+        nonce: nonce,
+        sig: sig,
         })
     }
     const response = await fetch("http://localhost:8001/signin", reqOpts)
  async function perform_signin(sig, nonce) {
    const reqOpts = {
      method: "POST",
      headers: {"Content-Type": "application/json"},
      body: JSON.stringify({ 
        address: address, 
        nonce: nonce,
        sig: sig,
        })
    }
    const response = await fetch("http://localhost:8001/signin", reqOpts)
    if (response.status === 200) {
      const data = await response.json()
      return data
    }
    return null
  }

First let's define the payload struct in our backend now and add complete the SiginHandler

type SigninPayload struct {
    Address string `json:"address"`
    Nonce   string `json:"nonce"`
    Sig     string `json:"sig"`
}

func (s SigninPayload) Validate() error {
    if !hexRegex.MatchString(s.Address) {
        return ErrInvalidAddress
    }
    if !nonceRegex.MatchString(s.Nonce) {
        return ErrInvalidNonce
    }
    if len(s.Sig) == 0 {
        return ErrMissingSig
    }
    return nil
}

func SigninHandler(storage *MemStorage) http.HandlerFunc {
    return func(w http.ResponseWriter, r *http.Request) {
        var p SigninPayload
        if err := bindReqBody(r, &p); err != nil {
            w.WriteHeader(http.StatusBadRequest)
            return
        }
        if err := p.Validate(); err != nil {
            w.WriteHeader(http.StatusBadRequest)
            return
        }
        address := strings.ToLower(p.Address)
        _, err := Authenticate(storage, address, p.Nonce, p.Sig)
        switch err {
        case nil:
        case ErrAuthError:
            w.WriteHeader(http.StatusUnauthorized)
            return
        default:
            w.WriteHeader(http.StatusInternalServerError)
            return
        }
        resp := struct {
        }{}
        renderJson(r, w, http.StatusOK, resp)
    }
}

We POST an address and a nonce and the signature and all of them are strings. For now let's return an empty struct, later we are going to return a valid JWT token.

Now install the following library which will help us to verify the signatures.

go get github.com/ethereum/go-ethereum/crypto

After a successful login we need to update the nonce for the user in the storage. Create a method that updates the user

func (m *MemStorage) Update(user User) error {
    m.lock.Lock()
    defer m.lock.Unlock()
    m.users[user.Address] = user
    return nil
}

Then let's write the Authenticate function now. This function will accept the data that the user submitted and will return our user if the request can be authenticated.

func Authenticate(storage *MemStorage, address string, nonce string, sigHex string) (User, error) {
    // first we get the user from the address provided
    user, err := storage.Get(address)
    // if  the user does not exist or there is an error return
    if err != nil {
        return user, err
    }
    // we need to match the nonces for a request to be valid. Just to make sure that the 
   // signed the correct nonce
    if user.Nonce != nonce {
        return user, ErrAuthError
    }

   // decode the provided signature into bytes
    sig := hexutil.MustDecode(sigHex)
    // https://github.com/ethereum/go-ethereum/blob/master/internal/ethapi/api.go#L516
    // check here why I am subtracting 27 from the last byte
    // I spent a lot of time to figure out that
    sig[crypto.RecoveryIDOffset] -= 27
    // now hash the nonce
    msg := accounts.TextHash([]byte(nonce))
    // recover the public key that signed that data
    recovered, err := crypto.SigToPub(msg, sig)
    if err != nil {
        return user, err
    }
    // create an ethereum address from the extracted public key
    recoveredAddr := crypto.PubkeyToAddress(*recovered)
    // and then make sure that it's the same as the one we have in our database
    if user.Address != strings.ToLower(recoveredAddr.Hex()) {
        return user, ErrAuthError
    }

   // update the nonce here so that the signature cannot be resused
    nonce, err = GetNonce()
    if err != nil {
        return user, err
    }
    user.Nonce = nonce
    storage.Update(user)

    return user, nil
}

Check the above comments for the steps needed to verify the signature.

If we now start our backend and frontend we should be able to sign a signature in the frontend and verify it in the backend. You can verify that by checking the Network in your browser Development tools.

Code up to this point is on branch SigninBackendEndpoint

In the next we need to return a valid JWT token and allow access to the /welcome endpoint only to authenticated users.

JWT Token and securing the endpoint for authenticated only users

First install a go package to help us with JWT

go get -u github.com/golang-jwt/jwt/v4

Then let's create a struct to handle JWT signing and verification.

type JwtHmacProvider struct {
    hmacSecret []byte
    issuer     string
    duration   time.Duration
}

func NewJwtHmacProvider(hmacSecret string, issuer string, duration time.Duration) *JwtHmacProvider {
    ans := JwtHmacProvider{
        hmacSecret: []byte(hmacSecret),
        issuer:     issuer,
        duration:   duration,
    }
    return &ans
}

func (j *JwtHmacProvider) CreateStandard(subject string) (string, error) {
    now := time.Now()
    claims := jwt.RegisteredClaims{
        Issuer:    j.issuer,
        Subject:   subject,
        IssuedAt:  jwt.NewNumericDate(now),
        ExpiresAt: jwt.NewNumericDate(now.Add(j.duration)),
    }
    token := jwt.NewWithClaims(jwt.SigningMethodHS256, claims)
    return token.SignedString(j.hmacSecret)
}

func (j *JwtHmacProvider) Verify(tokenString string) (*jwt.RegisteredClaims, error) {
    token, err := jwt.ParseWithClaims(tokenString, &jwt.RegisteredClaims{}, func(token *jwt.Token) (interface{}, error) {
        if _, ok := token.Method.(*jwt.SigningMethodHMAC); !ok {
            return nil, fmt.Errorf("Unexpected signing method: %v", token.Header["alg"])
        }
        return j.hmacSecret, nil
    })
    if err != nil {
        return nil, ErrAuthError
    }
    if claims, ok := token.Claims.(*jwt.RegisteredClaims); ok && token.Valid {
        return claims, nil
    }
    return nil, ErrAuthError
}

Modify the SigninHandler to use that

func SigninHandler(storage *MemStorage, jwtProvider *JwtHmacProvider) http.HandlerFunc {
    return func(w http.ResponseWriter, r *http.Request) {
        var p SigninPayload
        if err := bindReqBody(r, &p); err != nil {
            w.WriteHeader(http.StatusBadRequest)
            return
        }
        if err := p.Validate(); err != nil {
            w.WriteHeader(http.StatusBadRequest)
            return
        }
        address := strings.ToLower(p.Address)
        user, err := Authenticate(storage, address, p.Nonce, p.Sig)
        switch err {
        case nil:
        case ErrAuthError:
            w.WriteHeader(http.StatusUnauthorized)
            return
        default:
            w.WriteHeader(http.StatusInternalServerError)
            return
        }
        signedToken, err := jwtProvider.CreateStandard(user.Address)
        if err != nil {
            w.WriteHeader(http.StatusInternalServerError)
            return
        }
        resp := struct {
            AccessToken string `json:"access"`
        }{
            AccessToken: signedToken,
        }
        renderJson(r, w, http.StatusOK, resp)
    }
}

Create a middleware to verify the JWT token and add it to run before the /welocme


func AuthMiddleware(storage *MemStorage, jwtProvider *JwtHmacProvider) func(next http.Handler) http.Handler {
    return func(next http.Handler) http.Handler {
        return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
            headerValue := r.Header.Get("Authorization")
            const prefix = "Bearer "
            if len(headerValue) < len(prefix) {
                w.WriteHeader(http.StatusUnauthorized)
                return
            }
            tokenString := headerValue[len(prefix):]
            if len(tokenString) == 0 {
                w.WriteHeader(http.StatusUnauthorized)
                return
            }

            claims, err := jwtProvider.Verify(tokenString)
            if err != nil {
                w.WriteHeader(http.StatusUnauthorized)
                return
            }

            user, err := storage.Get(claims.Subject)
            if err != nil {
                if errors.Is(err, ErrUserNotExists) {
                    w.WriteHeader(http.StatusUnauthorized)
                    return
                }
                w.WriteHeader(http.StatusInternalServerError)
                return
            }

            ctx := context.WithValue(r.Context(), "user", user)
            next.ServeHTTP(w, r.WithContext(ctx))

        })
    }
}

and where we change the routes and run the middleware just before of each invocation of the /welcome

r.Group(func(r chi.Router) {
        r.Use(AuthMiddleware(storage, jwtProvider))
        r.Get("/welcome", WelcomeHandler())
    })

And here is the welcome handler

func WelcomeHandler() http.HandlerFunc {
    return func(w http.ResponseWriter, r *http.Request) {
        user := getUserFromReqContext(r)
        resp := struct {
            Msg string `json:"msg"`
        }{
            Msg: "Congrats " + user.Address + " you made it",
        }
        renderJson(r, w, http.StatusOK, resp)
    }
}

// ============================================================================

and a small utility function

func getUserFromReqContext(r *http.Request) User {
    ctx := r.Context()
    key := ctx.Value("user").(User)
    return key
}

Our backend now it's ready. Some final changes in the frontend. Here is a git diff which you can apply

diff --git a/frontend/src/components/MetaMaskLogin.vue b/frontend/src/components/MetaMaskLogin.vue
index cb94eaf..2595cc4 100644
--- a/frontend/src/components/MetaMaskLogin.vue
+++ b/frontend/src/components/MetaMaskLogin.vue
@@ -1,22 +1,29 @@
 <template >
-  <button @click="login">
+  <button v-if="!loggedIn" @click="login">
     <img src="../assets/metamask-logo.svg"  
     width="60" height="60"
     @click="login"
     />
     <span>Login with Metamask</span>
   </button>
+  <div v-else>
+    <div>jwt token: {{ token }} </div>
+    <button @click="get_welcome">click to get welcome</button>
+    <div>{{ (welcome != null) ? welcome.msg : "" }}</div>
+  </div>
 </template>


 <script setup>
   import { Buffer } from 'buffer'
+  import { ref} from 'vue'
   const ethereum = window.ethereum
-  var account = null
-  var address = null
-  var token = null
+  let account = null
+  let address = null
+  let token = ref(null)
+  let welcome = ref(null)
+  let loggedIn = ref(false)

-  console.log(token)

   async function login() {
     if (address === null || account === null){
@@ -37,7 +44,23 @@
     }

     const signature = await sign(nonce)
-    token = await perform_signin(signature, nonce)
+    const data = await perform_signin(signature, nonce)
+    token.value = data.access
+    loggedIn.value = true
+  }
+
+  async function get_welcome() {
+    const reqOpts = {
+      method: "GET",
+      headers: {"Content-Type": "application/json",
+                "Authorization": "Bearer " + token.value},
+    }
+    const response = await fetch("http://localhost:8001/welcome", reqOpts)
+    if (response.status === 200) {
+      welcome.value = await response.json()
+    }else {
+      console.log(response.status)
+    }
   }

   async function get_nonce() {

You can now start backend using go run main.go and the frontend using yarn serve

Open your browser . Click the Login with Metamask button and follow the flow.

Summary

In this tutorial we build a reference implementation of a Golang webserver that is capable of verifying the user's identity by using Digital Signature Authentication .

We also coded a basic frontend to demonstrate how Login using Metamask can be implemented.

All the code is available on github.

References

ethereum.org/en/developers/docs/accounts

oreilly.com/library/view/mastering-ethereum..

docs.metamask.io/guide/ethereum-provider.html

goethereumbook.org/en/signatures

go-chi.io/#

pkg.go.dev/github.com/golang-jwt/jwt#sectio..

Disclaimer I am not an expert in cryptography and the process I am describing here is my understanding of how things work. Use this only as a reference implementation and do your own research.

P.S If you have any questions or discover issues feel free to contact me or make a Pull Request. Finally, I would like to ask you to share this article if you find it useful.

ย