A Step-by-Step Guide to Authenticating Users in Your DApp with 'Sign in with Ethereum'

Written by deeppatel | Published 2023/01/05
Tech Story Tags: solidity | web3-writing-contest | web3 | nextjs | blockchain | dapps | ethereum | programming

TLDRA Dapp which connects to your backend maintains a session and connects with the Database. We are going to use the NextUI and React Hooks libraries. We will be using the code from the GitHub/Deep11 repo. Let’s get started with it.via the TL;DR App

In an ideal scenario, A Dapp should not have any centralized backend server, everything should be on-chain.

Then why Backend?

  1. With a fully on-chain approach, we need to (at least right now) compromise on user experience
  2. Not everything needs to be on a chain, it just doesn’t make sense. Wouldn’t will it be great if we can make our apps sufficiently decentralized?
    There is a greatArticle By Varun Srinivasan on Sufficient Decentralization which everyone should give read
  3. Better scalability: A backend can help a Dapp scale more easily by handling tasks that might otherwise slow down the blockchain or consume a large amount of gas.
  4. Off-chain computation: Some tasks, such as image or video processing, can be resource-intensive and may not be practical to perform on the blockchain. A backend can handle these tasks and communicate with the Dapp as needed.

For this or any reason you might need to have the backend connected to your Dapp, but how? let’s get started with it

So, what are we building Deep? 👀

A Dapp which connects to your backend maintains a session and connects with the Database.

Watch a demonstration here.

What is SIWE (Sign-In with Ethereum)?

Sign-In with Ethereum describes how Ethereum accounts to authenticate with off-chain services by signing a standard message format parameterized by scope, session details, and security mechanisms (e.g., a nonce). This specification aims to provide a self-custodied alternative to centralized identity providers, improve interoperability across off-chain services for Ethereum-based authentication, and provide wallet vendors with a consistent machine-readable message format to achieve enhanced user experiences and consent management.

More details can be found here.

Here are some steps we will be following

  1. Connect wallet
  2. Sign SIWE message with the nonce generated by the backend
  3. Verify the submitted SIWE message and signature via the POST request
  4. Add validated SIWE fields to the session (via JWT, cookie, etc.)
  5. Store user details(eg: name) in MongoDB
  6. Update user information
  7. Maintaining sessions b/w page refreshes

Tools We are going to use:

  1. NextUI — Component library, of course, I hate CSS and I’m not going to write any of it
  2. Wagmi — React Hooks for Ethereum
  3. Iron Session

Clone the code from the GitHub repo, to follow through

Step1: Let’s create some API routes first

under pages/api create a file nonce.ts with the following content

pages/api/nonce.ts

import { withIronSessionApiRoute } from 'iron-session/next'
import { sessionOptions } from 'lib/session'
import { NextApiRequest, NextApiResponse } from 'next'
import { generateNonce } from 'siwe'

const handler = async (req: NextApiRequest, res: NextApiResponse) => {
  const { method } = req
  switch (method) {
    case 'GET':
      req.session.nonce = generateNonce()
      await req.session.save()
      res.setHeader('Content-Type', 'text/plain')
      res.send(req.session.nonce)
      break
    default:
      res.setHeader('Allow', ['GET'])
      res.status(405).end(`Method ${method} Not Allowed`)
  }
}

export default withIronSessionApiRoute(handler, sessionOptions)

Next, add an API route to verify a SIWE message and make the user session.

pages/api/verify.ts

import { handleLoginOrSignup } from 'core/services/user.service'
import { withIronSessionApiRoute } from 'iron-session/next'
import dbConnect from 'lib/dbConnect'
import { sessionOptions } from 'lib/session'
import { NextApiRequest, NextApiResponse } from 'next'
import { SiweMessage } from 'siwe'

const handler = async (req: NextApiRequest, res: NextApiResponse) => {
    const { method } = req
    switch (method) {
        case 'POST':
            try {
                await dbConnect();
                const { message, signature } = req.body
                const siweMessage = new SiweMessage(message)
                const fields = await siweMessage.validate(signature)

                if (fields.nonce !== req.session.nonce) {
                    return res.status(422).json({ message: 'Invalid nonce.' })
                }

                req.session.siwe = fields;
		// maintaining users details in MongoDB from below line
	        // we will get backto this later ignore for now
                const user = await handleLoginOrSignup(fields.address);
                req.session.user = user;
                await req.session.save()
                res.json({ ok: true })
            } catch (_error) {
                console.log('error -> verify', _error)
                res.json({ ok: false, error: _error })
            }
            break
        default:
            res.setHeader('Allow', ['POST'])
            res.status(405).end(`Method ${method} Not Allowed`)
    }
}

export default withIronSessionApiRoute(handler, sessionOptions)
const { message, signature } = req.body
const siweMessage = new SiweMessage(message)
const fields = await siweMessage.validate(signature)

We will be passing a message and signature done by the user's wallet from the Frontend, on the API side we are verifying it and the nonce, nonce will prevent the replay attacks.

our iron session config file is going to look something like

