Skip to content

Commit 9e10cbf

Browse files
authored
Merge pull request #372 from labzero/jeffrey/recaptcha
Implement reCAPTCHA
2 parents 8e6ec02 + d26bbc8 commit 9e10cbf

File tree

16 files changed

+209
-32
lines changed

16 files changed

+209
-32
lines changed

.env.sample

+4
Original file line numberDiff line numberDiff line change
@@ -17,6 +17,10 @@ GOOGLE_SERVER_APIKEY=
1717
# Google Analytics ID
1818
GOOGLE_MEASUREMENT_ID=
1919

20+
# ReCAPTCHA
21+
RECAPTCHA_SITE_KEY=
22+
RECAPTCHA_SECRET_KEY=
23+
2024
# JSON Web Token secret to encrypt ID token cookie
2125
JWT_SECRET=
2226

README.md

+4
Original file line numberDiff line numberDiff line change
@@ -46,6 +46,10 @@ For `GOOGLE_*` env variables:
4646
- Go back to the Credentials tab and create two API keys - one for the client, and one for the server.
4747
- On each API key, add `http://lunch.pink`, `https://lunch.pink`, `http://*.lunch.pink`, and `https://*.lunch.pink` as HTTP referrers.
4848

49+
#### reCAPTCHA
50+
51+
For `RECAPTCHA_*` env variables, [sign up for reCAPTCHA](https://www.google.com/recaptcha) and generate a site and server key.
52+
4953
#### Database
5054

5155
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`.

package.json

+3-1
Original file line numberDiff line numberDiff line change
@@ -61,6 +61,7 @@
6161
"react-bootstrap": "^2.7.0",
6262
"react-dom": "npm:@preact/compat@*",
6363
"react-flip-toolkit": "^7.0.17",
64+
"react-google-recaptcha": "^3.1.0",
6465
"react-icons": "^4.7.1",
6566
"react-redux": "^8.0.5",
6667
"react-scroll": "^1.8.9",
@@ -112,6 +113,7 @@
112113
"@types/react-autosuggest": "^10.1.6",
113114
"@types/react-dom": "^18.2.4",
114115
"@types/react-geosuggest": "^2.7.13",
116+
"@types/react-google-recaptcha": "^2.1.9",
115117
"@types/react-scroll": "^1.8.7",
116118
"@types/serialize-javascript": "^5.0.2",
117119
"@types/sinon": "^10.0.15",
@@ -236,4 +238,4 @@
236238
"prepare": "husky install"
237239
},
238240
"packageManager": "yarn@3.5.1"
239-
}
241+
}

src/client.tsx

+1
Original file line numberDiff line numberDiff line change
@@ -69,6 +69,7 @@ const context: AppContext = {
6969
};
7070
},
7171
googleApiKey: window.App.googleApiKey,
72+
recaptchaSiteKey: window.App.recaptchaSiteKey,
7273
// Initialize a new Redux store
7374
// http://redux.js.org/docs/basics/UsageWithReact.html
7475
store,

src/components/RestaurantMarker/RestaurantMarker.tsx

+1
Original file line numberDiff line numberDiff line change
@@ -91,6 +91,7 @@ export interface RestaurantMarkerProps extends AppContext {
9191
const RestaurantMarker = ({ restaurant, ...props }: RestaurantMarkerProps) => {
9292
const context = {
9393
googleApiKey: props.googleApiKey,
94+
recaptchaSiteKey: props.recaptchaSiteKey,
9495
insertCss: props.insertCss,
9596
store: props.store,
9697
pathname: props.pathname,

src/config.ts

+1
Original file line numberDiff line numberDiff line change
@@ -42,3 +42,4 @@ export const auth = {
4242
sendgrid: { secret: process.env.SENDGRID_API_KEY },
4343
};
4444
export const googleApiKey = process.env.GOOGLE_CLIENT_APIKEY;
45+
export const recaptchaSiteKey = process.env.RECAPTCHA_SITE_KEY;

src/interfaces.ts

+2
Original file line numberDiff line numberDiff line change
@@ -620,6 +620,7 @@ export interface App {
620620
apiUrl: string;
621621
state: NonNormalizedState;
622622
googleApiKey: string;
623+
recaptchaSiteKey: string;
623624
cache?: Cache;
624625
}
625626

@@ -630,6 +631,7 @@ export interface WindowWithApp extends Window {
630631
export interface AppContext extends ResolveContext {
631632
insertCss: InsertCSS;
632633
googleApiKey: string;
634+
recaptchaSiteKey: string;
633635
query?: URLSearchParams;
634636
store: EnhancedStore<State, Action>;
635637
fetch: FetchWithCache;

src/middlewares/invitation.ts

+24-3
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,5 @@
11
import { Request, Router } from "express";
2+
import fetch from "node-fetch";
23
import { bsHost } from "../config";
34
import generateToken from "../helpers/generateToken";
45
import generateUrl from "../helpers/generateUrl";
@@ -69,11 +70,31 @@ Add them here: ${generateUrl(
6970
}
7071
})
7172
.post("/", async (req, res, next) => {
72-
const { email } = req.body;
73+
const { email, "g-recaptcha-response": clientRecaptchaResponse } =
74+
req.body;
7375

7476
try {
75-
if (!email) {
76-
req.flash("error", "Email is required.");
77+
if (!email || !clientRecaptchaResponse) {
78+
if (!email) {
79+
req.flash("error", "Email is required.");
80+
}
81+
if (!clientRecaptchaResponse) {
82+
req.flash("error", "No reCAPTCHA response.");
83+
}
84+
return req.session.save(() => {
85+
res.redirect("/invitation/new");
86+
});
87+
}
88+
89+
const recaptchaResponse = await fetch(
90+
`https://www.google.com/recaptcha/api/siteverify?secret=${process.env.RECAPTCHA_SECRET_KEY}&response=${clientRecaptchaResponse}`,
91+
{
92+
method: "POST",
93+
}
94+
).then((response) => response.json());
95+
96+
if (!recaptchaResponse.success) {
97+
req.flash("error", "Bad reCAPTCHA response. Please try again.");
7798
return req.session.save(() => {
7899
res.redirect("/invitation/new");
79100
});

src/middlewares/tests/invitation.test.ts

+16-3
Original file line numberDiff line numberDiff line change
@@ -25,7 +25,13 @@ describe("middlewares/invitation", () => {
2525
let UserMock: SequelizeMockObject;
2626
let flashSpy: SinonSpy;
2727

28+
let requestParams: Record<string, string>;
29+
2830
beforeEach(() => {
31+
requestParams = {
32+
email: "jeffrey@labzero.com",
33+
"g-recaptcha-response": "12345",
34+
};
2935
InvitationMock = dbMock.define("invitation", {});
3036
RoleMock = dbMock.define("role", {});
3137
UserMock = dbMock.define("user", {});
@@ -43,6 +49,13 @@ describe("middlewares/invitation", () => {
4349
sendMail: sendMailSpy,
4450
},
4551
}),
52+
"node-fetch": mockEsmodule({
53+
default: async () => ({
54+
json: async () => ({
55+
success: true,
56+
}),
57+
}),
58+
}),
4659
...deps,
4760
}).default;
4861

@@ -83,7 +96,7 @@ describe("middlewares/invitation", () => {
8396

8497
request(app)
8598
.post("/")
86-
.send({ email: "jeffrey@labzero.com" })
99+
.send(requestParams)
87100
.then((r) => {
88101
response = r;
89102
done();
@@ -118,7 +131,7 @@ describe("middlewares/invitation", () => {
118131

119132
request(app)
120133
.post("/")
121-
.send({ email: "jeffrey@labzero.com" })
134+
.send(requestParams)
122135
.then((r) => {
123136
response = r;
124137
done();
@@ -151,7 +164,7 @@ describe("middlewares/invitation", () => {
151164
})
152165
);
153166

154-
return request(app).post("/").send({ email: "jeffrey@labzero.com" });
167+
return request(app).post("/").send(requestParams);
155168
});
156169

157170
it("sends confirmation", () => {
+26
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,26 @@
1+
const submitRecaptchaForm = (
2+
action: string,
3+
formData: {
4+
email: string;
5+
"g-recaptcha-response": string;
6+
}
7+
) => {
8+
const newForm = document.createElement("form");
9+
newForm.method = "POST";
10+
newForm.action = action;
11+
12+
// Add all original form data
13+
Object.entries(formData).forEach(([key, value]) => {
14+
const input = document.createElement("input");
15+
input.type = "hidden";
16+
input.name = key;
17+
input.value = value;
18+
newForm.appendChild(input);
19+
});
20+
21+
document.body.appendChild(newForm);
22+
newForm.submit();
23+
document.body.removeChild(newForm);
24+
};
25+
26+
export default submitRecaptchaForm;

src/routes/main/invitation/new/New.tsx

+30-1
Original file line numberDiff line numberDiff line change
@@ -5,26 +5,34 @@ import Col from "react-bootstrap/Col";
55
import Form from "react-bootstrap/Form";
66
import Container from "react-bootstrap/Container";
77
import Row from "react-bootstrap/Row";
8+
import ReCAPTCHA from "react-google-recaptcha";
9+
import submitRecaptchaForm from "../../../helpers/submitRecaptchaForm";
810
import s from "./New.scss";
911

1012
interface NewProps {
1113
email?: string;
14+
recaptchaSiteKey: string;
1215
}
1316

1417
interface NewState {
1518
email?: string;
1619
}
1720

21+
const action = "/invitation?success=sent";
22+
1823
class New extends Component<NewProps, NewState> {
1924
emailField: RefObject<HTMLInputElement>;
2025

26+
recaptchaRef: RefObject<any>;
27+
2128
static defaultProps = {
2229
email: "",
2330
};
2431

2532
constructor(props: NewProps) {
2633
super(props);
2734
this.emailField = createRef();
35+
this.recaptchaRef = createRef();
2836

2937
this.state = {
3038
email: props.email,
@@ -38,8 +46,24 @@ class New extends Component<NewProps, NewState> {
3846
handleChange = (event: ChangeEvent<HTMLInputElement | HTMLTextAreaElement>) =>
3947
this.setState({ email: event.currentTarget.value });
4048

49+
handleSubmit = async (event: React.TargetedEvent<HTMLFormElement>) => {
50+
event.preventDefault();
51+
52+
const token = await this.recaptchaRef.current.executeAsync();
53+
54+
const email = this.state.email;
55+
56+
if (email != null) {
57+
submitRecaptchaForm(action, {
58+
email,
59+
"g-recaptcha-response": token,
60+
});
61+
}
62+
};
63+
4164
render() {
4265
const { email } = this.state;
66+
const { recaptchaSiteKey } = this.props;
4367

4468
return (
4569
<div className={s.root}>
@@ -49,7 +73,12 @@ class New extends Component<NewProps, NewState> {
4973
Enter your email address and we will send you a link to confirm your
5074
request.
5175
</p>
52-
<form action="/invitation?success=sent" method="post">
76+
<form action={action} method="post" onSubmit={this.handleSubmit}>
77+
<ReCAPTCHA
78+
ref={this.recaptchaRef}
79+
size="invisible"
80+
sitekey={recaptchaSiteKey}
81+
/>
5382
<Row>
5483
<Col sm={6}>
5584
<Form.Group className="mb-3" controlId="invitationNew-email">

src/routes/main/invitation/new/index.tsx

+2-1
Original file line numberDiff line numberDiff line change
@@ -15,11 +15,12 @@ import New from "./New";
1515

1616
export default (context: RouteContext<AppRoute, AppContext>) => {
1717
const email = context.query?.get("email");
18+
const recaptchaSiteKey = context.recaptchaSiteKey;
1819

1920
return {
2021
component: (
2122
<LayoutContainer path={context.pathname}>
22-
<New email={email} />
23+
<New email={email} recaptchaSiteKey={recaptchaSiteKey} />
2324
</LayoutContainer>
2425
),
2526
title: "Invitation",

0 commit comments

Comments
 (0)