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

Validate form verification on the backend #392

Merged
merged 14 commits into from
Dec 19, 2023
Merged
1 change: 1 addition & 0 deletions .github/workflows/build-and-deploy.yml
Original file line number Diff line number Diff line change
Expand Up @@ -40,6 +40,7 @@ jobs:
SESSION_SECRET: ${{secrets.SESSION_SECRET}}
GOOGLE_CLIENT: ${{secrets.GOOGLE_CLIENT}}
GOOGLE_SECRET: ${{secrets.GOOGLE_SECRET}}
GRECAPTCHA_SECRET: ${{secrets.GRECAPTCHA_SECRET}}
ADMIN_EMAILS: ${{secrets.ADMIN_EMAILS}}
PRODUCTION_DOMAIN: ${{secrets.PRODUCTION_DOMAIN}}
AWS_ACCESS_KEY_ID: ${{ secrets.AWS_ACCESS_KEY_ID }}
Expand Down
1 change: 1 addition & 0 deletions .github/workflows/clean-up-pr.yml
Original file line number Diff line number Diff line change
Expand Up @@ -39,6 +39,7 @@ jobs:
SESSION_SECRET: ${{secrets.SESSION_SECRET}}
GOOGLE_CLIENT: ${{secrets.GOOGLE_CLIENT}}
GOOGLE_SECRET: ${{secrets.GOOGLE_SECRET}}
GRECAPTCHA_SECRET: ${{secrets.GRECAPTCHA_SECRET}}
ADMIN_EMAILS: ${{secrets.ADMIN_EMAILS}}
PRODUCTION_DOMAIN: ${{secrets.PRODUCTION_DOMAIN}}
AWS_ACCESS_KEY_ID: ${{ secrets.AWS_ACCESS_KEY_ID }}
Expand Down
1 change: 1 addition & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -82,6 +82,7 @@ MONGO_URL=<secret>
SESSION_SECRET=<secret>
GOOGLE_CLIENT=<client>
GOOGLE_SECRET=<secret>
GRECAPTCHA_SECRET=<secret>
ADMIN_EMAILS=["<your email>"]
```

Expand Down
14 changes: 10 additions & 4 deletions api/src/controllers/reviews.ts
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,8 @@ import express from "express";
import { ObjectID } from "mongodb";
import { VoteData } from "../types/types";
import { COLLECTION_NAMES, getCollection, addDocument, getDocuments, updateDocument, deleteDocument, deleteDocuments } from "../helpers/mongo";
import axios from "axios";
import { verifyCaptcha } from "../helpers/recaptcha";

const router = express.Router();

Expand Down Expand Up @@ -136,10 +138,14 @@ router.post("/", async function (req, res, next) {
verified: true,
})
.count();
// set the review as verified
if (verifiedCount >= 3) {
req.body.verified = true;
}

// Set on server so the client can't automatically verify their own review.
req.body.verified = verifiedCount >= 3; // auto-verify if use has posted 3+ reviews

// Verify the captcha
const verifyResponse = await verifyCaptcha(req.body);
if (!verifyResponse?.success) return res.status(400).json({ error: "ReCAPTCHA token is invalid", data: verifyResponse });
delete req.body.captchaToken; // so it doesn't get stored in DB

//check if review already exists for same professor, course, and user
let query: ReviewFilter = {
Expand Down
19 changes: 19 additions & 0 deletions api/src/helpers/recaptcha.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,19 @@
import axios from 'axios';
import { ReviewData } from '../types/types';

export async function verifyCaptcha(review: ReviewData) {
const reqBody = {
secret: process.env.GRECAPTCHA_SECRET ?? '',
response: review.captchaToken ?? '',
};
const query = new URLSearchParams(reqBody);
const response = await axios
.post('https://www.google.com/recaptcha/api/siteverify?' + query)
.then((x) => x.data)
.catch((e) => {
console.error('Error validating captcha response', e);
return { success: false };
});

return response;
}
39 changes: 20 additions & 19 deletions api/src/types/environment.d.ts
Original file line number Diff line number Diff line change
@@ -1,24 +1,25 @@
declare global {
namespace NodeJS {
/**
* Define schema for environment variables
*/
interface ProcessEnv {
MONGO_URL: string;
NODE_ENV: 'development' | 'production' | 'staging';
PORT?: string;
PUBLIC_API_URL: string;
PUBLIC_API_GRAPHQL_URL: string;
MONGO_URL: string;
SESSION_SECRET: string;
GOOGLE_CLIENT: string;
GOOGLE_SECRET: string;
PRODUCTION_DOMAIN: string;
GITHUB_ADMIN_USERNAMES: string;
ADMIN_EMAILS: string;
}
namespace NodeJS {
/**
* Define schema for environment variables
*/
interface ProcessEnv {
MONGO_URL: string;
NODE_ENV: 'development' | 'production' | 'staging';
PORT?: string;
PUBLIC_API_URL: string;
PUBLIC_API_GRAPHQL_URL: string;
MONGO_URL: string;
SESSION_SECRET: string;
GOOGLE_CLIENT: string;
GOOGLE_SECRET: string;
GRECAPTCHA_SECRET: string;
PRODUCTION_DOMAIN: string;
GITHUB_ADMIN_USERNAMES: string;
ADMIN_EMAILS: string;
}
}
}

// need to export something to be considered a 'module'
export {}
export {};
24 changes: 24 additions & 0 deletions api/src/types/types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -50,6 +50,30 @@ export interface VoteData {
score: number;
}

/** @todo should have some shared types between server and client */
export interface ReviewData {
_id?: string;
professorID: string;
courseID: string;
userID: string;
userDisplay: string;
reviewContent: string;
rating: number;
difficulty: number;
timestamp: string;
gradeReceived: string;
forCredit: boolean;
quarter: string;
score: number;
takeAgain: boolean;
textbook: boolean;
attendance: boolean;
tags: string[];
verified?: boolean;
captchaToken: string;
}


declare module "express-session" {
export interface SessionData {
/**
Expand Down
19 changes: 5 additions & 14 deletions site/src/component/ReviewForm/ReviewForm.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -57,7 +57,7 @@ const ReviewForm: FC<ReviewFormProps> = (props) => {
const [textbook, setTextbook] = useState<boolean>(false);
const [attendance, setAttendance] = useState<boolean>(false);
const [selectedTags, setSelectedTags] = useState<string[]>([]);
const [verified, setVerified] = useState(false);
const [captchaToken, setCaptchaToken] = useState("");
const [submitted, setSubmitted] = useState(false);
const [overCharLimit, setOverCharLimit] = useState(false);
const [cookies, setCookie] = useCookies(["user"]);
Expand Down Expand Up @@ -86,7 +86,7 @@ const ReviewForm: FC<ReviewFormProps> = (props) => {
const postReview = async (review: ReviewData) => {
const res = await axios.post<ReviewData>("/api/reviews", review).catch((err) => err.response);
if (res.status === 400) {
alert("You have already submitted a review for this course/professor");
alert(res.data.error ?? "You have already submitted a review for this course/professor");
} else if (res.data.hasOwnProperty("error")) {
alert("You must be logged in to add a review!");
} else {
Expand All @@ -110,7 +110,7 @@ const ReviewForm: FC<ReviewFormProps> = (props) => {
return;
}

if (!verified) {
if (!captchaToken) {
alert("Please complete the CAPTCHA");
return;
}
Expand All @@ -136,7 +136,7 @@ const ReviewForm: FC<ReviewFormProps> = (props) => {
textbook: textbook,
attendance: attendance,
tags: selectedTags,
verified: false,
captchaToken: captchaToken,
};
if (content.length > 500) {
setOverCharLimit(true);
Expand Down Expand Up @@ -424,16 +424,7 @@ const ReviewForm: FC<ReviewFormProps> = (props) => {
<ReCAPTCHA
className="d-inline"
sitekey="6Le6rfIUAAAAAOdqD2N-QUEW9nEtfeNyzkXucLm4"
onChange={(token) => {
// if verified
if (token) {
setVerified(true);
}
// captcha expired
else {
setVerified(false);
}
}}
onChange={(token) => setCaptchaToken(token ?? "")}
/>
<div>
<Button className="py-2 px-4 float-right" type="submit" variant="secondary">
Expand Down
3 changes: 2 additions & 1 deletion site/src/types/types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -80,7 +80,8 @@ export interface ReviewData {
textbook: boolean;
attendance: boolean;
tags: string[];
verified: boolean;
verified?: boolean;
captchaToken?: string;
}

export interface ReportData {
Expand Down
1 change: 1 addition & 0 deletions stacks/backend.ts
Original file line number Diff line number Diff line change
Expand Up @@ -39,6 +39,7 @@ export function BackendStack({app, stack}: StackContext) {
PUBLIC_API_GRAPHQL_URL: process.env.PUBLIC_API_GRAPHQL_URL,
GOOGLE_CLIENT: process.env.GOOGLE_CLIENT,
GOOGLE_SECRET: process.env.GOOGLE_SECRET,
GRECAPTCHA_SECRET: process.env.GRECAPTCHA_SECRET,
PRODUCTION_DOMAIN: process.env.PRODUCTION_DOMAIN,
ADMIN_EMAILS: process.env.ADMIN_EMAILS,
NODE_ENV: process.env.NODE_ENV
Expand Down