feat: Implement secure email/password authentication with user registration, API rate limiting, and dynamic homepage navigation.

This commit is contained in:
Yusuf İpek
2026-02-07 06:43:09 +03:00
parent d38e8b8749
commit 5b436fff2d
11 changed files with 707 additions and 166 deletions
+11 -1
View File
@@ -1,7 +1,17 @@
export default function AuthLayout({
import { redirect } from 'next/navigation';
import { auth } from '@/lib/auth';
export default async function AuthLayout({
children,
}: {
children: React.ReactNode;
}) {
const session = await auth();
// If already logged in, redirect to dashboard
if (session?.user) {
redirect('/dashboard');
}
return <>{children}</>;
}
+122 -116
View File
@@ -1,51 +1,146 @@
'use client';
import { useState } from 'react';
import { useState, useEffect, Suspense } from 'react';
import Link from 'next/link';
import { useRouter } from 'next/navigation';
import { Video, Loader2, Github, Mail } from 'lucide-react';
import { useRouter, useSearchParams } from 'next/navigation';
import { Video, Loader2 } from 'lucide-react';
import { Button } from '@/components/ui/button';
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card';
import { Input } from '@/components/ui/input';
import { Label } from '@/components/ui/label';
import { Separator } from '@/components/ui/separator';
import { signIn } from 'next-auth/react';
export default function LoginPage() {
function LoginForm() {
const router = useRouter();
const searchParams = useSearchParams();
const [isLoading, setIsLoading] = useState(false);
const [error, setError] = useState('');
const [email, setEmail] = useState('');
const [password, setPassword] = useState('');
const [showSuccess, setShowSuccess] = useState(false);
useEffect(() => {
if (searchParams.get('registered') === 'true') {
setShowSuccess(true);
}
}, [searchParams]);
const handleEmailLogin = async (e: React.FormEvent) => {
e.preventDefault();
setIsLoading(true);
setError('');
try {
// TODO: Implement actual sign in
// await signIn('credentials', { email, password, redirect: false });
await new Promise(resolve => setTimeout(resolve, 500));
const result = await signIn('credentials', {
email,
password,
redirect: false,
});
if (result?.error) {
setError('Invalid email or password');
return;
}
router.push('/dashboard');
} catch (error) {
console.error('Login failed:', error);
router.refresh();
} catch {
setError('Something went wrong. Please try again.');
} finally {
setIsLoading(false);
}
};
const handleOAuthLogin = async (provider: string) => {
setIsLoading(true);
try {
// TODO: Implement OAuth
// await signIn(provider, { callbackUrl: '/dashboard' });
await new Promise(resolve => setTimeout(resolve, 500));
router.push('/dashboard');
} catch (error) {
console.error('OAuth login failed:', error);
} finally {
setIsLoading(false);
}
};
return (
<Card>
<CardHeader className="text-center">
<CardTitle>Welcome back</CardTitle>
<CardDescription>
Sign in to your account to continue
</CardDescription>
</CardHeader>
<CardContent>
{showSuccess && (
<div className="p-3 rounded-md bg-green-500/10 text-green-600 text-sm mb-4">
Account created successfully! Please sign in.
</div>
)}
{/* Email Form */}
<form onSubmit={handleEmailLogin} className="space-y-4">
<div className="space-y-2">
<Label htmlFor="email">Email</Label>
<Input
id="email"
type="email"
placeholder="[email protected]"
value={email}
onChange={(e) => {
setEmail(e.target.value);
setError('');
}}
required
disabled={isLoading}
/>
</div>
<div className="space-y-2">
<Label htmlFor="password">Password</Label>
<Input
id="password"
type="password"
placeholder="••••••••"
value={password}
onChange={(e) => {
setPassword(e.target.value);
setError('');
}}
required
disabled={isLoading}
/>
</div>
{error && (
<div className="p-3 rounded-md bg-destructive/10 text-destructive text-sm">
{error}
</div>
)}
<Button type="submit" className="w-full" disabled={isLoading}>
{isLoading && <Loader2 className="h-4 w-4 mr-2 animate-spin" />}
Sign in
</Button>
</form>
<p className="text-center text-sm text-muted-foreground mt-6">
Don&apos;t have an account?{' '}
<Link href="/register" className="text-primary hover:underline">
Sign up
</Link>
</p>
</CardContent>
</Card>
);
}
function LoginFormSkeleton() {
return (
<Card>
<CardHeader className="text-center">
<CardTitle>Welcome back</CardTitle>
<CardDescription>
Sign in to your account to continue
</CardDescription>
</CardHeader>
<CardContent className="space-y-4">
<div className="h-10 bg-muted animate-pulse rounded-md" />
<div className="h-10 bg-muted animate-pulse rounded-md" />
<div className="h-10 bg-primary/20 animate-pulse rounded-md" />
</CardContent>
</Card>
);
}
export default function LoginPage() {
return (
<div className="min-h-screen flex items-center justify-center p-4 bg-background">
<div className="w-full max-w-md">
@@ -55,98 +150,9 @@ export default function LoginPage() {
<span className="font-bold text-2xl">OpenFrame</span>
</Link>
<Card>
<CardHeader className="text-center">
<CardTitle>Welcome back</CardTitle>
<CardDescription>
Sign in to your account to continue
</CardDescription>
</CardHeader>
<CardContent>
{/* OAuth Buttons */}
<div className="grid gap-2">
<Button
variant="outline"
onClick={() => handleOAuthLogin('google')}
disabled={isLoading}
>
<svg className="h-4 w-4 mr-2" viewBox="0 0 24 24">
<path
fill="currentColor"
d="M22.56 12.25c0-.78-.07-1.53-.2-2.25H12v4.26h5.92c-.26 1.37-1.04 2.53-2.21 3.31v2.77h3.57c2.08-1.92 3.28-4.74 3.28-8.09z"
/>
<path
fill="currentColor"
d="M12 23c2.97 0 5.46-.98 7.28-2.66l-3.57-2.77c-.98.66-2.23 1.06-3.71 1.06-2.86 0-5.29-1.93-6.16-4.53H2.18v2.84C3.99 20.53 7.7 23 12 23z"
/>
<path
fill="currentColor"
d="M5.84 14.09c-.22-.66-.35-1.36-.35-2.09s.13-1.43.35-2.09V7.07H2.18C1.43 8.55 1 10.22 1 12s.43 3.45 1.18 4.93l2.85-2.22.81-.62z"
/>
<path
fill="currentColor"
d="M12 5.38c1.62 0 3.06.56 4.21 1.64l3.15-3.15C17.45 2.09 14.97 1 12 1 7.7 1 3.99 3.47 2.18 7.07l3.66 2.84c.87-2.6 3.3-4.53 6.16-4.53z"
/>
</svg>
Continue with Google
</Button>
<Button
variant="outline"
onClick={() => handleOAuthLogin('github')}
disabled={isLoading}
>
<Github className="h-4 w-4 mr-2" />
Continue with GitHub
</Button>
</div>
<div className="relative my-6">
<Separator />
<span className="absolute left-1/2 top-1/2 -translate-x-1/2 -translate-y-1/2 bg-card px-2 text-xs text-muted-foreground">
or continue with email
</span>
</div>
{/* Email Form */}
<form onSubmit={handleEmailLogin} className="space-y-4">
<div className="space-y-2">
<Label htmlFor="email">Email</Label>
<Input
id="email"
type="email"
placeholder="[email protected]"
value={email}
onChange={(e) => setEmail(e.target.value)}
required
disabled={isLoading}
/>
</div>
<div className="space-y-2">
<Label htmlFor="password">Password</Label>
<Input
id="password"
type="password"
placeholder="••••••••"
value={password}
onChange={(e) => setPassword(e.target.value)}
required
disabled={isLoading}
/>
</div>
<Button type="submit" className="w-full" disabled={isLoading}>
{isLoading && <Loader2 className="h-4 w-4 mr-2 animate-spin" />}
Sign in
</Button>
</form>
<p className="text-center text-sm text-muted-foreground mt-6">
Don&apos;t have an account?{' '}
<Link href="/register" className="text-primary hover:underline">
Sign up
</Link>
</p>
</CardContent>
</Card>
<Suspense fallback={<LoginFormSkeleton />}>
<LoginForm />
</Suspense>
<p className="text-center text-xs text-muted-foreground mt-4">
By continuing, you agree to our Terms of Service and Privacy Policy
+208
View File
@@ -0,0 +1,208 @@
'use client';
import { useState } from 'react';
import Link from 'next/link';
import { useRouter } from 'next/navigation';
import { Video, Loader2, KeyRound, UserPlus } from 'lucide-react';
import { Button } from '@/components/ui/button';
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card';
import { Input } from '@/components/ui/input';
import { Label } from '@/components/ui/label';
export default function RegisterPage() {
const router = useRouter();
const [isLoading, setIsLoading] = useState(false);
const [error, setError] = useState('');
const [formData, setFormData] = useState({
name: '',
email: '',
password: '',
confirmPassword: '',
inviteCode: '',
});
const handleChange = (e: React.ChangeEvent<HTMLInputElement>) => {
setFormData(prev => ({
...prev,
[e.target.name]: e.target.value,
}));
setError('');
};
const handleRegister = async (e: React.FormEvent) => {
e.preventDefault();
setError('');
setIsLoading(true);
// Client-side validation
if (formData.password !== formData.confirmPassword) {
setError('Passwords do not match');
setIsLoading(false);
return;
}
if (formData.password.length < 8) {
setError('Password must be at least 8 characters');
setIsLoading(false);
return;
}
try {
const response = await fetch('/api/auth/register', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
name: formData.name,
email: formData.email,
password: formData.password,
inviteCode: formData.inviteCode,
}),
});
const data = await response.json();
if (!response.ok) {
setError(data.error || 'Registration failed');
return;
}
// Redirect to login on success
router.push('/login?registered=true');
} catch {
setError('Something went wrong. Please try again.');
} finally {
setIsLoading(false);
}
};
return (
<div className="min-h-screen flex items-center justify-center p-4 bg-background">
<div className="w-full max-w-md">
{/* Logo */}
<Link href="/" className="flex items-center justify-center gap-2 mb-8">
<Video className="h-8 w-8 text-primary" />
<span className="font-bold text-2xl">OpenFrame</span>
</Link>
<Card>
<CardHeader className="text-center">
<CardTitle className="flex items-center justify-center gap-2">
<UserPlus className="h-5 w-5" />
Create Account
</CardTitle>
<CardDescription>
Join OpenFrame to collaborate on video projects
</CardDescription>
</CardHeader>
<CardContent>
<form onSubmit={handleRegister} className="space-y-4">
{/* Invite Code - First and prominent */}
<div className="space-y-2">
<Label htmlFor="inviteCode" className="flex items-center gap-2">
<KeyRound className="h-4 w-4 text-amber-500" />
Invite Code
</Label>
<Input
id="inviteCode"
name="inviteCode"
type="text"
placeholder="Enter your invite code"
value={formData.inviteCode}
onChange={handleChange}
required
disabled={isLoading}
className="border-amber-500/30 focus:border-amber-500"
/>
<p className="text-xs text-muted-foreground">
An invite code is required to create an account
</p>
</div>
<div className="h-px bg-border my-4" />
<div className="space-y-2">
<Label htmlFor="name">Full Name</Label>
<Input
id="name"
name="name"
type="text"
placeholder="John Doe"
value={formData.name}
onChange={handleChange}
required
disabled={isLoading}
minLength={2}
/>
</div>
<div className="space-y-2">
<Label htmlFor="email">Email</Label>
<Input
id="email"
name="email"
type="email"
placeholder="[email protected]"
value={formData.email}
onChange={handleChange}
required
disabled={isLoading}
/>
</div>
<div className="space-y-2">
<Label htmlFor="password">Password</Label>
<Input
id="password"
name="password"
type="password"
placeholder="••••••••"
value={formData.password}
onChange={handleChange}
required
disabled={isLoading}
minLength={8}
/>
</div>
<div className="space-y-2">
<Label htmlFor="confirmPassword">Confirm Password</Label>
<Input
id="confirmPassword"
name="confirmPassword"
type="password"
placeholder="••••••••"
value={formData.confirmPassword}
onChange={handleChange}
required
disabled={isLoading}
/>
</div>
{error && (
<div className="p-3 rounded-md bg-destructive/10 text-destructive text-sm">
{error}
</div>
)}
<Button type="submit" className="w-full" disabled={isLoading}>
{isLoading && <Loader2 className="h-4 w-4 mr-2 animate-spin" />}
Create Account
</Button>
</form>
<p className="text-center text-sm text-muted-foreground mt-6">
Already have an account?{' '}
<Link href="/login" className="text-primary hover:underline">
Sign in
</Link>
</p>
</CardContent>
</Card>
<p className="text-center text-xs text-muted-foreground mt-4">
By continuing, you agree to our Terms of Service and Privacy Policy
</p>
</div>
</div>
);
}
+132
View File
@@ -0,0 +1,132 @@
import { NextRequest, NextResponse } from 'next/server';
import { db } from '@/lib/db';
import bcrypt from 'bcryptjs';
import { checkRateLimit, getClientIp, rateLimitHeaders, RATE_LIMIT_CONFIGS } from '@/lib/rate-limit';
export async function POST(request: NextRequest) {
try {
// Rate limiting by IP
const clientIp = getClientIp(request);
const rateLimitKey = `register:${clientIp}`;
const rateLimit = await checkRateLimit(rateLimitKey, 'register');
if (!rateLimit.allowed) {
return NextResponse.json(
{ error: 'Too many registration attempts. Please try again later.' },
{
status: 429,
headers: rateLimitHeaders(rateLimit, RATE_LIMIT_CONFIGS.register.maxRequests),
}
);
}
const body = await request.json();
const { name, email, password, inviteCode } = body;
// Validate invite code using constant-time comparison to prevent timing attacks
const validInviteCode = process.env.INVITE_CODE;
if (!validInviteCode || !inviteCode) {
return NextResponse.json(
{ error: 'Invalid invite code' },
{ status: 403 }
);
}
// Constant-time comparison
const { timingSafeEqual } = await import('crypto');
const validBuffer = Buffer.from(validInviteCode);
const providedBuffer = Buffer.from(String(inviteCode));
// Ensure same length for comparison (prevents length-based timing leak)
const isValidLength = validBuffer.length === providedBuffer.length;
const compareBuffer = isValidLength ? providedBuffer : validBuffer;
const isValidCode = isValidLength && timingSafeEqual(validBuffer, compareBuffer);
if (!isValidCode) {
return NextResponse.json(
{ error: 'Invalid invite code' },
{ status: 403 }
);
}
// Validate required fields
if (!name || typeof name !== 'string' || name.trim().length < 2) {
return NextResponse.json(
{ error: 'Name must be at least 2 characters' },
{ status: 400 }
);
}
if (!email || typeof email !== 'string') {
return NextResponse.json(
{ error: 'Email is required' },
{ status: 400 }
);
}
// Basic email validation
const emailRegex = /^[^\s@]+@[^\s@]+\.[^\s@]+$/;
if (!emailRegex.test(email)) {
return NextResponse.json(
{ error: 'Invalid email format' },
{ status: 400 }
);
}
if (!password || typeof password !== 'string' || password.length < 8) {
return NextResponse.json(
{ error: 'Password must be at least 8 characters' },
{ status: 400 }
);
}
// Check if email already exists
const existingUser = await db.user.findUnique({
where: { email: email.toLowerCase() },
});
if (existingUser) {
return NextResponse.json(
{ error: 'An account with this email already exists' },
{ status: 409 }
);
}
// Hash password
const hashedPassword = await bcrypt.hash(password, 12);
// Create user
const user = await db.user.create({
data: {
name: name.trim(),
email: email.toLowerCase(),
password: hashedPassword,
},
select: {
id: true,
name: true,
email: true,
createdAt: true,
},
});
const response = NextResponse.json(
{ message: 'Account created successfully', user },
{ status: 201 }
);
// Add rate limit headers to successful response
const headers = rateLimitHeaders(rateLimit, RATE_LIMIT_CONFIGS.register.maxRequests);
Object.entries(headers).forEach(([key, value]) => {
response.headers.set(key, value);
});
return response;
} catch (error) {
console.error('Registration error:', error);
return NextResponse.json(
{ error: 'Failed to create account' },
{ status: 500 }
);
}
}
+47 -21
View File
@@ -1,8 +1,12 @@
import Link from 'next/link';
import { Video, MessageSquare, Mic, Share2, ArrowRight, Play } from 'lucide-react';
import { Button } from '@/components/ui/button';
import { auth } from '@/lib/auth';
export default async function HomePage() {
const session = await auth();
const isLoggedIn = !!session?.user;
export default function HomePage() {
return (
<div className="min-h-screen bg-background">
{/* Header */}
@@ -13,12 +17,25 @@ export default function HomePage() {
<span className="font-bold text-xl">OpenFrame</span>
</Link>
<div className="flex items-center gap-4">
<Button asChild variant="ghost">
<Link href="/login">Sign in</Link>
</Button>
<Button asChild>
<Link href="/dashboard">Get Started</Link>
</Button>
{isLoggedIn ? (
<>
<Button asChild variant="ghost">
<Link href="/dashboard">Dashboard</Link>
</Button>
<Button asChild>
<Link href="/projects/new">New Project</Link>
</Button>
</>
) : (
<>
<Button asChild variant="ghost">
<Link href="/login">Sign in</Link>
</Button>
<Button asChild>
<Link href="/register">Get Started</Link>
</Button>
</>
)}
</div>
</div>
</header>
@@ -31,16 +48,25 @@ export default function HomePage() {
<span className="text-primary">reimagined</span>
</h1>
<p className="text-xl text-muted-foreground max-w-2xl">
Collect timestamped feedback on your videos with text and voice comments.
Collect timestamped feedback on your videos with text and voice comments.
Share with your team and clients, iterate faster.
</p>
<div className="flex flex-col sm:flex-row gap-4">
<Button asChild size="lg">
<Link href="/dashboard">
Start for free
<ArrowRight className="h-4 w-4 ml-2" />
</Link>
</Button>
{isLoggedIn ? (
<Button asChild size="lg">
<Link href="/dashboard">
Go to Dashboard
<ArrowRight className="h-4 w-4 ml-2" />
</Link>
</Button>
) : (
<Button asChild size="lg">
<Link href="/register">
Start for free
<ArrowRight className="h-4 w-4 ml-2" />
</Link>
</Button>
)}
<Button asChild variant="outline" size="lg">
<Link href="#features">
<Play className="h-4 w-4 mr-2" />
@@ -88,8 +114,8 @@ export default function HomePage() {
Join teams who have already switched to OpenFrame for faster, clearer video feedback.
</p>
<Button asChild size="lg">
<Link href="/dashboard">
Get started for free
<Link href={isLoggedIn ? '/dashboard' : '/register'}>
{isLoggedIn ? 'Go to Dashboard' : 'Get started for free'}
<ArrowRight className="h-4 w-4 ml-2" />
</Link>
</Button>
@@ -112,11 +138,11 @@ export default function HomePage() {
);
}
function FeatureCard({
icon: Icon,
title,
description
}: {
function FeatureCard({
icon: Icon,
title,
description
}: {
icon: React.ComponentType<{ className?: string }>;
title: string;
description: string;
+6
View File
@@ -9,6 +9,7 @@
"@base-ui/react": "^1.1.0",
"@prisma/adapter-pg": "^7.3.0",
"@prisma/client": "^7.3.0",
"bcryptjs": "^3.0.3",
"class-variance-authority": "^0.7.1",
"clsx": "^2.1.1",
"date-fns": "^4.1.0",
@@ -29,6 +30,7 @@
},
"devDependencies": {
"@tailwindcss/postcss": "^4",
"@types/bcryptjs": "^3.0.0",
"@types/node": "^20",
"@types/pg": "^8.16.0",
"@types/react": "^19",
@@ -489,6 +491,8 @@
"@tybys/wasm-util": ["@tybys/[email protected]", "", { "dependencies": { "tslib": "^2.4.0" } }, "sha512-9tTaPJLSiejZKx+Bmog4uSubteqTvFrVrURwkmHixBo0G4seD0zUxp98E1DzUBJxLQ3NPwXrGKDiVjwx/DpPsg=="],
"@types/bcryptjs": ["@types/[email protected]", "", { "dependencies": { "bcryptjs": "*" } }, "sha512-WRZOuCuaz8UcZZE4R5HXTco2goQSI2XxjGY3hbM/xDvwmqFWd4ivooImsMx65OKM6CtNKbnZ5YL+YwAwK7c1dg=="],
"@types/estree": ["@types/[email protected]", "", {}, "sha512-dWHzHa2WqEXI/O1E9OjrocMTKJl2mSrEolh1Iomrv6U+JuNwaHXsXx9bLu5gG7BUWFIN0skIQJQ/L1rIex4X6w=="],
"@types/json-schema": ["@types/[email protected]", "", {}, "sha512-5+fP8P8MFNC+AyZCDxrB2pkZFPGzqQWUzpSeuuVLvm8VMcorNYavBqoFcxK8bQz4Qsbn4oUEEem4wDLfcysGHA=="],
@@ -623,6 +627,8 @@
"baseline-browser-mapping": ["[email protected]", "", { "bin": { "baseline-browser-mapping": "dist/cli.js" } }, "sha512-ipDqC8FrAl/76p2SSWKSI+H9tFwm7vYqXQrItCuiVPt26Km0jS+NzSsBWAaBusvSbQcfJG+JitdMm+wZAgTYqg=="],
"bcryptjs": ["[email protected]", "", { "bin": { "bcrypt": "bin/bcrypt" } }, "sha512-GlF5wPWnSa/X5LKM1o0wz0suXIINz1iHRLvTS+sLyi7XPbe5ycmYI3DlZqVGZZtDgl4DmasFg7gOB3JYbphV5g=="],
"body-parser": ["[email protected]", "", { "dependencies": { "bytes": "^3.1.2", "content-type": "^1.0.5", "debug": "^4.4.3", "http-errors": "^2.0.0", "iconv-lite": "^0.7.0", "on-finished": "^2.4.1", "qs": "^6.14.1", "raw-body": "^3.0.1", "type-is": "^2.0.1" } }, "sha512-oP5VkATKlNwcgvxi0vM0p/D3n2C3EReYVX+DNYs5TjZFn/oQt2j+4sVJtSMr18pdRr8wjTcBl6LoV+FUwzPmNA=="],
"brace-expansion": ["[email protected]", "", { "dependencies": { "balanced-match": "^1.0.0", "concat-map": "0.0.1" } }, "sha512-9T9UjW3r0UW5c1Q7GTwllptXwhvYmEzFhzMfZ9H7FQWt+uZePjZPjBP/W1ZEyZ1twGWom5/56TF4lPcqjnDHcg=="],
+32 -27
View File
@@ -1,26 +1,16 @@
import NextAuth from 'next-auth';
import { PrismaAdapter } from '@auth/prisma-adapter';
import Google from 'next-auth/providers/google';
import GitHub from 'next-auth/providers/github';
import Credentials from 'next-auth/providers/credentials';
import bcrypt from 'bcryptjs';
import { db } from '@/lib/db';
// Dummy hash for timing-safe comparison when user doesn't exist
// This prevents user enumeration via timing attacks
const DUMMY_HASH = '$2a$12$000000000000000000000uGG3k3xK2CVTxXrT7VW2sGd1XrY6Ky';
export const { handlers, signIn, signOut, auth } = NextAuth({
adapter: PrismaAdapter(db),
// Note: We don't use PrismaAdapter with Credentials + JWT strategy
// The adapter is for OAuth providers that need to store accounts/sessions in DB
providers: [
// Google OAuth - uncomment and add credentials when ready
// Google({
// clientId: process.env.GOOGLE_CLIENT_ID,
// clientSecret: process.env.GOOGLE_CLIENT_SECRET,
// }),
// GitHub OAuth - uncomment and add credentials when ready
// GitHub({
// clientId: process.env.GITHUB_ID,
// clientSecret: process.env.GITHUB_SECRET,
// }),
// Email/Password - for development, add proper provider in production
Credentials({
name: 'credentials',
credentials: {
@@ -28,28 +18,43 @@ export const { handlers, signIn, signOut, auth } = NextAuth({
password: { label: 'Password', type: 'password' },
},
async authorize(credentials) {
// TODO: Implement proper credential validation
// This is a placeholder for development
if (!credentials?.email) {
if (!credentials?.email || !credentials?.password) {
return null;
}
// In production, verify password hash here
const email = credentials.email as string;
const password = credentials.password as string;
// Find user by email
const user = await db.user.findUnique({
where: { email: credentials.email as string },
where: { email: email.toLowerCase() },
});
return user;
// Always perform bcrypt comparison to prevent timing attacks
// If user doesn't exist, compare against dummy hash
const hashToCompare = user?.password || DUMMY_HASH;
const isValidPassword = await bcrypt.compare(password, hashToCompare);
// Only return user if they exist AND password is valid
if (!user || !user.password || !isValidPassword) {
return null;
}
return {
id: user.id,
name: user.name,
email: user.email,
image: user.image,
};
},
}),
],
session: {
strategy: 'jwt',
maxAge: 30 * 24 * 60 * 60, // 30 days
},
pages: {
signIn: '/login',
// signUp: '/register',
// error: '/auth/error',
},
callbacks: {
async session({ session, token }) {
+114
View File
@@ -0,0 +1,114 @@
import { db } from '@/lib/db';
interface RateLimitConfig {
windowMs: number; // Time window in milliseconds
maxRequests: number; // Max requests per window
}
interface RateLimitResult {
allowed: boolean;
remaining: number;
resetAt: Date;
}
// Default configs for different actions
export const RATE_LIMIT_CONFIGS: Record<string, RateLimitConfig> = {
register: { windowMs: 60 * 60 * 1000, maxRequests: 5 }, // 5 per hour
login: { windowMs: 15 * 60 * 1000, maxRequests: 10 }, // 10 per 15 min
api: { windowMs: 60 * 1000, maxRequests: 100 }, // 100 per minute
};
/**
* Check and update rate limit for a given key and action
* Uses PostgreSQL UNLOGGED table for performance
*/
export async function checkRateLimit(
key: string,
action: string,
config?: RateLimitConfig
): Promise<RateLimitResult> {
const { windowMs, maxRequests } = config || RATE_LIMIT_CONFIGS[action] || RATE_LIMIT_CONFIGS.api;
const windowSeconds = Math.floor(windowMs / 1000);
try {
// Atomic upsert with window check
// If window expired, reset count; otherwise increment
const result = await db.$queryRaw<Array<{
count: number;
window_start: Date;
is_new_window: boolean;
}>>`
INSERT INTO rate_limits (key, action, count, window_start)
VALUES (${key}, ${action}, 1, NOW())
ON CONFLICT (key, action) DO UPDATE SET
count = CASE
WHEN rate_limits.window_start < NOW() - (${windowSeconds} || ' seconds')::INTERVAL
THEN 1
ELSE rate_limits.count + 1
END,
window_start = CASE
WHEN rate_limits.window_start < NOW() - (${windowSeconds} || ' seconds')::INTERVAL
THEN NOW()
ELSE rate_limits.window_start
END
RETURNING count, window_start,
(window_start = NOW()) as is_new_window
`;
const record = result[0];
const resetAt = new Date(record.window_start.getTime() + windowMs);
const remaining = Math.max(0, maxRequests - record.count);
const allowed = record.count <= maxRequests;
return { allowed, remaining, resetAt };
} catch (error) {
// If table doesn't exist, allow the request but log warning
console.error('Rate limit check failed (table may not exist):', error);
return {
allowed: true,
remaining: maxRequests,
resetAt: new Date(Date.now() + windowMs),
};
}
}
/**
* Get client IP from request headers
* Handles common proxy headers
*/
export function getClientIp(request: Request): string {
const forwardedFor = request.headers.get('x-forwarded-for');
if (forwardedFor) {
return forwardedFor.split(',')[0].trim();
}
const realIp = request.headers.get('x-real-ip');
if (realIp) {
return realIp;
}
// Fallback for local development
return '127.0.0.1';
}
/**
* Create rate limit headers for response
*/
export function rateLimitHeaders(result: RateLimitResult, maxRequests: number): HeadersInit {
return {
'X-RateLimit-Limit': maxRequests.toString(),
'X-RateLimit-Remaining': result.remaining.toString(),
'X-RateLimit-Reset': Math.floor(result.resetAt.getTime() / 1000).toString(),
};
}
/**
* Cleanup old rate limit entries (call periodically)
*/
export async function cleanupRateLimits(): Promise<void> {
try {
await db.$executeRaw`SELECT cleanup_rate_limits()`;
} catch (error) {
console.error('Rate limit cleanup failed:', error);
}
}
+2
View File
@@ -13,6 +13,7 @@
"@base-ui/react": "^1.1.0",
"@prisma/adapter-pg": "^7.3.0",
"@prisma/client": "^7.3.0",
"bcryptjs": "^3.0.3",
"class-variance-authority": "^0.7.1",
"clsx": "^2.1.1",
"date-fns": "^4.1.0",
@@ -33,6 +34,7 @@
},
"devDependencies": {
"@tailwindcss/postcss": "^4",
"@types/bcryptjs": "^3.0.0",
"@types/node": "^20",
"@types/pg": "^8.16.0",
"@types/react": "^19",
+31
View File
@@ -0,0 +1,31 @@
-- Rate Limiting Table (UNLOGGED for performance)
-- Run this migration manually: psql $DATABASE_URL -f prisma/migrations/rate_limit.sql
-- Drop if exists (for re-running)
DROP TABLE IF EXISTS rate_limits;
-- Create UNLOGGED table for rate limiting
-- UNLOGGED = no WAL writes = faster, but data lost on crash (acceptable for rate limits)
CREATE UNLOGGED TABLE rate_limits (
id SERIAL PRIMARY KEY,
key VARCHAR(255) NOT NULL, -- e.g., "register:192.168.1.1" or "login:[email protected]"
action VARCHAR(50) NOT NULL, -- e.g., "register", "login", "api"
count INTEGER NOT NULL DEFAULT 1,
window_start TIMESTAMP NOT NULL DEFAULT NOW(),
-- Unique constraint for upsert operations
UNIQUE(key, action)
);
-- Index for fast lookups
CREATE INDEX idx_rate_limits_key_action ON rate_limits(key, action);
-- Index for cleanup operations
CREATE INDEX idx_rate_limits_window_start ON rate_limits(window_start);
-- Auto-cleanup function: removes expired entries
CREATE OR REPLACE FUNCTION cleanup_rate_limits() RETURNS void AS $$
BEGIN
DELETE FROM rate_limits WHERE window_start < NOW() - INTERVAL '1 hour';
END;
$$ LANGUAGE plpgsql;
+2 -1
View File
@@ -18,6 +18,7 @@ model User {
email String? @unique
emailVerified DateTime?
image String?
password String? // Hashed password for email/password auth
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
@@ -248,7 +249,7 @@ model ShareLink {
expiresAt DateTime? // Link expiration
maxUses Int? // Maximum number of uses
useCount Int @default(0)
password String? // Optional password protection
passwordHash String? // Bcrypt hash of optional password protection
// Settings
allowGuests Boolean @default(true) // Allow comments without account