feat: Add Cloudflare Turnstile bot protection for login and registration
- Integrate Turnstile CAPTCHA verification for sign-in and sign-up routes - Add Turnstile token validation on server-side for both login and register endpoints - Update .env example - Implement Turnstile component in aim-login for client-side verification - Enhance security by requiring bot protection during authentication flows
- commit
- c5cc06cad1e774c9d5fda3dd4b523b1ced3180e9
- author
- Kit · · 2025-02-16 15:01 UTC
- parent
- 96cccd7
- signature
- SSH Unverified
SHA256:H91DFuEG4jpk5edzLA4aUKkpv4zv9Q339ipRmkph6TkThe key is unknown
8 files · +309 -53
M .cursor/rules/global.mdc +1 -1
| @@ -3,7 +3,7 @@ description: | |||
| 3 | 3 | globs: | |
| 4 | 4 | --- | |
| 5 | 5 | # Debugging | |
| 6 | - | - Always add console.debug for client sided code . | |
| 6 | + | - Always add console.debug for client sided code. | |
| 7 | 7 | - Always add either console.log or follow the logger implementation if any server/backend code. | |
| 8 | 8 | ||
| 9 | 9 | # Language | |
| @@ -3,7 +3,7 @@ description: | |||||
| 3 | globs: | 3 | globs: | ||
| 4 | --- | 4 | --- | ||
| 5 | # Debugging | 5 | # Debugging | ||
| 6 | - | - Always add console.debug for client sided code . | 6 | + | - Always add console.debug for client sided code. |
| 7 | - Always add either console.log or follow the logger implementation if any server/backend code. | 7 | - Always add either console.log or follow the logger implementation if any server/backend code. | ||
| 8 | 8 | ||||
| 9 | # Language | 9 | # Language | ||
M .env example +12 -1
| @@ -1,2 +1,13 @@ | |||
| 1 | + | # Turso database | |
| 1 | 2 | TURSO_DATABASE_URL= | |
| 2 | - | TURSO_AUTH_TOKEN=⏎̸ | |
| 3 | + | TURSO_AUTH_TOKEN= | |
| 4 | + | ||
| 5 | + | # Log level | |
| 6 | + | LOG_LEVEL=debug | |
| 7 | + | ||
| 8 | + | # Default chat room | |
| 9 | + | PUBLIC_DEFAULT_CHAT_ROOM_ID=00000000-0000-0000-0000-000000000001 | |
| 10 | + | ||
| 11 | + | # Cloudflare Turnstile test keys (no quotes around the value) | |
| 12 | + | PUBLIC_TURNSTILE_SITE_KEY= | |
| 13 | + | TURNSTILE_SECRET_KEY=⏎̸ | |
| @@ -1,2 +1,13 @@ | |||||
| 1 | + | # Turso database | |||
| 1 | TURSO_DATABASE_URL= | 2 | TURSO_DATABASE_URL= | ||
| 2 | - | TURSO_AUTH_TOKEN=⏎̸ | 3 | + | TURSO_AUTH_TOKEN= |
| 4 | + | ||||
| 5 | + | # Log level | |||
| 6 | + | LOG_LEVEL=debug | |||
| 7 | + | ||||
| 8 | + | # Default chat room | |||
| 9 | + | PUBLIC_DEFAULT_CHAT_ROOM_ID=00000000-0000-0000-0000-000000000001 | |||
| 10 | + | ||||
| 11 | + | # Cloudflare Turnstile test keys (no quotes around the value) | |||
| 12 | + | PUBLIC_TURNSTILE_SITE_KEY= | |||
| 13 | + | TURNSTILE_SECRET_KEY=⏎̸ | |||
M src/lib/components/aim-login.svelte +18 -6
| @@ -5,6 +5,7 @@ | |||
| 5 | 5 | import type { User } from '$lib/types/chat'; | |
| 6 | 6 | import { invalidateAll } from '$app/navigation'; | |
| 7 | 7 | import { chatState } from '$lib/states/chat.svelte'; | |
| 8 | + | import Turnstile from './turnstile.svelte'; | |
| 8 | 9 | ||
| 9 | 10 | let { onLoginSuccess, showAuth = $bindable() }: { onLoginSuccess?: (user: User) => void, showAuth:Boolean } = $props(); | |
| 10 | 11 | ||
| @@ -15,6 +16,7 @@ | |||
| 15 | 16 | let siPassword = $state(''); | |
| 16 | 17 | let error = $state(''); | |
| 17 | 18 | let loginStatus = $state('idle'); | |
| 19 | + | let siTurnstileToken = $state(''); | |
| 18 | 20 | ||
| 19 | 21 | // Sign Up state | |
| 20 | 22 | let suUsername = $state(''); | |
| @@ -23,6 +25,7 @@ | |||
| 23 | 25 | let captchaAnswer = $state(''); | |
| 24 | 26 | let signupError = $state(''); | |
| 25 | 27 | let signupStatus = $state('idle'); | |
| 28 | + | let suTurnstileToken = $state(''); | |
| 26 | 29 | ||
| 27 | 30 | function handleClose() { | |
| 28 | 31 | console.debug("Closing AIM Login component."); | |
| @@ -32,8 +35,8 @@ | |||
| 32 | 35 | async function handleSigninSubmit() { | |
| 33 | 36 | error = ''; | |
| 34 | 37 | loginStatus = 'loading'; | |
| 35 | - | if (!siUsername || !siPassword) { | |
| 36 | - | error = 'Please enter both username and password'; | |
| 38 | + | if (!siUsername || !siPassword || !siTurnstileToken) { | |
| 39 | + | error = 'Please complete all fields including the security check'; | |
| 37 | 40 | loginStatus = 'error'; | |
| 38 | 41 | return; | |
| 39 | 42 | } | |
| @@ -45,7 +48,8 @@ | |||
| 45 | 48 | headers: { 'Content-Type': 'application/json' }, | |
| 46 | 49 | body: JSON.stringify({ | |
| 47 | 50 | username: siUsername, | |
| 48 | - | password: siPassword | |
| 51 | + | password: siPassword, | |
| 52 | + | turnstileToken: siTurnstileToken | |
| 49 | 53 | }) | |
| 50 | 54 | }); | |
| 51 | 55 | ||
| @@ -81,8 +85,8 @@ | |||
| 81 | 85 | async function handleSignupSubmit() { | |
| 82 | 86 | signupError = ''; | |
| 83 | 87 | signupStatus = 'loading'; | |
| 84 | - | if (!suUsername || !suPassword || !suConfirmPassword || !captchaAnswer) { | |
| 85 | - | signupError = 'Please fill all fields'; | |
| 88 | + | if (!suUsername || !suPassword || !suConfirmPassword || !captchaAnswer || !suTurnstileToken) { | |
| 89 | + | signupError = 'Please complete all fields including the security check'; | |
| 86 | 90 | signupStatus = 'error'; | |
| 87 | 91 | return; | |
| 88 | 92 | } | |
| @@ -97,7 +101,13 @@ | |||
| 97 | 101 | const res = await fetch('/api/register', { | |
| 98 | 102 | method: 'POST', | |
| 99 | 103 | headers: { 'Content-Type': 'application/json' }, | |
| 100 | - | body: JSON.stringify({ suUsername, suPassword, suConfirmPassword, captchaAnswer }) | |
| 104 | + | body: JSON.stringify({ | |
| 105 | + | suUsername, | |
| 106 | + | suPassword, | |
| 107 | + | suConfirmPassword, | |
| 108 | + | captchaAnswer, | |
| 109 | + | turnstileToken: suTurnstileToken | |
| 110 | + | }) | |
| 101 | 111 | }); | |
| 102 | 112 | ||
| 103 | 113 | const data = await res.json() as RegisterResponse; | |
| @@ -202,6 +212,7 @@ | |||
| 202 | 212 | <label for="si-password">Mot de passe</label> | |
| 203 | 213 | <input type="password" id="si-password" bind:value={siPassword} placeholder="Mot de passe" /> | |
| 204 | 214 | </div> | |
| 215 | + | <Turnstile onVerify={(token: string) => siTurnstileToken = token} /> | |
| 205 | 216 | {#if error} | |
| 206 | 217 | <div class="error">{error}</div> | |
| 207 | 218 | {/if} | |
| @@ -251,6 +262,7 @@ | |||
| 251 | 262 | <label for="captcha">Quelle est la signification de PDR ?</label> | |
| 252 | 263 | <input type="text" id="captcha" bind:value={captchaAnswer} placeholder="Entrez votre réponse" /> | |
| 253 | 264 | </div> | |
| 265 | + | <Turnstile onVerify={(token: string) => suTurnstileToken = token} /> | |
| 254 | 266 | {#if signupError} | |
| 255 | 267 | <div class="error">{signupError}</div> | |
| 256 | 268 | {/if} | |
| @@ -5,6 +5,7 @@ | |||||
| 5 | import type { User } from '$lib/types/chat'; | 5 | import type { User } from '$lib/types/chat'; | ||
| 6 | import { invalidateAll } from '$app/navigation'; | 6 | import { invalidateAll } from '$app/navigation'; | ||
| 7 | import { chatState } from '$lib/states/chat.svelte'; | 7 | import { chatState } from '$lib/states/chat.svelte'; | ||
| 8 | + | import Turnstile from './turnstile.svelte'; | |||
| 8 | 9 | ||||
| 9 | let { onLoginSuccess, showAuth = $bindable() }: { onLoginSuccess?: (user: User) => void, showAuth:Boolean } = $props(); | 10 | let { onLoginSuccess, showAuth = $bindable() }: { onLoginSuccess?: (user: User) => void, showAuth:Boolean } = $props(); | ||
| 10 | 11 | ||||
| @@ -15,6 +16,7 @@ | |||||
| 15 | let siPassword = $state(''); | 16 | let siPassword = $state(''); | ||
| 16 | let error = $state(''); | 17 | let error = $state(''); | ||
| 17 | let loginStatus = $state('idle'); | 18 | let loginStatus = $state('idle'); | ||
| 19 | + | let siTurnstileToken = $state(''); | |||
| 18 | 20 | ||||
| 19 | // Sign Up state | 21 | // Sign Up state | ||
| 20 | let suUsername = $state(''); | 22 | let suUsername = $state(''); | ||
| @@ -23,6 +25,7 @@ | |||||
| 23 | let captchaAnswer = $state(''); | 25 | let captchaAnswer = $state(''); | ||
| 24 | let signupError = $state(''); | 26 | let signupError = $state(''); | ||
| 25 | let signupStatus = $state('idle'); | 27 | let signupStatus = $state('idle'); | ||
| 28 | + | let suTurnstileToken = $state(''); | |||
| 26 | 29 | ||||
| 27 | function handleClose() { | 30 | function handleClose() { | ||
| 28 | console.debug("Closing AIM Login component."); | 31 | console.debug("Closing AIM Login component."); | ||
| @@ -32,8 +35,8 @@ | |||||
| 32 | async function handleSigninSubmit() { | 35 | async function handleSigninSubmit() { | ||
| 33 | error = ''; | 36 | error = ''; | ||
| 34 | loginStatus = 'loading'; | 37 | loginStatus = 'loading'; | ||
| 35 | - | if (!siUsername || !siPassword) { | 38 | + | if (!siUsername || !siPassword || !siTurnstileToken) { |
| 36 | - | error = 'Please enter both username and password'; | 39 | + | error = 'Please complete all fields including the security check'; |
| 37 | loginStatus = 'error'; | 40 | loginStatus = 'error'; | ||
| 38 | return; | 41 | return; | ||
| 39 | } | 42 | } | ||
| @@ -45,7 +48,8 @@ | |||||
| 45 | headers: { 'Content-Type': 'application/json' }, | 48 | headers: { 'Content-Type': 'application/json' }, | ||
| 46 | body: JSON.stringify({ | 49 | body: JSON.stringify({ | ||
| 47 | username: siUsername, | 50 | username: siUsername, | ||
| 48 | - | password: siPassword | 51 | + | password: siPassword, |
| 52 | + | turnstileToken: siTurnstileToken | |||
| 49 | }) | 53 | }) | ||
| 50 | }); | 54 | }); | ||
| 51 | 55 | ||||
| @@ -81,8 +85,8 @@ | |||||
| 81 | async function handleSignupSubmit() { | 85 | async function handleSignupSubmit() { | ||
| 82 | signupError = ''; | 86 | signupError = ''; | ||
| 83 | signupStatus = 'loading'; | 87 | signupStatus = 'loading'; | ||
| 84 | - | if (!suUsername || !suPassword || !suConfirmPassword || !captchaAnswer) { | 88 | + | if (!suUsername || !suPassword || !suConfirmPassword || !captchaAnswer || !suTurnstileToken) { |
| 85 | - | signupError = 'Please fill all fields'; | 89 | + | signupError = 'Please complete all fields including the security check'; |
| 86 | signupStatus = 'error'; | 90 | signupStatus = 'error'; | ||
| 87 | return; | 91 | return; | ||
| 88 | } | 92 | } | ||
| @@ -97,7 +101,13 @@ | |||||
| 97 | const res = await fetch('/api/register', { | 101 | const res = await fetch('/api/register', { | ||
| 98 | method: 'POST', | 102 | method: 'POST', | ||
| 99 | headers: { 'Content-Type': 'application/json' }, | 103 | headers: { 'Content-Type': 'application/json' }, | ||
| 100 | - | body: JSON.stringify({ suUsername, suPassword, suConfirmPassword, captchaAnswer }) | 104 | + | body: JSON.stringify({ |
| 105 | + | suUsername, | |||
| 106 | + | suPassword, | |||
| 107 | + | suConfirmPassword, | |||
| 108 | + | captchaAnswer, | |||
| 109 | + | turnstileToken: suTurnstileToken | |||
| 110 | + | }) | |||
| 101 | }); | 111 | }); | ||
| 102 | 112 | ||||
| 103 | const data = await res.json() as RegisterResponse; | 113 | const data = await res.json() as RegisterResponse; | ||
| @@ -202,6 +212,7 @@ | |||||
| 202 | <label for="si-password">Mot de passe</label> | 212 | <label for="si-password">Mot de passe</label> | ||
| 203 | <input type="password" id="si-password" bind:value={siPassword} placeholder="Mot de passe" /> | 213 | <input type="password" id="si-password" bind:value={siPassword} placeholder="Mot de passe" /> | ||
| 204 | </div> | 214 | </div> | ||
| 215 | + | <Turnstile onVerify={(token: string) => siTurnstileToken = token} /> | |||
| 205 | {#if error} | 216 | {#if error} | ||
| 206 | <div class="error">{error}</div> | 217 | <div class="error">{error}</div> | ||
| 207 | {/if} | 218 | {/if} | ||
| @@ -251,6 +262,7 @@ | |||||
| 251 | <label for="captcha">Quelle est la signification de PDR ?</label> | 262 | <label for="captcha">Quelle est la signification de PDR ?</label> | ||
| 252 | <input type="text" id="captcha" bind:value={captchaAnswer} placeholder="Entrez votre réponse" /> | 263 | <input type="text" id="captcha" bind:value={captchaAnswer} placeholder="Entrez votre réponse" /> | ||
| 253 | </div> | 264 | </div> | ||
| 265 | + | <Turnstile onVerify={(token: string) => suTurnstileToken = token} /> | |||
| 254 | {#if signupError} | 266 | {#if signupError} | ||
| 255 | <div class="error">{signupError}</div> | 267 | <div class="error">{signupError}</div> | ||
| 256 | {/if} | 268 | {/if} | ||
A src/lib/components/turnstile.svelte +123 -0
| @@ -0,0 +1,123 @@ | |||
| 1 | + | <script lang="ts"> | |
| 2 | + | import { onMount } from 'svelte'; | |
| 3 | + | import { fade } from 'svelte/transition'; | |
| 4 | + | import { PUBLIC_TURNSTILE_SITE_KEY } from '$env/static/public'; | |
| 5 | + | import type { Turnstile, TurnstileOptions } from '$lib/types/turnstile'; | |
| 6 | + | ||
| 7 | + | let isVerified = $state(false); | |
| 8 | + | ||
| 9 | + | type OnVerifyCallback = (token: string) => void; | |
| 10 | + | let { onVerify } = $props<{ onVerify: OnVerifyCallback }>(); | |
| 11 | + | ||
| 12 | + | let turnstileElement = $state<HTMLDivElement | null>(null); | |
| 13 | + | let widgetId = ''; | |
| 14 | + | ||
| 15 | + | // Ensure site key is a string and trim any whitespace | |
| 16 | + | const siteKey = String(PUBLIC_TURNSTILE_SITE_KEY).trim(); | |
| 17 | + | ||
| 18 | + | onMount(() => { | |
| 19 | + | if (!window.turnstile) { | |
| 20 | + | const script = document.createElement('script'); | |
| 21 | + | script.src = 'https://challenges.cloudflare.com/turnstile/v0/api.js'; | |
| 22 | + | script.async = true; | |
| 23 | + | script.defer = true; | |
| 24 | + | document.head.appendChild(script); | |
| 25 | + | ||
| 26 | + | script.onload = initTurnstile; | |
| 27 | + | } else { | |
| 28 | + | initTurnstile(); | |
| 29 | + | } | |
| 30 | + | ||
| 31 | + | return () => { | |
| 32 | + | if (widgetId) { | |
| 33 | + | window.turnstile?.remove(widgetId); | |
| 34 | + | } | |
| 35 | + | }; | |
| 36 | + | }); | |
| 37 | + | ||
| 38 | + | function initTurnstile() { | |
| 39 | + | if (!turnstileElement || !window.turnstile) return; | |
| 40 | + | ||
| 41 | + | // Validate site key is a non-empty string | |
| 42 | + | if (!siteKey) { | |
| 43 | + | console.error('Turnstile site key is missing'); | |
| 44 | + | return; | |
| 45 | + | } | |
| 46 | + | ||
| 47 | + | const options: TurnstileOptions = { | |
| 48 | + | sitekey: siteKey, | |
| 49 | + | callback: (token: string) => { | |
| 50 | + | if (typeof onVerify === 'function') { | |
| 51 | + | onVerify(token); | |
| 52 | + | isVerified = true; | |
| 53 | + | } else { | |
| 54 | + | console.error('onVerify callback is not a function'); | |
| 55 | + | } | |
| 56 | + | }, | |
| 57 | + | 'refresh-expired': 'auto', | |
| 58 | + | size: 'compact', | |
| 59 | + | 'error-callback': (error: unknown) => { | |
| 60 | + | console.error('Turnstile error:', error); | |
| 61 | + | isVerified = false; // Reset verification state on error | |
| 62 | + | } | |
| 63 | + | }; | |
| 64 | + | ||
| 65 | + | try { | |
| 66 | + | widgetId = window.turnstile.render(turnstileElement, options); | |
| 67 | + | ||
| 68 | + | // Add resize handler | |
| 69 | + | const handleResize = () => scaleTurnstileToFit(); | |
| 70 | + | window.addEventListener('resize', handleResize); | |
| 71 | + | // Initial scale | |
| 72 | + | scaleTurnstileToFit(); | |
| 73 | + | ||
| 74 | + | } catch (error) { | |
| 75 | + | console.error('Failed to render Turnstile:', error); | |
| 76 | + | } | |
| 77 | + | } | |
| 78 | + | ||
| 79 | + | function scaleTurnstileToFit() { | |
| 80 | + | const container = turnstileElement; | |
| 81 | + | if (!container) return; | |
| 82 | + | ||
| 83 | + | const iframe = container.querySelector('iframe'); | |
| 84 | + | if (!iframe) return; | |
| 85 | + | ||
| 86 | + | const containerWidth = container.offsetWidth; | |
| 87 | + | const scale = Math.min(1, containerWidth / 300); // 300px is approximate base width | |
| 88 | + | ||
| 89 | + | iframe.style.transform = `scale(${scale})`; | |
| 90 | + | iframe.style.transformOrigin = 'center center'; | |
| 91 | + | } | |
| 92 | + | </script> | |
| 93 | + | ||
| 94 | + | {#if !isVerified} | |
| 95 | + | <div | |
| 96 | + | class="turnstile-wrapper" | |
| 97 | + | transition:fade={{ duration: 300 }} | |
| 98 | + | > | |
| 99 | + | <div bind:this={turnstileElement} class="cf-turnstile" data-theme="light"></div> | |
| 100 | + | </div> | |
| 101 | + | {/if} | |
| 102 | + | ||
| 103 | + | <style> | |
| 104 | + | .turnstile-wrapper { | |
| 105 | + | width: 100%; | |
| 106 | + | display: flex; | |
| 107 | + | justify-content: center; | |
| 108 | + | align-items: center; | |
| 109 | + | min-height: 65px; /* Approximate height for compact size */ | |
| 110 | + | overflow: hidden; | |
| 111 | + | } | |
| 112 | + | ||
| 113 | + | .cf-turnstile { | |
| 114 | + | display: flex; | |
| 115 | + | justify-content: center; | |
| 116 | + | align-items: center; | |
| 117 | + | } | |
| 118 | + | ||
| 119 | + | /* Handle iframe scaling */ | |
| 120 | + | :global(.turnstile-wrapper iframe) { | |
| 121 | + | transition: transform 0.2s ease-in-out; | |
| 122 | + | } | |
| 123 | + | </style> ⏎̸ | |
| @@ -0,0 +1,123 @@ | |||||
| 1 | + | <script lang="ts"> | |||
| 2 | + | import { onMount } from 'svelte'; | |||
| 3 | + | import { fade } from 'svelte/transition'; | |||
| 4 | + | import { PUBLIC_TURNSTILE_SITE_KEY } from '$env/static/public'; | |||
| 5 | + | import type { Turnstile, TurnstileOptions } from '$lib/types/turnstile'; | |||
| 6 | + | ||||
| 7 | + | let isVerified = $state(false); | |||
| 8 | + | ||||
| 9 | + | type OnVerifyCallback = (token: string) => void; | |||
| 10 | + | let { onVerify } = $props<{ onVerify: OnVerifyCallback }>(); | |||
| 11 | + | ||||
| 12 | + | let turnstileElement = $state<HTMLDivElement | null>(null); | |||
| 13 | + | let widgetId = ''; | |||
| 14 | + | ||||
| 15 | + | // Ensure site key is a string and trim any whitespace | |||
| 16 | + | const siteKey = String(PUBLIC_TURNSTILE_SITE_KEY).trim(); | |||
| 17 | + | ||||
| 18 | + | onMount(() => { | |||
| 19 | + | if (!window.turnstile) { | |||
| 20 | + | const script = document.createElement('script'); | |||
| 21 | + | script.src = 'https://challenges.cloudflare.com/turnstile/v0/api.js'; | |||
| 22 | + | script.async = true; | |||
| 23 | + | script.defer = true; | |||
| 24 | + | document.head.appendChild(script); | |||
| 25 | + | ||||
| 26 | + | script.onload = initTurnstile; | |||
| 27 | + | } else { | |||
| 28 | + | initTurnstile(); | |||
| 29 | + | } | |||
| 30 | + | ||||
| 31 | + | return () => { | |||
| 32 | + | if (widgetId) { | |||
| 33 | + | window.turnstile?.remove(widgetId); | |||
| 34 | + | } | |||
| 35 | + | }; | |||
| 36 | + | }); | |||
| 37 | + | ||||
| 38 | + | function initTurnstile() { | |||
| 39 | + | if (!turnstileElement || !window.turnstile) return; | |||
| 40 | + | ||||
| 41 | + | // Validate site key is a non-empty string | |||
| 42 | + | if (!siteKey) { | |||
| 43 | + | console.error('Turnstile site key is missing'); | |||
| 44 | + | return; | |||
| 45 | + | } | |||
| 46 | + | ||||
| 47 | + | const options: TurnstileOptions = { | |||
| 48 | + | sitekey: siteKey, | |||
| 49 | + | callback: (token: string) => { | |||
| 50 | + | if (typeof onVerify === 'function') { | |||
| 51 | + | onVerify(token); | |||
| 52 | + | isVerified = true; | |||
| 53 | + | } else { | |||
| 54 | + | console.error('onVerify callback is not a function'); | |||
| 55 | + | } | |||
| 56 | + | }, | |||
| 57 | + | 'refresh-expired': 'auto', | |||
| 58 | + | size: 'compact', | |||
| 59 | + | 'error-callback': (error: unknown) => { | |||
| 60 | + | console.error('Turnstile error:', error); | |||
| 61 | + | isVerified = false; // Reset verification state on error | |||
| 62 | + | } | |||
| 63 | + | }; | |||
| 64 | + | ||||
| 65 | + | try { | |||
| 66 | + | widgetId = window.turnstile.render(turnstileElement, options); | |||
| 67 | + | ||||
| 68 | + | // Add resize handler | |||
| 69 | + | const handleResize = () => scaleTurnstileToFit(); | |||
| 70 | + | window.addEventListener('resize', handleResize); | |||
| 71 | + | // Initial scale | |||
| 72 | + | scaleTurnstileToFit(); | |||
| 73 | + | ||||
| 74 | + | } catch (error) { | |||
| 75 | + | console.error('Failed to render Turnstile:', error); | |||
| 76 | + | } | |||
| 77 | + | } | |||
| 78 | + | ||||
| 79 | + | function scaleTurnstileToFit() { | |||
| 80 | + | const container = turnstileElement; | |||
| 81 | + | if (!container) return; | |||
| 82 | + | ||||
| 83 | + | const iframe = container.querySelector('iframe'); | |||
| 84 | + | if (!iframe) return; | |||
| 85 | + | ||||
| 86 | + | const containerWidth = container.offsetWidth; | |||
| 87 | + | const scale = Math.min(1, containerWidth / 300); // 300px is approximate base width | |||
| 88 | + | ||||
| 89 | + | iframe.style.transform = `scale(${scale})`; | |||
| 90 | + | iframe.style.transformOrigin = 'center center'; | |||
| 91 | + | } | |||
| 92 | + | </script> | |||
| 93 | + | ||||
| 94 | + | {#if !isVerified} | |||
| 95 | + | <div | |||
| 96 | + | class="turnstile-wrapper" | |||
| 97 | + | transition:fade={{ duration: 300 }} | |||
| 98 | + | > | |||
| 99 | + | <div bind:this={turnstileElement} class="cf-turnstile" data-theme="light"></div> | |||
| 100 | + | </div> | |||
| 101 | + | {/if} | |||
| 102 | + | ||||
| 103 | + | <style> | |||
| 104 | + | .turnstile-wrapper { | |||
| 105 | + | width: 100%; | |||
| 106 | + | display: flex; | |||
| 107 | + | justify-content: center; | |||
| 108 | + | align-items: center; | |||
| 109 | + | min-height: 65px; /* Approximate height for compact size */ | |||
| 110 | + | overflow: hidden; | |||
| 111 | + | } | |||
| 112 | + | ||||
| 113 | + | .cf-turnstile { | |||
| 114 | + | display: flex; | |||
| 115 | + | justify-content: center; | |||
| 116 | + | align-items: center; | |||
| 117 | + | } | |||
| 118 | + | ||||
| 119 | + | /* Handle iframe scaling */ | |||
| 120 | + | :global(.turnstile-wrapper iframe) { | |||
| 121 | + | transition: transform 0.2s ease-in-out; | |||
| 122 | + | } | |||
| 123 | + | </style> ⏎̸ | |||
A src/lib/types/turnstile.d.ts +23 -0
| @@ -0,0 +1,23 @@ | |||
| 1 | + | interface Turnstile { | |
| 2 | + | render: (element: HTMLElement, options: TurnstileOptions) => string; | |
| 3 | + | remove: (widgetId: string) => void; | |
| 4 | + | } | |
| 5 | + | ||
| 6 | + | interface TurnstileOptions { | |
| 7 | + | sitekey: string; | |
| 8 | + | callback: (token: string) => void; | |
| 9 | + | "refresh-expired"?: "auto" | "manual"; | |
| 10 | + | size?: "invisible" | "normal" | "compact"; | |
| 11 | + | "error-callback"?: (error: unknown) => void; | |
| 12 | + | "expired-callback"?: () => void; | |
| 13 | + | theme?: "light" | "dark" | "auto"; | |
| 14 | + | language?: string; | |
| 15 | + | } | |
| 16 | + | ||
| 17 | + | declare global { | |
| 18 | + | interface Window { | |
| 19 | + | turnstile?: Turnstile; | |
| 20 | + | } | |
| 21 | + | } | |
| 22 | + | ||
| 23 | + | export type { Turnstile, TurnstileOptions }; ⏎̸ | |
| @@ -0,0 +1,23 @@ | |||||
| 1 | + | interface Turnstile { | |||
| 2 | + | render: (element: HTMLElement, options: TurnstileOptions) => string; | |||
| 3 | + | remove: (widgetId: string) => void; | |||
| 4 | + | } | |||
| 5 | + | ||||
| 6 | + | interface TurnstileOptions { | |||
| 7 | + | sitekey: string; | |||
| 8 | + | callback: (token: string) => void; | |||
| 9 | + | "refresh-expired"?: "auto" | "manual"; | |||
| 10 | + | size?: "invisible" | "normal" | "compact"; | |||
| 11 | + | "error-callback"?: (error: unknown) => void; | |||
| 12 | + | "expired-callback"?: () => void; | |||
| 13 | + | theme?: "light" | "dark" | "auto"; | |||
| 14 | + | language?: string; | |||
| 15 | + | } | |||
| 16 | + | ||||
| 17 | + | declare global { | |||
| 18 | + | interface Window { | |||
| 19 | + | turnstile?: Turnstile; | |||
| 20 | + | } | |||
| 21 | + | } | |||
| 22 | + | ||||
| 23 | + | export type { Turnstile, TurnstileOptions }; ⏎̸ | |||
A src/lib/utils/turnstile.server.ts +44 -0
| @@ -0,0 +1,44 @@ | |||
| 1 | + | import { TURNSTILE_SECRET_KEY } from '$env/static/private'; | |
| 2 | + | import { createLogger } from './logger.server'; | |
| 3 | + | ||
| 4 | + | const log = createLogger('turnstile'); | |
| 5 | + | ||
| 6 | + | interface TurnstileVerifyResponse { | |
| 7 | + | "success": boolean; | |
| 8 | + | "error-codes"?: string[]; | |
| 9 | + | "challenge_ts"?: string; | |
| 10 | + | "hostname"?: string; | |
| 11 | + | "action"?: string; | |
| 12 | + | "cdata"?: string; | |
| 13 | + | } | |
| 14 | + | ||
| 15 | + | export async function validateTurnstileToken(token: string, remoteip?: string): Promise<boolean> { | |
| 16 | + | try { | |
| 17 | + | const formData = new FormData(); | |
| 18 | + | formData.append('secret', TURNSTILE_SECRET_KEY); | |
| 19 | + | formData.append('response', token); | |
| 20 | + | if (remoteip) { | |
| 21 | + | formData.append('remoteip', remoteip); | |
| 22 | + | } | |
| 23 | + | ||
| 24 | + | const url = 'https://challenges.cloudflare.com/turnstile/v0/siteverify'; | |
| 25 | + | const result = await fetch(url, { | |
| 26 | + | body: formData, | |
| 27 | + | method: 'POST', | |
| 28 | + | }); | |
| 29 | + | ||
| 30 | + | const outcome: TurnstileVerifyResponse = await result.json(); | |
| 31 | + | ||
| 32 | + | if (!outcome.success) { | |
| 33 | + | log.warn('Turnstile validation failed', { | |
| 34 | + | errors: outcome["error-codes"], | |
| 35 | + | remoteip: remoteip ? `${remoteip.split('.')[0]}.xxx.xxx.xxx` : undefined | |
| 36 | + | }); | |
| 37 | + | } | |
| 38 | + | ||
| 39 | + | return outcome.success; | |
| 40 | + | } catch (error) { | |
| 41 | + | log.error('Error validating Turnstile token', { error }); | |
| 42 | + | return false; | |
| 43 | + | } | |
| 44 | + | } ⏎̸ | |
| @@ -0,0 +1,44 @@ | |||||
| 1 | + | import { TURNSTILE_SECRET_KEY } from '$env/static/private'; | |||
| 2 | + | import { createLogger } from './logger.server'; | |||
| 3 | + | ||||
| 4 | + | const log = createLogger('turnstile'); | |||
| 5 | + | ||||
| 6 | + | interface TurnstileVerifyResponse { | |||
| 7 | + | "success": boolean; | |||
| 8 | + | "error-codes"?: string[]; | |||
| 9 | + | "challenge_ts"?: string; | |||
| 10 | + | "hostname"?: string; | |||
| 11 | + | "action"?: string; | |||
| 12 | + | "cdata"?: string; | |||
| 13 | + | } | |||
| 14 | + | ||||
| 15 | + | export async function validateTurnstileToken(token: string, remoteip?: string): Promise<boolean> { | |||
| 16 | + | try { | |||
| 17 | + | const formData = new FormData(); | |||
| 18 | + | formData.append('secret', TURNSTILE_SECRET_KEY); | |||
| 19 | + | formData.append('response', token); | |||
| 20 | + | if (remoteip) { | |||
| 21 | + | formData.append('remoteip', remoteip); | |||
| 22 | + | } | |||
| 23 | + | ||||
| 24 | + | const url = 'https://challenges.cloudflare.com/turnstile/v0/siteverify'; | |||
| 25 | + | const result = await fetch(url, { | |||
| 26 | + | body: formData, | |||
| 27 | + | method: 'POST', | |||
| 28 | + | }); | |||
| 29 | + | ||||
| 30 | + | const outcome: TurnstileVerifyResponse = await result.json(); | |||
| 31 | + | ||||
| 32 | + | if (!outcome.success) { | |||
| 33 | + | log.warn('Turnstile validation failed', { | |||
| 34 | + | errors: outcome["error-codes"], | |||
| 35 | + | remoteip: remoteip ? `${remoteip.split('.')[0]}.xxx.xxx.xxx` : undefined | |||
| 36 | + | }); | |||
| 37 | + | } | |||
| 38 | + | ||||
| 39 | + | return outcome.success; | |||
| 40 | + | } catch (error) { | |||
| 41 | + | log.error('Error validating Turnstile token', { error }); | |||
| 42 | + | return false; | |||
| 43 | + | } | |||
| 44 | + | } ⏎̸ | |||
M src/routes/api/register/+server.ts +75 -41
| @@ -3,33 +3,90 @@ import type { RegisterResponseSuccess, RegisterResponseError } from '$lib/types/ | |||
| 3 | 3 | import db from '$lib/db/db.server'; | |
| 4 | 4 | import { users } from '$lib/db/schema'; | |
| 5 | 5 | import { hashPassword } from '$lib/utils/password'; | |
| 6 | + | import { validateTurnstileToken } from '$lib/utils/turnstile.server'; | |
| 7 | + | import { createLogger } from '$lib/utils/logger.server'; | |
| 8 | + | ||
| 9 | + | const log = createLogger('register-server'); | |
| 6 | 10 | ||
| 7 | 11 | // In-memory map to track failed captcha attempts per IP | |
| 8 | 12 | const captchaAttempts = new Map<string, { count: number, lastAttempt: number }>(); | |
| 9 | - | const sleep = (ms: number) => new Promise(resolve => setTimeout(resolve, ms)); | |
| 13 | + | ||
| 14 | + | // Cleanup old entries every hour | |
| 15 | + | setInterval(() => { | |
| 16 | + | const now = Date.now(); | |
| 17 | + | const ONE_HOUR = 60 * 60 * 1000; | |
| 18 | + | for (const [ip, data] of captchaAttempts.entries()) { | |
| 19 | + | if (now - data.lastAttempt > ONE_HOUR) { | |
| 20 | + | captchaAttempts.delete(ip); | |
| 21 | + | } | |
| 22 | + | } | |
| 23 | + | }, 60 * 60 * 1000); | |
| 10 | 24 | ||
| 11 | 25 | export const POST: RequestHandler = async ({ request }) => { | |
| 12 | - | console.log("[Register] New registration attempt received"); | |
| 26 | + | log.debug('New registration attempt received'); | |
| 13 | 27 | ||
| 14 | - | // Only allow POST | |
| 15 | 28 | if (request.method !== 'POST') { | |
| 29 | + | log.warn('Invalid method used', { method: request.method }); | |
| 16 | 30 | return new Response(JSON.stringify({ error: 'Method Not Allowed' } as RegisterResponseError), { status: 405 }); | |
| 17 | 31 | } | |
| 18 | 32 | ||
| 33 | + | // Get the IP address for rate limiting and Turnstile validation | |
| 34 | + | const ip = request.headers.get('x-forwarded-for') || 'unknown'; | |
| 35 | + | const maskedIp = ip.split('.').map((octet, idx) => idx < 3 ? 'xxx' : octet).join('.'); | |
| 36 | + | const now = Date.now(); | |
| 37 | + | const attemptData = captchaAttempts.get(ip) || { count: 0, lastAttempt: 0 }; | |
| 38 | + | ||
| 19 | 39 | let body: unknown; | |
| 20 | 40 | try { | |
| 21 | 41 | body = await request.json(); | |
| 22 | 42 | } catch { | |
| 23 | - | console.log("[Register] Invalid JSON payload received"); | |
| 43 | + | log.warn('Invalid JSON payload received'); | |
| 24 | 44 | return new Response(JSON.stringify({ error: 'Invalid JSON' } as RegisterResponseError), { status: 400 }); | |
| 25 | 45 | } | |
| 26 | 46 | ||
| 27 | - | const { suUsername, suPassword, suConfirmPassword, captchaAnswer } = body as { suUsername: string, suPassword: string, suConfirmPassword: string, captchaAnswer: string }; | |
| 47 | + | const { suUsername, suPassword, suConfirmPassword, captchaAnswer, turnstileToken } = body as { | |
| 48 | + | suUsername: string, | |
| 49 | + | suPassword: string, | |
| 50 | + | suConfirmPassword: string, | |
| 51 | + | captchaAnswer: string, | |
| 52 | + | turnstileToken: string | |
| 53 | + | }; | |
| 54 | + | ||
| 55 | + | if (!suUsername || !suPassword || !suConfirmPassword || !captchaAnswer || !turnstileToken) { | |
| 56 | + | log.warn('Missing required fields', { | |
| 57 | + | hasUsername: Boolean(suUsername), | |
| 58 | + | hasPassword: Boolean(suPassword), | |
| 59 | + | hasConfirmPassword: Boolean(suConfirmPassword), | |
| 60 | + | hasCaptcha: Boolean(captchaAnswer), | |
| 61 | + | hasTurnstileToken: Boolean(turnstileToken) | |
| 62 | + | }); | |
| 63 | + | return new Response(JSON.stringify({ error: 'All fields are required' } as RegisterResponseError), { status: 400 }); | |
| 64 | + | } | |
| 65 | + | ||
| 66 | + | // Validate Turnstile token | |
| 67 | + | const isValidTurnstile = await validateTurnstileToken(turnstileToken, ip); | |
| 68 | + | if (!isValidTurnstile) { | |
| 69 | + | log.warn('Invalid Turnstile token', { maskedIp }); | |
| 70 | + | return new Response(JSON.stringify({ error: 'Security check failed. Please try again.' } as RegisterResponseError), { status: 400 }); | |
| 71 | + | } | |
| 72 | + | ||
| 73 | + | // Validate PDR captcha | |
| 74 | + | const normalizedAnswer = captchaAnswer.trim().toLowerCase(); | |
| 75 | + | if (normalizedAnswer !== 'poudlard' && normalizedAnswer !== 'poudlard rp') { | |
| 76 | + | attemptData.count++; | |
| 77 | + | attemptData.lastAttempt = now; | |
| 78 | + | captchaAttempts.set(ip, attemptData); | |
| 79 | + | log.warn('Invalid captcha answer', { maskedIp, attemptCount: attemptData.count }); | |
| 80 | + | return new Response(JSON.stringify({ error: 'Invalid answer to the PDR question' } as RegisterResponseError), { status: 400 }); | |
| 81 | + | } | |
| 82 | + | ||
| 83 | + | // Reset captcha attempts on success | |
| 84 | + | captchaAttempts.delete(ip); | |
| 28 | 85 | ||
| 29 | 86 | // Destructure and validate required fields from the payload. | |
| 30 | 87 | if (typeof suUsername !== 'string' || typeof suPassword !== 'string' || | |
| 31 | - | typeof suConfirmPassword !== 'string' || typeof captchaAnswer !== 'string') { | |
| 32 | - | console.log("[Register] Missing or invalid input fields"); | |
| 88 | + | typeof suConfirmPassword !== 'string') { | |
| 89 | + | log.warn('Missing or invalid input fields'); | |
| 33 | 90 | return new Response(JSON.stringify({ error: 'Missing or invalid input fields' } as RegisterResponseError), { status: 400 }); | |
| 34 | 91 | } | |
| 35 | 92 | ||
| @@ -37,75 +94,52 @@ export const POST: RequestHandler = async ({ request }) => { | |||
| 37 | 94 | const username = suUsername.trim(); | |
| 38 | 95 | const password = suPassword.trim(); | |
| 39 | 96 | const confirmPassword = suConfirmPassword.trim(); | |
| 40 | - | const captcha = captchaAnswer.trim().toLowerCase(); | |
| 41 | 97 | ||
| 42 | 98 | // Basic validations. | |
| 43 | - | if (!username || !password || !confirmPassword || !captcha) { | |
| 99 | + | if (!username || !password || !confirmPassword) { | |
| 100 | + | log.warn('All fields must be filled'); | |
| 44 | 101 | return new Response(JSON.stringify({ error: 'All fields must be filled' } as RegisterResponseError), { status: 400 }); | |
| 45 | 102 | } | |
| 46 | 103 | ||
| 47 | 104 | if (username.length > 32) { | |
| 105 | + | log.warn('Username must be at most 32 characters'); | |
| 48 | 106 | return new Response(JSON.stringify({ error: 'Username must be at most 32 characters' } as RegisterResponseError), { status: 400 }); | |
| 49 | 107 | } | |
| 50 | 108 | // Only allow letters, numbers, underscores, and dashes in the username. | |
| 51 | 109 | const usernameRegex = /^[a-zA-Z0-9_-]+$/; | |
| 52 | 110 | if (!usernameRegex.test(username)) { | |
| 111 | + | log.warn('Invalid username. Only letters, numbers, underscores, and dashes are allowed.'); | |
| 53 | 112 | return new Response(JSON.stringify({ error: 'Invalid username. Only letters, numbers, underscores, and dashes are allowed.' } as RegisterResponseError), { status: 400 }); | |
| 54 | 113 | } | |
| 55 | 114 | ||
| 56 | 115 | if (password !== confirmPassword) { | |
| 116 | + | log.warn('Passwords do not match'); | |
| 57 | 117 | return new Response(JSON.stringify({ error: 'Passwords do not match' } as RegisterResponseError), { status: 400 }); | |
| 58 | 118 | } | |
| 59 | 119 | ||
| 60 | 120 | if (password.length < 8 || password.length > 64) { | |
| 121 | + | log.warn('Password must be between 8 and 64 characters'); | |
| 61 | 122 | return new Response(JSON.stringify({ error: 'Password must be between 8 and 64 characters' } as RegisterResponseError), { status: 400 }); | |
| 62 | 123 | } | |
| 63 | 124 | ||
| 64 | - | // Captcha validation logic. | |
| 65 | - | // Expected captcha answer. | |
| 66 | - | const expectedCaptcha = "point de rencontre"; | |
| 67 | - | // Get the IP address for rate limiting. In production, a more reliable method should be used. | |
| 68 | - | const ip = request.headers.get('x-forwarded-for') || 'unknown'; | |
| 69 | - | const maskedIp = ip.split('.').map((octet, idx) => idx < 3 ? 'xxx' : octet).join('.'); | |
| 70 | - | const now = Date.now(); | |
| 71 | - | const attemptData = captchaAttempts.get(ip) || { count: 0, lastAttempt: 0 }; | |
| 72 | - | // If the user has failed three or more times, apply aggressive exponential backoff. | |
| 73 | - | if (attemptData.count >= 3) { | |
| 74 | - | const delay = Math.pow(2, attemptData.count - 3 + 1) * 1000; // delay in milliseconds | |
| 75 | - | console.log(`[Register] Rate limit exceeded for IP ${maskedIp} - applying ${delay}ms delay`); | |
| 76 | - | await sleep(delay); | |
| 77 | - | } | |
| 78 | - | if (captcha !== expectedCaptcha) { | |
| 79 | - | attemptData.count++; | |
| 80 | - | attemptData.lastAttempt = now; | |
| 81 | - | captchaAttempts.set(ip, attemptData); | |
| 82 | - | console.log(`[Register] Captcha failed for IP ${maskedIp}. Attempt count: ${attemptData.count}`); | |
| 83 | - | const remaining = Math.max(0, 3 - attemptData.count); | |
| 84 | - | return new Response(JSON.stringify({ error: `Incorrect captcha answer. ${remaining > 0 ? remaining + " attempt(s) remaining." : "Please wait before trying again."}` } as RegisterResponseError), { status: 400 }); | |
| 85 | - | } | |
| 86 | - | // Reset captcha attempt data on success. | |
| 87 | - | if (captcha === expectedCaptcha) { | |
| 88 | - | captchaAttempts.delete(ip); | |
| 89 | - | } | |
| 90 | - | ||
| 91 | 125 | // Securely hash the user's password. | |
| 92 | 126 | let hashedPassword: string; | |
| 93 | 127 | try { | |
| 94 | 128 | hashedPassword = await hashPassword(password); | |
| 95 | 129 | } catch { | |
| 96 | - | console.log("[Register] Error hashing password"); | |
| 130 | + | log.error('Error hashing password'); | |
| 97 | 131 | return new Response(JSON.stringify({ error: 'Internal Server Error' } as RegisterResponseError), { status: 500 }); | |
| 98 | 132 | } | |
| 99 | 133 | // Insert the new user into the database. | |
| 100 | 134 | try { | |
| 101 | 135 | await db.insert(users).values({ | |
| 102 | - | password: hashedPassword, | |
| 103 | 136 | nickname: username, | |
| 137 | + | password: hashedPassword, | |
| 104 | 138 | createdAt: Date.now() | |
| 105 | 139 | }); | |
| 106 | - | console.log(`[Register] User ${username} registered successfully`); | |
| 107 | - | } catch (err) { | |
| 108 | - | console.log("Database insertion error", err); | |
| 140 | + | log.info(`User ${username} registered successfully`); | |
| 141 | + | } catch (err: unknown) { | |
| 142 | + | log.error('Database insertion error', { error: err as object }); | |
| 109 | 143 | // Assume a duplicate user error if the email (or derived unique field) already exists. | |
| 110 | 144 | return new Response(JSON.stringify({ error: 'User registration failed. Possibly user already exists.' } as RegisterResponseError), { status: 409 }); | |
| 111 | 145 | } | |
| @@ -3,33 +3,90 @@ import type { RegisterResponseSuccess, RegisterResponseError } from '$lib/types/ | |||||
| 3 | import db from '$lib/db/db.server'; | 3 | import db from '$lib/db/db.server'; | ||
| 4 | import { users } from '$lib/db/schema'; | 4 | import { users } from '$lib/db/schema'; | ||
| 5 | import { hashPassword } from '$lib/utils/password'; | 5 | import { hashPassword } from '$lib/utils/password'; | ||
| 6 | + | import { validateTurnstileToken } from '$lib/utils/turnstile.server'; | |||
| 7 | + | import { createLogger } from '$lib/utils/logger.server'; | |||
| 8 | + | ||||
| 9 | + | const log = createLogger('register-server'); | |||
| 6 | 10 | ||||
| 7 | // In-memory map to track failed captcha attempts per IP | 11 | // In-memory map to track failed captcha attempts per IP | ||
| 8 | const captchaAttempts = new Map<string, { count: number, lastAttempt: number }>(); | 12 | const captchaAttempts = new Map<string, { count: number, lastAttempt: number }>(); | ||
| 9 | - | const sleep = (ms: number) => new Promise(resolve => setTimeout(resolve, ms)); | 13 | + | |
| 14 | + | // Cleanup old entries every hour | |||
| 15 | + | setInterval(() => { | |||
| 16 | + | const now = Date.now(); | |||
| 17 | + | const ONE_HOUR = 60 * 60 * 1000; | |||
| 18 | + | for (const [ip, data] of captchaAttempts.entries()) { | |||
| 19 | + | if (now - data.lastAttempt > ONE_HOUR) { | |||
| 20 | + | captchaAttempts.delete(ip); | |||
| 21 | + | } | |||
| 22 | + | } | |||
| 23 | + | }, 60 * 60 * 1000); | |||
| 10 | 24 | ||||
| 11 | export const POST: RequestHandler = async ({ request }) => { | 25 | export const POST: RequestHandler = async ({ request }) => { | ||
| 12 | - | console.log("[Register] New registration attempt received"); | 26 | + | log.debug('New registration attempt received'); |
| 13 | 27 | ||||
| 14 | - | // Only allow POST | |||
| 15 | if (request.method !== 'POST') { | 28 | if (request.method !== 'POST') { | ||
| 29 | + | log.warn('Invalid method used', { method: request.method }); | |||
| 16 | return new Response(JSON.stringify({ error: 'Method Not Allowed' } as RegisterResponseError), { status: 405 }); | 30 | return new Response(JSON.stringify({ error: 'Method Not Allowed' } as RegisterResponseError), { status: 405 }); | ||
| 17 | } | 31 | } | ||
| 18 | 32 | ||||
| 33 | + | // Get the IP address for rate limiting and Turnstile validation | |||
| 34 | + | const ip = request.headers.get('x-forwarded-for') || 'unknown'; | |||
| 35 | + | const maskedIp = ip.split('.').map((octet, idx) => idx < 3 ? 'xxx' : octet).join('.'); | |||
| 36 | + | const now = Date.now(); | |||
| 37 | + | const attemptData = captchaAttempts.get(ip) || { count: 0, lastAttempt: 0 }; | |||
| 38 | + | ||||
| 19 | let body: unknown; | 39 | let body: unknown; | ||
| 20 | try { | 40 | try { | ||
| 21 | body = await request.json(); | 41 | body = await request.json(); | ||
| 22 | } catch { | 42 | } catch { | ||
| 23 | - | console.log("[Register] Invalid JSON payload received"); | 43 | + | log.warn('Invalid JSON payload received'); |
| 24 | return new Response(JSON.stringify({ error: 'Invalid JSON' } as RegisterResponseError), { status: 400 }); | 44 | return new Response(JSON.stringify({ error: 'Invalid JSON' } as RegisterResponseError), { status: 400 }); | ||
| 25 | } | 45 | } | ||
| 26 | 46 | ||||
| 27 | - | const { suUsername, suPassword, suConfirmPassword, captchaAnswer } = body as { suUsername: string, suPassword: string, suConfirmPassword: string, captchaAnswer: string }; | 47 | + | const { suUsername, suPassword, suConfirmPassword, captchaAnswer, turnstileToken } = body as { |
| 48 | + | suUsername: string, | |||
| 49 | + | suPassword: string, | |||
| 50 | + | suConfirmPassword: string, | |||
| 51 | + | captchaAnswer: string, | |||
| 52 | + | turnstileToken: string | |||
| 53 | + | }; | |||
| 54 | + | ||||
| 55 | + | if (!suUsername || !suPassword || !suConfirmPassword || !captchaAnswer || !turnstileToken) { | |||
| 56 | + | log.warn('Missing required fields', { | |||
| 57 | + | hasUsername: Boolean(suUsername), | |||
| 58 | + | hasPassword: Boolean(suPassword), | |||
| 59 | + | hasConfirmPassword: Boolean(suConfirmPassword), | |||
| 60 | + | hasCaptcha: Boolean(captchaAnswer), | |||
| 61 | + | hasTurnstileToken: Boolean(turnstileToken) | |||
| 62 | + | }); | |||
| 63 | + | return new Response(JSON.stringify({ error: 'All fields are required' } as RegisterResponseError), { status: 400 }); | |||
| 64 | + | } | |||
| 65 | + | ||||
| 66 | + | // Validate Turnstile token | |||
| 67 | + | const isValidTurnstile = await validateTurnstileToken(turnstileToken, ip); | |||
| 68 | + | if (!isValidTurnstile) { | |||
| 69 | + | log.warn('Invalid Turnstile token', { maskedIp }); | |||
| 70 | + | return new Response(JSON.stringify({ error: 'Security check failed. Please try again.' } as RegisterResponseError), { status: 400 }); | |||
| 71 | + | } | |||
| 72 | + | ||||
| 73 | + | // Validate PDR captcha | |||
| 74 | + | const normalizedAnswer = captchaAnswer.trim().toLowerCase(); | |||
| 75 | + | if (normalizedAnswer !== 'poudlard' && normalizedAnswer !== 'poudlard rp') { | |||
| 76 | + | attemptData.count++; | |||
| 77 | + | attemptData.lastAttempt = now; | |||
| 78 | + | captchaAttempts.set(ip, attemptData); | |||
| 79 | + | log.warn('Invalid captcha answer', { maskedIp, attemptCount: attemptData.count }); | |||
| 80 | + | return new Response(JSON.stringify({ error: 'Invalid answer to the PDR question' } as RegisterResponseError), { status: 400 }); | |||
| 81 | + | } | |||
| 82 | + | ||||
| 83 | + | // Reset captcha attempts on success | |||
| 84 | + | captchaAttempts.delete(ip); | |||
| 28 | 85 | ||||
| 29 | // Destructure and validate required fields from the payload. | 86 | // Destructure and validate required fields from the payload. | ||
| 30 | if (typeof suUsername !== 'string' || typeof suPassword !== 'string' || | 87 | if (typeof suUsername !== 'string' || typeof suPassword !== 'string' || | ||
| 31 | - | typeof suConfirmPassword !== 'string' || typeof captchaAnswer !== 'string') { | 88 | + | typeof suConfirmPassword !== 'string') { |
| 32 | - | console.log("[Register] Missing or invalid input fields"); | 89 | + | log.warn('Missing or invalid input fields'); |
| 33 | return new Response(JSON.stringify({ error: 'Missing or invalid input fields' } as RegisterResponseError), { status: 400 }); | 90 | return new Response(JSON.stringify({ error: 'Missing or invalid input fields' } as RegisterResponseError), { status: 400 }); | ||
| 34 | } | 91 | } | ||
| 35 | 92 | ||||
| @@ -37,75 +94,52 @@ export const POST: RequestHandler = async ({ request }) => { | |||||
| 37 | const username = suUsername.trim(); | 94 | const username = suUsername.trim(); | ||
| 38 | const password = suPassword.trim(); | 95 | const password = suPassword.trim(); | ||
| 39 | const confirmPassword = suConfirmPassword.trim(); | 96 | const confirmPassword = suConfirmPassword.trim(); | ||
| 40 | - | const captcha = captchaAnswer.trim().toLowerCase(); | |||
| 41 | 97 | ||||
| 42 | // Basic validations. | 98 | // Basic validations. | ||
| 43 | - | if (!username || !password || !confirmPassword || !captcha) { | 99 | + | if (!username || !password || !confirmPassword) { |
| 100 | + | log.warn('All fields must be filled'); | |||
| 44 | return new Response(JSON.stringify({ error: 'All fields must be filled' } as RegisterResponseError), { status: 400 }); | 101 | return new Response(JSON.stringify({ error: 'All fields must be filled' } as RegisterResponseError), { status: 400 }); | ||
| 45 | } | 102 | } | ||
| 46 | 103 | ||||
| 47 | if (username.length > 32) { | 104 | if (username.length > 32) { | ||
| 105 | + | log.warn('Username must be at most 32 characters'); | |||
| 48 | return new Response(JSON.stringify({ error: 'Username must be at most 32 characters' } as RegisterResponseError), { status: 400 }); | 106 | return new Response(JSON.stringify({ error: 'Username must be at most 32 characters' } as RegisterResponseError), { status: 400 }); | ||
| 49 | } | 107 | } | ||
| 50 | // Only allow letters, numbers, underscores, and dashes in the username. | 108 | // Only allow letters, numbers, underscores, and dashes in the username. | ||
| 51 | const usernameRegex = /^[a-zA-Z0-9_-]+$/; | 109 | const usernameRegex = /^[a-zA-Z0-9_-]+$/; | ||
| 52 | if (!usernameRegex.test(username)) { | 110 | if (!usernameRegex.test(username)) { | ||
| 111 | + | log.warn('Invalid username. Only letters, numbers, underscores, and dashes are allowed.'); | |||
| 53 | return new Response(JSON.stringify({ error: 'Invalid username. Only letters, numbers, underscores, and dashes are allowed.' } as RegisterResponseError), { status: 400 }); | 112 | return new Response(JSON.stringify({ error: 'Invalid username. Only letters, numbers, underscores, and dashes are allowed.' } as RegisterResponseError), { status: 400 }); | ||
| 54 | } | 113 | } | ||
| 55 | 114 | ||||
| 56 | if (password !== confirmPassword) { | 115 | if (password !== confirmPassword) { | ||
| 116 | + | log.warn('Passwords do not match'); | |||
| 57 | return new Response(JSON.stringify({ error: 'Passwords do not match' } as RegisterResponseError), { status: 400 }); | 117 | return new Response(JSON.stringify({ error: 'Passwords do not match' } as RegisterResponseError), { status: 400 }); | ||
| 58 | } | 118 | } | ||
| 59 | 119 | ||||
| 60 | if (password.length < 8 || password.length > 64) { | 120 | if (password.length < 8 || password.length > 64) { | ||
| 121 | + | log.warn('Password must be between 8 and 64 characters'); | |||
| 61 | return new Response(JSON.stringify({ error: 'Password must be between 8 and 64 characters' } as RegisterResponseError), { status: 400 }); | 122 | return new Response(JSON.stringify({ error: 'Password must be between 8 and 64 characters' } as RegisterResponseError), { status: 400 }); | ||
| 62 | } | 123 | } | ||
| 63 | 124 | ||||
| 64 | - | // Captcha validation logic. | |||
| 65 | - | // Expected captcha answer. | |||
| 66 | - | const expectedCaptcha = "point de rencontre"; | |||
| 67 | - | // Get the IP address for rate limiting. In production, a more reliable method should be used. | |||
| 68 | - | const ip = request.headers.get('x-forwarded-for') || 'unknown'; | |||
| 69 | - | const maskedIp = ip.split('.').map((octet, idx) => idx < 3 ? 'xxx' : octet).join('.'); | |||
| 70 | - | const now = Date.now(); | |||
| 71 | - | const attemptData = captchaAttempts.get(ip) || { count: 0, lastAttempt: 0 }; | |||
| 72 | - | // If the user has failed three or more times, apply aggressive exponential backoff. | |||
| 73 | - | if (attemptData.count >= 3) { | |||
| 74 | - | const delay = Math.pow(2, attemptData.count - 3 + 1) * 1000; // delay in milliseconds | |||
| 75 | - | console.log(`[Register] Rate limit exceeded for IP ${maskedIp} - applying ${delay}ms delay`); | |||
| 76 | - | await sleep(delay); | |||
| 77 | - | } | |||
| 78 | - | if (captcha !== expectedCaptcha) { | |||
| 79 | - | attemptData.count++; | |||
| 80 | - | attemptData.lastAttempt = now; | |||
| 81 | - | captchaAttempts.set(ip, attemptData); | |||
| 82 | - | console.log(`[Register] Captcha failed for IP ${maskedIp}. Attempt count: ${attemptData.count}`); | |||
| 83 | - | const remaining = Math.max(0, 3 - attemptData.count); | |||
| 84 | - | return new Response(JSON.stringify({ error: `Incorrect captcha answer. ${remaining > 0 ? remaining + " attempt(s) remaining." : "Please wait before trying again."}` } as RegisterResponseError), { status: 400 }); | |||
| 85 | - | } | |||
| 86 | - | // Reset captcha attempt data on success. | |||
| 87 | - | if (captcha === expectedCaptcha) { | |||
| 88 | - | captchaAttempts.delete(ip); | |||
| 89 | - | } | |||
| 90 | - | ||||
| 91 | // Securely hash the user's password. | 125 | // Securely hash the user's password. | ||
| 92 | let hashedPassword: string; | 126 | let hashedPassword: string; | ||
| 93 | try { | 127 | try { | ||
| 94 | hashedPassword = await hashPassword(password); | 128 | hashedPassword = await hashPassword(password); | ||
| 95 | } catch { | 129 | } catch { | ||
| 96 | - | console.log("[Register] Error hashing password"); | 130 | + | log.error('Error hashing password'); |
| 97 | return new Response(JSON.stringify({ error: 'Internal Server Error' } as RegisterResponseError), { status: 500 }); | 131 | return new Response(JSON.stringify({ error: 'Internal Server Error' } as RegisterResponseError), { status: 500 }); | ||
| 98 | } | 132 | } | ||
| 99 | // Insert the new user into the database. | 133 | // Insert the new user into the database. | ||
| 100 | try { | 134 | try { | ||
| 101 | await db.insert(users).values({ | 135 | await db.insert(users).values({ | ||
| 102 | - | password: hashedPassword, | |||
| 103 | nickname: username, | 136 | nickname: username, | ||
| 137 | + | password: hashedPassword, | |||
| 104 | createdAt: Date.now() | 138 | createdAt: Date.now() | ||
| 105 | }); | 139 | }); | ||
| 106 | - | console.log(`[Register] User ${username} registered successfully`); | 140 | + | log.info(`User ${username} registered successfully`); |
| 107 | - | } catch (err) { | 141 | + | } catch (err: unknown) { |
| 108 | - | console.log("Database insertion error", err); | 142 | + | log.error('Database insertion error', { error: err as object }); |
| 109 | // Assume a duplicate user error if the email (or derived unique field) already exists. | 143 | // Assume a duplicate user error if the email (or derived unique field) already exists. | ||
| 110 | return new Response(JSON.stringify({ error: 'User registration failed. Possibly user already exists.' } as RegisterResponseError), { status: 409 }); | 144 | return new Response(JSON.stringify({ error: 'User registration failed. Possibly user already exists.' } as RegisterResponseError), { status: 409 }); | ||
| 111 | } | 145 | } | ||
M src/routes/api/session/login/+server.ts +13 -4
| @@ -8,6 +8,7 @@ import { generateSessionToken, createSession } from '$lib/api/session.server'; | |||
| 8 | 8 | import { setSessionTokenCookie } from '$lib/api/session.cookie'; | |
| 9 | 9 | import { createSafeUser } from '$lib/types/chat'; | |
| 10 | 10 | import { createLogger } from '$lib/utils/logger.server'; | |
| 11 | + | import { validateTurnstileToken } from '$lib/utils/turnstile.server'; | |
| 11 | 12 | ||
| 12 | 13 | const log = createLogger('login-server'); | |
| 13 | 14 | ||
| @@ -35,7 +36,7 @@ export const POST: RequestHandler = async ({ request, cookies }) => { | |||
| 35 | 36 | return new Response(JSON.stringify({ error: 'Method Not Allowed' } as LoginResponseError), { status: 405 }); | |
| 36 | 37 | } | |
| 37 | 38 | ||
| 38 | - | // Get the IP address for rate limiting | |
| 39 | + | // Get the IP address for rate limiting and Turnstile validation | |
| 39 | 40 | const ip = request.headers.get('x-forwarded-for') || 'unknown'; | |
| 40 | 41 | const maskedIp = ip.split('.').map((octet, idx) => idx < 3 ? 'xxx' : octet).join('.'); | |
| 41 | 42 | const now = Date.now(); | |
| @@ -56,16 +57,24 @@ export const POST: RequestHandler = async ({ request, cookies }) => { | |||
| 56 | 57 | return new Response(JSON.stringify({ error: 'Invalid JSON' } as LoginResponseError), { status: 400 }); | |
| 57 | 58 | } | |
| 58 | 59 | ||
| 59 | - | const { username, password } = body as { username: string, password: string }; | |
| 60 | + | const { username, password, turnstileToken } = body as { username: string, password: string, turnstileToken: string }; | |
| 60 | 61 | ||
| 61 | - | if (typeof username !== 'string' || typeof password !== 'string') { | |
| 62 | + | if (typeof username !== 'string' || typeof password !== 'string' || typeof turnstileToken !== 'string') { | |
| 62 | 63 | log.warn('Missing or invalid input fields', { | |
| 63 | 64 | hasUsername: typeof username === 'string', | |
| 64 | - | hasPassword: typeof password === 'string' | |
| 65 | + | hasPassword: typeof password === 'string', | |
| 66 | + | hasTurnstileToken: typeof turnstileToken === 'string' | |
| 65 | 67 | }); | |
| 66 | 68 | return new Response(JSON.stringify({ error: 'Missing or invalid input fields' } as LoginResponseError), { status: 400 }); | |
| 67 | 69 | } | |
| 68 | 70 | ||
| 71 | + | // Validate Turnstile token | |
| 72 | + | const isValidTurnstile = await validateTurnstileToken(turnstileToken, ip); | |
| 73 | + | if (!isValidTurnstile) { | |
| 74 | + | log.warn('Invalid Turnstile token', { maskedIp }); | |
| 75 | + | return new Response(JSON.stringify({ error: 'Security check failed. Please try again.' } as LoginResponseError), { status: 400 }); | |
| 76 | + | } | |
| 77 | + | ||
| 69 | 78 | // Find user by username | |
| 70 | 79 | const user = await db.query.users.findFirst({ | |
| 71 | 80 | where: eq(users.nickname, username.trim()) | |
| @@ -8,6 +8,7 @@ import { generateSessionToken, createSession } from '$lib/api/session.server'; | |||||
| 8 | import { setSessionTokenCookie } from '$lib/api/session.cookie'; | 8 | import { setSessionTokenCookie } from '$lib/api/session.cookie'; | ||
| 9 | import { createSafeUser } from '$lib/types/chat'; | 9 | import { createSafeUser } from '$lib/types/chat'; | ||
| 10 | import { createLogger } from '$lib/utils/logger.server'; | 10 | import { createLogger } from '$lib/utils/logger.server'; | ||
| 11 | + | import { validateTurnstileToken } from '$lib/utils/turnstile.server'; | |||
| 11 | 12 | ||||
| 12 | const log = createLogger('login-server'); | 13 | const log = createLogger('login-server'); | ||
| 13 | 14 | ||||
| @@ -35,7 +36,7 @@ export const POST: RequestHandler = async ({ request, cookies }) => { | |||||
| 35 | return new Response(JSON.stringify({ error: 'Method Not Allowed' } as LoginResponseError), { status: 405 }); | 36 | return new Response(JSON.stringify({ error: 'Method Not Allowed' } as LoginResponseError), { status: 405 }); | ||
| 36 | } | 37 | } | ||
| 37 | 38 | ||||
| 38 | - | // Get the IP address for rate limiting | 39 | + | // Get the IP address for rate limiting and Turnstile validation |
| 39 | const ip = request.headers.get('x-forwarded-for') || 'unknown'; | 40 | const ip = request.headers.get('x-forwarded-for') || 'unknown'; | ||
| 40 | const maskedIp = ip.split('.').map((octet, idx) => idx < 3 ? 'xxx' : octet).join('.'); | 41 | const maskedIp = ip.split('.').map((octet, idx) => idx < 3 ? 'xxx' : octet).join('.'); | ||
| 41 | const now = Date.now(); | 42 | const now = Date.now(); | ||
| @@ -56,16 +57,24 @@ export const POST: RequestHandler = async ({ request, cookies }) => { | |||||
| 56 | return new Response(JSON.stringify({ error: 'Invalid JSON' } as LoginResponseError), { status: 400 }); | 57 | return new Response(JSON.stringify({ error: 'Invalid JSON' } as LoginResponseError), { status: 400 }); | ||
| 57 | } | 58 | } | ||
| 58 | 59 | ||||
| 59 | - | const { username, password } = body as { username: string, password: string }; | 60 | + | const { username, password, turnstileToken } = body as { username: string, password: string, turnstileToken: string }; |
| 60 | 61 | ||||
| 61 | - | if (typeof username !== 'string' || typeof password !== 'string') { | 62 | + | if (typeof username !== 'string' || typeof password !== 'string' || typeof turnstileToken !== 'string') { |
| 62 | log.warn('Missing or invalid input fields', { | 63 | log.warn('Missing or invalid input fields', { | ||
| 63 | hasUsername: typeof username === 'string', | 64 | hasUsername: typeof username === 'string', | ||
| 64 | - | hasPassword: typeof password === 'string' | 65 | + | hasPassword: typeof password === 'string', |
| 66 | + | hasTurnstileToken: typeof turnstileToken === 'string' | |||
| 65 | }); | 67 | }); | ||
| 66 | return new Response(JSON.stringify({ error: 'Missing or invalid input fields' } as LoginResponseError), { status: 400 }); | 68 | return new Response(JSON.stringify({ error: 'Missing or invalid input fields' } as LoginResponseError), { status: 400 }); | ||
| 67 | } | 69 | } | ||
| 68 | 70 | ||||
| 71 | + | // Validate Turnstile token | |||
| 72 | + | const isValidTurnstile = await validateTurnstileToken(turnstileToken, ip); | |||
| 73 | + | if (!isValidTurnstile) { | |||
| 74 | + | log.warn('Invalid Turnstile token', { maskedIp }); | |||
| 75 | + | return new Response(JSON.stringify({ error: 'Security check failed. Please try again.' } as LoginResponseError), { status: 400 }); | |||
| 76 | + | } | |||
| 77 | + | ||||
| 69 | // Find user by username | 78 | // Find user by username | ||
| 70 | const user = await db.query.users.findFirst({ | 79 | const user = await db.query.users.findFirst({ | ||
| 71 | where: eq(users.nickname, username.trim()) | 80 | where: eq(users.nickname, username.trim()) | ||