// this file is a wrapper with defaults to be used in both API routes and `getServerSideProps` functions
import type { IronSessionOptions } from "iron-session";
import { IUser } from "models/User";

export const sessionOptions: IronSessionOptions = {
  password: 'passowrd_cookie',
  cookieName: "iron-session/examples/next.js",
  cookieOptions: {
    secure: process.env.NODE_ENV === "production",
  },
};

// This is where we specify the typings of req.session.*
declare module "iron-session" {
  interface IronSessionData {
    siwe: any;
    nonce: any;
    user: IUser | undefined; // users details from DB
  }
}

Now, let’s create an API for checking user’s sessions and returning users details if a user is already logged in pages/api/me.ts Note: findUserByAddress is a helper method that is getting user's details from MongoDB, you can check out the code from GitHub, to explore this helper method

import { findUserByAddress } from 'core/services/user.service'
import { ReasonPhrases, StatusCodes } from 'http-status-codes'
import { withIronSessionApiRoute } from 'iron-session/next'
import { sessionOptions } from 'lib/session'
import { NextApiRequest, NextApiResponse } from 'next'

const handler = async (req: NextApiRequest, res: NextApiResponse) => {
  const { method } = req
  switch (method) {
    case 'GET':
      const address = req.session.siwe?.address
      if (address) {
        const user = await findUserByAddress(address)
        res.json({ address: req.session.siwe?.address, user })
        return
      }
      res.status(StatusCodes.UNAUTHORIZED).json({
        message: ReasonPhrases.UNAUTHORIZED,
      })
      break
    default:
      res.setHeader('Allow', ['GET'])
      res.status(405).end(`Method ${method} Not Allowed`)
  }
}

export default withIronSessionApiRoute(handler, sessionOptions)

And lastly logout route, where we are destroying the session

import { withIronSessionApiRoute } from 'iron-session/next'
import { sessionOptions } from 'lib/session'
import { NextApiRequest, NextApiResponse } from 'next'

const handler = async (req: NextApiRequest, res: NextApiResponse) => {
  const { method } = req
  switch (method) {
    case 'POST':
      await req.session.destroy()
      res.send({ ok: true })
      break
    default:
      res.setHeader('Allow', ['POST'])
      res.status(405).end(`Method ${method} Not Allowed`)
  }
}

export default withIronSessionApiRoute(handler, sessionOptions)

Now, let’s create a state file for maintaining all user data and related methods we are going to use context API for this

import { IUser } from 'models/User'
import React, { createContext, useContext, useState } from 'react'
import { SiweMessage } from 'siwe'
import { Connector, useAccount, useConnect } from 'wagmi'
import axios from 'axios'
import { useRouter } from 'next/router'
import { toast } from 'react-toastify'

export interface IUserState {
  user: IUser | undefined
  loadingUser: boolean
  setUser: React.Dispatch<React.SetStateAction<undefined>>
  handleSignOut: () => void
  handleSignIn: (connector: Connector) => Promise<void>
}

const UserContext = createContext<IUserState>({
  user: undefined,
  setUser: () => {},
  loadingUser: false,
  handleSignOut: () => {},
  handleSignIn: async () => {},
})

export function UserState({ children }: { children: JSX.Element }) {
  const router = useRouter()
  const [user, setUser] = useState(undefined)
  const [, connect] = useConnect()
  const [loadingUser, setLoadingUser] = useState(false)
  const [, disconnect] = useAccount({
    fetchEns: true,
  })

  const handleSignOut = async () => {
    disconnect()
    await axios.post('/api/logout')
    setUser(undefined)
    router.replace('/')
  }

  const handleSignIn = async (connector: Connector) => {
    try {
      const res = await connect(connector) // connect from useConnect
      if (!res.data) throw res.error ?? new Error('Something went wrong')

      setLoadingUser(true)
      const nonceRes = await axios('/api/nonce')
      const message = new SiweMessage({
        domain: window.location.host,
        address: res.data.account,
        statement: 'Sign in with Ethereum to the app.',
        uri: window.location.origin,
        version: '1',
        chainId: res.data.chain?.id,
        nonce: nonceRes.data,
      })

      const signer = await connector.getSigner()
      const signature = await signer.signMessage(message.prepareMessage())
      // console.log('message', message, { signature })
      await axios.post('/api/verify', {
        message,
        signature,
      })
      const me = await axios('/api/me')
      setUser(me.data.user)
      // It worked! User is signed in with Ethereum
    } catch (error) {
      // Do something with the error
      toast.error('Something went wrong!')
      handleSignOut()
      console.log('error', error)
    } finally {
      setLoadingUser(false)
    }
  }

  return (
    <UserContext.Provider
      value={{ user, setUser, handleSignOut, handleSignIn, loadingUser }}
    >
      {children}
    </UserContext.Provider>
  )
}

export function useUserContext() {
  return useContext(UserContext)
}


Breaking downhandleSignIn method

const res = await connect(connector) // connect from useConnect
if (!res.data) throw res.error ?? new Error('Something went wrong')

