feat(security): Enhance client-side signup validation and login security checks
- commit
- 059b851be41656c20de9a5a9c8c1300a6d67a810
- author
- Kit · · 2025-02-18 07:02 UTC
- parent
- 38bffed
- signature
- SSH Unverified
SHA256:H91DFuEG4jpk5edzLA4aUKkpv4zv9Q339ipRmkph6TkThe key is unknown
2 files · +74 -12
M src/lib/components/aim-login.svelte +52 -9
| @@ -89,16 +89,59 @@ | |||
| 89 | 89 | } | |
| 90 | 90 | } | |
| 91 | 91 | ||
| 92 | + | // New: Create a helper function for client-side validations based on server rules. | |
| 93 | + | function validateSignupData(): string | null { | |
| 94 | + | // Trim values | |
| 95 | + | const username = suUsername.trim(); | |
| 96 | + | const password = suPassword.trim(); | |
| 97 | + | const confirmPasswordValue = suConfirmPassword.trim(); | |
| 98 | + | ||
| 99 | + | if (!username || !password || !confirmPasswordValue) { | |
| 100 | + | return 'All fields must be filled'; | |
| 101 | + | } | |
| 102 | + | ||
| 103 | + | if (username.length < 3) { | |
| 104 | + | return 'Username must be at least 3 characters'; | |
| 105 | + | } | |
| 106 | + | ||
| 107 | + | if (username.length > 32) { | |
| 108 | + | return 'Username must be at most 32 characters'; | |
| 109 | + | } | |
| 110 | + | ||
| 111 | + | // Only allow letters, numbers, underscores, and dashes in the username. | |
| 112 | + | const usernameRegex = /^[a-zA-Z0-9_-]+$/; | |
| 113 | + | if (!usernameRegex.test(username)) { | |
| 114 | + | return 'Invalid username. Only letters, numbers, underscores, and dashes are allowed.'; | |
| 115 | + | } | |
| 116 | + | ||
| 117 | + | if (password !== confirmPasswordValue) { | |
| 118 | + | return 'Passwords do not match'; | |
| 119 | + | } | |
| 120 | + | ||
| 121 | + | if (password.length < 8 || password.length > 64) { | |
| 122 | + | return 'Password must be between 8 and 64 characters'; | |
| 123 | + | } | |
| 124 | + | ||
| 125 | + | // All checks passed. | |
| 126 | + | return null; | |
| 127 | + | } | |
| 128 | + | ||
| 92 | 129 | async function handleSignupSubmit() { | |
| 93 | 130 | signupError = ''; | |
| 94 | 131 | signupStatus = 'loading'; | |
| 95 | - | if (!suUsername || !suPassword || !suConfirmPassword || !captchaAnswer || !suTurnstileToken) { | |
| 96 | - | signupError = 'Please complete all fields including the security check'; | |
| 132 | + | ||
| 133 | + | // Client-side validations before submitting the form. | |
| 134 | + | const validationError = validateSignupData(); | |
| 135 | + | if (validationError) { | |
| 136 | + | signupError = validationError; | |
| 97 | 137 | signupStatus = 'error'; | |
| 98 | 138 | return; | |
| 99 | 139 | } | |
| 100 | - | if (suPassword !== suConfirmPassword) { | |
| 101 | - | signupError = 'Passwords do not match'; | |
| 140 | + | ||
| 141 | + | // Additional check for captcha and Turnstile token, | |
| 142 | + | // in case those fields are not filled. | |
| 143 | + | if (!captchaAnswer || !suTurnstileToken) { | |
| 144 | + | signupError = 'Please complete all fields including the security check'; | |
| 102 | 145 | signupStatus = 'error'; | |
| 103 | 146 | return; | |
| 104 | 147 | } | |
| @@ -108,10 +151,10 @@ | |||
| 108 | 151 | const res = await fetch('/api/register', { | |
| 109 | 152 | method: 'POST', | |
| 110 | 153 | headers: { 'Content-Type': 'application/json' }, | |
| 111 | - | body: JSON.stringify({ | |
| 112 | - | suUsername, | |
| 113 | - | suPassword, | |
| 114 | - | suConfirmPassword, | |
| 154 | + | body: JSON.stringify({ | |
| 155 | + | suUsername, | |
| 156 | + | suPassword, | |
| 157 | + | suConfirmPassword, | |
| 115 | 158 | captchaAnswer, | |
| 116 | 159 | turnstileToken: suTurnstileToken | |
| 117 | 160 | }) | |
| @@ -127,7 +170,7 @@ | |||
| 127 | 170 | console.debug("Registration succeeded:", data); | |
| 128 | 171 | signupStatus = 'success'; | |
| 129 | 172 | ||
| 130 | - | // Auto login after registration | |
| 173 | + | // Auto login after registration. | |
| 131 | 174 | await handleAutoLoginSignup(); | |
| 132 | 175 | return; | |
| 133 | 176 | } catch (err) { | |
| @@ -89,16 +89,59 @@ | |||||
| 89 | } | 89 | } | ||
| 90 | } | 90 | } | ||
| 91 | 91 | ||||
| 92 | + | // New: Create a helper function for client-side validations based on server rules. | |||
| 93 | + | function validateSignupData(): string | null { | |||
| 94 | + | // Trim values | |||
| 95 | + | const username = suUsername.trim(); | |||
| 96 | + | const password = suPassword.trim(); | |||
| 97 | + | const confirmPasswordValue = suConfirmPassword.trim(); | |||
| 98 | + | ||||
| 99 | + | if (!username || !password || !confirmPasswordValue) { | |||
| 100 | + | return 'All fields must be filled'; | |||
| 101 | + | } | |||
| 102 | + | ||||
| 103 | + | if (username.length < 3) { | |||
| 104 | + | return 'Username must be at least 3 characters'; | |||
| 105 | + | } | |||
| 106 | + | ||||
| 107 | + | if (username.length > 32) { | |||
| 108 | + | return 'Username must be at most 32 characters'; | |||
| 109 | + | } | |||
| 110 | + | ||||
| 111 | + | // Only allow letters, numbers, underscores, and dashes in the username. | |||
| 112 | + | const usernameRegex = /^[a-zA-Z0-9_-]+$/; | |||
| 113 | + | if (!usernameRegex.test(username)) { | |||
| 114 | + | return 'Invalid username. Only letters, numbers, underscores, and dashes are allowed.'; | |||
| 115 | + | } | |||
| 116 | + | ||||
| 117 | + | if (password !== confirmPasswordValue) { | |||
| 118 | + | return 'Passwords do not match'; | |||
| 119 | + | } | |||
| 120 | + | ||||
| 121 | + | if (password.length < 8 || password.length > 64) { | |||
| 122 | + | return 'Password must be between 8 and 64 characters'; | |||
| 123 | + | } | |||
| 124 | + | ||||
| 125 | + | // All checks passed. | |||
| 126 | + | return null; | |||
| 127 | + | } | |||
| 128 | + | ||||
| 92 | async function handleSignupSubmit() { | 129 | async function handleSignupSubmit() { | ||
| 93 | signupError = ''; | 130 | signupError = ''; | ||
| 94 | signupStatus = 'loading'; | 131 | signupStatus = 'loading'; | ||
| 95 | - | if (!suUsername || !suPassword || !suConfirmPassword || !captchaAnswer || !suTurnstileToken) { | 132 | + | |
| 96 | - | signupError = 'Please complete all fields including the security check'; | 133 | + | // Client-side validations before submitting the form. |
| 134 | + | const validationError = validateSignupData(); | |||
| 135 | + | if (validationError) { | |||
| 136 | + | signupError = validationError; | |||
| 97 | signupStatus = 'error'; | 137 | signupStatus = 'error'; | ||
| 98 | return; | 138 | return; | ||
| 99 | } | 139 | } | ||
| 100 | - | if (suPassword !== suConfirmPassword) { | 140 | + | |
| 101 | - | signupError = 'Passwords do not match'; | 141 | + | // Additional check for captcha and Turnstile token, |
| 142 | + | // in case those fields are not filled. | |||
| 143 | + | if (!captchaAnswer || !suTurnstileToken) { | |||
| 144 | + | signupError = 'Please complete all fields including the security check'; | |||
| 102 | signupStatus = 'error'; | 145 | signupStatus = 'error'; | ||
| 103 | return; | 146 | return; | ||
| 104 | } | 147 | } | ||
| @@ -108,10 +151,10 @@ | |||||
| 108 | const res = await fetch('/api/register', { | 151 | const res = await fetch('/api/register', { | ||
| 109 | method: 'POST', | 152 | method: 'POST', | ||
| 110 | headers: { 'Content-Type': 'application/json' }, | 153 | headers: { 'Content-Type': 'application/json' }, | ||
| 111 | - | body: JSON.stringify({ | 154 | + | body: JSON.stringify({ |
| 112 | - | suUsername, | 155 | + | suUsername, |
| 113 | - | suPassword, | 156 | + | suPassword, |
| 114 | - | suConfirmPassword, | 157 | + | suConfirmPassword, |
| 115 | captchaAnswer, | 158 | captchaAnswer, | ||
| 116 | turnstileToken: suTurnstileToken | 159 | turnstileToken: suTurnstileToken | ||
| 117 | }) | 160 | }) | ||
| @@ -127,7 +170,7 @@ | |||||
| 127 | console.debug("Registration succeeded:", data); | 170 | console.debug("Registration succeeded:", data); | ||
| 128 | signupStatus = 'success'; | 171 | signupStatus = 'success'; | ||
| 129 | 172 | ||||
| 130 | - | // Auto login after registration | 173 | + | // Auto login after registration. |
| 131 | await handleAutoLoginSignup(); | 174 | await handleAutoLoginSignup(); | ||
| 132 | return; | 175 | return; | ||
| 133 | } catch (err) { | 176 | } catch (err) { | ||
M src/routes/api/session/login/+server.ts +22 -3
| @@ -2,7 +2,7 @@ import type { RequestHandler } from '@sveltejs/kit'; | |||
| 2 | 2 | import type { LoginResponseSuccess, LoginResponseError } from '$lib/types/payloads'; | |
| 3 | 3 | import db from '$lib/db/db.server'; | |
| 4 | 4 | import { users } from '$lib/db/schema'; | |
| 5 | - | import { eq } from 'drizzle-orm'; | |
| 5 | + | import { eq } from 'drizzle-orm/sql'; | |
| 6 | 6 | import { verifyPassword } from '$lib/utils/password'; | |
| 7 | 7 | import { generateSessionToken, createSession } from '$lib/api/session.server'; | |
| 8 | 8 | import { setSessionTokenCookie } from '$lib/api/session.cookie'; | |
| @@ -28,6 +28,13 @@ setInterval(() => { | |||
| 28 | 28 | } | |
| 29 | 29 | }, 60 * 60 * 1000); | |
| 30 | 30 | ||
| 31 | + | // Helper to parse clearance cookie from headers | |
| 32 | + | function getClearanceCookie(cookieHeader: string | null): string | null { | |
| 33 | + | if (!cookieHeader) return null; | |
| 34 | + | const match = cookieHeader.match(/cf-turnstile-clearance=([^;]+)/); | |
| 35 | + | return match ? match[1] : null; | |
| 36 | + | } | |
| 37 | + | ||
| 31 | 38 | export const POST: RequestHandler = async ({ request, cookies }) => { | |
| 32 | 39 | log.debug('New login attempt received'); | |
| 33 | 40 | ||
| @@ -68,8 +75,20 @@ export const POST: RequestHandler = async ({ request, cookies }) => { | |||
| 68 | 75 | return new Response(JSON.stringify({ error: 'Missing or invalid input fields' } as LoginResponseError), { status: 400 }); | |
| 69 | 76 | } | |
| 70 | 77 | ||
| 71 | - | // Validate Turnstile token | |
| 72 | - | const isValidTurnstile = await validateTurnstileToken(turnstileToken, ip); | |
| 78 | + | // NEW: Check if a clearance cookie exists | |
| 79 | + | const cookieHeader = request.headers.get('cookie'); | |
| 80 | + | const clearanceCookie = getClearanceCookie(cookieHeader); | |
| 81 | + | let isValidTurnstile = false; | |
| 82 | + | ||
| 83 | + | if (clearanceCookie) { | |
| 84 | + | // Optional: Validate the clearance cookie if needed. | |
| 85 | + | log.debug('Clearance cookie detected; bypassing token challenge.'); | |
| 86 | + | isValidTurnstile = true; | |
| 87 | + | } else { | |
| 88 | + | // Fallback: Validate the turnstile token | |
| 89 | + | isValidTurnstile = await validateTurnstileToken(turnstileToken, ip); | |
| 90 | + | } | |
| 91 | + | ||
| 73 | 92 | if (!isValidTurnstile) { | |
| 74 | 93 | log.warn('Invalid Turnstile token', { maskedIp }); | |
| 75 | 94 | return new Response(JSON.stringify({ error: 'Security check failed. Please try again.' } as LoginResponseError), { status: 400 }); | |
| @@ -2,7 +2,7 @@ import type { RequestHandler } from '@sveltejs/kit'; | |||||
| 2 | import type { LoginResponseSuccess, LoginResponseError } from '$lib/types/payloads'; | 2 | import type { LoginResponseSuccess, LoginResponseError } from '$lib/types/payloads'; | ||
| 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 { eq } from 'drizzle-orm'; | 5 | + | import { eq } from 'drizzle-orm/sql'; |
| 6 | import { verifyPassword } from '$lib/utils/password'; | 6 | import { verifyPassword } from '$lib/utils/password'; | ||
| 7 | import { generateSessionToken, createSession } from '$lib/api/session.server'; | 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'; | ||
| @@ -28,6 +28,13 @@ setInterval(() => { | |||||
| 28 | } | 28 | } | ||
| 29 | }, 60 * 60 * 1000); | 29 | }, 60 * 60 * 1000); | ||
| 30 | 30 | ||||
| 31 | + | // Helper to parse clearance cookie from headers | |||
| 32 | + | function getClearanceCookie(cookieHeader: string | null): string | null { | |||
| 33 | + | if (!cookieHeader) return null; | |||
| 34 | + | const match = cookieHeader.match(/cf-turnstile-clearance=([^;]+)/); | |||
| 35 | + | return match ? match[1] : null; | |||
| 36 | + | } | |||
| 37 | + | ||||
| 31 | export const POST: RequestHandler = async ({ request, cookies }) => { | 38 | export const POST: RequestHandler = async ({ request, cookies }) => { | ||
| 32 | log.debug('New login attempt received'); | 39 | log.debug('New login attempt received'); | ||
| 33 | 40 | ||||
| @@ -68,8 +75,20 @@ export const POST: RequestHandler = async ({ request, cookies }) => { | |||||
| 68 | return new Response(JSON.stringify({ error: 'Missing or invalid input fields' } as LoginResponseError), { status: 400 }); | 75 | return new Response(JSON.stringify({ error: 'Missing or invalid input fields' } as LoginResponseError), { status: 400 }); | ||
| 69 | } | 76 | } | ||
| 70 | 77 | ||||
| 71 | - | // Validate Turnstile token | 78 | + | // NEW: Check if a clearance cookie exists |
| 72 | - | const isValidTurnstile = await validateTurnstileToken(turnstileToken, ip); | 79 | + | const cookieHeader = request.headers.get('cookie'); |
| 80 | + | const clearanceCookie = getClearanceCookie(cookieHeader); | |||
| 81 | + | let isValidTurnstile = false; | |||
| 82 | + | ||||
| 83 | + | if (clearanceCookie) { | |||
| 84 | + | // Optional: Validate the clearance cookie if needed. | |||
| 85 | + | log.debug('Clearance cookie detected; bypassing token challenge.'); | |||
| 86 | + | isValidTurnstile = true; | |||
| 87 | + | } else { | |||
| 88 | + | // Fallback: Validate the turnstile token | |||
| 89 | + | isValidTurnstile = await validateTurnstileToken(turnstileToken, ip); | |||
| 90 | + | } | |||
| 91 | + | ||||
| 73 | if (!isValidTurnstile) { | 92 | if (!isValidTurnstile) { | ||
| 74 | log.warn('Invalid Turnstile token', { maskedIp }); | 93 | log.warn('Invalid Turnstile token', { maskedIp }); | ||
| 75 | return new Response(JSON.stringify({ error: 'Security check failed. Please try again.' } as LoginResponseError), { status: 400 }); | 94 | return new Response(JSON.stringify({ error: 'Security check failed. Please try again.' } as LoginResponseError), { status: 400 }); | ||