Implemented login captcha

This commit is contained in:
WardPearce
2026-02-14 04:51:26 +13:00
parent 8359695994
commit 4f5da73d57
14 changed files with 169 additions and 17 deletions
+40
View File
@@ -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",
+2
View File
@@ -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",
+1
View File
@@ -5,6 +5,7 @@ declare global {
// interface Error {}
interface Locals {
userId: string;
captchaKey: string;
}
// interface PageData {}
// interface PageState {}
+8
View File
@@ -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);
}
+14 -4
View File
@@ -111,7 +111,11 @@ export async function postSubscribeBackend(authorId: string) {
if (resp.ok) parseChannelRSS(authorId);
}
export async function createUserBackend(username: string, rawPassword: string): Promise<boolean> {
export async function createUserBackend(
username: string,
rawPassword: string,
captchaPayload: string
): Promise<boolean> {
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<boolean> {
export async function loginUserBackend(
username: string,
rawPassword: string,
captchaPayload: string
): Promise<boolean> {
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'
});
+4
View File
@@ -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;
+34
View File
@@ -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()
});
}
}
+12
View File
@@ -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
}
});
@@ -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 @@
</script>
<nav class="center-align">
<article class="padding">
<article class="padding left-align">
<h3>{$_(needToRegister ? 'createAccount' : 'login')}</h3>
<p class="no-margin">{$_(needToRegister ? 'materialiousCreate' : 'materialiousLogin')}</p>
@@ -54,6 +56,23 @@
{/if}
</div>
<article
class="surface-container-highest no-padding"
style="width: 100%;height: fit-content;"
>
<altcha-widget
challengeurl="/api/captcha"
hidelogo
hidefooter
onstatechange={(ev) => {
const { payload, state } = ev.detail;
if (state === 'verified' && payload) {
captchaPayload = payload;
}
}}
></altcha-widget>
</article>
<nav class="right-align">
<button
type="button"
@@ -0,0 +1,13 @@
import { json } from '@sveltejs/kit';
import { createChallenge } from 'altcha-lib';
export async function GET({ locals }) {
const currentDate = new Date();
return json(
await createChallenge({
hmacKey: locals.captchaKey,
expires: new Date(currentDate.getTime() + 1 * 60 * 60 * 1000)
})
);
}
@@ -21,7 +21,6 @@ export async function POST({ request, locals }) {
if (!data.success) {
throw error(400, data.error.message);
}
const challenge = data.data.challenge as IGetChallengeResponse;
const requestKey = data.data.requestKey;
const visitorData = data.data.visitorData;
@@ -2,6 +2,7 @@ import { isOwnBackend } from '$lib/shared';
import psl from 'psl';
import { env } from '$env/dynamic/private';
import { error } from '@sveltejs/kit';
import { verifyCaptcha } from '$lib/server/captcha.js';
const allowedDomains: string[] = [
'youtube.com',
@@ -31,6 +32,7 @@ dynamicAllowDomains.forEach((domain) => {
async function proxyRequest(
request: Request,
urlToProxy: string,
captchaKey: string,
userId: string | undefined = undefined
): Promise<Response> {
const backendRestrictions = isOwnBackend();
@@ -100,20 +102,20 @@ async function proxyRequest(
}
export async function GET({ request, params, locals }) {
return await proxyRequest(request, params.urlToProxy, locals.userId);
return await proxyRequest(request, params.urlToProxy, locals.captchaKey, locals.userId);
}
export async function PATCH({ request, params, locals }) {
return await proxyRequest(request, params.urlToProxy, locals.userId);
return await proxyRequest(request, params.urlToProxy, locals.captchaKey, locals.userId);
}
export async function DELETE({ request, params, locals }) {
return await proxyRequest(request, params.urlToProxy, locals.userId);
return await proxyRequest(request, params.urlToProxy, locals.captchaKey, locals.userId);
}
export async function PUT({ request, params, locals }) {
return await proxyRequest(request, params.urlToProxy, locals.userId);
return await proxyRequest(request, params.urlToProxy, locals.captchaKey, locals.userId);
}
export async function POST({ request, params, locals }) {
return await proxyRequest(request, params.urlToProxy, locals.userId);
return await proxyRequest(request, params.urlToProxy, locals.captchaKey, locals.userId);
}
@@ -3,6 +3,7 @@ import { createUser } from '$lib/server/user.js';
import { error } from '@sveltejs/kit';
import z from 'zod';
import { setAuthCookie } from '$lib/server/misc.js';
import { verifyCaptcha } from '$lib/server/captcha';
const zUserCreate = z.object({
username: z.string().min(3).max(18),
@@ -14,10 +15,11 @@ const zUserCreate = z.object({
masterKey: z.object({
cipher: z.string().max(255),
nonce: z.string().max(255)
})
}),
captchaPayload: z.string()
});
export async function POST({ request, cookies }) {
export async function POST({ request, cookies, locals }) {
if (!isOwnBackend()?.internalAuth || !isOwnBackend()?.registrationAllowed) {
throw error(500);
}
@@ -26,6 +28,8 @@ export async function POST({ request, cookies }) {
if (!userToCreate.success) throw error(400);
await verifyCaptcha(userToCreate.data.captchaPayload, locals.captchaKey, 1);
const createdUser = await createUser({
username: userToCreate.data.username,
password: {
@@ -3,13 +3,15 @@ import { error, json } from '@sveltejs/kit';
import z from 'zod';
import { isOwnBackend } from '$lib/shared';
import { setAuthCookie } from '$lib/server/misc';
import { verifyCaptcha } from '$lib/server/captcha.js';
const zUserLogin = z.object({
username: z.string(),
passwordHash: z.string()
passwordHash: z.string(),
captchaPayload: z.string()
});
export async function POST({ request, cookies }) {
export async function POST({ request, cookies, locals }) {
if (!isOwnBackend()?.internalAuth) {
throw error(500);
}
@@ -18,6 +20,8 @@ export async function POST({ request, cookies }) {
if (!userLogin.success) throw error(401);
await verifyCaptcha(userLogin.data.captchaPayload, locals.captchaKey, 1);
const userModel = await authenticateUser(userLogin.data.username, userLogin.data.passwordHash);
setAuthCookie(userModel.id, cookies);