Skip to content

Commit 5f44cb5

Browse files
authored
Merge pull request #34 from kleros/feat/siwe-authentication-for-file-uploads-and-notifications
feat(web): add-siwe-authentication-for-file-uploads-and-notification-…
2 parents 95514fb + 90181c7 commit 5f44cb5

File tree

27 files changed

+1423
-153
lines changed

27 files changed

+1423
-153
lines changed

web/.gitignore

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -30,6 +30,7 @@ parcel-bundle-reports
3030
src/hooks/contracts
3131
src/graphql
3232
generatedGitInfo.json
33+
generatedNetlifyInfo.json
3334

3435
# logs
3536
npm-debug.log*

web/netlify.toml

Lines changed: 14 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,14 @@
1+
## Yarn 3 cache does not work out of the box as of Jan 2022. Context:
2+
## https://github.com/netlify/build/issues/1535#issuecomment-1021947989
3+
[build.environment]
4+
NETLIFY_USE_YARN = "true"
5+
NETLIFY_YARN_WORKSPACES = "true"
6+
YARN_ENABLE_GLOBAL_CACHE = "true"
7+
# YARN_CACHE_FOLDER = "$HOME/.yarn_cache"
8+
# YARN_VERSION = "3.2.0"
9+
10+
[functions]
11+
directory = "web/netlify/functions/"
12+
13+
[dev]
14+
framework = "parcel"

web/netlify/functions/authUser.ts

