diff --git a/.env.sample b/.env.sample index 14b7e579e..f0063fad4 100644 --- a/.env.sample +++ b/.env.sample @@ -17,6 +17,10 @@ GOOGLE_SERVER_APIKEY= # Google Analytics ID GOOGLE_MEASUREMENT_ID= +# ReCAPTCHA +RECAPTCHA_SITE_KEY= +RECAPTCHA_SECRET_KEY= + # JSON Web Token secret to encrypt ID token cookie JWT_SECRET= diff --git a/README.md b/README.md index 2aa40f915..55c8cc011 100644 --- a/README.md +++ b/README.md @@ -46,6 +46,10 @@ For `GOOGLE_*` env variables: - Go back to the Credentials tab and create two API keys - one for the client, and one for the server. - On each API key, add `http://lunch.pink`, `https://lunch.pink`, `http://*.lunch.pink`, and `https://*.lunch.pink` as HTTP referrers. +#### reCAPTCHA + +For `RECAPTCHA_*` env variables, [sign up for reCAPTCHA](https://www.google.com/recaptcha) and generate a site and server key. + #### Database Set up a PostgreSQL database and enter the admin credentials into `.env`. If you want to use another database dialect, change it in `database.js`. diff --git a/package.json b/package.json index ec8bfb4ef..f1b9ccddc 100644 --- a/package.json +++ b/package.json @@ -61,6 +61,7 @@ "react-bootstrap": "^2.7.0", "react-dom": "npm:@preact/compat@*", "react-flip-toolkit": "^7.0.17", + "react-google-recaptcha": "^3.1.0", "react-icons": "^4.7.1", "react-redux": "^8.0.5", "react-scroll": "^1.8.9", @@ -112,6 +113,7 @@ "@types/react-autosuggest": "^10.1.6", "@types/react-dom": "^18.2.4", "@types/react-geosuggest": "^2.7.13", + "@types/react-google-recaptcha": "^2.1.9", "@types/react-scroll": "^1.8.7", "@types/serialize-javascript": "^5.0.2", "@types/sinon": "^10.0.15", @@ -236,4 +238,4 @@ "prepare": "husky install" }, "packageManager": "yarn@3.5.1" -} +} \ No newline at end of file diff --git a/src/client.tsx b/src/client.tsx index cc0279031..be48b17e3 100644 --- a/src/client.tsx +++ b/src/client.tsx @@ -69,6 +69,7 @@ const context: AppContext = { }; }, googleApiKey: window.App.googleApiKey, + recaptchaSiteKey: window.App.recaptchaSiteKey, // Initialize a new Redux store // http://redux.js.org/docs/basics/UsageWithReact.html store, diff --git a/src/components/RestaurantMarker/RestaurantMarker.tsx b/src/components/RestaurantMarker/RestaurantMarker.tsx index e03b874ce..7c2592e08 100644 --- a/src/components/RestaurantMarker/RestaurantMarker.tsx +++ b/src/components/RestaurantMarker/RestaurantMarker.tsx @@ -91,6 +91,7 @@ export interface RestaurantMarkerProps extends AppContext { const RestaurantMarker = ({ restaurant, ...props }: RestaurantMarkerProps) => { const context = { googleApiKey: props.googleApiKey, + recaptchaSiteKey: props.recaptchaSiteKey, insertCss: props.insertCss, store: props.store, pathname: props.pathname, diff --git a/src/config.ts b/src/config.ts index 9b78f4bd9..7fc97c905 100644 --- a/src/config.ts +++ b/src/config.ts @@ -42,3 +42,4 @@ export const auth = { sendgrid: { secret: process.env.SENDGRID_API_KEY }, }; export const googleApiKey = process.env.GOOGLE_CLIENT_APIKEY; +export const recaptchaSiteKey = process.env.RECAPTCHA_SITE_KEY; diff --git a/src/interfaces.ts b/src/interfaces.ts index fdfc24fbf..e1444522d 100644 --- a/src/interfaces.ts +++ b/src/interfaces.ts @@ -620,6 +620,7 @@ export interface App { apiUrl: string; state: NonNormalizedState; googleApiKey: string; + recaptchaSiteKey: string; cache?: Cache; } @@ -630,6 +631,7 @@ export interface WindowWithApp extends Window { export interface AppContext extends ResolveContext { insertCss: InsertCSS; googleApiKey: string; + recaptchaSiteKey: string; query?: URLSearchParams; store: EnhancedStore; fetch: FetchWithCache; diff --git a/src/middlewares/invitation.ts b/src/middlewares/invitation.ts index 288a0a90d..256455437 100644 --- a/src/middlewares/invitation.ts +++ b/src/middlewares/invitation.ts @@ -1,4 +1,5 @@ import { Request, Router } from "express"; +import fetch from "node-fetch"; import { bsHost } from "../config"; import generateToken from "../helpers/generateToken"; import generateUrl from "../helpers/generateUrl"; @@ -69,11 +70,31 @@ Add them here: ${generateUrl( } }) .post("/", async (req, res, next) => { - const { email } = req.body; + const { email, "g-recaptcha-response": clientRecaptchaResponse } = + req.body; try { - if (!email) { - req.flash("error", "Email is required."); + if (!email || !clientRecaptchaResponse) { + if (!email) { + req.flash("error", "Email is required."); + } + if (!clientRecaptchaResponse) { + req.flash("error", "No reCAPTCHA response."); + } + return req.session.save(() => { + res.redirect("/invitation/new"); + }); + } + + const recaptchaResponse = await fetch( + `https://www.google.com/recaptcha/api/siteverify?secret=${process.env.RECAPTCHA_SECRET_KEY}&response=${clientRecaptchaResponse}`, + { + method: "POST", + } + ).then((response) => response.json()); + + if (!recaptchaResponse.success) { + req.flash("error", "Bad reCAPTCHA response. Please try again."); return req.session.save(() => { res.redirect("/invitation/new"); }); diff --git a/src/middlewares/tests/invitation.test.ts b/src/middlewares/tests/invitation.test.ts index 84758f91c..a5fc64f7e 100644 --- a/src/middlewares/tests/invitation.test.ts +++ b/src/middlewares/tests/invitation.test.ts @@ -25,7 +25,13 @@ describe("middlewares/invitation", () => { let UserMock: SequelizeMockObject; let flashSpy: SinonSpy; + let requestParams: Record; + beforeEach(() => { + requestParams = { + email: "jeffrey@labzero.com", + "g-recaptcha-response": "12345", + }; InvitationMock = dbMock.define("invitation", {}); RoleMock = dbMock.define("role", {}); UserMock = dbMock.define("user", {}); @@ -43,6 +49,13 @@ describe("middlewares/invitation", () => { sendMail: sendMailSpy, }, }), + "node-fetch": mockEsmodule({ + default: async () => ({ + json: async () => ({ + success: true, + }), + }), + }), ...deps, }).default; @@ -83,7 +96,7 @@ describe("middlewares/invitation", () => { request(app) .post("/") - .send({ email: "jeffrey@labzero.com" }) + .send(requestParams) .then((r) => { response = r; done(); @@ -118,7 +131,7 @@ describe("middlewares/invitation", () => { request(app) .post("/") - .send({ email: "jeffrey@labzero.com" }) + .send(requestParams) .then((r) => { response = r; done(); @@ -151,7 +164,7 @@ describe("middlewares/invitation", () => { }) ); - return request(app).post("/").send({ email: "jeffrey@labzero.com" }); + return request(app).post("/").send(requestParams); }); it("sends confirmation", () => { diff --git a/src/routes/helpers/submitRecaptchaForm.ts b/src/routes/helpers/submitRecaptchaForm.ts new file mode 100644 index 000000000..208802c2a --- /dev/null +++ b/src/routes/helpers/submitRecaptchaForm.ts @@ -0,0 +1,26 @@ +const submitRecaptchaForm = ( + action: string, + formData: { + email: string; + "g-recaptcha-response": string; + } +) => { + const newForm = document.createElement("form"); + newForm.method = "POST"; + newForm.action = action; + + // Add all original form data + Object.entries(formData).forEach(([key, value]) => { + const input = document.createElement("input"); + input.type = "hidden"; + input.name = key; + input.value = value; + newForm.appendChild(input); + }); + + document.body.appendChild(newForm); + newForm.submit(); + document.body.removeChild(newForm); +}; + +export default submitRecaptchaForm; diff --git a/src/routes/main/invitation/new/New.tsx b/src/routes/main/invitation/new/New.tsx index 5adb3dcf2..8cdca35c8 100644 --- a/src/routes/main/invitation/new/New.tsx +++ b/src/routes/main/invitation/new/New.tsx @@ -5,19 +5,26 @@ import Col from "react-bootstrap/Col"; import Form from "react-bootstrap/Form"; import Container from "react-bootstrap/Container"; import Row from "react-bootstrap/Row"; +import ReCAPTCHA from "react-google-recaptcha"; +import submitRecaptchaForm from "../../../helpers/submitRecaptchaForm"; import s from "./New.scss"; interface NewProps { email?: string; + recaptchaSiteKey: string; } interface NewState { email?: string; } +const action = "/invitation?success=sent"; + class New extends Component { emailField: RefObject; + recaptchaRef: RefObject; + static defaultProps = { email: "", }; @@ -25,6 +32,7 @@ class New extends Component { constructor(props: NewProps) { super(props); this.emailField = createRef(); + this.recaptchaRef = createRef(); this.state = { email: props.email, @@ -38,8 +46,24 @@ class New extends Component { handleChange = (event: ChangeEvent) => this.setState({ email: event.currentTarget.value }); + handleSubmit = async (event: React.TargetedEvent) => { + event.preventDefault(); + + const token = await this.recaptchaRef.current.executeAsync(); + + const email = this.state.email; + + if (email != null) { + submitRecaptchaForm(action, { + email, + "g-recaptcha-response": token, + }); + } + }; + render() { const { email } = this.state; + const { recaptchaSiteKey } = this.props; return (
@@ -49,7 +73,12 @@ class New extends Component { Enter your email address and we will send you a link to confirm your request.

-
+ + diff --git a/src/routes/main/invitation/new/index.tsx b/src/routes/main/invitation/new/index.tsx index 008ed006f..76136cfde 100644 --- a/src/routes/main/invitation/new/index.tsx +++ b/src/routes/main/invitation/new/index.tsx @@ -15,11 +15,12 @@ import New from "./New"; export default (context: RouteContext) => { const email = context.query?.get("email"); + const recaptchaSiteKey = context.recaptchaSiteKey; return { component: ( - + ), title: "Invitation", diff --git a/src/routes/main/landing/Landing.tsx b/src/routes/main/landing/Landing.tsx index 060672ac2..333f78544 100644 --- a/src/routes/main/landing/Landing.tsx +++ b/src/routes/main/landing/Landing.tsx @@ -1,36 +1,70 @@ -import React from "react"; +import React, { useState } from "react"; +import ReCAPTCHA from "react-google-recaptcha"; import withStyles from "isomorphic-style-loader/withStyles"; import Button from "react-bootstrap/Button"; import Col from "react-bootstrap/Col"; import Form from "react-bootstrap/Form"; import Container from "react-bootstrap/Container"; import Row from "react-bootstrap/Row"; +import submitRecaptchaForm from "../../helpers/submitRecaptchaForm"; import search from "./search.png"; import tag from "./tag.png"; import vote from "./vote.png"; import decide from "./decide.png"; import s from "./Landing.scss"; -const renderForm = () => ( - - - Email - { + const formRef = React.createRef(); + const recaptchaRef = React.createRef(); + + const [email, setEmail] = useState(""); + + const handleSubmit = async (event: React.TargetedEvent) => { + event.preventDefault(); + + const token = await recaptchaRef.current.executeAsync(); + + submitRecaptchaForm(action, { + email, + "g-recaptcha-response": token, + }); + }; + + return ( + + - {" "} - - -); + + Email + setEmail(event.currentTarget.value)} + placeholder="Enter your email" + required + type="email" + /> + {" "} + + + ); +}; -const Landing = () => ( +const Landing = ({ recaptchaSiteKey }: { recaptchaSiteKey: string }) => (
@@ -45,7 +79,7 @@ const Landing = () => ( Unsure what to eat? Want to leave the office for a bit and grab some grub with your team? Try Lunch!

- {renderForm()} +
@@ -107,7 +141,7 @@ const Landing = () => (

Sign up today!

- {renderForm()} +
diff --git a/src/routes/main/landing/index.tsx b/src/routes/main/landing/index.tsx index dd976c098..f5b36e5ff 100644 --- a/src/routes/main/landing/index.tsx +++ b/src/routes/main/landing/index.tsx @@ -16,12 +16,13 @@ import Landing from "./Landing"; export default (context: RouteContext) => { const state = context.store.getState(); + const recaptchaSiteKey = context.recaptchaSiteKey; return renderIfLoggedOut(state, () => ({ chunks: ["landing"], component: ( - + ), fullTitle: "Lunch – Team voting for nearby restaurants", diff --git a/src/server.tsx b/src/server.tsx index dcb56f95c..7b7207e63 100644 --- a/src/server.tsx +++ b/src/server.tsx @@ -385,6 +385,7 @@ const render: RequestHandler = async (req, res, next) => { const context: AppContext = { insertCss, googleApiKey: config.googleApiKey!, + recaptchaSiteKey: config.recaptchaSiteKey!, // The twins below are wild, be careful! pathname: req.path, query: new URLSearchParams(req.query as { [key: string]: string }), @@ -451,6 +452,7 @@ const render: RequestHandler = async (req, res, next) => { data.app = { apiUrl: config.api.clientUrl, googleApiKey: config.googleApiKey!, + recaptchaSiteKey: config.recaptchaSiteKey!, state: initialState, cache: route.payload, }; diff --git a/yarn.lock b/yarn.lock index dd9ef3649..bc469917d 100644 --- a/yarn.lock +++ b/yarn.lock @@ -2752,6 +2752,15 @@ __metadata: languageName: node linkType: hard +"@types/react-google-recaptcha@npm:^2.1.9": + version: 2.1.9 + resolution: "@types/react-google-recaptcha@npm:2.1.9" + dependencies: + "@types/react": "*" + checksum: 5a90bb50fe0a49f2e2ceb7950859cfdbe019aa0a75261451fb50ad83dcd4aa360a1941ab6c9b3c627f2b7a0dab2c6c09d6297469d3d4d02ebaf36b3fc34429b6 + languageName: node + linkType: hard + "@types/react-scroll@npm:^1.8.7": version: 1.8.7 resolution: "@types/react-scroll@npm:1.8.7" @@ -9238,6 +9247,7 @@ __metadata: "@types/react-autosuggest": ^10.1.6 "@types/react-dom": ^18.2.4 "@types/react-geosuggest": ^2.7.13 + "@types/react-google-recaptcha": ^2.1.9 "@types/react-scroll": ^1.8.7 "@types/serialize-javascript": ^5.0.2 "@types/sinon": ^10.0.15 @@ -9328,6 +9338,7 @@ __metadata: react-dom: "npm:@preact/compat@*" react-error-overlay: ^4.0.1 react-flip-toolkit: ^7.0.17 + react-google-recaptcha: ^3.1.0 react-icons: ^4.7.1 react-redux: ^8.0.5 react-refresh: ^0.14.0 @@ -11277,7 +11288,7 @@ __metadata: languageName: node linkType: hard -"prop-types@npm:^15.5.7, prop-types@npm:^15.6.2, prop-types@npm:^15.7.2, prop-types@npm:^15.8.1": +"prop-types@npm:^15.5.0, prop-types@npm:^15.5.7, prop-types@npm:^15.6.2, prop-types@npm:^15.7.2, prop-types@npm:^15.8.1": version: 15.8.1 resolution: "prop-types@npm:15.8.1" dependencies: @@ -11449,6 +11460,18 @@ __metadata: languageName: node linkType: hard +"react-async-script@npm:^1.2.0": + version: 1.2.0 + resolution: "react-async-script@npm:1.2.0" + dependencies: + hoist-non-react-statics: ^3.3.0 + prop-types: ^15.5.0 + peerDependencies: + react: ">=16.4.1" + checksum: 303890eeaf9e18d59fca77f9c891bf3b52d2ec9ea88f0af9d19c160a1f101b447c5104ca46e2dd84c19de756d4797f1f054d041b888a3d57204d9145f4b1b532 + languageName: node + linkType: hard + "react-autosuggest@npm:^10.0.2": version: 10.1.0 resolution: "react-autosuggest@npm:10.1.0" @@ -11559,6 +11582,18 @@ __metadata: languageName: node linkType: hard +"react-google-recaptcha@npm:^3.1.0": + version: 3.1.0 + resolution: "react-google-recaptcha@npm:3.1.0" + dependencies: + prop-types: ^15.5.0 + react-async-script: ^1.2.0 + peerDependencies: + react: ">=16.4.1" + checksum: 9dc64daf9684d979b1f66d97e00a42c9ceaa9b9fe8b29c4d02d77edf86781e2008f2ae1bef14509351ff3b2ffbcc26463f0d88dd612d7280b455b8c07101e663 + languageName: node + linkType: hard + "react-icons@npm:^4.7.1": version: 4.7.1 resolution: "react-icons@npm:4.7.1"