diff --git a/.github/workflows/build-and-deploy.yml b/.github/workflows/build-and-deploy.yml index 7d515512..c9d4ab12 100644 --- a/.github/workflows/build-and-deploy.yml +++ b/.github/workflows/build-and-deploy.yml @@ -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 }} diff --git a/.github/workflows/clean-up-pr.yml b/.github/workflows/clean-up-pr.yml index e28c3280..5658d377 100644 --- a/.github/workflows/clean-up-pr.yml +++ b/.github/workflows/clean-up-pr.yml @@ -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 }} diff --git a/README.md b/README.md index a10720f6..f8321cbd 100644 --- a/README.md +++ b/README.md @@ -82,6 +82,7 @@ MONGO_URL= SESSION_SECRET= GOOGLE_CLIENT= GOOGLE_SECRET= +GRECAPTCHA_SECRET= ADMIN_EMAILS=[""] ``` diff --git a/api/src/controllers/reviews.ts b/api/src/controllers/reviews.ts index 57bd05be..e1a618f2 100644 --- a/api/src/controllers/reviews.ts +++ b/api/src/controllers/reviews.ts @@ -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(); @@ -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 = { diff --git a/api/src/helpers/recaptcha.ts b/api/src/helpers/recaptcha.ts new file mode 100644 index 00000000..3fc01e83 --- /dev/null +++ b/api/src/helpers/recaptcha.ts @@ -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; +} diff --git a/api/src/types/environment.d.ts b/api/src/types/environment.d.ts index 5f7311b0..852920c7 100644 --- a/api/src/types/environment.d.ts +++ b/api/src/types/environment.d.ts @@ -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 {} \ No newline at end of file +export {}; diff --git a/api/src/types/types.ts b/api/src/types/types.ts index 6809c7cf..20210e88 100644 --- a/api/src/types/types.ts +++ b/api/src/types/types.ts @@ -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 { /** diff --git a/site/src/component/ReviewForm/ReviewForm.tsx b/site/src/component/ReviewForm/ReviewForm.tsx index 045e532a..c5025eb7 100644 --- a/site/src/component/ReviewForm/ReviewForm.tsx +++ b/site/src/component/ReviewForm/ReviewForm.tsx @@ -57,7 +57,7 @@ const ReviewForm: FC = (props) => { const [textbook, setTextbook] = useState(false); const [attendance, setAttendance] = useState(false); const [selectedTags, setSelectedTags] = useState([]); - const [verified, setVerified] = useState(false); + const [captchaToken, setCaptchaToken] = useState(""); const [submitted, setSubmitted] = useState(false); const [overCharLimit, setOverCharLimit] = useState(false); const [cookies, setCookie] = useCookies(["user"]); @@ -86,7 +86,7 @@ const ReviewForm: FC = (props) => { const postReview = async (review: ReviewData) => { const res = await axios.post("/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 { @@ -110,7 +110,7 @@ const ReviewForm: FC = (props) => { return; } - if (!verified) { + if (!captchaToken) { alert("Please complete the CAPTCHA"); return; } @@ -136,7 +136,7 @@ const ReviewForm: FC = (props) => { textbook: textbook, attendance: attendance, tags: selectedTags, - verified: false, + captchaToken: captchaToken, }; if (content.length > 500) { setOverCharLimit(true); @@ -424,16 +424,7 @@ const ReviewForm: FC = (props) => { { - // if verified - if (token) { - setVerified(true); - } - // captcha expired - else { - setVerified(false); - } - }} + onChange={(token) => setCaptchaToken(token ?? "")} />