password validation meter

This commit is contained in:
Michael Freno
2026-01-01 14:51:23 -05:00
parent 658cf98b7b
commit 0fb071a5d7
6 changed files with 271 additions and 100 deletions

View File

@@ -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 (
<div class="w-3/4 space-y-2">
{/* Strength bar */}
<Show when={props.password.length > 0}>
<div class="space-y-1">
<div class="bg-surface h-2 w-full overflow-hidden rounded-full">
<div
class={`${config().color} h-full transition-all duration-300 ease-out`}
style={{ width: config().width }}
/>
</div>
<div class="flex justify-between text-xs">
<span class={config().textColor}>{config().label}</span>
<Show when={validation().isValid}>
<span class="text-green flex items-center gap-1">
<CheckCircle height={14} width={14} />
Valid
</span>
</Show>
</div>
</div>
</Show>
{/* Requirements checklist */}
<Show when={props.showRequirements !== false}>
<div class="space-y-1 text-sm">
<div class="text-subtext1 text-xs font-medium">
Password Requirements:
</div>
<For each={requirements()}>
{(req) => {
const isMet = createMemo(() => req.test(props.password));
return (
<div
class={`flex items-center gap-2 transition-colors ${
isMet()
? "text-green"
: req.optional
? "text-blue opacity-70"
: props.password.length > 0
? "text-red"
: "text-subtext0"
}`}
>
<Show
when={isMet()}
fallback={
<div
class={`h-4 w-4 rounded-full border-2 ${
req.optional
? "border-blue border-dashed"
: "border-subtext0"
}`}
/>
}
>
<CheckCircle height={16} width={16} />
</Show>
<span class="max-w-3/4">{req.label}</span>
</div>
);
}}
</For>
</div>
</Show>
</div>
);
}

View File