Lines changed: 112 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,112 @@
1+
import middy from "@middy/core";
2+
import jsonBodyParser from "@middy/http-json-body-parser";
3+
import { createClient } from "@supabase/supabase-js";
4+
import * as jwt from "jose";
5+
import { SiweMessage } from "siwe";
6+
7+
import { DEFAULT_CHAIN } from "consts/chains";
8+
import { ETH_SIGNATURE_REGEX } from "consts/index";
9+
10+
import { netlifyUri, netlifyDeployUri, netlifyDeployPrimeUri } from "src/generatedNetlifyInfo.json";
11+
import { Database } from "src/types/supabase-notification";
12+
13+
const authUser = async (event) => {
14+
try {
15+
if (!event.body) {
16+
throw new Error("No body provided");
17+
}
18+
19+
const signature = event?.body?.signature;
20+
if (!signature) {
21+
throw new Error("Missing key : signature");
22+
}
23+
24+
if (!ETH_SIGNATURE_REGEX.test(signature)) {
25+
throw new Error("Invalid signature");
26+
}
27+
28+
const message = event?.body?.message;
29+
if (!message) {
30+
throw new Error("Missing key : message");
31+
}
32+
33+
const address = event?.body?.address;
34+
if (!address) {
35+
throw new Error("Missing key : address");
36+
}
37+
38+
const siweMessage = new SiweMessage(message);
39+
40+
if (
41+
!(
42+
(netlifyUri && netlifyUri === siweMessage.uri) ||
43+
(netlifyDeployUri && netlifyDeployUri === siweMessage.uri) ||
44+
(netlifyDeployPrimeUri && netlifyDeployPrimeUri === siweMessage.uri)
45+
)
46+
) {
47+
console.debug(
48+
`Invalid URI: expected one of [${netlifyUri} ${netlifyDeployUri} ${netlifyDeployPrimeUri}] but got ${siweMessage.uri}`
49+
);
50+
throw new Error(`Invalid URI`);
51+
}
52+
53+
if (siweMessage.chainId !== DEFAULT_CHAIN) {
54+
console.debug(`Invalid chain ID: expected ${DEFAULT_CHAIN} but got ${siweMessage.chainId}`);
55+
throw new Error(`Invalid chain ID`);
56+
}
57+
58+
const lowerCaseAddress = siweMessage.address.toLowerCase();
59+
if (lowerCaseAddress !== address.toLowerCase()) {
60+
throw new Error("Address mismatch in provided address and message");
61+
}
62+
63+
const supabase = createClient<Database>(process.env.SUPABASE_URL!, process.env.SUPABASE_CLIENT_API_KEY!);
64+
65+
// get nonce from db, if its null that means it was already used
66+
const { error: nonceError, data: nonceData } = await supabase
67+
.from("user-nonce")
68+
.select("nonce")
69+
.eq("address", lowerCaseAddress)
70+
.single();
71+
72+
if (nonceError || !nonceData?.nonce) {
73+
throw new Error("Unable to fetch nonce from DB");
74+
}
75+
76+
try {
77+
await siweMessage.verify({ signature, nonce: nonceData.nonce, time: new Date().toISOString() });
78+
} catch (err) {
79+
throw new Error("Invalid signer");
80+
}
81+
82+
const { error } = await supabase.from("user-nonce").delete().match({ address: lowerCaseAddress });
83+
84+
if (error) {
85+
throw new Error("Error updating nonce in DB");
86+
}
87+
88+
const issuer = process.env.JWT_ISSUER ?? "Kleros"; // ex :- Kleros
89+
const audience = process.env.JWT_AUDIENCE ?? "Curate"; // ex :- Court, Curate, Escrow
90+
const authExp = process.env.JWT_EXP_TIME ?? "2h";
91+
const secret = process.env.JWT_SECRET;
92+
93+
if (!secret) {
94+
throw new Error("Secret not set in environment");
95+
}
96+
// user verified, generate auth token
97+
const encodedSecret = new TextEncoder().encode(secret);
98+
99+
const token = await new jwt.SignJWT({ id: address.toLowerCase() })
100+
.setProtectedHeader({ alg: "HS256" })
101+
.setIssuer(issuer)
102+
.setAudience(audience)
103+
.setExpirationTime(authExp)
104+
.sign(encodedSecret);
105+
106+
return { statusCode: 200, body: JSON.stringify({ message: "User authorised", token }) };
107+
} catch (err) {
108+
return { statusCode: 500, body: JSON.stringify({ message: `${err}` }) };
109+
}
110+
};
111+
112+
export const handler = middy(authUser).use(jsonBodyParser());
Lines changed: 39 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,39 @@
1+
import middy from "@middy/core";
2+
import { createClient } from "@supabase/supabase-js";
3+
4+
import { Database } from "../../src/types/supabase-notification";
5+
import { authMiddleware } from "../middleware/authMiddleware";
6+
7+
const fetchSettings = async (event) => {
8+
try {
9+
const address = event.auth.id;
10+
const lowerCaseAddress = address.toLowerCase() as `0x${string}`;
11+
12+
const supabaseUrl = process.env.SUPABASE_URL;
13+
const supabaseApiKey = process.env.SUPABASE_CLIENT_API_KEY;
14+
15+
if (!supabaseUrl || !supabaseApiKey) {
16+
throw new Error("Supabase URL or API key is undefined");
17+
}
18+
const supabase = createClient<Database>(supabaseUrl, supabaseApiKey);
19+
20+
const { error, data } = await supabase
21+
.from("user-settings")
22+
.select("address, email, telegram")
23+
.eq("address", lowerCaseAddress)
24+
.single();
25+
26+
if (!data) {
27+
return { statusCode: 404, message: "Error : User not found" };
28+
}
29+
30+
if (error) {
31+
throw error;
32+
}
33+
return { statusCode: 200, body: JSON.stringify({ data }) };
34+
} catch (err) {
35+
return { statusCode: 500, message: `Error ${err?.message ?? err}` };
36+
}
37+
};
38+
39+
export const handler = middy(fetchSettings).use(authMiddleware());

web/netlify/functions/getNonce.ts

