password validation meter
This commit is contained in:
151
src/components/PasswordStrengthMeter.tsx
Normal file
151
src/components/PasswordStrengthMeter.tsx
Normal 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>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -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 */
|
||||||
|
|||||||
@@ -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";
|
||||||
|
|||||||
@@ -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`}
|
||||||
|
|||||||
@@ -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";
|
||||||
|
|||||||
@@ -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,
|
||||||
|
|||||||
Reference in New Issue
Block a user