we are connected to the wallet here

Once the user is connected we generate a random nonce using the API we created earlier

const nonceRes = await axios('/api/nonce')
      const message = new SiweMessage({
        domain: window.location.host,
        address: res.data.account, // users waller address
        statement: 'Sign in with Ethereum to the app.',
        uri: window.location.origin,
        version: '1',
        chainId: res.data.chain?.id,
        nonce: nonceRes.data,
      })

const signer = await connector.getSigner()
const signature = await signer.signMessage(message.prepareMessage())

then we ask the user to sign a message, using SiweMessage constructor exposed by SIWE and creates a signature

await axios.post('/api/verify', {
        message,
        signature,
      })
const me = await axios('/api/me')
setUser(me.data.user)

Verifying users, and getting logged in user’s data using me API we created earlier, then set the user's data to state

Now let’s make the API for updating user details in the Database,

import withAuth from 'core/middleware/withAuth'
import { findUserById, updateUser } from 'core/services/user.service'
import { ReasonPhrases, StatusCodes } from 'http-status-codes'
import { withIronSessionApiRoute } from 'iron-session/next'
import { sessionOptions } from 'lib/session'
import { isValidObjectId } from 'mongoose'
import { NextApiRequest, NextApiResponse } from 'next'

const handler = async (req: NextApiRequest, res: NextApiResponse) => {
    const { method, query } = req
    const queryId = query.id;

    if (!queryId || !isValidObjectId(queryId)) {
        res.status(StatusCodes.BAD_REQUEST).json({
            message: "Valid id is required"
        })
        return;
    }
    switch (method) {
        case 'PUT':
            return await handlePatchUser();
        default:
            res.setHeader('Allow', ['PUT'])
            res.status(405).end(`Method ${method} Not Allowed`)
    };

    async function handlePatchUser() {
        if (req.session.user?._id !== queryId) {
            res.status(StatusCodes.FORBIDDEN).json({
                message: ReasonPhrases.FORBIDDEN
            });
            return;
        }
        const user = await updateUser(queryId, req.body);
        if (!user) {
            res.status(StatusCodes.BAD_REQUEST).json({
                message: "User not found with requested id"
            })
        }
        res.json({
            user
        })
    }
}

export default withIronSessionApiRoute(withAuth(handler), sessionOptions)

Notice we are using withAuth here it is a middleware we have created, so only authorized users can access our API routes Link to code

Now let’s create a profile in pages/profile.ts

import type { NextPage } from 'next'
import Head from 'next/head'
import { BaseLayout } from 'components/ui/Layout/BaseLayout'
import { ComponentWithLayout } from '../_app'
import { useFormik } from 'formik'
import axios from 'axios'
import { useUserContext } from 'core/state/user.state'
import { useState } from 'react'
import { Button, Loading } from '@nextui-org/react'
import { toast } from 'react-toastify'

const Profile: NextPage = () => {
  const { user } = useUserContext()
  const [loading, setLoading] = useState(false)
  const formik = useFormik({
    enableReinitialize: true,
    initialValues: {
      ...user,
    },
    onSubmit: async (values) => {
      try {
        setLoading(true)
        await axios.put(`/api/users/${user?._id}`, values)
        toast.success('Data saved successfully')
      } catch (error: any) {
        toast.error(error.message)
      } finally {
        setLoading(false)
      }
    },
  })

  return (
    <div className="flex flex-col items-center justify-center py-2">
      <Head>
        <title>Profile</title>
        <link rel="icon" href="/favicon.ico" />
      </Head>
      <section className="bg-blueGray-100 rounded-b-10xl">
        <div className="container mx-auto px-4">
          <div className="-mx-4 flex flex-wrap">
            <div className="w-full px-4">
              <div className="mx-auto max-w-xl rounded-xl bg-white py-14 px-8 md:px-20 md:pt-16 md:pb-20">
                <h3 className="font-heading mb-12 text-4xl font-medium">
                  Profile Details
                </h3>
                <input
                  className="placeholder-darkBlueGray-400 mb-5 w-full rounded-xl border px-12 py-5 text-xl focus:bottom-2 focus:outline-none focus:ring-2 focus:ring-blue-500 focus:ring-opacity-50"
                  type="text"
                  placeholder="Your Name"
                  name="name"
                  onChange={formik.handleChange}
                  value={formik.values.name}
                />

                <div className="text-right">
                  <Button
                    clickable={!loading}
                    color="primary"
                    className="inline-block w-full  text-center text-xl font-medium tracking-tighter md:w-auto"
                    onClick={formik.submitForm}
                    size="lg"
                    icon={
                      loading && (
                        <Loading type="spinner" color="white" size="md" />
                      )
                    }
                  >
                    Save
                  </Button>
                </div>
              </div>
            </div>
          </div>
        </div>
      </section>
    </div>
  )
}

export default Profile
;(Profile as ComponentWithLayout).Layout = BaseLayout

And that’s a wrap!

Also published here.


Written by deeppatel | Full stack & Blockchain developer
Published by HackerNoon on 2023/01/05