Skip to content

Commit

Permalink
Merge pull request #57 from kleros/feat/auth-for-uploads-and-settings
Browse files Browse the repository at this point in the history
Feat/auth for uploads and settings
  • Loading branch information
Harman-singh-waraich authored Jun 6, 2024
2 parents c2da74f + ee1eced commit 6cae895
Show file tree
Hide file tree
Showing 31 changed files with 1,518 additions and 139 deletions.
1 change: 1 addition & 0 deletions web/.gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -27,6 +27,7 @@ parcel-bundle-reports
src/hooks/contracts
src/graphql
generatedGitInfo.json
generatedNetlifyInfo.json

# logs
npm-debug.log*
Expand Down
14 changes: 14 additions & 0 deletions web/netlify.toml
Original file line number Diff line number Diff line change
@@ -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"
17 changes: 17 additions & 0 deletions web/netlify/config/index.ts
Original file line number Diff line number Diff line change
@@ -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;
112 changes: 112 additions & 0 deletions web/netlify/functions/authUser.ts
Original file line number Diff line number Diff line change
@@ -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<Database>(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());
38 changes: 38 additions & 0 deletions web/netlify/functions/fetch-settings.ts
Original file line number Diff line number Diff line change
@@ -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<Database>(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());
55 changes: 55 additions & 0 deletions web/netlify/functions/getNonce.ts
Original file line number Diff line number Diff line change
@@ -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<Database>(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);
96 changes: 96 additions & 0 deletions web/netlify/functions/update-settings.ts
Original file line number Diff line number Diff line change
@@ -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<Database>(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());
Loading

0 comments on commit 6cae895

Please sign in to comment.