Lines changed: 56 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,56 @@
1+
import middy from "@middy/core";
2+
import { createClient } from "@supabase/supabase-js";
3+
import { generateNonce } from "siwe";
4+
5+
import { ETH_ADDRESS_REGEX } from "src/consts";
6+
7+
import { Database } from "../../src/types/supabase-notification";
8+
9+
const getNonce = async (event) => {
10+
try {
11+
const { queryStringParameters } = event;
12+
13+
if (!queryStringParameters?.address) {
14+
return {
15+
statusCode: 400,
16+
body: JSON.stringify({ message: "Invalid query parameters" }),
17+
};
18+
}
19+
20+
const { address } = queryStringParameters;
21+
22+
if (!ETH_ADDRESS_REGEX.test(address)) {
23+
throw new Error("Invalid Ethereum address format");
24+
}
25+
26+
const lowerCaseAddress = address.toLowerCase() as `0x${string}`;
27+
28+
const supabaseUrl = process.env.SUPABASE_URL;
29+
const supabaseApiKey = process.env.SUPABASE_CLIENT_API_KEY;
30+
31+
if (!supabaseUrl || !supabaseApiKey) {
32+
throw new Error("Supabase URL or API key is undefined");
33+
}
34+
const supabase = createClient<Database>(supabaseUrl, supabaseApiKey);
35+
36+
// generate nonce and save in db
37+
const nonce = generateNonce();
38+
39+
const { error } = await supabase
40+
.from("user-nonce")
41+
.upsert({ address: lowerCaseAddress, nonce: nonce })
42+
.eq("address", lowerCaseAddress);
43+
44+
if (error) {
45+
throw error;
46+
}
47+
48+
return { statusCode: 200, body: JSON.stringify({ nonce }) };
49+
} catch (err) {
50+
console.log(err);
51+
52+
return { statusCode: 500, message: `Error ${err?.message ?? err}` };
53+
}
54+
};
55+
56+
export const handler = middy(getNonce);
Lines changed: 97 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,97 @@
1+
import middy from "@middy/core";
2+
import jsonBodyParser from "@middy/http-json-body-parser";
3+
import { createClient } from "@supabase/supabase-js";
4+
5+
import { EMAIL_REGEX, TELEGRAM_REGEX, ETH_ADDRESS_REGEX } from "../../src/consts/index";
6+
import { Database } from "../../src/types/supabase-notification";
7+
import { authMiddleware } from "../middleware/authMiddleware";
8+
9+
type NotificationSettings = {
10+
email?: string;
11+
telegram?: string;
12+
address: `0x${string}`;
13+
};
14+
15+
const validate = (input: any): NotificationSettings => {
16+
const requiredKeys: (keyof NotificationSettings)[] = ["address"];
17+
const optionalKeys: (keyof NotificationSettings)[] = ["email", "telegram"];
18+
const receivedKeys = Object.keys(input);
19+
20+
for (const key of requiredKeys) {
21+
if (!receivedKeys.includes(key)) {
22+
throw new Error(`Missing key: ${key}`);
23+
}
24+
}
25+
26+
const allExpectedKeys = [...requiredKeys, ...optionalKeys];
27+
for (const key of receivedKeys) {
28+
if (!allExpectedKeys.includes(key as keyof NotificationSettings)) {
29+
throw new Error(`Unexpected key: ${key}`);
30+
}
31+
}
32+
33+
const email = input.email ? input.email.trim() : "";
34+
if (email && !EMAIL_REGEX.test(email)) {
35+
throw new Error("Invalid email format");
36+
}
37+
38+
const telegram = input.telegram ? input.telegram.trim() : "";
39+
if (telegram && !TELEGRAM_REGEX.test(telegram)) {
40+
throw new Error("Invalid Telegram username format");
41+
}
42+
43+
if (!ETH_ADDRESS_REGEX.test(input.address)) {
44+
throw new Error("Invalid Ethereum address format");
45+
}
46+
47+
return {
48+
email: input.email?.trim(),
49+
telegram: input.telegram?.trim(),
50+
address: input.address.trim().toLowerCase(),
51+
};
52+
};
53+
54+
const updateSettings = async (event) => {
55+
try {
56+
if (!event.body) {
57+
throw new Error("No body provided");
58+
}
59+
60+
const { email, telegram, address } = validate(event.body);
61+
const lowerCaseAddress = address.toLowerCase() as `0x${string}`;
62+
63+
// Prevent using someone else's token
64+
if (event?.auth?.id.toLowerCase() !== lowerCaseAddress) {
65+
throw new Error("Unauthorised user");
66+
}
67+
68+
const supabaseUrl = process.env.SUPABASE_URL;
69+
const supabaseApiKey = process.env.SUPABASE_CLIENT_API_KEY;
70+
71+
if (!supabaseUrl || !supabaseApiKey) {
72+
throw new Error("Supabase URL or API key is undefined");
73+
}
74+
const supabase = createClient<Database>(supabaseUrl, supabaseApiKey);
75+
76+
// If the message is empty, delete the user record
77+
if (email === "" && telegram === "") {
78+
const { error } = await supabase.from("user-settings").delete().match({ address: lowerCaseAddress });
79+
if (error) throw error;
80+
return { statusCode: 200, body: JSON.stringify({ message: "Record deleted successfully." }) };
81+
}
82+
83+
// For a user matching this address, upsert the user record
84+
const { error } = await supabase
85+
.from("user-settings")
86+
.upsert({ address: lowerCaseAddress, email: email, telegram: telegram })
87+
.match({ address: lowerCaseAddress });
88+
if (error) {
89+
throw error;
90+
}
91+
return { statusCode: 200, body: JSON.stringify({ message: "Record updated successfully." }) };
92+
} catch (err) {
93+
return { statusCode: 500, body: JSON.stringify({ message: `${err}` }) };
94+
}
95+
};
96+
97+
export const handler = middy(updateSettings).use(jsonBodyParser()).use(authMiddleware());

