Add hcaptcha

This commit is contained in:
Evert Prants 2024-06-14 15:22:14 +03:00
parent cfec56bcf9
commit 00d2b02fb2
Signed by: evert
GPG Key ID: 1688DA83D222D0B5
9 changed files with 237 additions and 122 deletions

8
src/app.d.ts vendored
View File

@ -6,6 +6,10 @@ type SessionData = {
user?: UserSession; user?: UserSession;
}; };
type HCaptcha = {
render(id: string, config: { sitekey: string; size: string; theme: string }): string;
};
// See https://kit.svelte.dev/docs/types#app // See https://kit.svelte.dev/docs/types#app
// for information about these interfaces // for information about these interfaces
declare global { declare global {
@ -14,6 +18,10 @@ declare global {
? { [P in keyof O]: O[P] } ? { [P in keyof O]: O[P] }
: never; : never;
interface Window {
hcaptcha: HCaptcha | null;
}
namespace App { namespace App {
// interface Error {} // interface Error {}

View File

@ -6,15 +6,11 @@
<style> <style>
.aside-wrapper { .aside-wrapper {
min-height: 100vh;
display: flex; display: flex;
justify-content: flex-end; justify-content: flex-end;
} }
.aside-wrapper,
.aside-inner {
height: 100%;
}
.aside-inner { .aside-inner {
display: flex; display: flex;
flex-direction: column; flex-direction: column;

View File

@ -0,0 +1,24 @@
<script lang="ts">
import { browser } from '$app/environment';
import { env } from '$env/dynamic/public';
import { themeMode } from '$lib/theme-mode';
import { waitIsTruthy } from '$lib/utils';
import { onMount } from 'svelte';
onMount(async () => {
if (!browser) return;
await waitIsTruthy(() => !!window.hcaptcha);
if (!window.hcaptcha) return;
window.hcaptcha.render('hcaptcha-element', {
sitekey: env.PUBLIC_HCAPTCHA_KEY,
size: '',
theme: $themeMode
});
});
</script>
<svelte:head>
<script src="https://js.hcaptcha.com/1/api.js?render=explicit" async defer></script>
</svelte:head>
<div class="h-captcha" id="hcaptcha-element"></div>

View File

@ -1,93 +1,94 @@
{ {
"title": "Manage your account", "title": "Manage your account",
"altTitle": "Account management", "altTitle": "Account management",
"username": "Username", "username": "Username",
"usernameHint": "Only the English alphabet, numbers and _-. are allowed.", "usernameHint": "Only the English alphabet, numbers and _-. are allowed.",
"displayName": "Display Name", "displayName": "Display Name",
"displayNameHint": "Must be between 3 and 32 characters.", "displayNameHint": "Must be between 3 and 32 characters.",
"changeEmail": "Change email address", "changeEmail": "Change email address",
"currentEmail": "Current email address", "currentEmail": "Current email address",
"email": "Email address", "email": "Email address",
"newEmail": "New email address", "newEmail": "New email address",
"emailHint": "Hint", "emailHint": "Hint",
"password": "Password", "password": "Password",
"repeatPassword": "Repeat password", "repeatPassword": "Repeat password",
"changePassword": "Change password", "changePassword": "Change password",
"currentPassword": "Current password", "currentPassword": "Current password",
"newPassword": "New password", "newPassword": "New password",
"repeatNewPassword": "Repeat new password", "repeatNewPassword": "Repeat new password",
"forgotPassword": "Forgot your password?", "forgotPassword": "Forgot your password?",
"resetPassword": "Reset your password", "resetPassword": "Reset your password",
"setNewPassword": "Set a new password", "setNewPassword": "Set a new password",
"passwordHint": "At least 8 characters, a capital letter and a number required.", "passwordHint": "At least 8 characters, a capital letter and a number required.",
"submit": "Submit", "submit": "Submit",
"changeSuccess": "Account settings changed successfully!", "changeSuccess": "Account settings changed successfully!",
"activateSuccess": "Your account has been activated successfully! You may now log in.", "activateSuccess": "Your account has been activated successfully! You may now log in.",
"passwordSetSuccess": "Your new password has been set successfully! You may now log in.", "passwordSetSuccess": "Your new password has been set successfully! You may now log in.",
"passwordResetSucces": "If there is an account with that email address, we have sent a password reset email to it.", "passwordResetSucces": "If there is an account with that email address, we have sent a password reset email to it.",
"logout": "Log out", "logout": "Log out",
"admin": "Admin", "admin": "Admin",
"avatar": { "avatar": {
"title": "Profile avatar", "title": "Profile avatar",
"change": "Change avatar", "change": "Change avatar",
"remove": "Remove avatar", "remove": "Remove avatar",
"done": "Done", "done": "Done",
"restart": "Restart", "restart": "Restart",
"upload": "Upload", "upload": "Upload",
"uploadLabel": "Upload image file", "uploadLabel": "Upload image file",
"hint": "Allowed image formats: .png, .jpg, .jpeg. Image must be less than 10 MB in size." "hint": "Allowed image formats: .png, .jpg, .jpeg. Image must be less than 10 MB in size."
}, },
"login": { "login": {
"title": "Log in", "title": "Log in",
"email": "Email", "email": "Email",
"password": "Password", "password": "Password",
"submit": "Log in", "submit": "Log in",
"otp": "Two-factor authentication is enabled", "otp": "Two-factor authentication is enabled",
"otpCode": "Enter the code displayed on the authenticator app" "otpCode": "Enter the code displayed on the authenticator app"
}, },
"register": { "register": {
"title": "Create a new account", "title": "Create a new account",
"disabled": "Registrations are currently disabled by the administrator! Sorry.", "disabled": "Registrations are currently disabled by the administrator! Sorry.",
"userCreated": "User account has been created successfully! You may now log in.", "userCreated": "User account has been created successfully! You may now log in.",
"emailSent": "User account has been created successfully, and an activation email has been sent to your email address.", "emailSent": "User account has been created successfully, and an activation email has been sent to your email address.",
"submit": "Create account" "submit": "Create account"
}, },
"errors": { "errors": {
"invalidLogin": "Invalid email or password!", "invalidLogin": "Invalid email or password!",
"invalidRequest": "Invalid request! Please try again.", "invalidRequest": "Invalid request! Please try again.",
"emailRequired": "Email address is required.", "emailRequired": "Email address is required.",
"invalidUsername": "The username entered is invalid or not allowed", "invalidUsername": "The username entered is invalid or not allowed",
"invalidEmail": "The email address is invalid.", "invalidEmail": "The email address is invalid.",
"passwordRequired": "The password is required.", "passwordRequired": "The password is required.",
"passwordMismatch": "The passwords do not match!", "passwordMismatch": "The passwords do not match!",
"passwordSame": "Your new password cannot be your current password.", "passwordSame": "Your new password cannot be your current password.",
"invalidPassword": "The provided password is invalid.", "invalidPassword": "The provided password is invalid.",
"invalidDisplayName": "The provided display name is invalid.", "invalidDisplayName": "The provided display name is invalid.",
"otpFailed": "The code you entered was invalid. Please note that you will be given a new QR code for subsequent retries.", "otpFailed": "The code you entered was invalid. Please note that you will be given a new QR code for subsequent retries.",
"required": "Please fill in the required fields", "required": "Please fill in the required fields",
"existingRegistration": "The username or email address is already in use.", "existingRegistration": "The username or email address is already in use.",
"activationFailed": "Your account could not be activated - the URL might have been used or expired already." "activationFailed": "Your account could not be activated - the URL might have been used or expired already.",
}, "invalidCaptcha": "Please complete the captcha challenge."
"otp": { },
"title": "Two-factor authentication", "otp": {
"enabled": "Two-factor authentication is enabled.", "title": "Two-factor authentication",
"disabled": "Your account does not have two-factor authentication enabled.", "enabled": "Two-factor authentication is enabled.",
"unavailable": "Two-factor authentication is not set up.", "disabled": "Your account does not have two-factor authentication enabled.",
"activated": "Two-factor authentication has been activated successfully!", "unavailable": "Two-factor authentication is not set up.",
"deactivated": "Two-factor authentication has been deactivated successfully.", "activated": "Two-factor authentication has been activated successfully!",
"scan": "Scan this QR code with the authenticator app of your choice", "deactivated": "Two-factor authentication has been deactivated successfully.",
"code": "Enter the code displayed on the authenticator app", "scan": "Scan this QR code with the authenticator app of your choice",
"return": "Return to account management", "code": "Enter the code displayed on the authenticator app",
"retry": "Try again", "return": "Return to account management",
"activate": "Set up two factor authentication", "retry": "Try again",
"deactivate": "Deactivate" "activate": "Set up two factor authentication",
}, "deactivate": "Deactivate"
"authorizations": { },
"title": "Authorized applications", "authorizations": {
"description": "These applications are authorized automatically when requested, provided you have already consented to the information they require. You should revoke any applications you do not recognize.", "title": "Authorized applications",
"warning": "By revoking the authorization, you may be prompted to authorize the application again in the future. This does NOT ensure that your information is deleted by any third-party applications in question. Please contact each application's owner individually if you wish to remove your information from their servers.", "description": "These applications are authorized automatically when requested, provided you have already consented to the information they require. You should revoke any applications you do not recognize.",
"website": "Visit website", "warning": "By revoking the authorization, you may be prompted to authorize the application again in the future. This does NOT ensure that your information is deleted by any third-party applications in question. Please contact each application's owner individually if you wish to remove your information from their servers.",
"revoke": "Revoke", "website": "Visit website",
"none": "You currently do not have any authorized applications." "revoke": "Revoke",
} "none": "You currently do not have any authorized applications."
}
} }

View File

@ -1,26 +1,27 @@
{ {
"description": "{{siteName}} is a Single-Sign-On service used by other applications.", "description": "{{siteName}} is a Single-Sign-On service used by other applications.",
"cookieDisclaimer": "The website may use temporary cookies for storing your login session and ensuring your security. This web service is&nbsp;<a href=\"https://git.icynet.eu/IcyNetwork/sso-core\" target=\"_blank\">completely open source</a> and can be audited by anyone.", "metaDescription": "{{siteName}} - Single-Sign-On service",
"submit": "Submit", "cookieDisclaimer": "The website may use temporary cookies for storing your login session and ensuring your security. This web service is&nbsp;<a href=\"https://git.icynet.eu/IcyNetwork/sso-core\" target=\"_blank\">completely open source</a> and can be audited by anyone.",
"cancel": "Cancel", "submit": "Submit",
"manage": "Manage", "cancel": "Cancel",
"back": "Go back", "manage": "Manage",
"home": "Home page", "back": "Go back",
"required": "Required fields", "home": "Home page",
"page": "Page", "required": "Required fields",
"previous": "Previous", "page": "Page",
"next": "Next", "previous": "Previous",
"bool": { "next": "Next",
"true": "Yes", "bool": {
"false": "No" "true": "Yes",
}, "false": "No"
"available": "Available", },
"current": "Current", "available": "Available",
"remove": "Remove", "current": "Current",
"filter": "Filter", "remove": "Remove",
"clear": "Clear", "filter": "Filter",
"theme": { "clear": "Clear",
"light": "Light mode", "theme": {
"dark": "Dark mode" "light": "Light mode",
} "dark": "Dark mode"
}
} }

View File

@ -13,3 +13,22 @@ export const hasPrivileges = (list: string[], privileges: RequiredPrivileges) =>
} }
return list.includes(item); return list.includes(item);
}); });
export const waitIsTruthy = (check: () => boolean, checkInterval = 500, checkTimeout = 5000) => {
let time = 0;
return new Promise<void>((resolve, reject) => {
const interval = setInterval(() => {
if (check.call(null)) {
clearInterval(interval);
return resolve();
}
if (time > checkTimeout) {
clearInterval(interval);
return reject();
}
time += checkInterval;
}, checkInterval);
});
};

View File

@ -1,8 +1,18 @@
<script lang="ts"> <script lang="ts">
import { env } from '$env/dynamic/public';
import { t } from '$lib/i18n';
import { forwardInitialTheme } from '$lib/theme-mode'; import { forwardInitialTheme } from '$lib/theme-mode';
import '../app.css'; import '../app.css';
forwardInitialTheme(); forwardInitialTheme();
</script> </script>
<svelte:head>
<meta name="title" content={env.PUBLIC_SITE_NAME} />
<meta
name="description"
content={$t('common.metaDescription', { siteName: env.PUBLIC_SITE_NAME })}
/>
</svelte:head>
<slot></slot> <slot></slot>

View File

@ -12,9 +12,16 @@ interface RegisterData {
displayName: string; displayName: string;
email: string; email: string;
password: string; password: string;
'h-captcha-response': string;
} }
const fields: (keyof RegisterData)[] = ['username', 'displayName', 'email', 'password']; const fields: (keyof RegisterData)[] = [
'username',
'displayName',
'email',
'password',
'h-captcha-response'
];
const limiter = new RateLimiter({ const limiter = new RateLimiter({
IP: [6, 'm'] IP: [6, 'm']
@ -32,7 +39,14 @@ export const actions = {
const body = await request.formData(); const body = await request.formData();
const changes = Changesets.take<RegisterData>(fields, body); const changes = Changesets.take<RegisterData>(fields, body);
const { username, displayName, email, password } = changes; const {
username,
displayName,
email,
password,
['h-captcha-response']: captchaToken
} = changes;
// Each field must be present // Each field must be present
if (!username || !displayName || !email || !password) { if (!username || !displayName || !email || !password) {
return fail(400, { return fail(400, {
@ -87,6 +101,39 @@ export const actions = {
}); });
} }
if (env.HCAPTCHA_SECRET) {
if (!captchaToken) {
return fail(400, {
username,
displayName,
email,
errors: ['invalidCaptcha'],
fields: ['hcaptcha']
});
}
const requestBody = new FormData();
requestBody.append('response', captchaToken);
requestBody.append('secret', env.HCAPTCHA_SECRET);
requestBody.append('remoteip', event.getClientAddress());
const testResultRequest = await fetch('https://api.hcaptcha.com/siteverify', {
method: 'POST',
body: requestBody
});
const testResult = await testResultRequest.json();
if (!testResult.success) {
return fail(400, {
username,
displayName,
email,
errors: ['invalidCaptcha'],
fields: ['hcaptcha']
});
}
}
if (!(await Users.checkRegistration(username, email))) { if (!(await Users.checkRegistration(username, email))) {
return fail(400, { return fail(400, {
username, username,

View File

@ -13,6 +13,7 @@
import { enhance } from '$app/forms'; import { enhance } from '$app/forms';
import FormErrors from '$lib/components/form/FormErrors.svelte'; import FormErrors from '$lib/components/form/FormErrors.svelte';
import ButtonRow from '$lib/components/container/ButtonRow.svelte'; import ButtonRow from '$lib/components/container/ButtonRow.svelte';
import HCaptcha from '$lib/components/form/HCaptcha.svelte';
export let data: PageData; export let data: PageData;
export let form: ActionData; export let form: ActionData;
@ -51,8 +52,10 @@
<ColumnView> <ColumnView>
{#if !data.enabled} {#if !data.enabled}
<Alert type="error">{$t('account.register.disabled')}</Alert> <Alert type="error">{$t('account.register.disabled')}</Alert>
<div><a href="/login">{$t('account.login.title')}</a></div>
{:else if form?.success} {:else if form?.success}
<Alert type="success">{$t(`account.register.${form.success}`)}</Alert> <Alert type="success">{$t(`account.register.${form.success}`)}</Alert>
<div><a href="/login">{$t('account.login.title')}</a></div>
{:else} {:else}
<form action="" method="POST" use:enhance={enhanceFn}> <form action="" method="POST" use:enhance={enhanceFn}>
<FormWrapper> <FormWrapper>
@ -121,6 +124,12 @@
</FormControl> </FormControl>
</FormSection> </FormSection>
{#if env.PUBLIC_HCAPTCHA_KEY}
<FormControl>
<HCaptcha />
</FormControl>
{/if}
<ButtonRow> <ButtonRow>
<Button type="submit" variant="primary" disabled={submitted} <Button type="submit" variant="primary" disabled={submitted}
>{$t('account.register.submit')}</Button >{$t('account.register.submit')}</Button