diff --git a/.github/workflows/client.yml b/.github/workflows/client.yml index 8c0ac0286..186545bdb 100644 --- a/.github/workflows/client.yml +++ b/.github/workflows/client.yml @@ -45,7 +45,7 @@ jobs: env: VITE_GIT_REPO: "GitHub:zkldi/Tachi" VITE_TCHIC_MODE: "boku" - VITE_RECAPTCHA_KEY: "6LcsYbIpAAAAAEJffjIXmbQcxj_SBZG7BnSPjF4L" + VITE_HCAPTCHA_SITEKEY: "4d968a2e-80ec-4b02-80be-c2205a4e7bb8" VITE_SERVER_URL: "https://boku.tachi.ac" VITE_DISCORD: "https://discord.gg/E4n8ZZF5ES" VITE_CDN_URL: "https://cdn-boku.tachi.ac" @@ -64,7 +64,7 @@ jobs: VITE_TCHIC_MODE: "kamai" VITE_CDN_URL: "https://cdn-kamai.tachi.ac" VITE_GIT_REPO: "GitHub:zkldi/Tachi" - VITE_RECAPTCHA_KEY: "6LcsYbIpAAAAAEJffjIXmbQcxj_SBZG7BnSPjF4L" + VITE_HCAPTCHA_SITEKEY: "10000000-ffff-ffff-ffff-000000000001" TACHI_NAME: "Kamaitachi" BUILD_OUT_DIR: /tmp/kamai @@ -79,7 +79,7 @@ jobs: VITE_FLO_CLIENT_ID: "9krYLjq1rz9icCefO6OWxoMk" VITE_MIN_CLIENT_ID: "A0DBDBB063CD800530EF01C6488B282137E0191E" VITE_GIT_REPO: "GitHub:zkldi/Tachi" - VITE_RECAPTCHA_KEY: "6LcsYbIpAAAAAEJffjIXmbQcxj_SBZG7BnSPjF4L" + VITE_HCAPTCHA_SITEKEY: "10000000-ffff-ffff-ffff-000000000001" TACHI_NAME: "Tachi Dev" BUILD_OUT_DIR: /tmp/dev diff --git a/bun.lock b/bun.lock index 5dc283545..7372eaa50 100644 --- a/bun.lock +++ b/bun.lock @@ -78,12 +78,12 @@ "@fullcalendar/core": "catalog:", "@fullcalendar/daygrid": "catalog:", "@fullcalendar/react": "catalog:", + "@hcaptcha/react-hcaptcha": "catalog:", "@nivo/bar": "catalog:", "@nivo/core": "catalog:", "@nivo/line": "catalog:", "@types/lodash": "catalog:", "@types/natural-compare": "catalog:", - "@types/react-google-recaptcha": "catalog:", "deepmerge": "catalog:", "dotenv": "catalog:", "fast-json-stable-hash": "catalog:", @@ -99,7 +99,6 @@ "react-async": "catalog:", "react-bootstrap": "catalog:", "react-dom": "catalog:", - "react-google-recaptcha": "catalog:", "react-hot-toast": "catalog:", "react-markdown": "catalog:", "react-query": "catalog:", @@ -440,6 +439,7 @@ "@fullcalendar/core": "^6.0.0", "@fullcalendar/daygrid": "^6.0.0", "@fullcalendar/react": "^6.0.0", + "@hcaptcha/react-hcaptcha": "2.0.2", "@lezer/highlight": "^1.2.3", "@nivo/bar": "0.79.1", "@nivo/core": "0.79.0", @@ -467,7 +467,6 @@ "@types/pg": "^8.11.11", "@types/react": "17.0.13", "@types/react-dom": "17.0.8", - "@types/react-google-recaptcha": "2.1.2", "@types/react-router-bootstrap": "0.26.1", "@types/react-router-dom": "5.1.7", "@types/sql.js": "^1.4.11", @@ -542,7 +541,6 @@ "react-async": "10.0.1", "react-bootstrap": "2.9.0-beta.0", "react-dom": "17.0.2", - "react-google-recaptcha": "2.1.0", "react-hot-toast": "2.0.0", "react-markdown": "7.0.1", "react-query": "3.18.1", @@ -932,6 +930,8 @@ "@fullcalendar/react": ["@fullcalendar/react@6.1.20", "", { "peerDependencies": { "@fullcalendar/core": "~6.1.20", "react": "^16.7.0 || ^17 || ^18 || ^19", "react-dom": "^16.7.0 || ^17 || ^18 || ^19" } }, "sha512-1w0pZtceaUdfAnxMSCGHCQalhi+mR1jOe76sXzyAXpcPz/Lf0zHSdcGK/U2XpZlnQgQtBZW+d+QBnnzVQKCxAA=="], + "@hcaptcha/react-hcaptcha": ["@hcaptcha/react-hcaptcha@2.0.2", "", {}, "sha512-VbuH6VJ6m3BHmVBHs0fL9t+suZd7PQEqCzqL2BiUbBvbHI3XfvSgdiug2QiEPN8zskbPTIV/FfGPF53JCckrow=="], + "@httptoolkit/websocket-stream": ["@httptoolkit/websocket-stream@6.0.1", "", { "dependencies": { "@types/ws": "*", "duplexify": "^3.5.1", "inherits": "^2.0.1", "isomorphic-ws": "^4.0.1", "readable-stream": "^2.3.3", "safe-buffer": "^5.1.2", "ws": "*", "xtend": "^4.0.0" } }, "sha512-A0NOZI+Glp3Xgcz6Na7i7o09+/+xm2m0UCU8gdtM2nIv6/cjLmhMZMqehSpTlgbx9omtLmV8LVqOskPEyWnmZQ=="], "@humanfs/core": ["@humanfs/core@0.19.1", "", {}, "sha512-5DyQ4+1JEUzejeK1JGICcideyfUbGixgS9jNgex5nqkW+cY7WZhxBigmieN5Qnw9ZosSNVC9KQKyb+GUaGyKUA=="], @@ -1340,8 +1340,6 @@ "@types/react-dom": ["@types/react-dom@17.0.8", "", { "dependencies": { "@types/react": "*" } }, "sha512-0ohAiJAx1DAUEcY9UopnfwCE9sSMDGnY/oXjWMax6g3RpzmTt2GMyMVAXcbn0mo8XAff0SbQJl2/SBU+hjSZ1A=="], - "@types/react-google-recaptcha": ["@types/react-google-recaptcha@2.1.2", "", { "dependencies": { "@types/react": "*" } }, "sha512-Zeb0pwDeXHYIQy5mNgewMr+jf4xtrjSHTdJW8wrubNB6sNEaxqo8yk02HVpwy7p7Hzh5y6iSz6gKoPonVBlswQ=="], - "@types/react-router": ["@types/react-router@5.1.20", "", { "dependencies": { "@types/history": "^4.7.11", "@types/react": "*" } }, "sha512-jGjmu/ZqS7FjSH6owMcD5qpq19+1RS9DeVRqfl1FeBMxTDQAGwlMWOcs52NDoXaNKyG3d1cYQFMs9rCrb88o9Q=="], "@types/react-router-bootstrap": ["@types/react-router-bootstrap@0.26.1", "", { "dependencies": { "@types/react": "*" } }, "sha512-EdgZn/PmSA8+I0RW6WyDUgBvkVXc8AmbbgOJ8WJfec1R/V06X++OrIOzwzdgYl4g3uXvEB6oEIxzcjx1/8Opqw=="], @@ -2564,16 +2562,12 @@ "react-async": ["react-async@10.0.1", "", { "peerDependencies": { "react": ">=16.3.1" } }, "sha512-ORUz5ca0B57QgBIzEZM5SuhJ6xFjkvEEs0gylLNlWf06vuVcLZsjIw3wx58jJkZG38p+0nUAxRgFW2b7mnVZzA=="], - "react-async-script": ["react-async-script@1.2.0", "", { "dependencies": { "hoist-non-react-statics": "^3.3.0", "prop-types": "^15.5.0" }, "peerDependencies": { "react": ">=16.4.1" } }, "sha512-bCpkbm9JiAuMGhkqoAiC0lLkb40DJ0HOEJIku+9JDjxX3Rcs+ztEOG13wbrOskt3n2DTrjshhaQ/iay+SnGg5Q=="], - "react-bootstrap": ["react-bootstrap@2.9.0-beta.0", "", { "dependencies": { "@babel/runtime": "^7.22.5", "@restart/hooks": "^0.4.9", "@restart/ui": "^1.6.6", "@types/react-transition-group": "^4.4.6", "classnames": "^2.3.2", "dom-helpers": "^5.2.1", "invariant": "^2.2.4", "prop-types": "^15.8.1", "prop-types-extra": "^1.1.0", "react-transition-group": "^4.4.5", "uncontrollable": "^7.2.1", "warning": "^4.0.3" }, "peerDependencies": { "@types/react": ">=16.14.8", "react": ">=16.14.0", "react-dom": ">=16.14.0" }, "optionalPeers": ["@types/react"] }, "sha512-atmQhuRce7g/G+eclJn1eMZ1wbG6j8yYWrtr+Hlv/fVX0bw3QOUs62Q1G3qSqbkaZ4E625w/56GFR0rXf36Fog=="], "react-dom": ["react-dom@17.0.2", "", { "dependencies": { "loose-envify": "^1.1.0", "object-assign": "^4.1.1", "scheduler": "^0.20.2" }, "peerDependencies": { "react": "17.0.2" } }, "sha512-s4h96KtLDUQlsENhMn1ar8t2bEa+q/YAtj8pPPdIjPDGBDIVNsrD9aXNWqspUe6AzKCIG0C1HZZLqLV7qpOBGA=="], "react-fast-compare": ["react-fast-compare@2.0.4", "", {}, "sha512-suNP+J1VU1MWFKcyt7RtjiSWUjvidmQSlqu+eHslq+342xCbGTYmC0mEhPCOHxlW0CywylOC1u2DFAT+bv4dBw=="], - "react-google-recaptcha": ["react-google-recaptcha@2.1.0", "", { "dependencies": { "prop-types": "^15.5.0", "react-async-script": "^1.1.1" }, "peerDependencies": { "react": ">=16.4.1" } }, "sha512-K9jr7e0CWFigi8KxC3WPvNqZZ47df2RrMAta6KmRoE4RUi7Ys6NmNjytpXpg4HI/svmQJLKR+PncEPaNJ98DqQ=="], - "react-hot-toast": ["react-hot-toast@2.0.0", "", { "dependencies": { "goober": "^2.0.35" }, "peerDependencies": { "react": ">=16", "react-dom": ">=16" } }, "sha512-J0J2rcSvKetlziVquwESgk85pV9JB0Dz3RJIZcAEv7CWU2y2z8BQqxmZHZUxjPKIBGPqXAocZch4Kj4oUdX4+w=="], "react-is": ["react-is@17.0.2", "", {}, "sha512-w2GsyukL62IJnlaff/nRegPQR94C/XXamvMWmSHRJ4y7Ts/4ocGRmTHvOs8PSE6pB3dWOrD/nueuU5sduBsQ4w=="], diff --git a/docs/src/api/routes/auth.md b/docs/src/api/routes/auth.md index 4251954fc..4ca883bce 100644 --- a/docs/src/api/routes/auth.md +++ b/docs/src/api/routes/auth.md @@ -29,7 +29,7 @@ Logs a user in and returns a session cookie. | :---------: | :----: | :---------------------------------------------------------------------------------------: | | `username` | String | The user's username. This is compared case-insensitively. | | `!password` | String | The user's password. | -| `captcha` | String | Information about the captcha filled out by the user. We use a Google ReCaptcha instance. | +| `captcha` | String | The hCaptcha response token from the client widget; the server verifies it with hCaptcha. | !!! info The `!` prefix is special in that anything with it is assumed to be private and is **always** @@ -85,7 +85,7 @@ POST /api/v1/auth/login | `!password` | String | An 8 character or longer string. | | `!email` | String | Valid email. The `!` prefix keeps this field out of server request logs. | | `inviteCode` (Kamaitachi Only) | String (Undefined/Unused on Bokutachi) | If on Kamaitachi, this is the user's invitation code. | -| `captcha` | String | | +| `captcha` | String | The hCaptcha response token from the client widget; the server verifies it with hCaptcha. | ### Response diff --git a/old-docs/docs/api/routes/auth.md b/old-docs/docs/api/routes/auth.md index 4251954fc..4ca883bce 100644 --- a/old-docs/docs/api/routes/auth.md +++ b/old-docs/docs/api/routes/auth.md @@ -29,7 +29,7 @@ Logs a user in and returns a session cookie. | :---------: | :----: | :---------------------------------------------------------------------------------------: | | `username` | String | The user's username. This is compared case-insensitively. | | `!password` | String | The user's password. | -| `captcha` | String | Information about the captcha filled out by the user. We use a Google ReCaptcha instance. | +| `captcha` | String | The hCaptcha response token from the client widget; the server verifies it with hCaptcha. | !!! info The `!` prefix is special in that anything with it is assumed to be private and is **always** @@ -85,7 +85,7 @@ POST /api/v1/auth/login | `!password` | String | An 8 character or longer string. | | `!email` | String | Valid email. The `!` prefix keeps this field out of server request logs. | | `inviteCode` (Kamaitachi Only) | String (Undefined/Unused on Bokutachi) | If on Kamaitachi, this is the user's invitation code. | -| `captcha` | String | | +| `captcha` | String | The hCaptcha response token from the client widget; the server verifies it with hCaptcha. | ### Response diff --git a/package.json b/package.json index 053b3c1f4..de1a1d087 100644 --- a/package.json +++ b/package.json @@ -56,6 +56,7 @@ "@fullcalendar/core": "^6.0.0", "@fullcalendar/daygrid": "^6.0.0", "@fullcalendar/react": "^6.0.0", + "@hcaptcha/react-hcaptcha": "2.0.2", "@nivo/bar": "0.79.1", "@nivo/core": "0.79.0", "@nivo/line": "0.79.1", @@ -80,7 +81,6 @@ "@types/pg": "^8.11.11", "@types/react": "17.0.13", "@types/react-dom": "17.0.8", - "@types/react-google-recaptcha": "2.1.2", "@types/react-router-bootstrap": "0.26.1", "@types/react-router-dom": "5.1.7", "@types/supertest": "2.0.11", @@ -148,7 +148,6 @@ "react-async": "10.0.1", "react-bootstrap": "2.9.0-beta.0", "react-dom": "17.0.2", - "react-google-recaptcha": "2.1.0", "react-hot-toast": "2.0.0", "react-markdown": "7.0.1", "react-query": "3.18.1", diff --git a/typescript/client/README.md b/typescript/client/README.md index e56e30aa9..42877c323 100644 --- a/typescript/client/README.md +++ b/typescript/client/README.md @@ -43,4 +43,6 @@ VITE_GIT_REPO="" # What client mode to use. Should be boku, kamai or omni. # This affects some things, like what buttons should appear in certain places and what colours the site should be. VITE_TCHIC_MODE="omni" -``` \ No newline at end of file + +# hCaptcha site key (optional in local dev — omit to hide the widget; required for prod/staging server captcha enforcement). +VITE_HCAPTCHA_SITEKEY="" \ No newline at end of file diff --git a/typescript/client/package.json b/typescript/client/package.json index c6cc11fd0..75179554f 100644 --- a/typescript/client/package.json +++ b/typescript/client/package.json @@ -33,12 +33,12 @@ "@fullcalendar/core": "catalog:", "@fullcalendar/daygrid": "catalog:", "@fullcalendar/react": "catalog:", + "@hcaptcha/react-hcaptcha": "catalog:", "@nivo/bar": "catalog:", "@nivo/core": "catalog:", "@nivo/line": "catalog:", "@types/lodash": "catalog:", "@types/natural-compare": "catalog:", - "@types/react-google-recaptcha": "catalog:", "deepmerge": "catalog:", "dotenv": "catalog:", "fast-json-stable-hash": "catalog:", @@ -54,7 +54,6 @@ "react-async": "catalog:", "react-bootstrap": "catalog:", "react-dom": "catalog:", - "react-google-recaptcha": "catalog:", "react-hot-toast": "catalog:", "react-markdown": "catalog:", "react-query": "catalog:", diff --git a/typescript/client/src/app/pages/LoginPage.tsx b/typescript/client/src/app/pages/LoginPage.tsx index 113362fa2..06b35a9d4 100644 --- a/typescript/client/src/app/pages/LoginPage.tsx +++ b/typescript/client/src/app/pages/LoginPage.tsx @@ -4,10 +4,10 @@ import { UserContext } from "#context/UserContext"; import { APIFetchV1 } from "#util/api"; import { HumaniseError } from "#util/humanise-error"; import { HistorySafeGoBack } from "#util/misc"; +import HCaptcha from "@hcaptcha/react-hcaptcha"; import { useFormik } from "formik"; import React, { useContext, useEffect, useRef, useState } from "react"; import { Alert, Button, Form } from "react-bootstrap"; -import ReCAPTCHA from "react-google-recaptcha"; import toast from "react-hot-toast"; import { Link, useHistory } from "react-router-dom"; import { type UserDocument } from "tachi-common"; @@ -19,7 +19,7 @@ export default function LoginPage() { const { setUser } = useContext(UserContext); const history = useHistory(); - const recaptchaRef = useRef(null); + const hcaptchaRef = useRef | null>(null); const [localDevFirstAdminLogin, setLocalDevFirstAdminLogin] = useState< | { password: string; status: "ready"; username: string } @@ -74,8 +74,8 @@ export default function LoginPage() { false, ); - if (recaptchaRef.current) { - recaptchaRef.current.reset(); + if (hcaptchaRef.current) { + hcaptchaRef.current.resetCaptcha(); } if (!rj.success) { @@ -174,13 +174,16 @@ export default function LoginPage() { {err} - {import.meta.env.VITE_RECAPTCHA_KEY && ( - { - formik.setFieldValue("captcha", v); + {import.meta.env.VITE_HCAPTCHA_SITEKEY && ( + { + formik.setFieldValue("captcha", ""); }} - ref={recaptchaRef} - sitekey={import.meta.env.VITE_RECAPTCHA_KEY} + onVerify={(token) => { + formik.setFieldValue("captcha", token); + }} + ref={hcaptchaRef} + sitekey={import.meta.env.VITE_HCAPTCHA_SITEKEY} /> )} diff --git a/typescript/client/src/app/pages/RegisterPage.tsx b/typescript/client/src/app/pages/RegisterPage.tsx index e1550569d..bfff6f983 100644 --- a/typescript/client/src/app/pages/RegisterPage.tsx +++ b/typescript/client/src/app/pages/RegisterPage.tsx @@ -7,10 +7,10 @@ import { type UseFormik } from "#types/react"; import { APIFetchV1 } from "#util/api"; import { HumaniseError } from "#util/humanise-error"; import { HistorySafeGoBack } from "#util/misc"; +import HCaptcha from "@hcaptcha/react-hcaptcha"; import { useFormik } from "formik"; import React, { type MutableRefObject, useContext, useRef, useState } from "react"; import { Alert, Button, Form } from "react-bootstrap"; -import ReCAPTCHA from "react-google-recaptcha"; import toast from "react-hot-toast"; import { Link, useHistory } from "react-router-dom"; import { type UserDocument } from "tachi-common"; @@ -33,7 +33,7 @@ export default function RegisterPage() { const { setUser } = useContext(UserContext); const history = useHistory(); - const recaptchaRef = useRef(); + const hcaptchaRef = useRef | null>(null); const urlParams = new URLSearchParams(location.search); @@ -81,8 +81,8 @@ export default function RegisterPage() { true, ); - if (recaptchaRef.current) { - recaptchaRef.current.reset(); + if (hcaptchaRef.current) { + hcaptchaRef.current.resetCaptcha(); } if (!rj.success) { @@ -126,7 +126,7 @@ export default function RegisterPage() { return ( } heading="Register"> {readRules === "acknowledged" ? ( - + ) : (
@@ -200,7 +200,7 @@ function Description() { function RegisterForm({ formik, err, - recaptchaRef, + hcaptchaRef, }: { err: string; formik: UseFormik<{ @@ -211,7 +211,7 @@ function RegisterForm({ inviteCode: string; username: string; }>; - recaptchaRef: MutableRefObject; + hcaptchaRef: MutableRefObject | null>; }) { return (
@@ -275,13 +275,16 @@ function RegisterForm({ )} - {import.meta.env.VITE_RECAPTCHA_KEY && ( - { - formik.setFieldValue("captcha", v); + {import.meta.env.VITE_HCAPTCHA_SITEKEY && ( + { + formik.setFieldValue("captcha", ""); }} - ref={recaptchaRef} - sitekey={import.meta.env.VITE_RECAPTCHA_KEY} + onVerify={(token) => { + formik.setFieldValue("captcha", token); + }} + ref={hcaptchaRef} + sitekey={import.meta.env.VITE_HCAPTCHA_SITEKEY} /> )} diff --git a/typescript/server/.env.example b/typescript/server/.env.example index 2c795b471..2ebb52bb5 100644 --- a/typescript/server/.env.example +++ b/typescript/server/.env.example @@ -11,6 +11,7 @@ COMMIT_HASH= LOG_LEVEL= # Tachi server config (required) +# hCaptcha secret key (server-side siteverify). TACHI_CAPTCHA_SECRET_KEY= TACHI_SESSION_SECRET= TACHI_OUR_URL= diff --git a/typescript/server/src/lib/auth/auth.ts b/typescript/server/src/lib/auth/auth.ts index f79bb89e0..757f7b40a 100644 --- a/typescript/server/src/lib/auth/auth.ts +++ b/typescript/server/src/lib/auth/auth.ts @@ -3,7 +3,6 @@ import { Env, ServerConfig } from "#lib/setup/config"; import DB from "#services/pg/db"; import nodeFetch from "#utils/fetch"; import { Random20Hex } from "#utils/misc"; -import { CreateURLWithParams } from "#utils/url"; import { FormatUserDoc } from "#utils/user"; import bcrypt from "bcryptjs"; import { type Transaction } from "kysely"; @@ -173,20 +172,22 @@ export async function InsertDefaultUserSettings( } export async function ValidateCaptcha( - recaptcha: string, + captchaResponse: string, remoteAddr: string | undefined, fetch = nodeFetch, ) { - const url = CreateURLWithParams(`https://www.google.com/recaptcha/api/siteverify`, { - secret: ServerConfig.CAPTCHA_SECRET_KEY, - response: recaptcha, - remoteip: remoteAddr ?? "", - }); - - const googleCaptchaRes: unknown = await fetch(url.href).then((r) => r.json()); + const verifyRes: unknown = await fetch("https://api.hcaptcha.com/siteverify", { + method: "POST", + headers: { "Content-Type": "application/x-www-form-urlencoded" }, + body: new URLSearchParams({ + secret: ServerConfig.CAPTCHA_SECRET_KEY, + response: captchaResponse, + remoteip: remoteAddr ?? "", + }), + }).then((r) => r.json()); const err = p( - googleCaptchaRes, + verifyRes, { success: "boolean", }, @@ -196,20 +197,19 @@ export async function ValidateCaptcha( if (err) { log.warn( - { googleCaptchaRes, err }, - `Google ReCaptcha returned something without a success property? Assuming this captcha check failed.`, + { err, verifyRes }, + `hCaptcha returned something without a success property? Assuming this captcha check failed.`, ); return false; } - // asserted above - const gcr = googleCaptchaRes as { success: boolean }; + const hcr = verifyRes as { "error-codes"?: string[]; success: boolean }; - if (!gcr.success) { - log.debug({ gcr }, `Failed GCaptcha response`); + if (!hcr.success) { + log.debug({ errorCodes: hcr["error-codes"], hcr }, `Failed hCaptcha response`); } - return gcr.success; + return hcr.success; } export function MountAuthCookie(