web/netlify/functions/uploadToIPFS.ts

Lines changed: 10 additions & 19 deletions
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,8 @@
1-
import { Handler } from "@netlify/functions";
21
import { File, FilebaseClient } from "@filebase/client";
32
import amqp, { Connection } from "amqplib";
43
import busboy from "busboy";
4+
import middy from "@middy/core";
5+
import { authMiddleware } from "../middleware/authMiddleware";
56

67
const { FILEBASE_TOKEN, RABBITMQ_URL, FILEBASE_API_WRAPPER } = process.env;
78
const filebase = new FilebaseClient({ token: FILEBASE_TOKEN ?? "" });
@@ -50,7 +51,7 @@ const parseMultipart = ({ headers, body, isBase64Encoded }) =>
5051
bb.end();
5152
});
5253

53-
const pinToFilebase = async (data: FormData, dapp: string, operation: string): Promise<Array<string>> => {
54+
const pinToFilebase = async (data: FormData, operation: string): Promise<Array<string>> => {
5455
const cids = new Array<string>();
5556
for (const [_, dataElement] of Object.entries(data)) {
5657
if (dataElement.isFile) {
@@ -65,33 +66,21 @@ const pinToFilebase = async (data: FormData, dapp: string, operation: string): P
6566
return cids;
6667
};
6768

68-
export const handler: Handler = async (event) => {
69+
export const uploadToIpfs = async (event) => {
6970
const { queryStringParameters } = event;
7071

71-
if (
72-
!queryStringParameters ||
73-
!queryStringParameters.dapp ||
74-
!queryStringParameters.key ||
75-
!queryStringParameters.operation
76-
) {
72+
if (!queryStringParameters?.operation) {
7773
return {
7874
statusCode: 400,
79-
body: JSON.stringify({ message: "Invalid query parameters" }),
75+
body: JSON.stringify({ message: "Invalid query parameters, missing query : operation " }),
8076
};
8177
}
8278

83-
const { dapp, key, operation } = queryStringParameters;
84-
85-
if (key !== FILEBASE_API_WRAPPER) {
86-
return {
87-
statusCode: 403,
88-
body: JSON.stringify({ message: "Invalid API key" }),
89-
};
90-
}
79+
const { operation } = queryStringParameters;
9180

9281
try {
9382
const parsed = await parseMultipart(event);
94-
const cids = await pinToFilebase(parsed, dapp, operation);
83+
const cids = await pinToFilebase(parsed, operation);
9584

9685
return {
9786
statusCode: 200,
@@ -107,3 +96,5 @@ export const handler: Handler = async (event) => {
10796
};
10897
}
10998
};
99+
100+
export const handler = middy(uploadToIpfs).use(authMiddleware());

0 commit comments

Comments
 (0)