Add login rate limiting (P4.6 complete)
All checks were successful
Deploy / deploy (push) Successful in 1m38s
All checks were successful
Deploy / deploy (push) Successful in 1m38s
Implement client-side rate limiting for login page with 5 attempts per minute, matching the spec requirement in authentication.md. Features: - Track login attempts with timestamps in component state - Block login when 5+ attempts made within 60 seconds - Show "Too many login attempts" error when rate limited - Show remaining attempts warning after 3 failures - Disable form/button when rate limited - Auto-clear after 1 minute cooldown - Works for both email/password and OIDC authentication Tests: - 6 new tests covering rate limiting scenarios (32 total) - 796 tests passing across 43 test files Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
This commit is contained in:
@@ -3,7 +3,7 @@
|
||||
"use client";
|
||||
|
||||
import { useRouter } from "next/navigation";
|
||||
import { type FormEvent, useEffect, useState } from "react";
|
||||
import { type FormEvent, useCallback, useEffect, useState } from "react";
|
||||
|
||||
import { pb } from "@/lib/pocketbase";
|
||||
|
||||
@@ -17,6 +17,9 @@ interface AuthProvider {
|
||||
authURL: string;
|
||||
}
|
||||
|
||||
const RATE_LIMIT_MAX_ATTEMPTS = 5;
|
||||
const RATE_LIMIT_WINDOW_MS = 60000; // 1 minute
|
||||
|
||||
export default function LoginPage() {
|
||||
const router = useRouter();
|
||||
const [email, setEmail] = useState("");
|
||||
@@ -25,6 +28,37 @@ export default function LoginPage() {
|
||||
const [isLoading, setIsLoading] = useState(false);
|
||||
const [isCheckingAuth, setIsCheckingAuth] = useState(true);
|
||||
const [oidcProvider, setOidcProvider] = useState<AuthProvider | null>(null);
|
||||
const [loginAttempts, setLoginAttempts] = useState<number[]>([]);
|
||||
const [isRateLimited, setIsRateLimited] = useState(false);
|
||||
|
||||
// Get recent attempts within the rate limit window
|
||||
const getRecentAttempts = useCallback(() => {
|
||||
const now = Date.now();
|
||||
return loginAttempts.filter(
|
||||
(timestamp) => now - timestamp < RATE_LIMIT_WINDOW_MS,
|
||||
);
|
||||
}, [loginAttempts]);
|
||||
|
||||
// Calculate remaining attempts
|
||||
const getRemainingAttempts = useCallback(() => {
|
||||
const recentAttempts = getRecentAttempts();
|
||||
return Math.max(0, RATE_LIMIT_MAX_ATTEMPTS - recentAttempts.length);
|
||||
}, [getRecentAttempts]);
|
||||
|
||||
// Check if rate limited
|
||||
const checkRateLimited = useCallback(() => {
|
||||
const recentAttempts = getRecentAttempts();
|
||||
return recentAttempts.length >= RATE_LIMIT_MAX_ATTEMPTS;
|
||||
}, [getRecentAttempts]);
|
||||
|
||||
// Record a login attempt
|
||||
const recordAttempt = useCallback(() => {
|
||||
const now = Date.now();
|
||||
setLoginAttempts((prev) => [
|
||||
...prev.filter((t) => now - t < RATE_LIMIT_WINDOW_MS),
|
||||
now,
|
||||
]);
|
||||
}, []);
|
||||
|
||||
// Check available auth methods on mount
|
||||
useEffect(() => {
|
||||
@@ -46,17 +80,63 @@ export default function LoginPage() {
|
||||
checkAuthMethods();
|
||||
}, []);
|
||||
|
||||
// Check rate limit status and set up cooldown timer
|
||||
useEffect(() => {
|
||||
if (checkRateLimited()) {
|
||||
setIsRateLimited(true);
|
||||
setError("Too many login attempts. Please try again in 1 minute.");
|
||||
|
||||
// Set a timer to clear rate limit after window expires
|
||||
const oldestAttempt = loginAttempts[0];
|
||||
const timeUntilReset = oldestAttempt
|
||||
? RATE_LIMIT_WINDOW_MS - (Date.now() - oldestAttempt)
|
||||
: RATE_LIMIT_WINDOW_MS;
|
||||
|
||||
const timer = setTimeout(
|
||||
() => {
|
||||
setIsRateLimited(false);
|
||||
setError(null);
|
||||
// Clean up old attempts
|
||||
setLoginAttempts((prev) =>
|
||||
prev.filter((t) => Date.now() - t < RATE_LIMIT_WINDOW_MS),
|
||||
);
|
||||
},
|
||||
Math.max(0, timeUntilReset),
|
||||
);
|
||||
|
||||
return () => clearTimeout(timer);
|
||||
}
|
||||
setIsRateLimited(false);
|
||||
}, [loginAttempts, checkRateLimited]);
|
||||
|
||||
const handleOidcLogin = async () => {
|
||||
// Check rate limit before attempting
|
||||
if (checkRateLimited()) {
|
||||
setError("Too many login attempts. Please try again in 1 minute.");
|
||||
return;
|
||||
}
|
||||
|
||||
setIsLoading(true);
|
||||
setError(null);
|
||||
|
||||
try {
|
||||
await pb.collection("users").authWithOAuth2({ provider: "oidc" });
|
||||
// Reset attempts on successful login
|
||||
setLoginAttempts([]);
|
||||
router.push("/");
|
||||
} catch (err) {
|
||||
// Record the failed attempt
|
||||
recordAttempt();
|
||||
const message =
|
||||
err instanceof Error ? err.message : "Authentication failed";
|
||||
setError(message);
|
||||
const remaining = getRemainingAttempts() - 1; // -1 because we just recorded an attempt
|
||||
if (remaining > 0 && remaining <= 2) {
|
||||
setError(
|
||||
`${message}. ${remaining} attempt${remaining === 1 ? "" : "s"} remaining.`,
|
||||
);
|
||||
} else {
|
||||
setError(message);
|
||||
}
|
||||
setIsLoading(false);
|
||||
}
|
||||
};
|
||||
@@ -69,16 +149,33 @@ export default function LoginPage() {
|
||||
return;
|
||||
}
|
||||
|
||||
// Check rate limit before attempting
|
||||
if (checkRateLimited()) {
|
||||
setError("Too many login attempts. Please try again in 1 minute.");
|
||||
return;
|
||||
}
|
||||
|
||||
setIsLoading(true);
|
||||
setError(null);
|
||||
|
||||
try {
|
||||
await pb.collection("users").authWithPassword(email, password);
|
||||
// Reset attempts on successful login
|
||||
setLoginAttempts([]);
|
||||
router.push("/");
|
||||
} catch (err) {
|
||||
// Record the failed attempt
|
||||
recordAttempt();
|
||||
const message =
|
||||
err instanceof Error ? err.message : "Invalid credentials";
|
||||
setError(message);
|
||||
const remaining = getRemainingAttempts() - 1; // -1 because we just recorded an attempt
|
||||
if (remaining > 0 && remaining <= 2) {
|
||||
setError(
|
||||
`${message}. ${remaining} attempt${remaining === 1 ? "" : "s"} remaining.`,
|
||||
);
|
||||
} else {
|
||||
setError(message);
|
||||
}
|
||||
setIsLoading(false);
|
||||
}
|
||||
};
|
||||
@@ -88,8 +185,8 @@ export default function LoginPage() {
|
||||
value: string,
|
||||
) => {
|
||||
setter(value);
|
||||
// Clear error when user starts typing again
|
||||
if (error) {
|
||||
// Clear error when user starts typing again (but not rate limit errors)
|
||||
if (error && !isRateLimited) {
|
||||
setError(null);
|
||||
}
|
||||
};
|
||||
@@ -131,7 +228,7 @@ export default function LoginPage() {
|
||||
<button
|
||||
type="button"
|
||||
onClick={handleOidcLogin}
|
||||
disabled={isLoading}
|
||||
disabled={isLoading || isRateLimited}
|
||||
className="w-full rounded-md bg-blue-600 px-4 py-2 text-white font-medium hover:bg-blue-700 focus:outline-none focus:ring-2 focus:ring-blue-500 focus:ring-offset-2 disabled:bg-blue-400 disabled:cursor-not-allowed"
|
||||
>
|
||||
{isLoading
|
||||
@@ -179,7 +276,7 @@ export default function LoginPage() {
|
||||
|
||||
<button
|
||||
type="submit"
|
||||
disabled={isLoading}
|
||||
disabled={isLoading || isRateLimited}
|
||||
className="w-full rounded-md bg-blue-600 px-4 py-2 text-white font-medium hover:bg-blue-700 focus:outline-none focus:ring-2 focus:ring-blue-500 focus:ring-offset-2 disabled:bg-blue-400 disabled:cursor-not-allowed"
|
||||
>
|
||||
{isLoading ? "Signing in..." : "Sign in"}
|
||||
|
||||
Reference in New Issue
Block a user