From 4f5da73d5741d40c593f7c71c07763789e404e96 Mon Sep 17 00:00:00 2001 From: WardPearce Date: Sat, 14 Feb 2026 04:51:26 +1300 Subject: [PATCH] Implemented login captcha --- materialious/package-lock.json | 40 +++++++++++++++++++ materialious/package.json | 2 + materialious/src/app.d.ts | 1 + materialious/src/hooks.server.ts | 8 ++++ materialious/src/lib/api/backend.ts | 18 +++++++-- materialious/src/lib/css/global.css | 4 ++ materialious/src/lib/server/captcha.ts | 34 ++++++++++++++++ materialious/src/lib/server/database.ts | 12 ++++++ .../routes/(app)/internal/login/+page.svelte | 25 ++++++++++-- .../src/routes/api/captcha/+server.ts | 13 ++++++ .../src/routes/api/poToken/+server.ts | 1 - .../routes/api/proxy/[urlToProxy]/+server.ts | 12 +++--- .../src/routes/api/user/create/+server.ts | 8 +++- .../src/routes/api/user/login/+server.ts | 8 +++- 14 files changed, 169 insertions(+), 17 deletions(-) create mode 100644 materialious/src/lib/server/captcha.ts create mode 100644 materialious/src/routes/api/captcha/+server.ts diff --git a/materialious/package-lock.json b/materialious/package-lock.json index 08cfdd67..77daf739 100644 --- a/materialious/package-lock.json +++ b/materialious/package-lock.json @@ -21,6 +21,7 @@ "@capacitor/share": "^8.0.0", "@macfja/serializer": "^1.1.4", "@macfja/svelte-persistent-store": "^2.4.2", + "altcha": "^2.3.0", "beercss": "^4.0.2", "bgutils-js": "^3.2.0", "buffer": "^6.0.3", @@ -62,6 +63,7 @@ "@types/human-number": "^1.0.2", "@types/mousetrap": "^1.6.15", "@vite-pwa/sveltekit": "^1.0.0", + "altcha-lib": "^1.4.1", "eslint": "^9.39.1", "eslint-config-prettier": "^10.1.8", "eslint-plugin-svelte": "^3.13.0", @@ -92,6 +94,12 @@ "dev": true, "license": "MIT" }, + "node_modules/@altcha/crypto": { + "version": "0.0.1", + "resolved": "https://registry.npmjs.org/@altcha/crypto/-/crypto-0.0.1.tgz", + "integrity": "sha512-qZMdnoD3lAyvfSUMNtC2adRi666Pxdcw9zqfMU5qBOaJWqpN9K+eqQGWqeiKDMqL0SF+EytNG4kR/Pr/99GJ6g==", + "license": "MIT" + }, "node_modules/@asamuzakjp/css-color": { "version": "4.1.2", "resolved": "https://registry.npmjs.org/@asamuzakjp/css-color/-/css-color-4.1.2.tgz", @@ -5214,6 +5222,38 @@ "url": "https://github.com/sponsors/epoberezkin" } }, + "node_modules/altcha": { + "version": "2.3.0", + "resolved": "https://registry.npmjs.org/altcha/-/altcha-2.3.0.tgz", + "integrity": "sha512-vl8I0dQvSQB7/Mx09XuWZ1+LdSP7vEda6OLbg9kUQ2ZO2LT7MzgUyLK7Iips+GAV6c0ntVcS1XWOqhEPpwbDhQ==", + "license": "MIT", + "dependencies": { + "@altcha/crypto": "^0.0.1" + }, + "optionalDependencies": { + "@rollup/rollup-linux-x64-gnu": "4.18.0" + } + }, + "node_modules/altcha-lib": { + "version": "1.4.1", + "resolved": "https://registry.npmjs.org/altcha-lib/-/altcha-lib-1.4.1.tgz", + "integrity": "sha512-MAXP9tkQOA2SE9Gwoe3LAcZbcDpp3XzYc5GDVej/y3eMNaFG/eVnRY1/7SGFW0RPsViEjPf+hi5eANjuZrH1xA==", + "dev": true, + "license": "MIT" + }, + "node_modules/altcha/node_modules/@rollup/rollup-linux-x64-gnu": { + "version": "4.18.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-x64-gnu/-/rollup-linux-x64-gnu-4.18.0.tgz", + "integrity": "sha512-xuglR2rBVHA5UsI8h8UbX4VJ470PtGCf5Vpswh7p2ukaqBGFTnsfzxUBetoWBWymHMxbIG0Cmx7Y9qDZzr648w==", + "cpu": [ + "x64" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, "node_modules/ansi-regex": { "version": "5.0.1", "resolved": "https://registry.npmjs.org/ansi-regex/-/ansi-regex-5.0.1.tgz", diff --git a/materialious/package.json b/materialious/package.json index 9d70de62..c889a2f0 100644 --- a/materialious/package.json +++ b/materialious/package.json @@ -29,6 +29,7 @@ "@types/human-number": "^1.0.2", "@types/mousetrap": "^1.6.15", "@vite-pwa/sveltekit": "^1.0.0", + "altcha-lib": "^1.4.1", "eslint": "^9.39.1", "eslint-config-prettier": "^10.1.8", "eslint-plugin-svelte": "^3.13.0", @@ -65,6 +66,7 @@ "@capacitor/share": "^8.0.0", "@macfja/serializer": "^1.1.4", "@macfja/svelte-persistent-store": "^2.4.2", + "altcha": "^2.3.0", "beercss": "^4.0.2", "bgutils-js": "^3.2.0", "buffer": "^6.0.3", diff --git a/materialious/src/app.d.ts b/materialious/src/app.d.ts index 88e2475a..e87e6ce6 100644 --- a/materialious/src/app.d.ts +++ b/materialious/src/app.d.ts @@ -5,6 +5,7 @@ declare global { // interface Error {} interface Locals { userId: string; + captchaKey: string; } // interface PageData {} // interface PageState {} diff --git a/materialious/src/hooks.server.ts b/materialious/src/hooks.server.ts index c3c0d967..2d2d2da8 100644 --- a/materialious/src/hooks.server.ts +++ b/materialious/src/hooks.server.ts @@ -2,9 +2,17 @@ import { isOwnBackend } from '$lib/shared'; import { sequelize } from '$lib/server/database'; import { unsign } from 'cookie-signature'; import { env } from '$env/dynamic/private'; +import sodium from 'libsodium-wrappers-sumo'; + +let captchaKey = ''; +sodium.ready.then(() => { + captchaKey = sodium.to_base64(sodium.randombytes_buf(32)); +}); let sequelizeAuthenticated = false; export async function handle({ event, resolve }) { + event.locals.captchaKey = captchaKey; + if (!isOwnBackend()?.internalAuth) { return await resolve(event); } diff --git a/materialious/src/lib/api/backend.ts b/materialious/src/lib/api/backend.ts index 37aa39a5..bee4e095 100644 --- a/materialious/src/lib/api/backend.ts +++ b/materialious/src/lib/api/backend.ts @@ -111,7 +111,11 @@ export async function postSubscribeBackend(authorId: string) { if (resp.ok) parseChannelRSS(authorId); } -export async function createUserBackend(username: string, rawPassword: string): Promise { +export async function createUserBackend( + username: string, + rawPassword: string, + captchaPayload: string +): Promise { await sodium.ready; const passwordSalt = sodium.randombytes_buf(sodium.crypto_pwhash_SALTBYTES); @@ -155,7 +159,8 @@ export async function createUserBackend(username: string, rawPassword: string): masterKey: { cipher: sodium.to_base64(masterKeyCipher), nonce: sodium.to_base64(decryptionMasterKeyNonce) - } + }, + captchaPayload }), credentials: 'same-origin' }); @@ -167,7 +172,11 @@ export async function createUserBackend(username: string, rawPassword: string): return true; } -export async function loginUserBackend(username: string, rawPassword: string): Promise { +export async function loginUserBackend( + username: string, + rawPassword: string, + captchaPayload: string +): Promise { await sodium.ready; const passwordSaltsResp = await fetch(`/api/user/${username}/public`); @@ -188,7 +197,8 @@ export async function loginUserBackend(username: string, rawPassword: string): P method: 'POST', body: JSON.stringify({ username, - passwordHash: sodium.to_base64(loginHash) + passwordHash: sodium.to_base64(loginHash), + captchaPayload }), credentials: 'same-origin' }); diff --git a/materialious/src/lib/css/global.css b/materialious/src/lib/css/global.css index 4dec2928..16f69699 100644 --- a/materialious/src/lib/css/global.css +++ b/materialious/src/lib/css/global.css @@ -129,6 +129,10 @@ menu { color: var(--on-surface) !important; } +.altcha { + border: none !important; +} + @media screen and (max-width: 1000px) { menu.mobile { position: fixed !important; diff --git a/materialious/src/lib/server/captcha.ts b/materialious/src/lib/server/captcha.ts new file mode 100644 index 00000000..481337b1 --- /dev/null +++ b/materialious/src/lib/server/captcha.ts @@ -0,0 +1,34 @@ +import { error } from '@sveltejs/kit'; +import { verifySolution } from 'altcha-lib'; +import { CaptchaTable } from './database'; + +export async function verifyCaptcha(payload: string, key: string, maxUses: number = -1) { + const passedCaptcha = await verifySolution(payload, key, true); + if (!passedCaptcha) { + throw error(400, 'Unsupported payload'); + } + + let captchaSignature: string | undefined; + try { + const decodedCaptcha = JSON.parse(Buffer.from(payload, 'base64').toString()); + + captchaSignature = decodedCaptcha.signature; + } catch { + // Handle error outside of catch + } + + if (typeof captchaSignature === 'undefined') { + throw error(400, 'Unsupported payload'); + } + + if (maxUses !== -1) { + if ((await CaptchaTable.count({ where: { signature: captchaSignature } })) >= maxUses) { + throw error(400, 'Unsupported payload'); + } + + await CaptchaTable.create({ + signature: captchaSignature, + created: new Date() + }); + } +} diff --git a/materialious/src/lib/server/database.ts b/materialious/src/lib/server/database.ts index 938f1dbb..94679ca8 100644 --- a/materialious/src/lib/server/database.ts +++ b/materialious/src/lib/server/database.ts @@ -105,3 +105,15 @@ export const ChannelSubscriptionTable = sequelize.define('Subscriptions', { }); UserTable.hasMany(ChannelSubscriptionTable); + +export const CaptchaTable = sequelize.define('Captchas', { + signature: { + type: DataTypes.STRING, + allowNull: false, + primaryKey: true + }, + created: { + type: DataTypes.DATE, + allowNull: false + } +}); diff --git a/materialious/src/routes/(app)/internal/login/+page.svelte b/materialious/src/routes/(app)/internal/login/+page.svelte index 86517c6c..5b9fb34e 100644 --- a/materialious/src/routes/(app)/internal/login/+page.svelte +++ b/materialious/src/routes/(app)/internal/login/+page.svelte @@ -3,20 +3,22 @@ import { resolve } from '$app/paths'; import { createUserBackend, loginUserBackend } from '$lib/api/backend'; import { _ } from '$lib/i18n'; + import 'altcha'; let needToRegister = $state(false); let username = $state(''); let rawPassword = $state(''); + let captchaPayload = $state(''); let failed = $state(false); async function onLogin(event: Event) { event.preventDefault(); if (needToRegister) { - failed = !(await createUserBackend(username, rawPassword)); + failed = !(await createUserBackend(username, rawPassword, captchaPayload)); } else { - failed = !(await loginUserBackend(username, rawPassword)); + failed = !(await loginUserBackend(username, rawPassword, captchaPayload)); } if (!failed) { @@ -26,7 +28,7 @@