Skip to content

Commit

Permalink
Merge pull request #32 from OlympusDAO/hasura-changes
Browse files Browse the repository at this point in the history
Refactoring hasura interaction during auth flow
  • Loading branch information
chronos-ohm authored Mar 31, 2022
2 parents 2320f3a + 9931f8a commit 544c198
Show file tree
Hide file tree
Showing 3 changed files with 59 additions and 45 deletions.
2 changes: 1 addition & 1 deletion assets/diagrams/user-verified-insert-update.drawio
Original file line number Diff line number Diff line change
@@ -1 +1 @@
<mxfile host="app.diagrams.net" modified="2022-02-22T14:48:10.543Z" agent="5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/98.0.4758.102 Safari/537.36" etag="B8NpzW7VqkrjYUDLy_Uw" version="16.6.1" type="device"><diagram name="Page-1" id="edf60f1a-56cd-e834-aa8a-f176f3a09ee4">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</diagram></mxfile>
<mxfile host="app.diagrams.net" modified="2022-03-30T08:39:06.416Z" agent="5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/99.0.4844.82 Safari/537.36" etag="npVvsitDMySMr9JZaDLA" version="17.2.5" type="device"><diagram name="Page-1" id="edf60f1a-56cd-e834-aa8a-f176f3a09ee4">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</diagram></mxfile>
Binary file modified assets/diagrams/user-verified-insert-update.png
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
102 changes: 58 additions & 44 deletions backend/lib/auth.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,6 @@ import {
FIND_USER_BY_DISCORDID,
INSERT_USER_ONE,
INSERT_USER_VERIFIED_ONE,
SET_USER_ADDRESS,
SET_USER_VERIFIED_TOKENS,
SET_USER_VERIFIED_NONCE,
SET_USER_VERIFIED_AUTHSTATUS,
Expand All @@ -13,7 +12,6 @@ import {
import { utils } from "ethers";
import { randomBytes } from "crypto";
import { hasuraRequest } from "./hasura";
import { assert } from "console";

const recoverAddress = (nonce: string, signature: string) => {
const address = utils.verifyMessage(nonce, signature);
Expand All @@ -38,48 +36,60 @@ export const getAuthenticationChallenge = async (address: string, discordUserId:
const userVerified = (await checkUserVerified.json()) as any;
console.log(`FIND_USER_BY_DISCORDID: ${JSON.stringify(userVerified)}`);

var addressExists, discordUserIdExists;
// If any of these happens, something has gone terribly wrong. `discordUserId` and
// `address` are unique fields.
if (user.data.user.length !== 0 && user.data.user.length !== 1) {
console.log(`Unexpected number of records found for this Ethereum address. (address: ${address})`);
throw Error("Unexpected number of records found for this Ethereum address.");
if (user.data.user.length === 0) {
addressExists = false;
} else if (user.data.user.length === 1) {
addressExists = true;
} else {
throw Error(`Unexpected number of records found for this Ethereum address. (address: ${address})`);
}
if (userVerified.data.user.length !== 0 && userVerified.data.user.length !== 1) {
console.log(`Unexpected number of records found for this Discord User ID. (discordUserId: ${discordUserId})`);
throw Error("Unexpected number of records found for this Discord User ID.");
if (userVerified.data.user.length === 0) {
discordUserIdExists = false;
} else if (userVerified.data.user.length === 1) {
discordUserIdExists = true;
} else {
throw Error(`Unexpected number of records found for this Discord User ID. (discordUserId: ${discordUserId})`);
}

// If `address` doesn't exist in `user` table
if (user.data.user.length == 0) {
if (!addressExists) {
console.log(`(address: ${address}, discordUserId: ${discordUserId}) ADDRESS EXISTS ? - [NO]`);
// If `discordUserId` hasn't been registered yet, create a new `user` from scratch
// If `discordUserId` hasn't been registered yet, we'll just create a new `user` from scratch
// Fields `address` and `chainId` go into `user`.
// Field `discordUserId` goes into `user_verified`;
if (userVerified.data.user.length == 0) {
if (!discordUserIdExists) {
console.log(`(address: ${address}, discordUserId: ${discordUserId}) DISCORD ID EXISTS ? - [NO]`);
const insertResponse = await hasuraRequest(INSERT_USER_ONE, {
address,
chainId,
discordUserId,
});
const response = await insertResponse.json();
console.log(`INSERT_USER_ONE:`, JSON.stringify(response));
}
// If `discordUserId` had already been registered update the `address` and `chainId` fields, for this `discordUserId`
// (this is the case of a Discord user that had already registered, and is returning to register with a different address,
// such that the new address was not yet registered in `user`)
else if (userVerified.data.user.length == 1) {
// If `discordUserId` has already been registered, we first delete `user_verified` for that `discordUserId`, and only then
// insert a new `user` from scratch.
// This is the case of a Discord user that had already authenticated, and is returning to authenticate with a different `address`,
// such that the new `address` is NOT YET registered in `user`.
// Example: user authenticates on Verified Ohmies with `address` X. Then, later on, he comes back and authenticates with address Y,
// such that address Y was still not in `user`.
// Result: user_verified with this user's `discordUserId` gets deleted and
// `user` with address Y gets inserted now to be paired with a `user_verified` with this `discordUserId`.
else {
console.log(`(address: ${address}, discordUserId: ${discordUserId}) DISCORD ID EXISTS ? - [YES]`);
const setResponse = await hasuraRequest(SET_USER_ADDRESS, {
discordUserId, // where
address, // _set
chainId, // _set
// Delete old user verified
const deleteResponse = await hasuraRequest(DELETE_USER_VERIFIED_BY_DISCORDID, {
discordUserId,
});
const response = await setResponse.json();
console.log(`SET_USER_ADDRESS:`, JSON.stringify(response));
const dResponse = await deleteResponse.json();
console.log(`DELETE_USER_VERIFIED_BY_DISCORDID:`, JSON.stringify(dResponse));
}

const insertResponse = await hasuraRequest(INSERT_USER_ONE, {
address,
chainId,
discordUserId,
});
const response = await insertResponse.json();
console.log(`INSERT_USER_ONE:`, JSON.stringify(response));
// If `address` exists in `user` table
} else if (user.data.user.length == 1) {
} else {
console.log(`(address: ${address}, discordUserId: ${discordUserId}) ADDRESS EXISTS ? - [YES]`);
// If this `address` is already paired to a `discordUserId`
if (user.data.user[0].user_verified !== null) {
Expand All @@ -106,7 +116,7 @@ export const getAuthenticationChallenge = async (address: string, discordUserId:
});
const tResponse = await setTokensResponse.json();
console.log(`SET_USER_VERIFIED_TOKENS:`, JSON.stringify(tResponse));
// If it's a different `discordUserId` from the one this user is trying to register, the address is considered locked, so we throw error
// If it's a different `discordUserId` from the one this user is trying to authenticate with, the address is considered locked, so we throw error
} else {
console.log(`(address: ${address}, discordUserId: ${discordUserId}) SAME DISCORD ID AS THIS ONE ? - [NO]`);
throw Error("Another Discord user is already registered with this Ethereum address.");
Expand All @@ -115,23 +125,26 @@ export const getAuthenticationChallenge = async (address: string, discordUserId:
// If this `address` is not yet paired to a `discordUserId`, it's free to take
else {
console.log(`(address: ${address}, discordUserId: ${discordUserId}) ADDRESS PAIRED TO DISCORD ID ? - [NO]`);
// If `discordUserId` already exists, we first delete the old record with this `discordUserId`
// (this is the case of a Discord user that had already registered, and is returning to register with a different `address`,
// such that the new `address` was already registered in `user` but didn't have attributed a `discordUserId`.
// Example: user X registers on Verified Ohmies with `address` X and also on Odyssey with `address` Y. But then user X
// tries to register again on Verified Ohmies, but now with `address` Y.
// Result: user_verified with this user's `discordUserId` gets deleted and
// If `discordUserId` hasn't been registered yet, we simply pair it to the `address`
if (!discordUserIdExists) {
console.log(`(address: ${address}, discordUserId: ${discordUserId}) DISCORD ID EXISTS ? - [NO]`);
}
// If `discordUserId` has already been registered, we first delete `user_verified` for that `discordUserId`, and only then
// pair this `discordUserId` to the `address`.
// This is the case of a Discord user that had already authenticated and is returning to authenticate with a different `address`,
// such that the new `address` IS ALREADY registered in `user` but didn't have attributed a `discordUserId`.
// Example: user authenticates on Verified Ohmies with `address` X and also on Odyssey with `address` Y. But then user X
// tries to authenticate again on Verified Ohmies, but now with `address` Y.
// Result: `user_verified` with this user's `discordUserId` gets deleted and
// `user` with address Y gets updated to be paired with this `discordUserId`.
if (userVerified.data.user.length == 1) {
else {
console.log(`(address: ${address}, discordUserId: ${discordUserId}) DISCORD ID EXISTS ? - [YES]`);
// Delete old user
// Delete old user verified
const deleteResponse = await hasuraRequest(DELETE_USER_VERIFIED_BY_DISCORDID, {
discordUserId,
});
const dResponse = await deleteResponse.json();
console.log(`DELETE_USER_ONE:`, JSON.stringify(dResponse));
} else {
console.log(`(address: ${address}, discordUserId: ${discordUserId}) DISCORD ID EXISTS ? - [NO]`);
console.log(`DELETE_USER_VERIFIED_BY_DISCORDID:`, JSON.stringify(dResponse));
}

// Set the new `chainId` for this `address` and get the `id` of the updated `user`
Expand Down Expand Up @@ -179,9 +192,10 @@ export const getAuthenticationChallenge = async (address: string, discordUserId:
export const authenticate = async (discordUserId: string, signature: string) => {
const checkUser = await hasuraRequest(FIND_USER_BY_DISCORDID, { discordUserId });
const user = (await checkUser.json()) as any;
console.log(`FIND_USER_BY_DISCORDID:`, JSON.stringify(user));

if (user.data.user.length == 0) throw new Error("User not found.");
assert(user.data.user.length == 1);
if (user.data.user.length === 0) throw new Error("User not found.");
if (user.data.user.length !== 1) throw new Error("Unexpected number of records found for this Discord User ID.");
const address = user.data.user[0].address;
const chainId = user.data.user[0].chainId;
const nonce = user.data.user[0].user_verified.nonce;
Expand All @@ -193,7 +207,7 @@ export const authenticate = async (discordUserId: string, signature: string) =>
const recoveredAddress = recoverAddress(nonce, signature);

if (recoveredAddress.toLowerCase() === address.toLowerCase()) {
// If the check passes, return the discord user ID to attribute the role
// If the check passes, return the Discord User ID to attribute the role
return { address, chainId };
} else {
throw new Error("Bad signature.");
Expand Down

3 comments on commit 544c198

@github-actions
Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Deploy preview for verified-ohmies-backend ready!

✅ Preview
https://verified-ohmies-backend-a9yapji0u-odyssey2.vercel.app

Built with commit 544c198.
This pull request is being automatically deployed with vercel-action

@github-actions
Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Deploy preview for verified-ohmies-frontend ready!

✅ Preview
https://verified-ohmies-frontend-nx734afct-odyssey2.vercel.app

Built with commit 544c198.
This pull request is being automatically deployed with vercel-action

@github-actions
Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Deploy preview for verified-ohmies-discord ready!

✅ Preview
https://verified-ohmies-discord-mzrigztd9-odyssey2.vercel.app

Built with commit 544c198.
This pull request is being automatically deployed with vercel-action

Please sign in to comment.