Skip to content

Commit

Permalink
Merge pull request #48 from moonshotcollective/44/sign-challenges
Browse files Browse the repository at this point in the history
Enforce signatures on post/patch operations
  • Loading branch information
dgrcode authored Sep 24, 2021
2 parents 2af7e33 + fb0e648 commit 3cdd996
Show file tree
Hide file tree
Showing 8 changed files with 220 additions and 87 deletions.
3 changes: 3 additions & 0 deletions packages/backend/.eslintrc.js
Original file line number Diff line number Diff line change
Expand Up @@ -15,4 +15,7 @@ module.exports = {
"no-underscore-dangle": "off",
"jsx-a11y/accessible-emoji": ["off"],
},
parserOptions: {
ecmaVersion: 2020,
},
};
91 changes: 58 additions & 33 deletions packages/backend/index.js
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,7 @@ const bodyParser = require("body-parser");
const firebaseAdmin = require("firebase-admin");
const firebaseServiceAccount = require("./firebaseServiceAccountKey.json");
const { userOnly, adminOnly } = require("./middlewares/auth");
const { getSignMessageForId, verifySignature } = require("./utils/sign");

const app = express();

Expand All @@ -17,8 +18,6 @@ firebaseAdmin.initializeApp({
// Docs: https://firebase.google.com/docs/firestore/quickstart#node.js_1
const database = firebaseAdmin.firestore();

const currentMessage = "I am **ADDRESS** and I would like to sign in to Scaffold-Directory, plz!";

const dummyData = {
challenges: {
"simple-nft-example": {
Expand Down Expand Up @@ -50,8 +49,11 @@ app.use(bodyParser.json());
app.use(bodyParser.urlencoded({ extended: true }));

app.get("/sign-message", (req, res) => {
console.log("/sign-message");
res.status(200).send(currentMessage);
const messageId = req.query.messageId ?? "login";
const options = req.query;

console.log("/sign-message", messageId);
res.status(200).send(getSignMessageForId(messageId, options));
});

app.get("/builders", async (req, res) => {
Expand All @@ -71,34 +73,31 @@ app.get("/builders/:builderAddress", async (req, res) => {
app.post("/sign", async (request, response) => {
const ip = request.headers["x-forwarded-for"] || request.connection.remoteAddress;
console.log("POST from ip address:", ip, request.body.message);
if (request.body.message !== currentMessage.replace("**ADDRESS**", request.body.address)) {
response.send(" ⚠️ Secret message mismatch!?! Please reload and try again. Sorry! 😅");
} else {
const recovered = ethers.utils.verifyMessage(request.body.message, request.body.signature);
const userAddress = request.body.address;
if (recovered === userAddress) {
// we now know that the current user is th one that signed and sent this message
const user = await database.collection("users").doc(userAddress).get();
let userObject = {};
if (!user.exists) {
// Create user.
const userRef = database.collection("users").doc(userAddress);
await userRef.set(dummyData);
console.log("New user created: ", userAddress);
userObject = dummyData;
} else {
// Retrieve existing user.
console.log("Retrieving existing user: ", userAddress);
userObject = user.data();
}

const jwt = await firebaseAdmin.auth().createCustomToken(recovered, { isAdmin: !!userObject.isAdmin });

response.json({ ...userObject, token: jwt });
} else {
response.status(401).send(" 🚫 Signature verification failed! Please reload and try again. Sorry! 😅");
}

const signature = request.body.signature;
const userAddress = request.body.address;
const verifyOptions = {
messageId: "login",
address: userAddress,
};

if (!verifySignature(signature, verifyOptions)) {
response.status(401).send(" 🚫 Signature verification failed! Please reload and try again. Sorry! 😅");
return;
}

const user = await database.collection("users").doc(userAddress).get();
if (!user.exists) {
// Create user.
const userRef = database.collection("users").doc(userAddress);
await userRef.set(dummyData);
console.log("New user created: ", userAddress);
}

const isAdmin = !!user.data().isAdmin;
const jwt = await firebaseAdmin.auth().createCustomToken(userAddress, { isAdmin });

response.json({ isAdmin, token: jwt });
});

app.get("/user", userOnly, async (request, response) => {
Expand All @@ -116,10 +115,21 @@ app.get("/user", userOnly, async (request, response) => {
});

app.post("/challenges", userOnly, async (request, response) => {
const { challengeId, deployedUrl, branchUrl } = request.body;
const { challengeId, deployedUrl, branchUrl, signature } = request.body;
const address = request.address;
console.log("POST /challenges: ", address, challengeId, deployedUrl, branchUrl);

const verifyOptions = {
messageId: "challengeSubmit",
address,
challengeId,
};

if (!verifySignature(signature, verifyOptions)) {
response.status(401).send(" 🚫 Signature verification failed! Please reload and try again. Sorry! 😅");
return;
}

const userRef = await database.collection("users").doc(address);
const user = await userRef.get();
if (user.exists) {
Expand Down Expand Up @@ -151,7 +161,22 @@ async function setChallengeStatus(userAddress, challengeId, newStatus, comment)
}

app.patch("/challenges", adminOnly, async (request, response) => {
const { userAddress, challengeId, newStatus, comment } = request.body.params;
const { userAddress, challengeId, newStatus, comment, signature } = request.body.params;
const address = request.address;

const verifyOptions = {
messageId: "challengeReview",
address,
userAddress,
challengeId,
newStatus,
};

if (!verifySignature(signature, verifyOptions)) {
response.status(401).send(" 🚫 Signature verification failed! Please reload and try again. Sorry! 😅");
return;
}

if (newStatus !== "ACCEPTED" && newStatus !== "REJECTED") {
response.status(400).send("Invalid status");
} else {
Expand Down
43 changes: 43 additions & 0 deletions packages/backend/utils/sign.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,43 @@
const ethers = require("ethers");

const getSignMessageForId = (messageId, options) => {
switch (messageId) {
case "challengeSubmit":
// ToDo. Submission nonce.
return JSON.stringify({
messageId,
address: options.address,
challengeId: options.challengeId,
});
case "challengeReview":
// ToDo. Submission nonce.
return JSON.stringify({
messageId,
userAddress: options.userAddress,
challengeId: options.challengeId,
newStatus: options.newStatus,
});
case "login":
default:
return JSON.stringify({
messageId,
address: options.address,
});
}
};

const verifySignature = (signature, verifyOptions) => {
const trustedMessage = getSignMessageForId(verifyOptions.messageId, verifyOptions);
const signingAddress = ethers.utils.verifyMessage(trustedMessage, signature);

console.log("trustedMessage", trustedMessage);
console.log("signingAddress ", signingAddress);
console.log("verifyOptions.address", verifyOptions.address);

return signingAddress === verifyOptions.address;
};

module.exports = {
getSignMessageForId,
verifySignature,
};
4 changes: 2 additions & 2 deletions packages/react-app/src/App.jsx
Original file line number Diff line number Diff line change
Expand Up @@ -347,11 +347,11 @@ function App() {
<BuilderProfileView serverUrl={serverUrl} mainnetProvider={mainnetProvider} />
</Route>
<Route path="/challenge/:challengeId">
<ChallengeDetailView serverUrl={serverUrl} address={address} jwt={jwt} />
<ChallengeDetailView serverUrl={serverUrl} address={address} jwt={jwt} userProvider={userProvider} />
</Route>
{/* ToDo: Protect this route on the frontend? */}
<Route path="/challenge-review" exact>
<ChallengeReviewView serverUrl={serverUrl} jwt={jwt} address={address} />
<ChallengeReviewView serverUrl={serverUrl} jwt={jwt} address={address} userProvider={userProvider} />
</Route>
<Route path="/jwt-test">
<JwtTest serverUrl={serverUrl} jwt={jwt} userProvider={userProvider} />
Expand Down
34 changes: 33 additions & 1 deletion packages/react-app/src/components/ChallengeSubmission.jsx
Original file line number Diff line number Diff line change
Expand Up @@ -7,21 +7,53 @@ const { Text, Title } = Typography;

const serverPath = "challenges";

export default function ChallengeSubmission({ challenge, serverUrl, address, token }) {
export default function ChallengeSubmission({ challenge, serverUrl, address, token, userProvider }) {
const { challengeId } = useParams();
const [isSubmitting, setIsSubmitting] = useState(false);

const onFinish = async values => {
const { deployedUrl, branchUrl } = values;
setIsSubmitting(true);

let signMessage;
try {
const signMessageResponse = await axios.get(serverUrl + `sign-message`, {
params: {
messageId: "challengeSubmit",
address,
challengeId,
},
});

signMessage = JSON.stringify(signMessageResponse.data);
} catch (error) {
notification.error({
message: "Can't get the message to sign. Please try again.",
description: error.toString(),
});
setIsSubmitting(false);
return;
}

let signature;
try {
signature = await userProvider.send("personal_sign", [signMessage, address]);
} catch (error) {
notification.error({
message: "The signature was cancelled",
});
setIsSubmitting(false);
return;
}

try {
await axios.post(
serverUrl + serverPath,
{
challengeId,
deployedUrl,
branchUrl,
signature,
},
{
headers: {
Expand Down
10 changes: 8 additions & 2 deletions packages/react-app/src/views/ChallengeDetailView.jsx
Original file line number Diff line number Diff line change
Expand Up @@ -6,7 +6,7 @@ import ChallengeSubmission from "../components/ChallengeSubmission";

const { Title, Paragraph, Link: AntdLink } = Typography;

export default function ChallengeDetailView({ serverUrl, address, jwt }) {
export default function ChallengeDetailView({ serverUrl, address, jwt, userProvider }) {
const { challengeId } = useParams();
const history = useHistory();
if (jwt == null || jwt === "") {
Expand All @@ -30,7 +30,13 @@ export default function ChallengeDetailView({ serverUrl, address, jwt }) {
<AntdLink href={challenge.url} target="_blank">
Link to challenge
</AntdLink>
<ChallengeSubmission challenge={challenge} serverUrl={serverUrl} address={address} token={jwt} />
<ChallengeSubmission
challenge={challenge}
serverUrl={serverUrl}
address={address}
token={jwt}
userProvider={userProvider}
/>
</Space>
</div>
);
Expand Down
58 changes: 36 additions & 22 deletions packages/react-app/src/views/ChallengeReviewView.jsx
Original file line number Diff line number Diff line change
@@ -1,8 +1,9 @@
import React, { useEffect } from "react";
import axios from "axios";
import { notification } from "antd";
import ChallengeReviewList from "../components/ChallengeReviewList";

export default function ChallengeReviewView({ serverUrl, jwt, address }) {
export default function ChallengeReviewView({ serverUrl, jwt, address, userProvider }) {
const [challenges, setChallenges] = React.useState([]);
const [isLoading, setIsLoading] = React.useState(true);

Expand All @@ -25,29 +26,42 @@ export default function ChallengeReviewView({ serverUrl, jwt, address }) {
fetchSubmittedChallenges();
}, [serverUrl, address]);

async function handleApprove(userAddress, challengeId, comment) {
console.log(`approve ${challengeId} for ${userAddress} with comment ${comment}`);
await axios.patch(
serverUrl + `challenges`,
{
params: { userAddress, challengeId, comment, newStatus: "ACCEPTED" },
},
{
headers: {
authorization: `token ${jwt}`,
address,
const handleSendReview = reviewType => async (userAddress, challengeId, comment) => {
let signMessage;
try {
const signMessageResponse = await axios.get(serverUrl + `sign-message`, {
params: {
messageId: "challengeReview",
userAddress,
challengeId,
newStatus: reviewType,
},
},
);
fetchSubmittedChallenges();
}
});

async function handleReject(userAddress, challengeId, comment) {
console.log(`reject ${challengeId} for ${userAddress} with comment ${comment}`);
signMessage = JSON.stringify(signMessageResponse.data);
} catch (error) {
notification.error({
message: "Can't get the message to sign. Please try again.",
description: error.toString(),
});
return;
}

let signature;
try {
signature = await userProvider.send("personal_sign", [signMessage, address]);
} catch (error) {
notification.error({
message: "The signature was cancelled",
});
return;
}

console.log(`${reviewType.toLowerCase()} ${challengeId} for ${userAddress} with comment ${comment}`);
await axios.patch(
serverUrl + `challenges`,
{
params: { userAddress, challengeId, comment, newStatus: "REJECTED" },
params: { userAddress, challengeId, comment, newStatus: reviewType, signature },
},
{
headers: {
Expand All @@ -57,7 +71,7 @@ export default function ChallengeReviewView({ serverUrl, jwt, address }) {
},
);
fetchSubmittedChallenges();
}
};

return (
<div className="container">
Expand All @@ -66,8 +80,8 @@ export default function ChallengeReviewView({ serverUrl, jwt, address }) {
<ChallengeReviewList
challengeSubmissions={challenges}
isLoading={isLoading}
approveClick={handleApprove}
rejectClick={handleReject}
approveClick={handleSendReview("ACCEPTED")}
rejectClick={handleSendReview("REJECTED")}
/>
</div>
</div>
Expand Down
Loading

0 comments on commit 3cdd996

Please sign in to comment.