From 4860e8eb0c7e0d4f7ff0431e0ef8dbdf7ed7d9f4 Mon Sep 17 00:00:00 2001 From: Harman-singh-waraich Date: Mon, 27 May 2024 22:59:10 +0530 Subject: [PATCH 1/6] feat(web): add-siwe-authentication-for-file-uploads-and-notification-settings --- web/.gitignore | 1 + web/netlify.toml | 14 + web/netlify/functions/authUser.ts | 112 +++++++ web/netlify/functions/fetch-settings.ts | 33 ++ web/netlify/functions/getNonce.ts | 50 +++ web/netlify/functions/update-settings.ts | 90 ++++++ web/netlify/functions/uploadToIPFS.ts | 16 +- web/netlify/middleware/authMiddleware.ts | 39 +++ web/package.json | 6 +- web/scripts/generateBuildInfo.sh | 6 + web/scripts/runEnv.sh | 3 +- .../ActionButton/Modal/ChallengeItemModal.tsx | 79 ++--- .../ActionButton/Modal/RemoveModal.tsx | 79 ++--- web/src/components/EnsureAuth.tsx | 94 ++++++ web/src/hooks/queries/useUserSettings.tsx | 35 +++ web/src/hooks/useSessionStorage.ts | 22 ++ .../FormContactDetails/FormContact.tsx | 6 +- .../FormContactDetails/index.tsx | 71 +++-- .../Menu/Settings/Notifications/index.tsx | 10 +- web/src/pages/SubmitItem/index.tsx | 25 +- web/src/pages/SubmitList/index.tsx | 39 ++- web/src/types/supabase-datalake.ts | 296 ++++++++++++++++++ web/src/types/supabase-notification.ts | 242 ++++++++++++++ web/src/utils/authoriseUser.ts | 58 ++++ web/src/utils/uploadFileToIPFS.ts | 5 + web/src/utils/uploadSettingsToSupabase.ts | 6 + yarn.lock | 103 +++++- 27 files changed, 1398 insertions(+), 142 deletions(-) create mode 100644 web/netlify.toml create mode 100644 web/netlify/functions/authUser.ts create mode 100644 web/netlify/functions/fetch-settings.ts create mode 100644 web/netlify/functions/getNonce.ts create mode 100644 web/netlify/functions/update-settings.ts create mode 100644 web/netlify/middleware/authMiddleware.ts create mode 100755 web/scripts/generateBuildInfo.sh create mode 100644 web/src/components/EnsureAuth.tsx create mode 100644 web/src/hooks/queries/useUserSettings.tsx create mode 100644 web/src/hooks/useSessionStorage.ts create mode 100644 web/src/types/supabase-datalake.ts create mode 100644 web/src/types/supabase-notification.ts create mode 100644 web/src/utils/authoriseUser.ts diff --git a/web/.gitignore b/web/.gitignore index c9c64e8..1c23ba3 100644 --- a/web/.gitignore +++ b/web/.gitignore @@ -30,6 +30,7 @@ parcel-bundle-reports src/hooks/contracts src/graphql generatedGitInfo.json +generatedNetlifyInfo.json # logs npm-debug.log* diff --git a/web/netlify.toml b/web/netlify.toml new file mode 100644 index 0000000..4bcdd19 --- /dev/null +++ b/web/netlify.toml @@ -0,0 +1,14 @@ +## Yarn 3 cache does not work out of the box as of Jan 2022. Context: +## https://github.com/netlify/build/issues/1535#issuecomment-1021947989 +[build.environment] +NETLIFY_USE_YARN = "true" +NETLIFY_YARN_WORKSPACES = "true" +YARN_ENABLE_GLOBAL_CACHE = "true" +# YARN_CACHE_FOLDER = "$HOME/.yarn_cache" +# YARN_VERSION = "3.2.0" + +[functions] +directory = "web/netlify/functions/" + +[dev] +framework = "parcel" \ No newline at end of file diff --git a/web/netlify/functions/authUser.ts b/web/netlify/functions/authUser.ts new file mode 100644 index 0000000..c6faeea --- /dev/null +++ b/web/netlify/functions/authUser.ts @@ -0,0 +1,112 @@ +import middy from "@middy/core"; +import jsonBodyParser from "@middy/http-json-body-parser"; +import { createClient } from "@supabase/supabase-js"; +import * as jwt from "jose"; +import { SiweMessage } from "siwe"; + +import { DEFAULT_CHAIN } from "consts/chains"; +import { ETH_SIGNATURE_REGEX } from "consts/index"; + +import { netlifyUri, netlifyDeployUri, netlifyDeployPrimeUri } from "src/generatedNetlifyInfo.json"; +import { Database } from "src/types/supabase-notification"; + +const authUser = async (event) => { + try { + if (!event.body) { + throw new Error("No body provided"); + } + + const signature = event?.body?.signature; + if (!signature) { + throw new Error("Missing key : signature"); + } + + if (!ETH_SIGNATURE_REGEX.test(signature)) { + throw new Error("Invalid signature"); + } + + const message = event?.body?.message; + if (!message) { + throw new Error("Missing key : message"); + } + + const address = event?.body?.address; + if (!address) { + throw new Error("Missing key : address"); + } + + const siweMessage = new SiweMessage(message); + + if ( + !( + (netlifyUri && netlifyUri === siweMessage.uri) || + (netlifyDeployUri && netlifyDeployUri === siweMessage.uri) || + (netlifyDeployPrimeUri && netlifyDeployPrimeUri === siweMessage.uri) + ) + ) { + console.debug( + `Invalid URI: expected one of [${netlifyUri} ${netlifyDeployUri} ${netlifyDeployPrimeUri}] but got ${siweMessage.uri}` + ); + throw new Error(`Invalid URI`); + } + + if (siweMessage.chainId !== DEFAULT_CHAIN) { + console.debug(`Invalid chain ID: expected ${DEFAULT_CHAIN} but got ${siweMessage.chainId}`); + throw new Error(`Invalid chain ID`); + } + + const lowerCaseAddress = siweMessage.address.toLowerCase(); + if (lowerCaseAddress !== address.toLowerCase()) { + throw new Error("Address mismatch in provided address and message"); + } + + const supabase = createClient(process.env.SUPABASE_URL!, process.env.SUPABASE_CLIENT_API_KEY!); + + // get nonce from db, if its null that means it was already used + const { error: nonceError, data: nonceData } = await supabase + .from("user-nonce") + .select("nonce") + .eq("address", lowerCaseAddress) + .single(); + + if (nonceError || !nonceData?.nonce) { + throw new Error("Unable to fetch nonce from DB"); + } + + try { + await siweMessage.verify({ signature, nonce: nonceData.nonce, time: new Date().toISOString() }); + } catch (err) { + throw new Error("Invalid signer"); + } + + const { error } = await supabase.from("user-nonce").delete().match({ address: lowerCaseAddress }); + + if (error) { + throw new Error("Error updating nonce in DB"); + } + + const issuer = process.env.JWT_ISSUER ?? "Kleros"; // ex :- Kleros + const audience = process.env.JWT_AUDIENCE ?? "Curate"; // ex :- Court, Curate, Escrow + const authExp = process.env.JWT_EXP_TIME ?? "2h"; + const secret = process.env.JWT_SECRET; + + if (!secret) { + throw new Error("Secret not set in environment"); + } + // user verified, generate auth token + const encodedSecret = new TextEncoder().encode(secret); + + const token = await new jwt.SignJWT({ id: address.toLowerCase() }) + .setProtectedHeader({ alg: "HS256" }) + .setIssuer(issuer) + .setAudience(audience) + .setExpirationTime(authExp) + .sign(encodedSecret); + + return { statusCode: 200, body: JSON.stringify({ message: "User authorised", token }) }; + } catch (err) { + return { statusCode: 500, body: JSON.stringify({ message: `${err}` }) }; + } +}; + +export const handler = middy(authUser).use(jsonBodyParser()); diff --git a/web/netlify/functions/fetch-settings.ts b/web/netlify/functions/fetch-settings.ts new file mode 100644 index 0000000..73a4d04 --- /dev/null +++ b/web/netlify/functions/fetch-settings.ts @@ -0,0 +1,33 @@ +import middy from "@middy/core"; +import { createClient } from "@supabase/supabase-js"; + +import { Database } from "../../src/types/supabase-notification"; +import { authMiddleware } from "../middleware/authMiddleware"; + +const fetchSettings = async (event) => { + try { + const address = event.auth.id; + const lowerCaseAddress = address.toLowerCase() as `0x${string}`; + + const supabase = createClient(process.env.SUPABASE_URL!, process.env.SUPABASE_CLIENT_API_KEY!); + + const { error, data } = await supabase + .from("user-settings") + .select("address, email, telegram") + .eq("address", lowerCaseAddress) + .single(); + + if (!data) { + return { statusCode: 404, message: `Error : User not found` }; + } + + if (error) { + throw error; + } + return { statusCode: 200, body: JSON.stringify({ data }) }; + } catch (err) { + return { statusCode: 500, message: `Error ${err?.message ?? err}` }; + } +}; + +export const handler = middy(fetchSettings).use(authMiddleware()); diff --git a/web/netlify/functions/getNonce.ts b/web/netlify/functions/getNonce.ts new file mode 100644 index 0000000..b3020b2 --- /dev/null +++ b/web/netlify/functions/getNonce.ts @@ -0,0 +1,50 @@ +import middy from "@middy/core"; +import { createClient } from "@supabase/supabase-js"; +import { generateNonce } from "siwe"; + +import { ETH_ADDRESS_REGEX } from "src/consts"; + +import { Database } from "../../src/types/supabase-notification"; + +const getNonce = async (event) => { + try { + const { queryStringParameters } = event; + + if (!queryStringParameters?.address) { + return { + statusCode: 400, + body: JSON.stringify({ message: "Invalid query parameters" }), + }; + } + + const { address } = queryStringParameters; + + if (!ETH_ADDRESS_REGEX.test(address)) { + throw new Error("Invalid Ethereum address format"); + } + + const lowerCaseAddress = address.toLowerCase() as `0x${string}`; + + const supabase = createClient(process.env.SUPABASE_URL!, process.env.SUPABASE_CLIENT_API_KEY!); + + // generate nonce and save in db + const nonce = generateNonce(); + + const { error } = await supabase + .from("user-nonce") + .upsert({ address: lowerCaseAddress, nonce: nonce }) + .eq("address", lowerCaseAddress); + + if (error) { + throw error; + } + + return { statusCode: 200, body: JSON.stringify({ nonce }) }; + } catch (err) { + console.log(err); + + return { statusCode: 500, message: `Error ${err?.message ?? err}` }; + } +}; + +export const handler = middy(getNonce); diff --git a/web/netlify/functions/update-settings.ts b/web/netlify/functions/update-settings.ts new file mode 100644 index 0000000..16d8fdd --- /dev/null +++ b/web/netlify/functions/update-settings.ts @@ -0,0 +1,90 @@ +import middy from "@middy/core"; +import jsonBodyParser from "@middy/http-json-body-parser"; +import { createClient } from "@supabase/supabase-js"; + +import { EMAIL_REGEX, TELEGRAM_REGEX, ETH_ADDRESS_REGEX } from "../../src/consts/index"; +import { Database } from "../../src/types/supabase-notification"; +import { authMiddleware } from "../middleware/authMiddleware"; + +type NotificationSettings = { + email?: string; + telegram?: string; + address: `0x${string}`; +}; + +const validate = (input: any): NotificationSettings => { + const requiredKeys: (keyof NotificationSettings)[] = ["address"]; + const optionalKeys: (keyof NotificationSettings)[] = ["email", "telegram"]; + const receivedKeys = Object.keys(input); + + for (const key of requiredKeys) { + if (!receivedKeys.includes(key)) { + throw new Error(`Missing key: ${key}`); + } + } + + const allExpectedKeys = [...requiredKeys, ...optionalKeys]; + for (const key of receivedKeys) { + if (!allExpectedKeys.includes(key as keyof NotificationSettings)) { + throw new Error(`Unexpected key: ${key}`); + } + } + + const email = input.email ? input.email.trim() : ""; + if (email && !EMAIL_REGEX.test(email)) { + throw new Error("Invalid email format"); + } + + const telegram = input.telegram ? input.telegram.trim() : ""; + if (telegram && !TELEGRAM_REGEX.test(telegram)) { + throw new Error("Invalid Telegram username format"); + } + + if (!ETH_ADDRESS_REGEX.test(input.address)) { + throw new Error("Invalid Ethereum address format"); + } + + return { + email: input.email?.trim(), + telegram: input.telegram?.trim(), + address: input.address.trim().toLowerCase(), + }; +}; + +const updateSettings = async (event) => { + try { + if (!event.body) { + throw new Error("No body provided"); + } + + const { email, telegram, address } = validate(event.body); + const lowerCaseAddress = address.toLowerCase() as `0x${string}`; + + // Prevent using someone else's token + if (event?.auth?.id.toLowerCase() !== lowerCaseAddress) { + throw new Error("Unauthorised user"); + } + const supabase = createClient(process.env.SUPABASE_URL!, process.env.SUPABASE_CLIENT_API_KEY!); + + // If the message is empty, delete the user record + if (email === "" && telegram === "") { + const { error } = await supabase.from("user-settings").delete().match({ address: lowerCaseAddress }); + if (error) throw error; + return { statusCode: 200, body: JSON.stringify({ message: "Record deleted successfully." }) }; + } + + // For a user matching this address, upsert the user record + const { error } = await supabase + .from("user-settings") + .upsert({ address: lowerCaseAddress, email: email, telegram: telegram }) + .match({ address: lowerCaseAddress }); + if (error) { + throw error; + } + return { statusCode: 200, body: JSON.stringify({ message: "Record updated successfully." }) }; + } catch (err) { + return { statusCode: 500, body: JSON.stringify({ message: `${err}` }) }; + } +}; + +export const handler = middy(updateSettings).use(jsonBodyParser()).use(authMiddleware()); diff --git a/web/netlify/functions/uploadToIPFS.ts b/web/netlify/functions/uploadToIPFS.ts index a2ba53c..d6f409c 100644 --- a/web/netlify/functions/uploadToIPFS.ts +++ b/web/netlify/functions/uploadToIPFS.ts @@ -1,7 +1,8 @@ -import { Handler } from "@netlify/functions"; import { File, FilebaseClient } from "@filebase/client"; import amqp, { Connection } from "amqplib"; import busboy from "busboy"; +import middy from "@middy/core"; +import { authMiddleware } from "../middleware/authMiddleware"; const { FILEBASE_TOKEN, RABBITMQ_URL, FILEBASE_API_WRAPPER } = process.env; const filebase = new FilebaseClient({ token: FILEBASE_TOKEN ?? "" }); @@ -65,7 +66,7 @@ const pinToFilebase = async (data: FormData, dapp: string, operation: string): P return cids; }; -export const handler: Handler = async (event) => { +export const uploadToIpfs = async (event) => { const { queryStringParameters } = event; if ( @@ -80,14 +81,7 @@ export const handler: Handler = async (event) => { }; } - const { dapp, key, operation } = queryStringParameters; - - if (key !== FILEBASE_API_WRAPPER) { - return { - statusCode: 403, - body: JSON.stringify({ message: "Invalid API key" }), - }; - } + const { dapp, operation } = queryStringParameters; try { const parsed = await parseMultipart(event); @@ -107,3 +101,5 @@ export const handler: Handler = async (event) => { }; } }; + +export const handler = middy(uploadToIpfs).use(authMiddleware()); diff --git a/web/netlify/middleware/authMiddleware.ts b/web/netlify/middleware/authMiddleware.ts new file mode 100644 index 0000000..3ce8c4a --- /dev/null +++ b/web/netlify/middleware/authMiddleware.ts @@ -0,0 +1,39 @@ +import * as jwt from "jose"; + +export const authMiddleware = () => { + return { + before: async (request) => { + const { event } = request; + + const authToken = event?.headers?.["x-auth-token"]; + if (!authToken) { + return { + statusCode: 400, + body: JSON.stringify({ message: `Error : Missing x-auth-token in Header}` }), + }; + } + + try { + const issuer = process.env.JWT_ISSUER ?? "Kleros"; // ex :- Kleros + const audience = process.env.JWT_AUDIENCE ?? "Curate"; // ex :- Court, Curate, Escrow + const secret = process.env.JWT_SECRET; + + if (!secret) { + throw new Error("Secret not set in environment"); + } + + const encodedSecret = new TextEncoder().encode(secret); + + const { payload } = await jwt.jwtVerify(authToken, encodedSecret, { issuer, audience }); + + // add auth details to event + request.event.auth = payload; + } catch (err) { + return { + statusCode: 401, + body: JSON.stringify({ message: `Error : ${err?.message ?? "Not Authorised"}` }), + }; + } + }, + }; +}; diff --git a/web/package.json b/web/package.json index a95da2f..3442022 100644 --- a/web/package.json +++ b/web/package.json @@ -35,7 +35,7 @@ "build-local": "scripts/runEnv.sh local 'yarn generate && parcel build'", "build-devnet": "scripts/runEnv.sh devnet 'yarn generate && parcel build'", "build-testnet": "scripts/runEnv.sh testnet 'yarn generate && parcel build'", - "build-netlify": "scripts/runEnv.sh devnet 'node scripts/gitInfo.js && yarn generate && parcel build'", + "build-netlify": "scripts/runEnv.sh devnet 'scripts/generateBuildInfo.sh && yarn generate && parcel build'", "check-style": "eslint 'src/**/*.{js,jsx,ts,tsx}'", "check-types": "tsc --noEmit", "generate": "yarn generate:gql && yarn generate:hooks", @@ -73,6 +73,8 @@ "dependencies": { "@filebase/client": "^0.0.5", "@kleros/ui-components-library": "^2.11.1", + "@middy/core": "^5.3.5", + "@middy/http-json-body-parser": "^5.3.5", "@sentry/react": "^7.93.0", "@sentry/tracing": "^7.93.0", "@supabase/supabase-js": "^2.39.3", @@ -87,6 +89,7 @@ "ethers": "^5.7.2", "graphql": "^16.8.1", "graphql-request": "~6.1.0", + "jose": "^5.3.0", "moment": "^2.30.1", "overlayscrollbars": "^2.4.6", "overlayscrollbars-react": "^0.5.3", @@ -103,6 +106,7 @@ "react-scripts": "^5.0.1", "react-toastify": "^9.1.3", "react-use": "^17.4.3", + "siwe": "^2.3.2", "styled-components": "^5.3.11", "viem": "^1.21.4", "wagmi": "^1.4.13" diff --git a/web/scripts/generateBuildInfo.sh b/web/scripts/generateBuildInfo.sh new file mode 100755 index 0000000..2d2b7cc --- /dev/null +++ b/web/scripts/generateBuildInfo.sh @@ -0,0 +1,6 @@ +#!/usr/bin/env bash + +SCRIPT_DIR="$( cd "$( dirname "${BASH_SOURCE[0]}" )" >/dev/null 2>&1 && pwd )" + +jq -n --arg primeUri "$DEPLOY_PRIME_URL" --arg uri "$URL" --arg deployUri "$DEPLOY_URL" '{ netlifyDeployPrimeUri: $primeUri, netlifyUri: $uri, netlifyDeployUri: $deployUri }' > src/generatedNetlifyInfo.json +node $SCRIPT_DIR/gitInfo.js \ No newline at end of file diff --git a/web/scripts/runEnv.sh b/web/scripts/runEnv.sh index 144031c..2bd1488 100755 --- a/web/scripts/runEnv.sh +++ b/web/scripts/runEnv.sh @@ -17,10 +17,11 @@ if [[ ! " ${valid_deployments[@]} " =~ " ${deployment} " ]]; then exit 1 fi -node $SCRIPT_DIR/gitInfo.js envFile="$SCRIPT_DIR/../.env.${deployment}" [ -f "$envFile.public" ] && . $envFile.public [ -f "$envFile" ] && . $envFile +$SCRIPT_DIR/generateBuildInfo.sh + eval "$commands" diff --git a/web/src/components/ActionButton/Modal/ChallengeItemModal.tsx b/web/src/components/ActionButton/Modal/ChallengeItemModal.tsx index 9a9b794..92bdcab 100644 --- a/web/src/components/ActionButton/Modal/ChallengeItemModal.tsx +++ b/web/src/components/ActionButton/Modal/ChallengeItemModal.tsx @@ -17,6 +17,7 @@ import { IBaseModal } from "."; import EvidenceUpload, { Evidence } from "./EvidenceUpload"; import { uploadFileToIPFS } from "utils/uploadFileToIPFS"; import Modal from "components/Modal"; +import { EnsureAuth } from "components/EnsureAuth"; const ReStyledModal = styled(Modal)` gap: 32px; @@ -112,44 +113,46 @@ const ChallengeItemModal: React.FC = ({ - { - setIsChallengingItem(true); - - const evidenceFile = new File([JSON.stringify(evidence)], "evidence.json", { - type: "application/json", - }); - - uploadFileToIPFS(evidenceFile) - .then(async (res) => { - if (res.status === 200 && walletClient) { - const response = await res.json(); - const fileURI = response["cids"][0]; - - const { request } = await prepareWriteCurateV2({ - //@ts-ignore - address: registryAddress, - functionName: "challengeRequest", - args: [itemId as `0x${string}`, fileURI], - value: depositRequired, - }); - - wrapWithToast(async () => await walletClient.writeContract(request), publicClient) - .then((res) => { - console.log({ res }); - refetch(); - toggleModal(); - }) - .finally(() => setIsChallengingItem(false)); - } - }) - .catch((err) => console.log(err)); - }} - /> + + { + setIsChallengingItem(true); + + const evidenceFile = new File([JSON.stringify(evidence)], "evidence.json", { + type: "application/json", + }); + + uploadFileToIPFS(evidenceFile) + .then(async (res) => { + if (res.status === 200 && walletClient) { + const response = await res.json(); + const fileURI = response["cids"][0]; + + const { request } = await prepareWriteCurateV2({ + //@ts-ignore + address: registryAddress, + functionName: "challengeRequest", + args: [itemId as `0x${string}`, fileURI], + value: depositRequired, + }); + + wrapWithToast(async () => await walletClient.writeContract(request), publicClient) + .then((res) => { + console.log({ res }); + refetch(); + toggleModal(); + }) + .finally(() => setIsChallengingItem(false)); + } + }) + .catch((err) => console.log(err)); + }} + /> + ); }; diff --git a/web/src/components/ActionButton/Modal/RemoveModal.tsx b/web/src/components/ActionButton/Modal/RemoveModal.tsx index 42a1407..73e9f10 100644 --- a/web/src/components/ActionButton/Modal/RemoveModal.tsx +++ b/web/src/components/ActionButton/Modal/RemoveModal.tsx @@ -16,6 +16,7 @@ import { wrapWithToast } from "utils/wrapWithToast"; import EvidenceUpload, { Evidence } from "./EvidenceUpload"; import { uploadFileToIPFS } from "utils/uploadFileToIPFS"; import Modal from "components/Modal"; +import { EnsureAuth } from "components/EnsureAuth"; const ReStyledModal = styled(Modal)` gap: 32px; @@ -88,44 +89,46 @@ const RemoveModal: React.FC = ({ toggleModal, isItem, registryAddr - { - setIsRemovingItem(true); - - const evidenceFile = new File([JSON.stringify(evidence)], "evidence.json", { - type: "application/json", - }); - - uploadFileToIPFS(evidenceFile) - .then(async (res) => { - if (res.status === 200 && walletClient) { - const response = await res.json(); - const fileURI = response["cids"][0]; - - const { request } = await prepareWriteCurateV2({ - //@ts-ignore - address: registryAddress, - functionName: "removeItem", - args: [itemId as `0x${string}`, fileURI], - value: depositRequired, - }); - - wrapWithToast(async () => await walletClient.writeContract(request), publicClient) - .then((res) => { - console.log({ res }); - refetch(); - toggleModal(); - }) - .finally(() => setIsRemovingItem(false)); - } - }) - .catch((err) => console.log(err)); - }} - /> + + { + setIsRemovingItem(true); + + const evidenceFile = new File([JSON.stringify(evidence)], "evidence.json", { + type: "application/json", + }); + + uploadFileToIPFS(evidenceFile) + .then(async (res) => { + if (res.status === 200 && walletClient) { + const response = await res.json(); + const fileURI = response["cids"][0]; + + const { request } = await prepareWriteCurateV2({ + //@ts-ignore + address: registryAddress, + functionName: "removeItem", + args: [itemId as `0x${string}`, fileURI], + value: depositRequired, + }); + + wrapWithToast(async () => await walletClient.writeContract(request), publicClient) + .then((res) => { + console.log({ res }); + refetch(); + toggleModal(); + }) + .finally(() => setIsRemovingItem(false)); + } + }) + .catch((err) => console.log(err)); + }} + /> + ); }; diff --git a/web/src/components/EnsureAuth.tsx b/web/src/components/EnsureAuth.tsx new file mode 100644 index 0000000..d7db10d --- /dev/null +++ b/web/src/components/EnsureAuth.tsx @@ -0,0 +1,94 @@ +import React, { useMemo, useState } from "react"; + +import * as jwt from "jose"; +import { SiweMessage } from "siwe"; +import { useAccount, useNetwork, useSignMessage } from "wagmi"; + +import { Button } from "@kleros/ui-components-library"; + +import { DEFAULT_CHAIN } from "consts/chains"; +import { useSessionStorage } from "hooks/useSessionStorage"; +import { authoriseUser, getNonce } from "utils/authoriseUser"; + +interface IEnsureAuth { + children: React.ReactElement; + className?: string; +} + +export const EnsureAuth: React.FC = ({ children, className }) => { + const localToken = window.sessionStorage.getItem("auth-token"); + const [isLoading, setIsLoading] = useState(false); + + const [authToken, setAuthToken] = useSessionStorage("auth-token", localToken); + const { address } = useAccount(); + const { chain } = useNetwork(); + + const { signMessageAsync } = useSignMessage(); + + const isVerified = useMemo(() => { + if (!authToken || !address) return false; + + const payload = jwt.decodeJwt(authToken); + + if ((payload?.id as string).toLowerCase() !== address.toLowerCase()) return false; + if (payload.exp && payload.exp < Date.now() / 1000) return false; + + return true; + }, [authToken, address]); + + const handleSignIn = async () => { + try { + setIsLoading(true); + if (!address) return; + + const message = await createSiweMessage(address, "Sign In to Kleros with Ethereum.", chain?.id); + + const signature = await signMessageAsync({ message }); + + if (!signature) return; + + authoriseUser({ + address, + signature, + message, + }) + .then(async (res) => { + const response = await res.json(); + setAuthToken(response["token"]); + }) + .catch((err) => console.log({ err })) + .finally(() => setIsLoading(false)); + } catch (err) { + setIsLoading(false); + console.log({ err }); + } + }; + + return isVerified ? ( + children + ) : ( +