From b7bd0fac03801c4bd7563e2c2bdb39728e6b4191 Mon Sep 17 00:00:00 2001 From: DebaucheryLibrarian Date: Sat, 24 Jan 2026 17:53:01 +0100 Subject: [PATCH] Integrated hCaptcha. --- config/default.cjs | 5 +++++ package-lock.json | 33 +++++++++++++++++++++++++++++++++ package.json | 2 ++ pages/auth/signup/+Page.vue | 30 +++++++++++++++++++++++++++++- src/auth.js | 12 +++++++++++- src/web/main.js | 4 ++++ 6 files changed, 84 insertions(+), 2 deletions(-) diff --git a/config/default.cjs b/config/default.cjs index 9a56f02..08eda68 100755 --- a/config/default.cjs +++ b/config/default.cjs @@ -63,6 +63,11 @@ module.exports = { signup: true, usernameLength: [2, 24], usernamePattern: /^[a-zA-Z0-9_-]+$/, + captcha: { + enabled: false, + siteKey: '10000000-ffff-ffff-ffff-000000000001', + secretKey: '0x0000000000000000000000000000000000000000', + }, }, bans: { defaultExpiry: 60 * 24 * 3, // in minutes, 3 days diff --git a/package-lock.json b/package-lock.json index 6c74ce3..a285b0d 100644 --- a/package-lock.json +++ b/package-lock.json @@ -13,6 +13,7 @@ "@faker-js/faker": "^8.4.1", "@floating-ui/dom": "^1.5.3", "@floating-ui/vue": "^1.0.2", + "@hcaptcha/vue3-hcaptcha": "^1.3.0", "@resvg/resvg-js": "^2.6.0", "@toycode/markdown-it-class": "^1.2.4", "@vitejs/plugin-vue": "^4.5.2", @@ -38,6 +39,7 @@ "graphql": "^16.9.0", "graphql-parse-resolve-info": "^4.13.0", "graphql-scalars": "^1.24.2", + "hcaptcha": "^0.2.0", "ip-cidr": "^4.0.0", "js-cookie": "^3.0.5", "knex": "^3.1.0", @@ -3023,6 +3025,18 @@ } } }, + "node_modules/@hcaptcha/vue3-hcaptcha": { + "version": "1.3.0", + "resolved": "https://registry.npmjs.org/@hcaptcha/vue3-hcaptcha/-/vue3-hcaptcha-1.3.0.tgz", + "integrity": "sha512-IEonS6JiYdU7uy6aeib8cYtMO4nj8utwStbA9bWHyYbOvOvhpkV+AW8vfSKh6SntYxqle/TRwhv+kU9p92CfsA==", + "license": "MIT", + "dependencies": { + "vue": "^3.2.19" + }, + "peerDependencies": { + "vue": "^3.0.0" + } + }, "node_modules/@humanwhocodes/config-array": { "version": "0.11.13", "resolved": "https://registry.npmjs.org/@humanwhocodes/config-array/-/config-array-0.11.13.tgz", @@ -7319,6 +7333,12 @@ "node": ">= 0.4" } }, + "node_modules/hcaptcha": { + "version": "0.2.0", + "resolved": "https://registry.npmjs.org/hcaptcha/-/hcaptcha-0.2.0.tgz", + "integrity": "sha512-x25z3RoEa9oqfyuQsgk2olc+LCNVDAJaGKUP1qFhpAybB6qjqOf4qB2y1E3LJpXDvM229JWEywc6iWnzWvGjNw==", + "license": "MIT" + }, "node_modules/html-encoding-sniffer": { "version": "2.0.1", "resolved": "https://registry.npmjs.org/html-encoding-sniffer/-/html-encoding-sniffer-2.0.1.tgz", @@ -13761,6 +13781,14 @@ } } }, + "@hcaptcha/vue3-hcaptcha": { + "version": "1.3.0", + "resolved": "https://registry.npmjs.org/@hcaptcha/vue3-hcaptcha/-/vue3-hcaptcha-1.3.0.tgz", + "integrity": "sha512-IEonS6JiYdU7uy6aeib8cYtMO4nj8utwStbA9bWHyYbOvOvhpkV+AW8vfSKh6SntYxqle/TRwhv+kU9p92CfsA==", + "requires": { + "vue": "^3.2.19" + } + }, "@humanwhocodes/config-array": { "version": "0.11.13", "resolved": "https://registry.npmjs.org/@humanwhocodes/config-array/-/config-array-0.11.13.tgz", @@ -16771,6 +16799,11 @@ "function-bind": "^1.1.2" } }, + "hcaptcha": { + "version": "0.2.0", + "resolved": "https://registry.npmjs.org/hcaptcha/-/hcaptcha-0.2.0.tgz", + "integrity": "sha512-x25z3RoEa9oqfyuQsgk2olc+LCNVDAJaGKUP1qFhpAybB6qjqOf4qB2y1E3LJpXDvM229JWEywc6iWnzWvGjNw==" + }, "html-encoding-sniffer": { "version": "2.0.1", "resolved": "https://registry.npmjs.org/html-encoding-sniffer/-/html-encoding-sniffer-2.0.1.tgz", diff --git a/package.json b/package.json index e052f94..9f60459 100644 --- a/package.json +++ b/package.json @@ -13,6 +13,7 @@ "@faker-js/faker": "^8.4.1", "@floating-ui/dom": "^1.5.3", "@floating-ui/vue": "^1.0.2", + "@hcaptcha/vue3-hcaptcha": "^1.3.0", "@resvg/resvg-js": "^2.6.0", "@toycode/markdown-it-class": "^1.2.4", "@vitejs/plugin-vue": "^4.5.2", @@ -38,6 +39,7 @@ "graphql": "^16.9.0", "graphql-parse-resolve-info": "^4.13.0", "graphql-scalars": "^1.24.2", + "hcaptcha": "^0.2.0", "ip-cidr": "^4.0.0", "js-cookie": "^3.0.5", "knex": "^3.1.0", diff --git a/pages/auth/signup/+Page.vue b/pages/auth/signup/+Page.vue index 8898620..edd4731 100644 --- a/pages/auth/signup/+Page.vue +++ b/pages/auth/signup/+Page.vue @@ -112,6 +112,14 @@ + + import { ref, onMounted, inject } from 'vue'; +import VueHCaptcha from '@hcaptcha/vue3-hcaptcha'; import { post } from '#/src/api.js'; import navigate from '#/src/navigate.js'; const pageContext = inject('pageContext'); -const user = pageContext.user; +const { user, env } = pageContext; const username = ref(''); const email = ref(''); @@ -139,6 +148,7 @@ const passwordConfirm = ref(''); const errorMsg = ref(null); const userInput = ref(null); const showPassword = ref(false); +const captcha = ref(null); async function signup() { errorMsg.value = null; @@ -148,12 +158,20 @@ async function signup() { return; } + /* + if (env.captcha.enabled && !captcha.value) { + errorMsg.value = 'Please complete the CAPTCHA'; + return; + } + */ + try { const newUser = await post('/users', { username: username.value, email: email.value, password: password.value, redirect: pageContext.urlParsed.search.r, + captcha: captcha.value, }); navigate(`/user/${newUser.username}`, null, { redirect: true }); @@ -229,6 +247,16 @@ onMounted(() => { } } +.captcha { + display: flex; + justify-content: center; + margin-top: .5rem; +} + +.button-submit { + margin-top: .5rem; +} + .error { background: var(--error); color: var(--text-light); diff --git a/src/auth.js b/src/auth.js index 3a1f370..ce0197d 100755 --- a/src/auth.js +++ b/src/auth.js @@ -5,6 +5,7 @@ import fs from 'fs/promises'; import { createAvatar } from '@dicebear/core'; import { shapes } from '@dicebear/collection'; import { faker } from '@faker-js/faker'; +import { verify } from 'hcaptcha'; import { knexOwner as knex } from './knex.js'; import redis from './redis.js'; @@ -105,6 +106,15 @@ export async function signup(credentials, userIp) { throw new HttpError('Password must be 3 characters or longer', 400); } + if (config.auth.captcha.enabled) { + const captchaVerification = await verify(config.auth.captcha.secretKey, credentials.captcha); + + if (!captchaVerification.success) { + logger.warn(`Invalid sign-up CAPTCHA from '${curatedUsername}' (${credentials.email}, ${userIp})`); + throw new HttpError('Invalid CAPTCHA', 400); + } + } + const existingUser = await knex('users') .where(knex.raw('lower(username)'), curatedUsername.toLowerCase()) .orWhere(knex.raw('lower(email)'), credentials.email.toLowerCase()) @@ -134,7 +144,7 @@ export async function signup(credentials, userIp) { primary: true, }); - logger.verbose(`Signup from '${curatedUsername}' (${userId}, ${credentials.email}, ${userIp})`); + logger.info(`Signup from '${curatedUsername}' (${userId}, ${credentials.email}, ${userIp})`); await generateAvatar({ id: userId, diff --git a/src/web/main.js b/src/web/main.js index 21afd3c..e08cdfb 100644 --- a/src/web/main.js +++ b/src/web/main.js @@ -45,6 +45,10 @@ export default async function mainHandler(req, res, next) { psa: config.psa, links: config.links, socials, + captcha: { + enabled: config.auth.captcha.enabled, + siteKey: config.auth.captcha.siteKey, + }, }, meta: { now: new Date().toISOString(),