Implemented login captcha
This commit is contained in:
Generated
+40
@@ -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",
|
||||
|
||||
@@ -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",
|
||||
|
||||
Vendored
+1
@@ -5,6 +5,7 @@ declare global {
|
||||
// interface Error {}
|
||||
interface Locals {
|
||||
userId: string;
|
||||
captchaKey: string;
|
||||
}
|
||||
// interface PageData {}
|
||||
// interface PageState {}
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
@@ -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'
|
||||
});
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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()
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
|
||||
Reference in New Issue
Block a user