Add login rate limiting (P4.6 complete)
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:
2026-01-11 22:09:34 +00:00
parent 2e7d8dc4ca
commit c708c2ed8b
3 changed files with 372 additions and 19 deletions

View File

@@ -561,4 +561,253 @@ describe("LoginPage", () => {
});
});
});
describe("rate limiting", () => {
it("shows rate limit error after 5 failed attempts", async () => {
mockAuthWithPassword.mockRejectedValue(new Error("Invalid credentials"));
render(<LoginPage />);
// Wait for auth check to complete
await waitFor(() => {
expect(screen.getByLabelText(/email/i)).toBeInTheDocument();
});
const emailInput = screen.getByLabelText(/email/i);
const passwordInput = screen.getByLabelText(/password/i);
const submitButton = screen.getByRole("button", { name: /sign in/i });
// Make 5 failed login attempts
for (let i = 0; i < 5; i++) {
fireEvent.change(emailInput, {
target: { value: `test${i}@example.com` },
});
fireEvent.change(passwordInput, { target: { value: "wrongpassword" } });
fireEvent.click(submitButton);
await waitFor(() => {
expect(mockAuthWithPassword).toHaveBeenCalledTimes(i + 1);
});
}
// 6th attempt should show rate limit error
fireEvent.change(emailInput, {
target: { value: "another@example.com" },
});
fireEvent.change(passwordInput, { target: { value: "password" } });
fireEvent.click(submitButton);
await waitFor(() => {
expect(screen.getByRole("alert")).toBeInTheDocument();
expect(
screen.getByText(/too many login attempts/i),
).toBeInTheDocument();
});
// Should not have made 6th auth call
expect(mockAuthWithPassword).toHaveBeenCalledTimes(5);
});
it("disables form when rate limited", async () => {
mockAuthWithPassword.mockRejectedValue(new Error("Invalid credentials"));
render(<LoginPage />);
// Wait for auth check to complete
await waitFor(() => {
expect(screen.getByLabelText(/email/i)).toBeInTheDocument();
});
const emailInput = screen.getByLabelText(/email/i);
const passwordInput = screen.getByLabelText(/password/i);
const submitButton = screen.getByRole("button", { name: /sign in/i });
// Make 5 failed login attempts
for (let i = 0; i < 5; i++) {
fireEvent.change(emailInput, {
target: { value: `test${i}@example.com` },
});
fireEvent.change(passwordInput, { target: { value: "wrongpassword" } });
fireEvent.click(submitButton);
await waitFor(() => {
expect(mockAuthWithPassword).toHaveBeenCalledTimes(i + 1);
});
}
// Should be rate limited and button disabled
await waitFor(() => {
expect(submitButton).toBeDisabled();
});
});
it("re-enables form after cooldown period expires", async () => {
vi.useFakeTimers({ shouldAdvanceTime: true });
mockAuthWithPassword.mockRejectedValue(new Error("Invalid credentials"));
render(<LoginPage />);
// Wait for auth check to complete
await vi.waitFor(() => {
expect(screen.getByLabelText(/email/i)).toBeInTheDocument();
});
const emailInput = screen.getByLabelText(/email/i);
const passwordInput = screen.getByLabelText(/password/i);
const submitButton = screen.getByRole("button", { name: /sign in/i });
// Make 5 failed login attempts
for (let i = 0; i < 5; i++) {
fireEvent.change(emailInput, {
target: { value: `test${i}@example.com` },
});
fireEvent.change(passwordInput, { target: { value: "wrongpassword" } });
fireEvent.click(submitButton);
await vi.waitFor(() => {
expect(mockAuthWithPassword).toHaveBeenCalledTimes(i + 1);
});
}
// Should be rate limited
await vi.waitFor(() => {
expect(submitButton).toBeDisabled();
});
// Advance time by 61 seconds to expire cooldown
await vi.advanceTimersByTimeAsync(61000);
// Form should be re-enabled
await vi.waitFor(() => {
expect(
screen.getByRole("button", { name: /sign in/i }),
).not.toBeDisabled();
});
vi.useRealTimers();
});
it("resets attempt count on successful login", async () => {
// First 4 failed attempts, then success
mockAuthWithPassword
.mockRejectedValueOnce(new Error("Invalid credentials"))
.mockRejectedValueOnce(new Error("Invalid credentials"))
.mockRejectedValueOnce(new Error("Invalid credentials"))
.mockRejectedValueOnce(new Error("Invalid credentials"))
.mockResolvedValueOnce({ token: "test-token" });
render(<LoginPage />);
// Wait for auth check to complete
await waitFor(() => {
expect(screen.getByLabelText(/email/i)).toBeInTheDocument();
});
const emailInput = screen.getByLabelText(/email/i);
const passwordInput = screen.getByLabelText(/password/i);
const submitButton = screen.getByRole("button", { name: /sign in/i });
// Make 4 failed attempts
for (let i = 0; i < 4; i++) {
fireEvent.change(emailInput, {
target: { value: `test${i}@example.com` },
});
fireEvent.change(passwordInput, { target: { value: "wrongpassword" } });
fireEvent.click(submitButton);
await waitFor(() => {
expect(mockAuthWithPassword).toHaveBeenCalledTimes(i + 1);
});
}
// 5th attempt succeeds - should reset counter (though component unmounts)
fireEvent.change(emailInput, { target: { value: "good@example.com" } });
fireEvent.change(passwordInput, { target: { value: "correctpassword" } });
fireEvent.click(submitButton);
await waitFor(() => {
expect(mockPush).toHaveBeenCalledWith("/");
});
});
it("shows remaining attempts warning after 3 failures", async () => {
mockAuthWithPassword.mockRejectedValue(new Error("Invalid credentials"));
render(<LoginPage />);
// Wait for auth check to complete
await waitFor(() => {
expect(screen.getByLabelText(/email/i)).toBeInTheDocument();
});
const emailInput = screen.getByLabelText(/email/i);
const passwordInput = screen.getByLabelText(/password/i);
const submitButton = screen.getByRole("button", { name: /sign in/i });
// Make 3 failed login attempts
for (let i = 0; i < 3; i++) {
fireEvent.change(emailInput, {
target: { value: `test${i}@example.com` },
});
fireEvent.change(passwordInput, { target: { value: "wrongpassword" } });
fireEvent.click(submitButton);
await waitFor(() => {
expect(mockAuthWithPassword).toHaveBeenCalledTimes(i + 1);
});
}
// Should show remaining attempts warning
await waitFor(() => {
expect(screen.getByText(/2 attempts remaining/i)).toBeInTheDocument();
});
});
it("rate limits OIDC login attempts", async () => {
// Configure OIDC provider available
mockListAuthMethods.mockResolvedValue({
oauth2: {
enabled: true,
providers: [
{
name: "oidc",
displayName: "Pocket-ID",
state: "test-state",
codeVerifier: "test-verifier",
codeChallenge: "test-challenge",
codeChallengeMethod: "S256",
authURL: "https://id.example.com/auth",
},
],
},
});
mockAuthWithOAuth2.mockRejectedValue(
new Error("Authentication cancelled"),
);
render(<LoginPage />);
await waitFor(() => {
expect(
screen.getByRole("button", { name: /sign in with pocket-id/i }),
).toBeInTheDocument();
});
const oidcButton = screen.getByRole("button", {
name: /sign in with pocket-id/i,
});
// Make 5 failed OIDC attempts
for (let i = 0; i < 5; i++) {
fireEvent.click(oidcButton);
await waitFor(() => {
expect(mockAuthWithOAuth2).toHaveBeenCalledTimes(i + 1);
});
}
// 6th attempt should show rate limit error
fireEvent.click(oidcButton);
await waitFor(() => {
expect(screen.getByRole("alert")).toBeInTheDocument();
expect(
screen.getByText(/too many login attempts/i),
).toBeInTheDocument();
});
// Should not have made 6th auth call
expect(mockAuthWithOAuth2).toHaveBeenCalledTimes(5);
});
});
});

View File

@@ -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"}