feat: recaptcha->hcaptcha (#1577)

This commit is contained in:
zk
2026-05-24 16:09:23 +01:00
committed by GitHub
parent 3c029ded02
commit dcb7c8d41b
11 changed files with 63 additions and 62 deletions
+3 -3
View File
@@ -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
+4 -10
View File
@@ -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=="],
+2 -2
View File
@@ -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
+2 -2
View File
@@ -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
+1 -2
View File
@@ -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",
+3 -1
View File
@@ -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"
```
# hCaptcha site key (optional in local dev — omit to hide the widget; required for prod/staging server captcha enforcement).
VITE_HCAPTCHA_SITEKEY=""
+1 -2
View File
@@ -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:",
+13 -10
View File
@@ -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<any>(null);
const hcaptchaRef = useRef<InstanceType<typeof HCaptcha> | 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}
</Form.Group>
{import.meta.env.VITE_RECAPTCHA_KEY && (
<ReCAPTCHA
onChange={(v) => {
formik.setFieldValue("captcha", v);
{import.meta.env.VITE_HCAPTCHA_SITEKEY && (
<HCaptcha
onExpire={() => {
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}
/>
)}
@@ -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<any>();
const hcaptchaRef = useRef<InstanceType<typeof HCaptcha> | 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 (
<LoginPageLayout description={<Description />} heading="Register">
{readRules === "acknowledged" ? (
<RegisterForm err={err} formik={formik} recaptchaRef={recaptchaRef} />
<RegisterForm err={err} formik={formik} hcaptchaRef={hcaptchaRef} />
) : (
<div className="text-center">
<div className="mb-8">
@@ -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<any>;
hcaptchaRef: MutableRefObject<InstanceType<typeof HCaptcha> | null>;
}) {
return (
<Form className="d-flex flex-column gap-4 w-100" onSubmit={formik.handleSubmit}>
@@ -275,13 +275,16 @@ function RegisterForm({
</Form.Group>
)}
{import.meta.env.VITE_RECAPTCHA_KEY && (
<ReCAPTCHA
onChange={(v) => {
formik.setFieldValue("captcha", v);
{import.meta.env.VITE_HCAPTCHA_SITEKEY && (
<HCaptcha
onExpire={() => {
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}
/>
)}
+1
View File
@@ -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=
+17 -17
View File
@@ -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(