From 0fb071a5d73c8bf5f8052518bea65e7d5a60e89e Mon Sep 17 00:00:00 2001 From: Michael Freno Date: Thu, 1 Jan 2026 14:51:23 -0500 Subject: [PATCH] password validation meter --- src/components/PasswordStrengthMeter.tsx | 151 +++++++++++++++++++++++ src/config.ts | 10 ++ src/lib/validation.ts | 24 ++-- src/routes/login/index.tsx | 70 +++-------- src/server/api/routers/auth.ts | 4 +- src/server/security.ts | 112 +++++++++++------ 6 files changed, 271 insertions(+), 100 deletions(-) create mode 100644 src/components/PasswordStrengthMeter.tsx diff --git a/src/components/PasswordStrengthMeter.tsx b/src/components/PasswordStrengthMeter.tsx new file mode 100644 index 0000000..81df567 --- /dev/null +++ b/src/components/PasswordStrengthMeter.tsx @@ -0,0 +1,151 @@ +import { createMemo, For, Show } from "solid-js"; +import { validatePassword, type PasswordStrength } from "~/lib/validation"; +import { VALIDATION_CONFIG } from "~/config"; +import CheckCircle from "./icons/CheckCircle"; + +interface PasswordStrengthMeterProps { + password: string; + showRequirements?: boolean; +} + +interface Requirement { + label: string; + test: (password: string) => boolean; + optional?: boolean; +} + +export default function PasswordStrengthMeter( + props: PasswordStrengthMeterProps +) { + const validation = createMemo(() => validatePassword(props.password)); + + const strengthConfig = { + weak: { + color: "bg-red", + textColor: "text-red", + label: "Weak", + width: "25%" + }, + fair: { + color: "bg-yellow", + textColor: "text-yellow", + label: "Fair", + width: "50%" + }, + good: { + color: "bg-blue", + textColor: "text-blue", + label: "Good", + width: "75%" + }, + strong: { + color: "bg-green", + textColor: "text-green", + label: "Strong", + width: "100%" + } + }; + + const requirements = createMemo(() => { + const reqs: Requirement[] = [ + { + label: `At least ${VALIDATION_CONFIG.MIN_PASSWORD_LENGTH} characters`, + test: (pwd) => pwd.length >= VALIDATION_CONFIG.MIN_PASSWORD_LENGTH + } + ]; + + if (VALIDATION_CONFIG.PASSWORD_REQUIRE_UPPERCASE) { + reqs.push({ + label: "One uppercase letter", + test: (pwd) => /[A-Z]/.test(pwd) + }); + } + + if (VALIDATION_CONFIG.PASSWORD_REQUIRE_NUMBER) { + reqs.push({ + label: "One number", + test: (pwd) => /[0-9]/.test(pwd) + }); + } + + // Always show special character as optional/recommended + reqs.push({ + label: "One special character\n(recommended)", + test: (pwd) => /[^A-Za-z0-9]/.test(pwd), + optional: true + }); + + return reqs; + }); + + const strength = createMemo(() => validation().strength); + const config = createMemo(() => strengthConfig[strength()]); + + return ( +
+ {/* Strength bar */} + 0}> +
+
+
+
+
+ {config().label} + + + + Valid + + +
+
+ + + {/* Requirements checklist */} + +
+
+ Password Requirements: +
+ + {(req) => { + const isMet = createMemo(() => req.test(props.password)); + return ( +
0 + ? "text-red" + : "text-subtext0" + }`} + > + + } + > + + + {req.label} +
+ ); + }} +
+
+
+
+ ); +} diff --git a/src/config.ts b/src/config.ts index 869d127..f18c4fc 100644 --- a/src/config.ts +++ b/src/config.ts @@ -18,6 +18,10 @@ export const AUTH_CONFIG = { REMEMBER_ME_MAX_AGE: 60 * 60 * 24 * 14, // 14 days /** CSRF token cookie max age in seconds (14 days) */ CSRF_TOKEN_MAX_AGE: 60 * 60 * 24 * 14, // 14 days + /** Email login link JWT expiration (15 minutes - provides reasonable time to check email without being too permissive) */ + EMAIL_LOGIN_LINK_EXPIRY: "15m" as const, + /** Email verification link JWT expiration (15 minutes) */ + EMAIL_VERIFICATION_LINK_EXPIRY: "15m" as const, /** Lineage JWT expiration for mobile game */ LINEAGE_JWT_EXPIRY: "14d" as const } as const; @@ -232,6 +236,12 @@ export const ERROR_PAGE_CONFIG = { export const VALIDATION_CONFIG = { /** Minimum password length (must match securePasswordSchema in schemas/user.ts) */ MIN_PASSWORD_LENGTH: 8, + /** Require at least one uppercase letter in password */ + PASSWORD_REQUIRE_UPPERCASE: true, + /** Require at least one number in password */ + PASSWORD_REQUIRE_NUMBER: true, + /** Require at least one special character in password (false = optional but recommended) */ + PASSWORD_REQUIRE_SPECIAL: false, /** Maximum message length for contact form */ MAX_CONTACT_MESSAGE_LENGTH: 500, /** Minimum password confirmation match length before showing error */ diff --git a/src/lib/validation.ts b/src/lib/validation.ts index 9adbf93..ce18f5b 100644 --- a/src/lib/validation.ts +++ b/src/lib/validation.ts @@ -45,30 +45,34 @@ export function validatePassword(password: string): { ); } - // Require uppercase letter - if (!/[A-Z]/.test(password)) { + // Require uppercase letter (if configured) + if (VALIDATION_CONFIG.PASSWORD_REQUIRE_UPPERCASE && !/[A-Z]/.test(password)) { errors.push("Password must contain at least one uppercase letter"); } - // Require lowercase letter + // Require lowercase letter (always required for balanced security) if (!/[a-z]/.test(password)) { errors.push("Password must contain at least one lowercase letter"); } - // Require number - if (!/[0-9]/.test(password)) { + // Require number (if configured) + if (VALIDATION_CONFIG.PASSWORD_REQUIRE_NUMBER && !/[0-9]/.test(password)) { errors.push("Password must contain at least one number"); } - // Require special character - if (!/[^A-Za-z0-9]/.test(password)) { + // Require special character (if configured) + if ( + VALIDATION_CONFIG.PASSWORD_REQUIRE_SPECIAL && + !/[^A-Za-z0-9]/.test(password) + ) { errors.push("Password must contain at least one special character"); } // Check for common weak passwords const commonPasswords = [ "password", - "12345678", + "1234", + "5678", "qwerty", "letmein", "welcome", @@ -93,9 +97,9 @@ export function validatePassword(password: string): { let strength: PasswordStrength = "weak"; if (errors.length === 0) { - if (password.length >= 20) { + if (password.length >= 16) { strength = "strong"; - } else if (password.length >= 16) { + } else if (password.length >= 12) { strength = "good"; } else if (password.length >= VALIDATION_CONFIG.MIN_PASSWORD_LENGTH) { strength = "fair"; diff --git a/src/routes/login/index.tsx b/src/routes/login/index.tsx index 870340c..ad630eb 100644 --- a/src/routes/login/index.tsx +++ b/src/routes/login/index.tsx @@ -13,6 +13,7 @@ import GitHub from "~/components/icons/GitHub"; import Eye from "~/components/icons/Eye"; import EyeSlash from "~/components/icons/EyeSlash"; import CountdownCircleTimer from "~/components/CountdownCircleTimer"; +import PasswordStrengthMeter from "~/components/PasswordStrengthMeter"; import { isValidEmail, validatePassword } from "~/lib/validation"; import { getClientCookie } from "~/lib/cookies.client"; import { env } from "~/env/client"; @@ -52,11 +53,8 @@ export default function LoginPage() { const [showPasswordInput, setShowPasswordInput] = createSignal(false); const [showPasswordConfInput, setShowPasswordConfInput] = createSignal(false); const [passwordsMatch, setPasswordsMatch] = createSignal(false); - const [showPasswordLengthWarning, setShowPasswordLengthWarning] = - createSignal(false); - const [passwordLengthSufficient, setPasswordLengthSufficient] = - createSignal(false); - const [passwordBlurred, setPasswordBlurred] = createSignal(false); + const [password, setPassword] = createSignal(""); + const [passwordConf, setPasswordConf] = createSignal(""); let emailRef: HTMLInputElement | undefined; let passwordRef: HTMLInputElement | undefined; @@ -325,43 +323,15 @@ export default function LoginPage() { setPasswordsMatch(newPassword === newPasswordConf); }; - const checkPasswordLength = (password: string) => { - if (password.length >= VALIDATION_CONFIG.MIN_PASSWORD_LENGTH) { - setPasswordLengthSufficient(true); - setShowPasswordLengthWarning(false); - } else { - setPasswordLengthSufficient(false); - if (passwordBlurred()) { - setShowPasswordLengthWarning(true); - } - } - }; - - const passwordLengthBlurCheck = () => { - if ( - !passwordLengthSufficient() && - passwordRef && - passwordRef.value !== "" - ) { - setShowPasswordLengthWarning(true); - } - setPasswordBlurred(true); - }; - - const handleNewPasswordChange = (e: Event) => { - const target = e.target as HTMLInputElement; - checkPasswordLength(target.value); + const handlePasswordChange = (e: Event) => { + const target = e.currentTarget as HTMLInputElement; + setPassword(target.value); }; const handlePasswordConfChange = (e: Event) => { - const target = e.target as HTMLInputElement; - if (passwordRef) { - checkForMatch(passwordRef.value, target.value); - } - }; - - const handlePasswordBlur = () => { - passwordLengthBlurCheck(); + const target = e.currentTarget as HTMLInputElement; + setPasswordConf(target.value); + checkForMatch(password(), target.value); }; return ( @@ -477,8 +447,7 @@ export default function LoginPage() { required minLength={8} ref={passwordRef} - onInput={register() ? handleNewPasswordChange : undefined} - onBlur={register() ? handlePasswordBlur : undefined} + onInput={register() ? handlePasswordChange : undefined} placeholder=" " title="Password must be at least 8 characters" class="underlinedInput bg-transparent" @@ -514,18 +483,18 @@ export default function LoginPage() {
-
- Password too short! Min Length: 8 + + + {/* Password strength meter - shown only for registration */} + +
+
{/* Password confirmation - shown only for registration */} -
+
= 6 + passwordConf().length >= + VALIDATION_CONFIG.MIN_PASSWORD_CONF_LENGTH_FOR_ERROR ? "" : "opacity-0 select-none" } text-red text-center transition-opacity duration-200 ease-in-out`} diff --git a/src/server/api/routers/auth.ts b/src/server/api/routers/auth.ts index e76976c..90cac7d 100644 --- a/src/server/api/routers/auth.ts +++ b/src/server/api/routers/auth.ts @@ -1071,7 +1071,7 @@ export const authRouter = createTRPCRouter({ rememberMe: rememberMe ?? false }) .setProtectedHeader({ alg: "HS256" }) - .setExpirationTime("15m") + .setExpirationTime(AUTH_CONFIG.EMAIL_LOGIN_LINK_EXPIRY) .sign(secret); const domain = env.VITE_DOMAIN || "https://freno.me"; @@ -1453,7 +1453,7 @@ export const authRouter = createTRPCRouter({ const secret = new TextEncoder().encode(env.JWT_SECRET_KEY); const token = await new SignJWT({ email }) .setProtectedHeader({ alg: "HS256" }) - .setExpirationTime("15m") + .setExpirationTime(AUTH_CONFIG.EMAIL_VERIFICATION_LINK_EXPIRY) .sign(secret); const domain = env.VITE_DOMAIN || "https://freno.me"; diff --git a/src/server/security.ts b/src/server/security.ts index fb556b1..e7053ad 100644 --- a/src/server/security.ts +++ b/src/server/security.ts @@ -198,28 +198,34 @@ interface RateLimitRecord { resetAt: number; } -/** - * In-memory rate limit store - * In production, consider using Redis for distributed rate limiting - */ -const rateLimitStore = new Map(); - /** * Clear rate limit store (for testing only) + * Clears all rate limit records from the database */ -export function clearRateLimitStore(): void { - rateLimitStore.clear(); +export async function clearRateLimitStore(): Promise { + const { ConnectionFactory } = await import("./database"); + const conn = ConnectionFactory(); + await conn.execute({ + sql: "DELETE FROM RateLimit", + args: [] + }); } /** * Cleanup expired rate limit entries every 5 minutes + * Runs in background to prevent database bloat */ -setInterval(() => { - const now = Date.now(); - for (const [key, record] of rateLimitStore.entries()) { - if (now > record.resetAt) { - rateLimitStore.delete(key); - } +setInterval(async () => { + try { + const { ConnectionFactory } = await import("./database"); + const conn = ConnectionFactory(); + const now = new Date().toISOString(); + await conn.execute({ + sql: "DELETE FROM RateLimit WHERE reset_at < ?", + args: [now] + }); + } catch (error) { + console.error("Failed to cleanup expired rate limits:", error); } }, RATE_LIMIT_CLEANUP_INTERVAL_MS); @@ -270,26 +276,51 @@ export function getAuditContext(event: H3Event): { * @returns Remaining attempts before limit is hit * @throws TRPCError if rate limit exceeded */ -export function checkRateLimit( +export async function checkRateLimit( identifier: string, maxAttempts: number, windowMs: number, event?: H3Event -): number { +): Promise { + const { ConnectionFactory } = await import("./database"); + const { v4: uuid } = await import("uuid"); + const conn = ConnectionFactory(); const now = Date.now(); - const record = rateLimitStore.get(identifier); + const resetAt = new Date(now + windowMs); - if (!record || now > record.resetAt) { + // Try to get existing record + const result = await conn.execute({ + sql: "SELECT id, count, reset_at FROM RateLimit WHERE identifier = ?", + args: [identifier] + }); + + if (result.rows.length === 0) { // Create new record - rateLimitStore.set(identifier, { - count: 1, - resetAt: now + windowMs + await conn.execute({ + sql: "INSERT INTO RateLimit (id, identifier, count, reset_at) VALUES (?, ?, ?, ?)", + args: [uuid(), identifier, 1, resetAt.toISOString()] }); return maxAttempts - 1; } - if (record.count >= maxAttempts) { - const remainingMs = record.resetAt - now; + const record = result.rows[0]; + const recordResetAt = new Date(record.reset_at as string); + + // Check if window has expired + if (now > recordResetAt.getTime()) { + // Reset the record + await conn.execute({ + sql: "UPDATE RateLimit SET count = 1, reset_at = ?, updated_at = datetime('now') WHERE identifier = ?", + args: [resetAt.toISOString(), identifier] + }); + return maxAttempts - 1; + } + + const count = record.count as number; + + // Check if limit exceeded + if (count >= maxAttempts) { + const remainingMs = recordResetAt.getTime() - now; const remainingSec = Math.ceil(remainingMs / 1000); // Log rate limit exceeded (fire-and-forget) @@ -318,8 +349,12 @@ export function checkRateLimit( } // Increment count - record.count++; - return maxAttempts - record.count; + await conn.execute({ + sql: "UPDATE RateLimit SET count = count + 1, updated_at = datetime('now') WHERE identifier = ?", + args: [identifier] + }); + + return maxAttempts - count - 1; } /** @@ -331,13 +366,13 @@ export const RATE_LIMITS = CONFIG_RATE_LIMITS; /** * Rate limiting middleware for login operations */ -export function rateLimitLogin( +export async function rateLimitLogin( email: string, clientIP: string, event?: H3Event -): void { +): Promise { // Rate limit by IP - checkRateLimit( + await checkRateLimit( `login:ip:${clientIP}`, RATE_LIMITS.LOGIN_IP.maxAttempts, RATE_LIMITS.LOGIN_IP.windowMs, @@ -345,7 +380,7 @@ export function rateLimitLogin( ); // Rate limit by email - checkRateLimit( + await checkRateLimit( `login:email:${email}`, RATE_LIMITS.LOGIN_EMAIL.maxAttempts, RATE_LIMITS.LOGIN_EMAIL.windowMs, @@ -356,11 +391,11 @@ export function rateLimitLogin( /** * Rate limiting middleware for password reset */ -export function rateLimitPasswordReset( +export async function rateLimitPasswordReset( clientIP: string, event?: H3Event -): void { - checkRateLimit( +): Promise { + await checkRateLimit( `password-reset:ip:${clientIP}`, RATE_LIMITS.PASSWORD_RESET_IP.maxAttempts, RATE_LIMITS.PASSWORD_RESET_IP.windowMs, @@ -371,8 +406,11 @@ export function rateLimitPasswordReset( /** * Rate limiting middleware for registration */ -export function rateLimitRegistration(clientIP: string, event?: H3Event): void { - checkRateLimit( +export async function rateLimitRegistration( + clientIP: string, + event?: H3Event +): Promise { + await checkRateLimit( `registration:ip:${clientIP}`, RATE_LIMITS.REGISTRATION_IP.maxAttempts, RATE_LIMITS.REGISTRATION_IP.windowMs, @@ -383,11 +421,11 @@ export function rateLimitRegistration(clientIP: string, event?: H3Event): void { /** * Rate limiting middleware for email verification */ -export function rateLimitEmailVerification( +export async function rateLimitEmailVerification( clientIP: string, event?: H3Event -): void { - checkRateLimit( +): Promise { + await checkRateLimit( `email-verification:ip:${clientIP}`, RATE_LIMITS.EMAIL_VERIFICATION_IP.maxAttempts, RATE_LIMITS.EMAIL_VERIFICATION_IP.windowMs,