From f4d6efde933830038441c8fd7481fcc08e8095ca Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Carlos=20S=C3=A1nchez?= Date: Thu, 15 Feb 2024 19:59:43 +0100 Subject: [PATCH 1/6] front-end SIWE --- packages/nextjs/app/page.tsx | 5 ++++ packages/nextjs/components/SIWE.tsx | 43 +++++++++++++++++++++++++++++ 2 files changed, 48 insertions(+) create mode 100644 packages/nextjs/components/SIWE.tsx diff --git a/packages/nextjs/app/page.tsx b/packages/nextjs/app/page.tsx index bd11693..a1481e0 100644 --- a/packages/nextjs/app/page.tsx +++ b/packages/nextjs/app/page.tsx @@ -1,3 +1,4 @@ +import SIWE from "~~/components/SIWE"; import { listCollections } from "~~/services/database/collections"; // revalidate the data at most every hour (just testing) @@ -21,6 +22,10 @@ const Home = async () => {
  • {collection.id}
  • ))} +

    SIWE:

    +
    + +
    diff --git a/packages/nextjs/components/SIWE.tsx b/packages/nextjs/components/SIWE.tsx new file mode 100644 index 0000000..8b350ab --- /dev/null +++ b/packages/nextjs/components/SIWE.tsx @@ -0,0 +1,43 @@ +"use client"; + +import * as React from "react"; +import { useAccount, useNetwork, useSignMessage } from "wagmi"; +import { notification } from "~~/utils/scaffold-eth"; + +// ToDo. Nonce. +const SIWE = () => { + const { isConnected, address } = useAccount(); + const { chain } = useNetwork(); + const { signMessageAsync } = useSignMessage(); + + const signIn = async () => { + try { + const chainId = chain?.id; + if (!address || !chainId) return; + + const signature = await signMessageAsync({ message: `I want to sign in to grants.buidlguidl.com as ${address}` }); + + // Verify signature + const verifyRes = await fetch("/api/auth/siwe", { + method: "POST", + headers: { + "Content-Type": "application/json", + }, + body: JSON.stringify({ signature }), + }); + if (!verifyRes.ok) throw new Error("Error verifying message"); + } catch (error) { + notification.error("Error signing in"); + } + }; + + return ( +
    + +
    + ); +}; + +export default SIWE; From 498bf04ec7b5f3e6cbd8ece4eca053705efdeaf6 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Carlos=20S=C3=A1nchez?= Date: Thu, 15 Feb 2024 20:20:29 +0100 Subject: [PATCH 2/6] Emit JWT token on backend --- packages/nextjs/.env.example | 3 +++ packages/nextjs/app/api/auth/siwe/route.tsx | 23 +++++++++++++++++++++ packages/nextjs/components/SIWE.tsx | 2 +- packages/nextjs/package.json | 2 ++ yarn.lock | 6 ++++-- 5 files changed, 33 insertions(+), 3 deletions(-) create mode 100644 packages/nextjs/app/api/auth/siwe/route.tsx diff --git a/packages/nextjs/.env.example b/packages/nextjs/.env.example index e41580b..33d1cfb 100644 --- a/packages/nextjs/.env.example +++ b/packages/nextjs/.env.example @@ -15,3 +15,6 @@ FIRESTORE_EMULATOR_HOST=localhost:8080 # Setup you project id for firebase FIREBASE_PROJECT_ID="buidlguidl-v3" + +# JWT secret for signing and verifying tokens +JWT_SECRET="your_secret_here" diff --git a/packages/nextjs/app/api/auth/siwe/route.tsx b/packages/nextjs/app/api/auth/siwe/route.tsx new file mode 100644 index 0000000..408509f --- /dev/null +++ b/packages/nextjs/app/api/auth/siwe/route.tsx @@ -0,0 +1,23 @@ +import jwt from "jsonwebtoken"; +import { verifyMessage } from "viem"; + +// import { findUserByAddress } from "~~/services/database/users"; + +// ToDo. Only for admins? +export async function POST(request: Request) { + const { signature, address } = await request.json(); + if (!process.env.JWT_SECRET) return new Response("Internal Server Error: JWT", { status: 500 }); + if (!signature || !address) return new Response("Bad Request", { status: 400 }); + + const signedMessage = `I want to sign in to grants.buidlguidl.com as ${address}`; + const isMessageValid = await verifyMessage({ message: signedMessage, signature, address }); + + if (!isMessageValid) return new Response("Unauthorized", { status: 401 }); + + // Create a JWT token, with process.env.JWT_SECRET as the secret, and expires in 1 week + const token = jwt.sign({ address }, process.env.JWT_SECRET, { expiresIn: "1w" }); + console.log("token", token); + // const user = await findUserByAddress(signature); + // if (!user) return new Response("Unauthorized", { status: 401 }); + return new Response(token, { status: 200 }); +} diff --git a/packages/nextjs/components/SIWE.tsx b/packages/nextjs/components/SIWE.tsx index 8b350ab..921af35 100644 --- a/packages/nextjs/components/SIWE.tsx +++ b/packages/nextjs/components/SIWE.tsx @@ -23,7 +23,7 @@ const SIWE = () => { headers: { "Content-Type": "application/json", }, - body: JSON.stringify({ signature }), + body: JSON.stringify({ signature, address }), }); if (!verifyRes.ok) throw new Error("Error verifying message"); } catch (error) { diff --git a/packages/nextjs/package.json b/packages/nextjs/package.json index 77df898..0d019c0 100644 --- a/packages/nextjs/package.json +++ b/packages/nextjs/package.json @@ -22,6 +22,7 @@ "blo": "^1.0.1", "daisyui": "4.5.0", "firebase-admin": "^11.11.1", + "jsonwebtoken": "^9.0.2", "next": "^14.0.4", "next-themes": "^0.2.1", "nprogress": "^0.2.0", @@ -38,6 +39,7 @@ }, "devDependencies": { "@trivago/prettier-plugin-sort-imports": "^4.1.1", + "@types/jsonwebtoken": "^9", "@types/node": "^17.0.35", "@types/nprogress": "^0", "@types/react": "^18.0.9", diff --git a/yarn.lock b/yarn.lock index c29da88..24c8001 100644 --- a/yarn.lock +++ b/yarn.lock @@ -2301,6 +2301,7 @@ __metadata: "@heroicons/react": ^2.0.11 "@rainbow-me/rainbowkit": 1.3.5 "@trivago/prettier-plugin-sort-imports": ^4.1.1 + "@types/jsonwebtoken": ^9 "@types/node": ^17.0.35 "@types/nprogress": ^0 "@types/react": ^18.0.9 @@ -2317,6 +2318,7 @@ __metadata: eslint-config-prettier: ^8.5.0 eslint-plugin-prettier: ^4.2.1 firebase-admin: ^11.11.1 + jsonwebtoken: ^9.0.2 next: ^14.0.4 next-themes: ^0.2.1 nprogress: ^0.2.0 @@ -2994,7 +2996,7 @@ __metadata: languageName: node linkType: hard -"@types/jsonwebtoken@npm:^9.0.2": +"@types/jsonwebtoken@npm:^9, @types/jsonwebtoken@npm:^9.0.2": version: 9.0.5 resolution: "@types/jsonwebtoken@npm:9.0.5" dependencies: @@ -10315,7 +10317,7 @@ __metadata: languageName: node linkType: hard -"jsonwebtoken@npm:^9.0.0": +"jsonwebtoken@npm:^9.0.0, jsonwebtoken@npm:^9.0.2": version: 9.0.2 resolution: "jsonwebtoken@npm:9.0.2" dependencies: From 67a5ff9eb63d7cbbd1aad4343ef7cd9da2239f72 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Carlos=20S=C3=A1nchez?= Date: Thu, 15 Feb 2024 20:28:24 +0100 Subject: [PATCH 3/6] SIWE + JWT end to end working --- packages/nextjs/app/api/auth/siwe/route.tsx | 9 +++---- packages/nextjs/components/SIWE.tsx | 27 +++++++++++++++------ 2 files changed, 23 insertions(+), 13 deletions(-) diff --git a/packages/nextjs/app/api/auth/siwe/route.tsx b/packages/nextjs/app/api/auth/siwe/route.tsx index 408509f..af27bbd 100644 --- a/packages/nextjs/app/api/auth/siwe/route.tsx +++ b/packages/nextjs/app/api/auth/siwe/route.tsx @@ -14,10 +14,9 @@ export async function POST(request: Request) { if (!isMessageValid) return new Response("Unauthorized", { status: 401 }); - // Create a JWT token, with process.env.JWT_SECRET as the secret, and expires in 1 week const token = jwt.sign({ address }, process.env.JWT_SECRET, { expiresIn: "1w" }); - console.log("token", token); - // const user = await findUserByAddress(signature); - // if (!user) return new Response("Unauthorized", { status: 401 }); - return new Response(token, { status: 200 }); + + return new Response(JSON.stringify({ token }), { + headers: { "Content-Type": "application/json" }, + }); } diff --git a/packages/nextjs/components/SIWE.tsx b/packages/nextjs/components/SIWE.tsx index 921af35..3d12cf2 100644 --- a/packages/nextjs/components/SIWE.tsx +++ b/packages/nextjs/components/SIWE.tsx @@ -1,20 +1,22 @@ "use client"; import * as React from "react"; -import { useAccount, useNetwork, useSignMessage } from "wagmi"; +import { useLocalStorage } from "usehooks-ts"; +import { useAccount, useSignMessage } from "wagmi"; import { notification } from "~~/utils/scaffold-eth"; // ToDo. Nonce. +// ToDo. Check if expired? const SIWE = () => { const { isConnected, address } = useAccount(); - const { chain } = useNetwork(); + const [jwt, setJwt] = useLocalStorage("jwt", "", { + initializeWithValue: false, + }); + const { signMessageAsync } = useSignMessage(); const signIn = async () => { try { - const chainId = chain?.id; - if (!address || !chainId) return; - const signature = await signMessageAsync({ message: `I want to sign in to grants.buidlguidl.com as ${address}` }); // Verify signature @@ -26,6 +28,9 @@ const SIWE = () => { body: JSON.stringify({ signature, address }), }); if (!verifyRes.ok) throw new Error("Error verifying message"); + const { token } = await verifyRes.json(); + setJwt(token); + notification.success("Signed in successfully"); } catch (error) { notification.error("Error signing in"); } @@ -33,9 +38,15 @@ const SIWE = () => { return (
    - + {jwt ? ( +
    +

    Already signed in!!

    +
    + ) : ( + + )}
    ); }; From 02a96e392784491ebe08b3d3bae6946f8c42cc42 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Carlos=20S=C3=A1nchez?= Date: Fri, 16 Feb 2024 10:09:23 +0100 Subject: [PATCH 4/6] Admin page structure --- packages/nextjs/app/admin/page.tsx | 12 ++++++ packages/nextjs/components/Header.tsx | 53 ++++++++++++++++++++++++++- packages/nextjs/components/SIWE.tsx | 3 +- 3 files changed, 66 insertions(+), 2 deletions(-) create mode 100644 packages/nextjs/app/admin/page.tsx diff --git a/packages/nextjs/app/admin/page.tsx b/packages/nextjs/app/admin/page.tsx new file mode 100644 index 0000000..70f727c --- /dev/null +++ b/packages/nextjs/app/admin/page.tsx @@ -0,0 +1,12 @@ +import SIWE from "~~/components/SIWE"; + +const Home = () => { + return ( +
    +

    Admin page

    + +
    + ); +}; + +export default Home; diff --git a/packages/nextjs/components/Header.tsx b/packages/nextjs/components/Header.tsx index e4770b1..b927560 100644 --- a/packages/nextjs/components/Header.tsx +++ b/packages/nextjs/components/Header.tsx @@ -1,7 +1,54 @@ import React from "react"; import Image from "next/image"; import Link from "next/link"; +import { usePathname } from "next/navigation"; import { RainbowKitCustomConnectButton } from "./scaffold-eth"; +import { LockClosedIcon } from "@heroicons/react/24/outline"; + +type HeaderMenuLink = { + label: string; + href: string; + icon?: React.ReactNode; +}; + +export const menuLinks: HeaderMenuLink[] = [ + { + label: "Home", + href: "/", + }, + // ToDo. Show only on admins + { + label: "Admin", + href: "/admin", + icon: , + }, +]; + +export const HeaderMenuLinks = () => { + const pathname = usePathname(); + + return ( + <> + {menuLinks.map(({ label, href, icon }) => { + const isActive = pathname === href; + return ( +
  • + + {icon} + {label} + +
  • + ); + })} + + ); +}; /** * Site header @@ -9,13 +56,17 @@ import { RainbowKitCustomConnectButton } from "./scaffold-eth"; export const Header = () => { return (
    -
    +
    SE2 logo
    +
      + +
    +
    diff --git a/packages/nextjs/components/SIWE.tsx b/packages/nextjs/components/SIWE.tsx index 3d12cf2..192a7b2 100644 --- a/packages/nextjs/components/SIWE.tsx +++ b/packages/nextjs/components/SIWE.tsx @@ -5,7 +5,8 @@ import { useLocalStorage } from "usehooks-ts"; import { useAccount, useSignMessage } from "wagmi"; import { notification } from "~~/utils/scaffold-eth"; -// ToDo. Nonce. +// ToDo. Connect wallet tooltip if disabled +// ToDo. Nonce? // ToDo. Check if expired? const SIWE = () => { const { isConnected, address } = useAccount(); From 899e2826308375f9b82a0255da98241df0616ee6 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Carlos=20S=C3=A1nchez?= Date: Fri, 16 Feb 2024 10:27:53 +0100 Subject: [PATCH 5/6] JWT only for admins --- packages/nextjs/app/api/auth/siwe/route.tsx | 7 ++++--- 1 file changed, 4 insertions(+), 3 deletions(-) diff --git a/packages/nextjs/app/api/auth/siwe/route.tsx b/packages/nextjs/app/api/auth/siwe/route.tsx index af27bbd..ebfd408 100644 --- a/packages/nextjs/app/api/auth/siwe/route.tsx +++ b/packages/nextjs/app/api/auth/siwe/route.tsx @@ -1,14 +1,15 @@ import jwt from "jsonwebtoken"; import { verifyMessage } from "viem"; +import { findUserByAddress } from "~~/services/database/users"; -// import { findUserByAddress } from "~~/services/database/users"; - -// ToDo. Only for admins? export async function POST(request: Request) { const { signature, address } = await request.json(); if (!process.env.JWT_SECRET) return new Response("Internal Server Error: JWT", { status: 500 }); if (!signature || !address) return new Response("Bad Request", { status: 400 }); + const user = await findUserByAddress(address); + if (!user.exists || user.data?.role !== "admin") return new Response("Unauthorized", { status: 401 }); + const signedMessage = `I want to sign in to grants.buidlguidl.com as ${address}`; const isMessageValid = await verifyMessage({ message: signedMessage, signature, address }); From d3fdeb4bf0ded35b5bd6c44da96062ef665ce220 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Carlos=20S=C3=A1nchez?= Date: Fri, 16 Feb 2024 11:10:02 +0100 Subject: [PATCH 6/6] Admin page with admin data --- packages/nextjs/app/admin/page.tsx | 48 +++++++++++++++++++- packages/nextjs/app/api/auth/siwe/route.tsx | 5 +- packages/nextjs/app/api/grants/all/route.tsx | 22 +++++++++ packages/nextjs/app/page.tsx | 3 -- packages/nextjs/components/SIWE.tsx | 2 +- 5 files changed, 71 insertions(+), 9 deletions(-) create mode 100644 packages/nextjs/app/api/grants/all/route.tsx diff --git a/packages/nextjs/app/admin/page.tsx b/packages/nextjs/app/admin/page.tsx index 70f727c..f353de1 100644 --- a/packages/nextjs/app/admin/page.tsx +++ b/packages/nextjs/app/admin/page.tsx @@ -1,12 +1,56 @@ +"use client"; + +import { useEffect, useState } from "react"; +import { useReadLocalStorage } from "usehooks-ts"; +import { useAccount } from "wagmi"; import SIWE from "~~/components/SIWE"; +import { GrantData } from "~~/services/database/schema"; +import { notification } from "~~/utils/scaffold-eth"; + +const AdminPage = () => { + const [grants, setGrants] = useState([]); + const { isConnected } = useAccount(); + const jwt = useReadLocalStorage("jwt"); + + // In use effect, make get request (with JWT) to /api/grants/all + useEffect(() => { + const getGrants = async () => { + try { + const response = await fetch("/api/grants/all", { + headers: { + Authorization: `Bearer ${jwt}`, + }, + }); + const grants: GrantData[] = (await response.json()).data; + setGrants(grants); + } catch (error) { + notification.error("Error getting grants"); + } + }; + + if (jwt) getGrants(); + }, [jwt]); -const Home = () => { return (

    Admin page

    +

    Admin data:

    + {!isConnected || !jwt ? ( +

    Connect & authenticate to see admin data

    + ) : ( + <> +

    All grants:

    + {grants.map(grant => ( +
    +

    {grant.title}

    +

    {grant.description}

    +
    + ))} + + )}
    ); }; -export default Home; +export default AdminPage; diff --git a/packages/nextjs/app/api/auth/siwe/route.tsx b/packages/nextjs/app/api/auth/siwe/route.tsx index ebfd408..a0464e6 100644 --- a/packages/nextjs/app/api/auth/siwe/route.tsx +++ b/packages/nextjs/app/api/auth/siwe/route.tsx @@ -1,3 +1,4 @@ +import { NextResponse } from "next/server"; import jwt from "jsonwebtoken"; import { verifyMessage } from "viem"; import { findUserByAddress } from "~~/services/database/users"; @@ -17,7 +18,5 @@ export async function POST(request: Request) { const token = jwt.sign({ address }, process.env.JWT_SECRET, { expiresIn: "1w" }); - return new Response(JSON.stringify({ token }), { - headers: { "Content-Type": "application/json" }, - }); + return NextResponse.json({ token }); } diff --git a/packages/nextjs/app/api/grants/all/route.tsx b/packages/nextjs/app/api/grants/all/route.tsx new file mode 100644 index 0000000..a3df800 --- /dev/null +++ b/packages/nextjs/app/api/grants/all/route.tsx @@ -0,0 +1,22 @@ +import { NextResponse } from "next/server"; +import jwt from "jsonwebtoken"; +import { getAllGrants } from "~~/services/database/grants"; + +export async function GET(request: Request) { + // ToDo. We probably want to use a middleware for this. + const authHeader = request.headers.get("Authorization"); + if (!authHeader || !authHeader.startsWith("Bearer ")) { + return new Response("Unauthorized", { status: 401 }); + } + + const token = authHeader.split(" ")[1]; + try { + jwt.verify(token, process.env.JWT_SECRET || ""); + } catch (error) { + return new Response("Unauthorized", { status: 401 }); + } + + const grants = await getAllGrants(); + + return NextResponse.json({ data: grants }); +} diff --git a/packages/nextjs/app/page.tsx b/packages/nextjs/app/page.tsx index a03730d..4a3cab9 100644 --- a/packages/nextjs/app/page.tsx +++ b/packages/nextjs/app/page.tsx @@ -1,7 +1,6 @@ import { CompletedGrants } from "./_components/CompletedGrants"; import { GrantsStats } from "./_components/GrantsStats"; import { HomepageHero } from "./_components/HomepageHero"; -import SIWE from "~~/components/SIWE"; const Home = () => { return ( @@ -9,8 +8,6 @@ const Home = () => { -

    SIWE:

    - ); }; diff --git a/packages/nextjs/components/SIWE.tsx b/packages/nextjs/components/SIWE.tsx index 192a7b2..cae8006 100644 --- a/packages/nextjs/components/SIWE.tsx +++ b/packages/nextjs/components/SIWE.tsx @@ -5,7 +5,7 @@ import { useLocalStorage } from "usehooks-ts"; import { useAccount, useSignMessage } from "wagmi"; import { notification } from "~~/utils/scaffold-eth"; -// ToDo. Connect wallet tooltip if disabled +// ToDo. "Connect wallet" info tooltip if disabled // ToDo. Nonce? // ToDo. Check if expired? const SIWE = () => {