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>
290 lines
9.1 KiB
TypeScript
290 lines
9.1 KiB
TypeScript
// ABOUTME: Login page for user authentication.
|
|
// ABOUTME: Provides OIDC (Pocket-ID) login with email/password fallback.
|
|
"use client";
|
|
|
|
import { useRouter } from "next/navigation";
|
|
import { type FormEvent, useCallback, useEffect, useState } from "react";
|
|
|
|
import { pb } from "@/lib/pocketbase";
|
|
|
|
interface AuthProvider {
|
|
name: string;
|
|
displayName: string;
|
|
state: string;
|
|
codeVerifier: string;
|
|
codeChallenge: string;
|
|
codeChallengeMethod: string;
|
|
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("");
|
|
const [password, setPassword] = useState("");
|
|
const [error, setError] = useState<string | null>(null);
|
|
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(() => {
|
|
const checkAuthMethods = async () => {
|
|
try {
|
|
const authMethods = await pb.collection("users").listAuthMethods();
|
|
const oidc = authMethods.oauth2?.providers?.find(
|
|
(p: AuthProvider) => p.name === "oidc",
|
|
);
|
|
if (oidc) {
|
|
setOidcProvider(oidc);
|
|
}
|
|
} catch {
|
|
// If listAuthMethods fails, fall back to email/password
|
|
} finally {
|
|
setIsCheckingAuth(false);
|
|
}
|
|
};
|
|
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";
|
|
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);
|
|
}
|
|
};
|
|
|
|
const handleSubmit = async (e: FormEvent<HTMLFormElement>) => {
|
|
e.preventDefault();
|
|
|
|
// Basic validation - don't submit with empty fields
|
|
if (!email.trim() || !password.trim()) {
|
|
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";
|
|
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);
|
|
}
|
|
};
|
|
|
|
const handleInputChange = (
|
|
setter: React.Dispatch<React.SetStateAction<string>>,
|
|
value: string,
|
|
) => {
|
|
setter(value);
|
|
// Clear error when user starts typing again (but not rate limit errors)
|
|
if (error && !isRateLimited) {
|
|
setError(null);
|
|
}
|
|
};
|
|
|
|
// Show loading state while checking auth methods
|
|
if (isCheckingAuth) {
|
|
return (
|
|
<main
|
|
id="main-content"
|
|
className="flex min-h-screen items-center justify-center"
|
|
>
|
|
<div className="w-full max-w-md space-y-8 p-8">
|
|
<h1 className="text-2xl font-bold text-center">PhaseFlow</h1>
|
|
<div className="text-center text-gray-500">Loading...</div>
|
|
</div>
|
|
</main>
|
|
);
|
|
}
|
|
|
|
return (
|
|
<main
|
|
id="main-content"
|
|
className="flex min-h-screen items-center justify-center"
|
|
>
|
|
<div className="w-full max-w-md space-y-8 p-8">
|
|
<h1 className="text-2xl font-bold text-center">PhaseFlow</h1>
|
|
|
|
{error && (
|
|
<div
|
|
role="alert"
|
|
className="bg-red-50 border border-red-200 text-red-700 px-4 py-3 rounded"
|
|
>
|
|
{error}
|
|
</div>
|
|
)}
|
|
|
|
{oidcProvider ? (
|
|
// OIDC login button
|
|
<button
|
|
type="button"
|
|
onClick={handleOidcLogin}
|
|
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 with ${oidcProvider.displayName}`}
|
|
</button>
|
|
) : (
|
|
// Email/password form fallback
|
|
<form onSubmit={handleSubmit} className="space-y-6">
|
|
<div>
|
|
<label
|
|
htmlFor="email"
|
|
className="block text-sm font-medium text-gray-700"
|
|
>
|
|
Email
|
|
</label>
|
|
<input
|
|
id="email"
|
|
type="email"
|
|
value={email}
|
|
onChange={(e) => handleInputChange(setEmail, e.target.value)}
|
|
disabled={isLoading}
|
|
className="mt-1 block w-full rounded-md border border-gray-300 px-3 py-2 shadow-sm focus:border-blue-500 focus:outline-none focus:ring-1 focus:ring-blue-500 disabled:bg-gray-100 disabled:cursor-not-allowed"
|
|
required
|
|
/>
|
|
</div>
|
|
|
|
<div>
|
|
<label
|
|
htmlFor="password"
|
|
className="block text-sm font-medium text-gray-700"
|
|
>
|
|
Password
|
|
</label>
|
|
<input
|
|
id="password"
|
|
type="password"
|
|
value={password}
|
|
onChange={(e) => handleInputChange(setPassword, e.target.value)}
|
|
disabled={isLoading}
|
|
className="mt-1 block w-full rounded-md border border-gray-300 px-3 py-2 shadow-sm focus:border-blue-500 focus:outline-none focus:ring-1 focus:ring-blue-500 disabled:bg-gray-100 disabled:cursor-not-allowed"
|
|
required
|
|
/>
|
|
</div>
|
|
|
|
<button
|
|
type="submit"
|
|
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"}
|
|
</button>
|
|
</form>
|
|
)}
|
|
</div>
|
|
</main>
|
|
);
|
|
}
|