Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Implement reCAPTCHA #372

Merged
merged 1 commit into from
Nov 19, 2024
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
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": "[email protected]"
}
}
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: "[email protected]",
"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: "[email protected]" })
.send(requestParams)
.then((r) => {
response = r;
done();
Expand Down Expand Up @@ -118,7 +131,7 @@ describe("middlewares/invitation", () => {

request(app)
.post("/")
.send({ email: "[email protected]" })
.send(requestParams)
.then((r) => {
response = r;
done();
Expand Down Expand Up @@ -151,7 +164,7 @@ describe("middlewares/invitation", () => {
})
);

return request(app).post("/").send({ email: "[email protected]" });
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