Skip to content

Commit

Permalink
Merge pull request #372 from labzero/jeffrey/recaptcha
Browse files Browse the repository at this point in the history
Implement reCAPTCHA
  • Loading branch information
JeffreyATW authored Nov 19, 2024
2 parents 8e6ec02 + d26bbc8 commit 9e10cbf
Show file tree
Hide file tree
Showing 16 changed files with 209 additions and 32 deletions.
4 changes: 4 additions & 0 deletions .env.sample
Original file line number Diff line number Diff line change
Expand Up @@ -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=

Expand Down
4 changes: 4 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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`.
Expand Down
4 changes: 3 additions & 1 deletion package.json
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down Expand Up @@ -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",
Expand Down Expand Up @@ -236,4 +238,4 @@
"prepare": "husky install"
},
"packageManager": "yarn@3.5.1"
}
}
1 change: 1 addition & 0 deletions src/client.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down
1 change: 1 addition & 0 deletions src/components/RestaurantMarker/RestaurantMarker.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down
1 change: 1 addition & 0 deletions src/config.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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;
2 changes: 2 additions & 0 deletions src/interfaces.ts
Original file line number Diff line number Diff line change
Expand Up @@ -620,6 +620,7 @@ export interface App {
apiUrl: string;
state: NonNormalizedState;
googleApiKey: string;
recaptchaSiteKey: string;
cache?: Cache;
}

Expand All @@ -630,6 +631,7 @@ export interface WindowWithApp extends Window {
export interface AppContext extends ResolveContext {
insertCss: InsertCSS;
googleApiKey: string;
recaptchaSiteKey: string;
query?: URLSearchParams;
store: EnhancedStore<State, Action>;
fetch: FetchWithCache;
Expand Down
27 changes: 24 additions & 3 deletions src/middlewares/invitation.ts
Original file line number Diff line number Diff line change
@@ -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";
Expand Down Expand Up @@ -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");
});
Expand Down
19 changes: 16 additions & 3 deletions src/middlewares/tests/invitation.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -25,7 +25,13 @@ describe("middlewares/invitation", () => {
let UserMock: SequelizeMockObject;
let flashSpy: SinonSpy;

let requestParams: Record<string, string>;

beforeEach(() => {
requestParams = {
email: "jeffrey@labzero.com",
"g-recaptcha-response": "12345",
};
InvitationMock = dbMock.define("invitation", {});
RoleMock = dbMock.define("role", {});
UserMock = dbMock.define("user", {});
Expand All @@ -43,6 +49,13 @@ describe("middlewares/invitation", () => {
sendMail: sendMailSpy,
},
}),
"node-fetch": mockEsmodule({
default: async () => ({
json: async () => ({
success: true,
}),
}),
}),
...deps,
}).default;

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

request(app)
.post("/")
.send({ email: "jeffrey@labzero.com" })
.send(requestParams)
.then((r) => {
response = r;
done();
Expand Down Expand Up @@ -118,7 +131,7 @@ describe("middlewares/invitation", () => {

request(app)
.post("/")
.send({ email: "jeffrey@labzero.com" })
.send(requestParams)
.then((r) => {
response = r;
done();
Expand Down Expand Up @@ -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", () => {
Expand Down
26 changes: 26 additions & 0 deletions src/routes/helpers/submitRecaptchaForm.ts
Original file line number Diff line number Diff line change
@@ -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;
31 changes: 30 additions & 1 deletion src/routes/main/invitation/new/New.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -5,26 +5,34 @@ 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<NewProps, NewState> {
emailField: RefObject<HTMLInputElement>;

recaptchaRef: RefObject<any>;

static defaultProps = {
email: "",
};

constructor(props: NewProps) {
super(props);
this.emailField = createRef();
this.recaptchaRef = createRef();

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

handleSubmit = async (event: React.TargetedEvent<HTMLFormElement>) => {
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 (
<div className={s.root}>
Expand All @@ -49,7 +73,12 @@ class New extends Component<NewProps, NewState> {
Enter your email address and we will send you a link to confirm your
request.
</p>
<form action="/invitation?success=sent" method="post">
<form action={action} method="post" onSubmit={this.handleSubmit}>
<ReCAPTCHA
ref={this.recaptchaRef}
size="invisible"
sitekey={recaptchaSiteKey}
/>
<Row>
<Col sm={6}>
<Form.Group className="mb-3" controlId="invitationNew-email">
Expand Down
3 changes: 2 additions & 1 deletion src/routes/main/invitation/new/index.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -15,11 +15,12 @@ import New from "./New";

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

return {
component: (
<LayoutContainer path={context.pathname}>
<New email={email} />
<New email={email} recaptchaSiteKey={recaptchaSiteKey} />
</LayoutContainer>
),
title: "Invitation",
Expand Down
Loading

0 comments on commit 9e10cbf

Please sign in to comment.