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,
}: { }: {
children: React.ReactNode; children: React.ReactNode;
}) { }) {
const session = await auth();
// If already logged in, redirect to dashboard
if (session?.user) {
redirect('/dashboard');
}
return <>{children}</>; return <>{children}</>;
} }
+122 -116
View File
@@ -1,51 +1,146 @@
'use client'; 'use client';
import { useState } from 'react'; import { useState, useEffect, Suspense } from 'react';
import Link from 'next/link'; import Link from 'next/link';
import { useRouter } from 'next/navigation'; import { useRouter, useSearchParams } from 'next/navigation';
import { Video, Loader2, Github, Mail } from 'lucide-react'; import { Video, Loader2 } from 'lucide-react';
import { Button } from '@/components/ui/button'; import { Button } from '@/components/ui/button';
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card'; import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card';
import { Input } from '@/components/ui/input'; import { Input } from '@/components/ui/input';
import { Label } from '@/components/ui/label'; 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 router = useRouter();
const searchParams = useSearchParams();
const [isLoading, setIsLoading] = useState(false); const [isLoading, setIsLoading] = useState(false);
const [error, setError] = useState('');
const [email, setEmail] = useState(''); const [email, setEmail] = useState('');
const [password, setPassword] = 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) => { const handleEmailLogin = async (e: React.FormEvent) => {
e.preventDefault(); e.preventDefault();
setIsLoading(true); setIsLoading(true);
setError('');
try { try {
// TODO: Implement actual sign in const result = await signIn('credentials', {
// await signIn('credentials', { email, password, redirect: false }); email,
await new Promise(resolve => setTimeout(resolve, 500)); password,
redirect: false,
});
if (result?.error) {
setError('Invalid email or password');
return;
}
router.push('/dashboard'); router.push('/dashboard');
} catch (error) { router.refresh();
console.error('Login failed:', error); } catch {
setError('Something went wrong. Please try again.');
} finally { } finally {
setIsLoading(false); setIsLoading(false);
} }
}; };
const handleOAuthLogin = async (provider: string) => { return (
setIsLoading(true); <Card>
try { <CardHeader className="text-center">
// TODO: Implement OAuth <CardTitle>Welcome back</CardTitle>
// await signIn(provider, { callbackUrl: '/dashboard' }); <CardDescription>
await new Promise(resolve => setTimeout(resolve, 500)); Sign in to your account to continue
router.push('/dashboard'); </CardDescription>
} catch (error) { </CardHeader>
console.error('OAuth login failed:', error); <CardContent>
} finally { {showSuccess && (
setIsLoading(false); <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 ( return (
<div className="min-h-screen flex items-center justify-center p-4 bg-background"> <div className="min-h-screen flex items-center justify-center p-4 bg-background">
<div className="w-full max-w-md"> <div className="w-full max-w-md">
@@ -55,98 +150,9 @@ export default function LoginPage() {
<span className="font-bold text-2xl">OpenFrame</span> <span className="font-bold text-2xl">OpenFrame</span>
</Link> </Link>
<Card> <Suspense fallback={<LoginFormSkeleton />}>
<CardHeader className="text-center"> <LoginForm />
<CardTitle>Welcome back</CardTitle> </Suspense>
<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>
<p className="text-center text-xs text-muted-foreground mt-4"> <p className="text-center text-xs text-muted-foreground mt-4">
By continuing, you agree to our Terms of Service and Privacy Policy 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 Link from 'next/link';
import { Video, MessageSquare, Mic, Share2, ArrowRight, Play } from 'lucide-react'; import { Video, MessageSquare, Mic, Share2, ArrowRight, Play } from 'lucide-react';
import { Button } from '@/components/ui/button'; 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 ( return (
<div className="min-h-screen bg-background"> <div className="min-h-screen bg-background">
{/* Header */} {/* Header */}
@@ -13,12 +17,25 @@ export default function HomePage() {
<span className="font-bold text-xl">OpenFrame</span> <span className="font-bold text-xl">OpenFrame</span>
</Link> </Link>
<div className="flex items-center gap-4"> <div className="flex items-center gap-4">
<Button asChild variant="ghost"> {isLoggedIn ? (
<Link href="/login">Sign in</Link> <>
</Button> <Button asChild variant="ghost">
<Button asChild> <Link href="/dashboard">Dashboard</Link>
<Link href="/dashboard">Get Started</Link> </Button>
</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>
</div> </div>
</header> </header>
@@ -31,16 +48,25 @@ export default function HomePage() {
<span className="text-primary">reimagined</span> <span className="text-primary">reimagined</span>
</h1> </h1>
<p className="text-xl text-muted-foreground max-w-2xl"> <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. Share with your team and clients, iterate faster.
</p> </p>
<div className="flex flex-col sm:flex-row gap-4"> <div className="flex flex-col sm:flex-row gap-4">
<Button asChild size="lg"> {isLoggedIn ? (
<Link href="/dashboard"> <Button asChild size="lg">
Start for free <Link href="/dashboard">
<ArrowRight className="h-4 w-4 ml-2" /> Go to Dashboard
</Link> <ArrowRight className="h-4 w-4 ml-2" />
</Button> </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"> <Button asChild variant="outline" size="lg">
<Link href="#features"> <Link href="#features">
<Play className="h-4 w-4 mr-2" /> <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. Join teams who have already switched to OpenFrame for faster, clearer video feedback.
</p> </p>
<Button asChild size="lg"> <Button asChild size="lg">
<Link href="/dashboard"> <Link href={isLoggedIn ? '/dashboard' : '/register'}>
Get started for free {isLoggedIn ? 'Go to Dashboard' : 'Get started for free'}
<ArrowRight className="h-4 w-4 ml-2" /> <ArrowRight className="h-4 w-4 ml-2" />
</Link> </Link>
</Button> </Button>
@@ -112,11 +138,11 @@ export default function HomePage() {
); );
} }
function FeatureCard({ function FeatureCard({
icon: Icon, icon: Icon,
title, title,
description description
}: { }: {
icon: React.ComponentType<{ className?: string }>; icon: React.ComponentType<{ className?: string }>;
title: string; title: string;
description: string; description: string;
+6
View File
@@ -9,6 +9,7 @@
"@base-ui/react": "^1.1.0", "@base-ui/react": "^1.1.0",
"@prisma/adapter-pg": "^7.3.0", "@prisma/adapter-pg": "^7.3.0",
"@prisma/client": "^7.3.0", "@prisma/client": "^7.3.0",
"bcryptjs": "^3.0.3",
"class-variance-authority": "^0.7.1", "class-variance-authority": "^0.7.1",
"clsx": "^2.1.1", "clsx": "^2.1.1",
"date-fns": "^4.1.0", "date-fns": "^4.1.0",
@@ -29,6 +30,7 @@
}, },
"devDependencies": { "devDependencies": {
"@tailwindcss/postcss": "^4", "@tailwindcss/postcss": "^4",
"@types/bcryptjs": "^3.0.0",
"@types/node": "^20", "@types/node": "^20",
"@types/pg": "^8.16.0", "@types/pg": "^8.16.0",
"@types/react": "^19", "@types/react": "^19",
@@ -489,6 +491,8 @@
"@tybys/wasm-util": ["@tybys/[email protected]", "", { "dependencies": { "tslib": "^2.4.0" } }, "sha512-9tTaPJLSiejZKx+Bmog4uSubteqTvFrVrURwkmHixBo0G4seD0zUxp98E1DzUBJxLQ3NPwXrGKDiVjwx/DpPsg=="], "@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/estree": ["@types/[email protected]", "", {}, "sha512-dWHzHa2WqEXI/O1E9OjrocMTKJl2mSrEolh1Iomrv6U+JuNwaHXsXx9bLu5gG7BUWFIN0skIQJQ/L1rIex4X6w=="],
"@types/json-schema": ["@types/[email protected]", "", {}, "sha512-5+fP8P8MFNC+AyZCDxrB2pkZFPGzqQWUzpSeuuVLvm8VMcorNYavBqoFcxK8bQz4Qsbn4oUEEem4wDLfcysGHA=="], "@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=="], "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=="], "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=="], "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 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 Credentials from 'next-auth/providers/credentials';
import bcrypt from 'bcryptjs';
import { db } from '@/lib/db'; 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({ 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: [ 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({ Credentials({
name: 'credentials', name: 'credentials',
credentials: { credentials: {
@@ -28,28 +18,43 @@ export const { handlers, signIn, signOut, auth } = NextAuth({
password: { label: 'Password', type: 'password' }, password: { label: 'Password', type: 'password' },
}, },
async authorize(credentials) { async authorize(credentials) {
// TODO: Implement proper credential validation if (!credentials?.email || !credentials?.password) {
// This is a placeholder for development
if (!credentials?.email) {
return null; 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({ 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: { session: {
strategy: 'jwt', strategy: 'jwt',
maxAge: 30 * 24 * 60 * 60, // 30 days
}, },
pages: { pages: {
signIn: '/login', signIn: '/login',
// signUp: '/register',
// error: '/auth/error',
}, },
callbacks: { callbacks: {
async session({ session, token }) { 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", "@base-ui/react": "^1.1.0",
"@prisma/adapter-pg": "^7.3.0", "@prisma/adapter-pg": "^7.3.0",
"@prisma/client": "^7.3.0", "@prisma/client": "^7.3.0",
"bcryptjs": "^3.0.3",
"class-variance-authority": "^0.7.1", "class-variance-authority": "^0.7.1",
"clsx": "^2.1.1", "clsx": "^2.1.1",
"date-fns": "^4.1.0", "date-fns": "^4.1.0",
@@ -33,6 +34,7 @@
}, },
"devDependencies": { "devDependencies": {
"@tailwindcss/postcss": "^4", "@tailwindcss/postcss": "^4",
"@types/bcryptjs": "^3.0.0",
"@types/node": "^20", "@types/node": "^20",
"@types/pg": "^8.16.0", "@types/pg": "^8.16.0",
"@types/react": "^19", "@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 email String? @unique
emailVerified DateTime? emailVerified DateTime?
image String? image String?
password String? // Hashed password for email/password auth
createdAt DateTime @default(now()) createdAt DateTime @default(now())
updatedAt DateTime @updatedAt updatedAt DateTime @updatedAt
@@ -248,7 +249,7 @@ model ShareLink {
expiresAt DateTime? // Link expiration expiresAt DateTime? // Link expiration
maxUses Int? // Maximum number of uses maxUses Int? // Maximum number of uses
useCount Int @default(0) useCount Int @default(0)
password String? // Optional password protection passwordHash String? // Bcrypt hash of optional password protection
// Settings // Settings
allowGuests Boolean @default(true) // Allow comments without account allowGuests Boolean @default(true) // Allow comments without account