diff --git a/web/.gitignore b/web/.gitignore index 1539118..8b5458c 100644 --- a/web/.gitignore +++ b/web/.gitignore @@ -27,6 +27,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/config/index.ts b/web/netlify/config/index.ts new file mode 100644 index 0000000..18c0898 --- /dev/null +++ b/web/netlify/config/index.ts @@ -0,0 +1,17 @@ +const config = { + /***** jwt variables *****/ + jwtIssuer: process.env.JWT_ISSUER ?? "Kleros", // ex :- Kleros + jwtAudience: process.env.JWT_AUDIENCE ?? "Escrow", // ex :- Court, Curate, Escrow + jwtExpTime: process.env.JWT_EXP_TIME ?? "2h", + jwtSecret: process.env.JWT_SECRET, + + /***** supabase variables *****/ + supabaseUrl: process.env.SUPABASE_URL, + supabaseApiKey: process.env.SUPABASE_CLIENT_API_KEY, + + /***** ipfs variables *****/ + filebaseToken: process.env.FILEBASE_TOKEN, + rabbitMqUrl: process.env.RABBITMQ_URL, +}; + +export default config; diff --git a/web/netlify/functions/authUser.ts b/web/netlify/functions/authUser.ts new file mode 100644 index 0000000..5debff2 --- /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"; + +import config from "../config"; + +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"); + } + + if (!config.supabaseUrl || !config.supabaseApiKey) { + throw new Error("Supabase URL or API key is undefined"); + } + const supabase = createClient(config.supabaseUrl, config.supabaseApiKey); + + // 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"); + } + + if (!config.jwtSecret) { + throw new Error("Secret not set in environment"); + } + // user verified, generate auth token + const encodedSecret = new TextEncoder().encode(config.jwtSecret); + + const token = await new jwt.SignJWT({ id: address.toLowerCase() }) + .setProtectedHeader({ alg: "HS256" }) + .setIssuer(config.jwtIssuer) + .setAudience(config.jwtAudience) + .setExpirationTime(config.jwtExpTime) + .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..faa4c75 --- /dev/null +++ b/web/netlify/functions/fetch-settings.ts @@ -0,0 +1,38 @@ +import middy from "@middy/core"; +import { createClient } from "@supabase/supabase-js"; + +import { Database } from "../../src/types/supabase-notification"; +import { authMiddleware } from "../middleware/authMiddleware"; + +import config from "../config"; + +const fetchSettings = async (event) => { + try { + const address = event.auth.id; + const lowerCaseAddress = address.toLowerCase() as `0x${string}`; + + if (!config.supabaseUrl || !config.supabaseApiKey) { + throw new Error("Supabase URL or API key is undefined"); + } + const supabase = createClient(config.supabaseUrl, config.supabaseApiKey); + + 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..d9b7b94 --- /dev/null +++ b/web/netlify/functions/getNonce.ts @@ -0,0 +1,55 @@ +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"; + +import config from "../config"; + +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}`; + + if (!config.supabaseUrl || !config.supabaseApiKey) { + throw new Error("Supabase URL or API key is undefined"); + } + const supabase = createClient(config.supabaseUrl, config.supabaseApiKey); + + // 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..3008f65 --- /dev/null +++ b/web/netlify/functions/update-settings.ts @@ -0,0 +1,96 @@ +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"; + +import config from "../config"; + +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"); + } + + if (!config.supabaseUrl || !config.supabaseApiKey) { + throw new Error("Supabase URL or API key is undefined"); + } + const supabase = createClient(config.supabaseUrl, config.supabaseApiKey); + + // 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 26c64bd..d9676bf 100644 --- a/web/netlify/functions/uploadToIPFS.ts +++ b/web/netlify/functions/uploadToIPFS.ts @@ -1,10 +1,12 @@ -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 ?? "" }); +import config from "../config"; + +const filebase = new FilebaseClient({ token: config.filebaseToken ?? "" }); type FormElement = | { isFile: true; filename: string; mimeType: string; content: Buffer } @@ -14,7 +16,7 @@ type FormData = { [key: string]: FormElement }; const emitRabbitMQLog = async (cid: string, operation: string) => { let connection: Connection | undefined; try { - connection = await amqp.connect(RABBITMQ_URL ?? ""); + connection = await amqp.connect(config.rabbitMqUrl ?? ""); const channel = await connection.createChannel(); await channel.assertExchange("ipfs", "topic"); @@ -50,7 +52,7 @@ const parseMultipart = ({ headers, body, isBase64Encoded }) => bb.end(); }); -const pinToFilebase = async (data: FormData, dapp: string, operation: string): Promise> => { +const pinToFilebase = async (data: FormData, operation: string): Promise> => { const cids = new Array(); for (const [_, dataElement] of Object.entries(data)) { if (dataElement.isFile) { @@ -58,40 +60,28 @@ const pinToFilebase = async (data: FormData, dapp: string, operation: string): P const path = `${filename}`; const cid = await filebase.storeDirectory([new File([content], path, { type: mimeType })]); await emitRabbitMQLog(cid, operation); - cids.push(`ipfs://${cid}/${path}`); + cids.push(`/ipfs/${cid}/${path}`); } } return cids; }; -export const handler: Handler = async (event) => { +export const uploadToIpfs = async (event) => { const { queryStringParameters } = event; - if ( - !queryStringParameters || - !queryStringParameters.dapp || - !queryStringParameters.key || - !queryStringParameters.operation - ) { + if (!queryStringParameters?.operation) { return { statusCode: 400, - body: JSON.stringify({ message: "Invalid query parameters" }), + body: JSON.stringify({ message: "Invalid query parameters, missing query : operation " }), }; } - const { dapp, key, operation } = queryStringParameters; - - if (key !== FILEBASE_API_WRAPPER) { - return { - statusCode: 403, - body: JSON.stringify({ message: "Invalid API key" }), - }; - } + const { operation } = queryStringParameters; try { const parsed = await parseMultipart(event); - const cids = await pinToFilebase(parsed, dapp, operation); + const cids = await pinToFilebase(parsed, operation); return { statusCode: 200, @@ -107,3 +97,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..25976b5 --- /dev/null +++ b/web/netlify/middleware/authMiddleware.ts @@ -0,0 +1,39 @@ +import * as jwt from "jose"; +import config from "../config"; + +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 { + if (!config.jwtSecret) { + throw new Error("Secret not set in environment"); + } + + const encodedSecret = new TextEncoder().encode(config.jwtSecret); + + const { payload } = await jwt.jwtVerify(authToken, encodedSecret, { + issuer: config.jwtIssuer, + audience: config.jwtAudience, + }); + + // 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 3447cc3..dd19f6a 100644 --- a/web/package.json +++ b/web/package.json @@ -34,7 +34,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", @@ -72,6 +72,8 @@ "dependencies": { "@filebase/client": "^0.0.5", "@kleros/ui-components-library": "^2.12.0", + "@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", @@ -80,13 +82,14 @@ "@web3modal/react": "^2.2.2", "@yornaath/batshit": "^0.9.0", "alchemy-sdk": "^3.3.1", - "amqplib": "^0.10.3", + "amqplib": "^0.10.4", "chart.js": "^3.9.1", "chartjs-adapter-moment": "^1.0.1", "core-js": "^3.35.0", "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..3822f82 --- /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..473d5cd 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/EnsureAuth.tsx b/web/src/components/EnsureAuth.tsx new file mode 100644 index 0000000..819e6bb --- /dev/null +++ b/web/src/components/EnsureAuth.tsx @@ -0,0 +1,119 @@ +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"; +import styled from "styled-components"; + +const Container = styled.div` + display: flex; + flex-direction: column; + gap: 16px; + justify-content: center; + align-items: center; +`; + +const StyledInfo = styled.p` + margin: 0; + padding: 0; +`; + +interface IEnsureAuth { + children: React.ReactElement; + message?: string; + buttonText?: string; + className?: string; +} + +export const EnsureAuth: React.FC = ({ children, message, buttonText, 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 + ) : ( + + {message ? {message} : null} +