In an ideal scenario, A Dapp should not have any centralized backend server, everything should be on-chain.
For this or any reason you might need to have the backend connected to your Dapp, but how? let’s get started with it
A Dapp which connects to your backend maintains a session and connects with the Database.
Watch a demonstration here.
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
Tools We are going to use:
Clone the code from the
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.