Integrated hCaptcha.

This commit is contained in:
DebaucheryLibrarian 2026-01-24 17:53:01 +01:00
parent 9933b4fbf0
commit b7bd0fac03
6 changed files with 84 additions and 2 deletions

View File

@ -63,6 +63,11 @@ module.exports = {
signup: true, signup: true,
usernameLength: [2, 24], usernameLength: [2, 24],
usernamePattern: /^[a-zA-Z0-9_-]+$/, usernamePattern: /^[a-zA-Z0-9_-]+$/,
captcha: {
enabled: false,
siteKey: '10000000-ffff-ffff-ffff-000000000001',
secretKey: '0x0000000000000000000000000000000000000000',
},
}, },
bans: { bans: {
defaultExpiry: 60 * 24 * 3, // in minutes, 3 days defaultExpiry: 60 * 24 * 3, // in minutes, 3 days

33
package-lock.json generated
View File

@ -13,6 +13,7 @@
"@faker-js/faker": "^8.4.1", "@faker-js/faker": "^8.4.1",
"@floating-ui/dom": "^1.5.3", "@floating-ui/dom": "^1.5.3",
"@floating-ui/vue": "^1.0.2", "@floating-ui/vue": "^1.0.2",
"@hcaptcha/vue3-hcaptcha": "^1.3.0",
"@resvg/resvg-js": "^2.6.0", "@resvg/resvg-js": "^2.6.0",
"@toycode/markdown-it-class": "^1.2.4", "@toycode/markdown-it-class": "^1.2.4",
"@vitejs/plugin-vue": "^4.5.2", "@vitejs/plugin-vue": "^4.5.2",
@ -38,6 +39,7 @@
"graphql": "^16.9.0", "graphql": "^16.9.0",
"graphql-parse-resolve-info": "^4.13.0", "graphql-parse-resolve-info": "^4.13.0",
"graphql-scalars": "^1.24.2", "graphql-scalars": "^1.24.2",
"hcaptcha": "^0.2.0",
"ip-cidr": "^4.0.0", "ip-cidr": "^4.0.0",
"js-cookie": "^3.0.5", "js-cookie": "^3.0.5",
"knex": "^3.1.0", "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": { "node_modules/@humanwhocodes/config-array": {
"version": "0.11.13", "version": "0.11.13",
"resolved": "https://registry.npmjs.org/@humanwhocodes/config-array/-/config-array-0.11.13.tgz", "resolved": "https://registry.npmjs.org/@humanwhocodes/config-array/-/config-array-0.11.13.tgz",
@ -7319,6 +7333,12 @@
"node": ">= 0.4" "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": { "node_modules/html-encoding-sniffer": {
"version": "2.0.1", "version": "2.0.1",
"resolved": "https://registry.npmjs.org/html-encoding-sniffer/-/html-encoding-sniffer-2.0.1.tgz", "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": { "@humanwhocodes/config-array": {
"version": "0.11.13", "version": "0.11.13",
"resolved": "https://registry.npmjs.org/@humanwhocodes/config-array/-/config-array-0.11.13.tgz", "resolved": "https://registry.npmjs.org/@humanwhocodes/config-array/-/config-array-0.11.13.tgz",
@ -16771,6 +16799,11 @@
"function-bind": "^1.1.2" "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": { "html-encoding-sniffer": {
"version": "2.0.1", "version": "2.0.1",
"resolved": "https://registry.npmjs.org/html-encoding-sniffer/-/html-encoding-sniffer-2.0.1.tgz", "resolved": "https://registry.npmjs.org/html-encoding-sniffer/-/html-encoding-sniffer-2.0.1.tgz",

View File

@ -13,6 +13,7 @@
"@faker-js/faker": "^8.4.1", "@faker-js/faker": "^8.4.1",
"@floating-ui/dom": "^1.5.3", "@floating-ui/dom": "^1.5.3",
"@floating-ui/vue": "^1.0.2", "@floating-ui/vue": "^1.0.2",
"@hcaptcha/vue3-hcaptcha": "^1.3.0",
"@resvg/resvg-js": "^2.6.0", "@resvg/resvg-js": "^2.6.0",
"@toycode/markdown-it-class": "^1.2.4", "@toycode/markdown-it-class": "^1.2.4",
"@vitejs/plugin-vue": "^4.5.2", "@vitejs/plugin-vue": "^4.5.2",
@ -38,6 +39,7 @@
"graphql": "^16.9.0", "graphql": "^16.9.0",
"graphql-parse-resolve-info": "^4.13.0", "graphql-parse-resolve-info": "^4.13.0",
"graphql-scalars": "^1.24.2", "graphql-scalars": "^1.24.2",
"hcaptcha": "^0.2.0",
"ip-cidr": "^4.0.0", "ip-cidr": "^4.0.0",
"js-cookie": "^3.0.5", "js-cookie": "^3.0.5",
"knex": "^3.1.0", "knex": "^3.1.0",

View File

@ -112,6 +112,14 @@
</div> </div>
</div> </div>
<VueHCaptcha
v-if="env.captcha.enabled"
:sitekey="env.captcha.siteKey"
class="captcha"
@verify="(verification) => captcha = verification"
@expired="captcha = null"
/>
<button class="button button-submit">Sign up</button> <button class="button button-submit">Sign up</button>
<a <a
@ -124,12 +132,13 @@
<script setup> <script setup>
import { ref, onMounted, inject } from 'vue'; import { ref, onMounted, inject } from 'vue';
import VueHCaptcha from '@hcaptcha/vue3-hcaptcha';
import { post } from '#/src/api.js'; import { post } from '#/src/api.js';
import navigate from '#/src/navigate.js'; import navigate from '#/src/navigate.js';
const pageContext = inject('pageContext'); const pageContext = inject('pageContext');
const user = pageContext.user; const { user, env } = pageContext;
const username = ref(''); const username = ref('');
const email = ref(''); const email = ref('');
@ -139,6 +148,7 @@ const passwordConfirm = ref('');
const errorMsg = ref(null); const errorMsg = ref(null);
const userInput = ref(null); const userInput = ref(null);
const showPassword = ref(false); const showPassword = ref(false);
const captcha = ref(null);
async function signup() { async function signup() {
errorMsg.value = null; errorMsg.value = null;
@ -148,12 +158,20 @@ async function signup() {
return; return;
} }
/*
if (env.captcha.enabled && !captcha.value) {
errorMsg.value = 'Please complete the CAPTCHA';
return;
}
*/
try { try {
const newUser = await post('/users', { const newUser = await post('/users', {
username: username.value, username: username.value,
email: email.value, email: email.value,
password: password.value, password: password.value,
redirect: pageContext.urlParsed.search.r, redirect: pageContext.urlParsed.search.r,
captcha: captcha.value,
}); });
navigate(`/user/${newUser.username}`, null, { redirect: true }); 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 { .error {
background: var(--error); background: var(--error);
color: var(--text-light); color: var(--text-light);

View File

@ -5,6 +5,7 @@ import fs from 'fs/promises';
import { createAvatar } from '@dicebear/core'; import { createAvatar } from '@dicebear/core';
import { shapes } from '@dicebear/collection'; import { shapes } from '@dicebear/collection';
import { faker } from '@faker-js/faker'; import { faker } from '@faker-js/faker';
import { verify } from 'hcaptcha';
import { knexOwner as knex } from './knex.js'; import { knexOwner as knex } from './knex.js';
import redis from './redis.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); 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') const existingUser = await knex('users')
.where(knex.raw('lower(username)'), curatedUsername.toLowerCase()) .where(knex.raw('lower(username)'), curatedUsername.toLowerCase())
.orWhere(knex.raw('lower(email)'), credentials.email.toLowerCase()) .orWhere(knex.raw('lower(email)'), credentials.email.toLowerCase())
@ -134,7 +144,7 @@ export async function signup(credentials, userIp) {
primary: true, primary: true,
}); });
logger.verbose(`Signup from '${curatedUsername}' (${userId}, ${credentials.email}, ${userIp})`); logger.info(`Signup from '${curatedUsername}' (${userId}, ${credentials.email}, ${userIp})`);
await generateAvatar({ await generateAvatar({
id: userId, id: userId,

View File

@ -45,6 +45,10 @@ export default async function mainHandler(req, res, next) {
psa: config.psa, psa: config.psa,
links: config.links, links: config.links,
socials, socials,
captcha: {
enabled: config.auth.captcha.enabled,
siteKey: config.auth.captcha.siteKey,
},
}, },
meta: { meta: {
now: new Date().toISOString(), now: new Date().toISOString(),