@@ -18,6 +18,10 @@ export const AUTH_CONFIG = {
REMEMBER_ME_MAX_AGE: 60 * 60 * 24 * 14, // 14 days REMEMBER_ME_MAX_AGE: 60 * 60 * 24 * 14, // 14 days
/** CSRF token cookie max age in seconds (14 days) */ /** CSRF token cookie max age in seconds (14 days) */
CSRF_TOKEN_MAX_AGE: 60 * 60 * 24 * 14, // 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 expiration for mobile game */
LINEAGE_JWT_EXPIRY: "14d" as const LINEAGE_JWT_EXPIRY: "14d" as const
} as const; } as const;
@@ -232,6 +236,12 @@ export const ERROR_PAGE_CONFIG = {
export const VALIDATION_CONFIG = { export const VALIDATION_CONFIG = {
/** Minimum password length (must match securePasswordSchema in schemas/user.ts) */ /** Minimum password length (must match securePasswordSchema in schemas/user.ts) */
MIN_PASSWORD_LENGTH: 8, 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 */ /** Maximum message length for contact form */
MAX_CONTACT_MESSAGE_LENGTH: 500, MAX_CONTACT_MESSAGE_LENGTH: 500,
/** Minimum password confirmation match length before showing error */ /** Minimum password confirmation match length before showing error */

View File

@@ -45,30 +45,34 @@ export function validatePassword(password: string): {
); );
} }
// Require uppercase letter // Require uppercase letter (if configured)
if (!/[A-Z]/.test(password)) { if (VALIDATION_CONFIG.PASSWORD_REQUIRE_UPPERCASE && !/[A-Z]/.test(password)) {
errors.push("Password must contain at least one uppercase letter"); 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)) { if (!/[a-z]/.test(password)) {
errors.push("Password must contain at least one lowercase letter"); errors.push("Password must contain at least one lowercase letter");
} }
// Require number // Require number (if configured)
if (!/[0-9]/.test(password)) { if (VALIDATION_CONFIG.PASSWORD_REQUIRE_NUMBER && !/[0-9]/.test(password)) {
errors.push("Password must contain at least one number"); errors.push("Password must contain at least one number");
} }
// Require special character // Require special character (if configured)
if (!/[^A-Za-z0-9]/.test(password)) { if (
VALIDATION_CONFIG.PASSWORD_REQUIRE_SPECIAL &&
!/[^A-Za-z0-9]/.test(password)
) {
errors.push("Password must contain at least one special character"); errors.push("Password must contain at least one special character");
} }
// Check for common weak passwords // Check for common weak passwords
const commonPasswords = [ const commonPasswords = [
"password", "password",
"12345678", "1234",
"5678",
"qwerty", "qwerty",
"letmein", "letmein",
"welcome", "welcome",
@@ -93,9 +97,9 @@ export function validatePassword(password: string): {
let strength: PasswordStrength = "weak"; let strength: PasswordStrength = "weak";
if (errors.length === 0) { if (errors.length === 0) {
if (password.length >= 20) { if (password.length >= 16) {
strength = "strong"; strength = "strong";
} else if (password.length >= 16) { } else if (password.length >= 12) {
strength = "good"; strength = "good";
} else if (password.length >= VALIDATION_CONFIG.MIN_PASSWORD_LENGTH) { } else if (password.length >= VALIDATION_CONFIG.MIN_PASSWORD_LENGTH) {
strength = "fair"; strength = "fair";

View File

@@ -13,6 +13,7 @@ import GitHub from "~/components/icons/GitHub";
import Eye from "~/components/icons/Eye"; import Eye from "~/components/icons/Eye";
import EyeSlash from "~/components/icons/EyeSlash"; import EyeSlash from "~/components/icons/EyeSlash";
import CountdownCircleTimer from "~/components/CountdownCircleTimer"; import CountdownCircleTimer from "~/components/CountdownCircleTimer";
import PasswordStrengthMeter from "~/components/PasswordStrengthMeter";
import { isValidEmail, validatePassword } from "~/lib/validation"; import { isValidEmail, validatePassword } from "~/lib/validation";
import { getClientCookie } from "~/lib/cookies.client"; import { getClientCookie } from "~/lib/cookies.client";
import { env } from "~/env/client"; import { env } from "~/env/client";
@@ -52,11 +53,8 @@ export default function LoginPage() {
const [showPasswordInput, setShowPasswordInput] = createSignal(false); const [showPasswordInput, setShowPasswordInput] = createSignal(false);
const [showPasswordConfInput, setShowPasswordConfInput] = createSignal(false); const [showPasswordConfInput, setShowPasswordConfInput] = createSignal(false);
const [passwordsMatch, setPasswordsMatch] = createSignal(false); const [passwordsMatch, setPasswordsMatch] = createSignal(false);
const [showPasswordLengthWarning, setShowPasswordLengthWarning] = const [password, setPassword] = createSignal("");
createSignal(false); const [passwordConf, setPasswordConf] = createSignal("");
const [passwordLengthSufficient, setPasswordLengthSufficient] =
createSignal(false);
const [passwordBlurred, setPasswordBlurred] = createSignal(false);
let emailRef: HTMLInputElement | undefined; let emailRef: HTMLInputElement | undefined;
let passwordRef: HTMLInputElement | undefined; let passwordRef: HTMLInputElement | undefined;
@@ -325,43 +323,15 @@ export default function LoginPage() {
setPasswordsMatch(newPassword === newPasswordConf); setPasswordsMatch(newPassword === newPasswordConf);
}; };
const checkPasswordLength = (password: string) => { const handlePasswordChange = (e: Event) => {
if (password.length >= VALIDATION_CONFIG.MIN_PASSWORD_LENGTH) { const target = e.currentTarget as HTMLInputElement;
setPasswordLengthSufficient(true); setPassword(target.value);
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 handlePasswordConfChange = (e: Event) => { const handlePasswordConfChange = (e: Event) => {
const target = e.target as HTMLInputElement; const target = e.currentTarget as HTMLInputElement;
if (passwordRef) { setPasswordConf(target.value);
checkForMatch(passwordRef.value, target.value); checkForMatch(password(), target.value);
}
};
const handlePasswordBlur = () => {
passwordLengthBlurCheck();
}; };
return ( return (
@@ -477,8 +447,7 @@ export default function LoginPage() {
required required
minLength={8} minLength={8}
ref={passwordRef} ref={passwordRef}
onInput={register() ? handleNewPasswordChange : undefined} onInput={register() ? handlePasswordChange : undefined}
onBlur={register() ? handlePasswordBlur : undefined}
placeholder=" " placeholder=" "
title="Password must be at least 8 characters" title="Password must be at least 8 characters"
class="underlinedInput bg-transparent" class="underlinedInput bg-transparent"
@@ -514,18 +483,18 @@ export default function LoginPage() {
</Show> </Show>
</button> </button>
</div> </div>
<div </Show>
class={`${
showPasswordLengthWarning() ? "" : "opacity-0 select-none" {/* Password strength meter - shown only for registration */}
} text-red text-center transition-opacity duration-200 ease-in-out`} <Show when={register()}>
> <div class="mx-auto flex justify-center px-4 py-2">
Password too short! Min Length: 8 <PasswordStrengthMeter password={password()} />
</div> </div>
</Show> </Show>
{/* Password confirmation - shown only for registration */} {/* Password confirmation - shown only for registration */}
<Show when={register()}> <Show when={register()}>
<div class="-mt-4 flex justify-center"> <div class="flex justify-center">
<div class="input-group mx-4"> <div class="input-group mx-4">
<input <input
type={showPasswordConfInput() ? "text" : "password"} type={showPasswordConfInput() ? "text" : "password"}
@@ -571,9 +540,8 @@ export default function LoginPage() {
<div <div
class={`${ class={`${
!passwordsMatch() && !passwordsMatch() &&
passwordLengthSufficient() && passwordConf().length >=
passwordConfRef && VALIDATION_CONFIG.MIN_PASSWORD_CONF_LENGTH_FOR_ERROR
passwordConfRef.value.length >= 6
? "" ? ""
: "opacity-0 select-none" : "opacity-0 select-none"
} text-red text-center transition-opacity duration-200 ease-in-out`} } text-red text-center transition-opacity duration-200 ease-in-out`}

View File

@@ -1071,7 +1071,7 @@ export const authRouter = createTRPCRouter({
rememberMe: rememberMe ?? false rememberMe: rememberMe ?? false
}) })
.setProtectedHeader({ alg: "HS256" }) .setProtectedHeader({ alg: "HS256" })
.setExpirationTime("15m") .setExpirationTime(AUTH_CONFIG.EMAIL_LOGIN_LINK_EXPIRY)
.sign(secret); .sign(secret);
const domain = env.VITE_DOMAIN || "https://freno.me"; 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 secret = new TextEncoder().encode(env.JWT_SECRET_KEY);
const token = await new SignJWT({ email }) const token = await new SignJWT({ email })
.setProtectedHeader({ alg: "HS256" }) .setProtectedHeader({ alg: "HS256" })
.setExpirationTime("15m") .setExpirationTime(AUTH_CONFIG.EMAIL_VERIFICATION_LINK_EXPIRY)
.sign(secret); .sign(secret);
const domain = env.VITE_DOMAIN || "https://freno.me"; const domain = env.VITE_DOMAIN || "https://freno.me";

View File

@@ -198,28 +198,34 @@ interface RateLimitRecord {
resetAt: number; resetAt: number;
} }
/**
* In-memory rate limit store
* In production, consider using Redis for distributed rate limiting
*/
const rateLimitStore = new Map<string, RateLimitRecord>();
/** /**
* Clear rate limit store (for testing only) * Clear rate limit store (for testing only)
* Clears all rate limit records from the database
*/ */
export function clearRateLimitStore(): void { export async function clearRateLimitStore(): Promise<void> {
rateLimitStore.clear(); const { ConnectionFactory } = await import("./database");
const conn = ConnectionFactory();
await conn.execute({
sql: "DELETE FROM RateLimit",
args: []
});
} }
/** /**
* Cleanup expired rate limit entries every 5 minutes * Cleanup expired rate limit entries every 5 minutes
* Runs in background to prevent database bloat
*/ */
setInterval(() => { setInterval(async () => {
const now = Date.now(); try {
for (const [key, record] of rateLimitStore.entries()) { const { ConnectionFactory } = await import("./database");
if (now > record.resetAt) { const conn = ConnectionFactory();
rateLimitStore.delete(key); 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); }, RATE_LIMIT_CLEANUP_INTERVAL_MS);
@@ -270,26 +276,51 @@ export function getAuditContext(event: H3Event): {
* @returns Remaining attempts before limit is hit * @returns Remaining attempts before limit is hit
* @throws TRPCError if rate limit exceeded * @throws TRPCError if rate limit exceeded
*/ */
export function checkRateLimit( export async function checkRateLimit(
identifier: string, identifier: string,
maxAttempts: number, maxAttempts: number,
windowMs: number, windowMs: number,
event?: H3Event event?: H3Event
): number { ): Promise<number> {
const { ConnectionFactory } = await import("./database");
const { v4: uuid } = await import("uuid");
const conn = ConnectionFactory();
const now = Date.now(); 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 // Create new record
rateLimitStore.set(identifier, { await conn.execute({
count: 1, sql: "INSERT INTO RateLimit (id, identifier, count, reset_at) VALUES (?, ?, ?, ?)",
resetAt: now + windowMs args: [uuid(), identifier, 1, resetAt.toISOString()]
}); });
return maxAttempts - 1; return maxAttempts - 1;
} }
if (record.count >= maxAttempts) { const record = result.rows[0];
const remainingMs = record.resetAt - now; 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); const remainingSec = Math.ceil(remainingMs / 1000);
// Log rate limit exceeded (fire-and-forget) // Log rate limit exceeded (fire-and-forget)
@@ -318,8 +349,12 @@ export function checkRateLimit(
} }
// Increment count // Increment count
record.count++; await conn.execute({
return maxAttempts - record.count; 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 * Rate limiting middleware for login operations
*/ */
export function rateLimitLogin( export async function rateLimitLogin(
email: string, email: string,
clientIP: string, clientIP: string,
event?: H3Event event?: H3Event
): void { ): Promise<void> {
// Rate limit by IP // Rate limit by IP
checkRateLimit( await checkRateLimit(
`login:ip:${clientIP}`, `login:ip:${clientIP}`,
RATE_LIMITS.LOGIN_IP.maxAttempts, RATE_LIMITS.LOGIN_IP.maxAttempts,
RATE_LIMITS.LOGIN_IP.windowMs, RATE_LIMITS.LOGIN_IP.windowMs,
@@ -345,7 +380,7 @@ export function rateLimitLogin(
); );
// Rate limit by email // Rate limit by email
checkRateLimit( await checkRateLimit(
`login:email:${email}`, `login:email:${email}`,
RATE_LIMITS.LOGIN_EMAIL.maxAttempts, RATE_LIMITS.LOGIN_EMAIL.maxAttempts,
RATE_LIMITS.LOGIN_EMAIL.windowMs, RATE_LIMITS.LOGIN_EMAIL.windowMs,
@@ -356,11 +391,11 @@ export function rateLimitLogin(
/** /**
* Rate limiting middleware for password reset * Rate limiting middleware for password reset
*/ */
export function rateLimitPasswordReset( export async function rateLimitPasswordReset(
clientIP: string, clientIP: string,
event?: H3Event event?: H3Event
): void { ): Promise<void> {
checkRateLimit( await checkRateLimit(
`password-reset:ip:${clientIP}`, `password-reset:ip:${clientIP}`,
RATE_LIMITS.PASSWORD_RESET_IP.maxAttempts, RATE_LIMITS.PASSWORD_RESET_IP.maxAttempts,
RATE_LIMITS.PASSWORD_RESET_IP.windowMs, RATE_LIMITS.PASSWORD_RESET_IP.windowMs,
@@ -371,8 +406,11 @@ export function rateLimitPasswordReset(
/** /**
* Rate limiting middleware for registration * Rate limiting middleware for registration
*/ */
export function rateLimitRegistration(clientIP: string, event?: H3Event): void { export async function rateLimitRegistration(
checkRateLimit( clientIP: string,
event?: H3Event
): Promise<void> {
await checkRateLimit(
`registration:ip:${clientIP}`, `registration:ip:${clientIP}`,
RATE_LIMITS.REGISTRATION_IP.maxAttempts, RATE_LIMITS.REGISTRATION_IP.maxAttempts,
RATE_LIMITS.REGISTRATION_IP.windowMs, RATE_LIMITS.REGISTRATION_IP.windowMs,
@@ -383,11 +421,11 @@ export function rateLimitRegistration(clientIP: string, event?: H3Event): void {
/** /**
* Rate limiting middleware for email verification * Rate limiting middleware for email verification
*/ */
export function rateLimitEmailVerification( export async function rateLimitEmailVerification(
clientIP: string, clientIP: string,
event?: H3Event event?: H3Event
): void { ): Promise<void> {
checkRateLimit( await checkRateLimit(
`email-verification:ip:${clientIP}`, `email-verification:ip:${clientIP}`,
RATE_LIMITS.EMAIL_VERIFICATION_IP.maxAttempts, RATE_LIMITS.EMAIL_VERIFICATION_IP.maxAttempts,
RATE_LIMITS.EMAIL_VERIFICATION_IP.windowMs, RATE_LIMITS.EMAIL_VERIFICATION_IP.windowMs,