feat: add rate limiting and harden auth/calendar endpoints
Add an in-memory sliding-window rate limiter and wire it into signup, resend-verification, reset-password, and verify-email routes. Store password reset tokens as SHA-256 hashes, raise bcrypt cost to 12, and switch verification codes to a CSPRNG. Stop echoing calendar access/refresh tokens back to API clients, bound the weather cache size, return JSON 401s for unauthenticated API calls instead of an HTML redirect, and drop the dead mock login route. v1.113.0
This commit is contained in:
parent
8c247e9115
commit
f6c4a21215
@ -1,23 +0,0 @@
|
|||||||
# Database (Use your Supabase details)
|
|
||||||
# Note: 6543 is for the pooled connection (PGBouncer)
|
|
||||||
DATABASE_URL="postgresql://postgres:[PASSWORD]@[LXC_IP]:6543/postgres?pgbouncer=true"
|
|
||||||
# Note: 5432 is for direct migrations
|
|
||||||
DIRECT_URL="postgresql://postgres:[PASSWORD]@[LXC_IP]:5432/postgres"
|
|
||||||
|
|
||||||
# App Settings
|
|
||||||
# NEXTAUTH_SECRET="f6e6f96860081d4da8b04e6e6f968600" # You can use any 32-char string
|
|
||||||
# NEXTAUTH_URL="http://192.168.178.8:3000"
|
|
||||||
# NEXT_PUBLIC_BASE_URL="http://192.168.178.8:3000"
|
|
||||||
|
|
||||||
# NextAuth
|
|
||||||
NEXTAUTH_SECRET=Ca7EoJzZSMJO2CkqwdWd
|
|
||||||
NEXTAUTH_URL=https://todo.martin-bierschenk.de
|
|
||||||
NEXT_PUBLIC_BASE_URL=https://todo.martin-bierschenk.de
|
|
||||||
|
|
||||||
|
|
||||||
# SMTP (Optional: For password resets/emails)
|
|
||||||
SMTP_HOST=w00ff033.kasserver.com
|
|
||||||
SMTP_PORT=587
|
|
||||||
SMTP_USERNAME=mail@carrylight.de
|
|
||||||
SMTP_PASSWORD=QijU8e2A8p3FE8WS8esR
|
|
||||||
SMTP_SECURE=true
|
|
||||||
10
.gitignore
vendored
10
.gitignore
vendored
@ -40,4 +40,12 @@ prisma/dev.db-journal
|
|||||||
# Next.js
|
# Next.js
|
||||||
.next/
|
.next/
|
||||||
|
|
||||||
certificates
|
certificates
|
||||||
|
|
||||||
|
# Design reference files (not tracked)
|
||||||
|
docs/design/
|
||||||
|
|
||||||
|
# Dokumentationen (nicht tracken)
|
||||||
|
APP_ERKLÄRUNG.md
|
||||||
|
.env.production.save
|
||||||
|
.env*.save
|
||||||
|
|||||||
@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"name": "my-weekly-todo-list",
|
"name": "my-weekly-todo-list",
|
||||||
"version": "1.102.0",
|
"version": "1.113.0",
|
||||||
"description": "A web-based weekly task management application that organizes to-dos and calendar events in a single, intuitive weekly view",
|
"description": "A web-based weekly task management application that organizes to-dos and calendar events in a single, intuitive weekly view",
|
||||||
"main": "index.js",
|
"main": "index.js",
|
||||||
"scripts": {
|
"scripts": {
|
||||||
|
|||||||
@ -1,55 +0,0 @@
|
|||||||
import { NextRequest, NextResponse } from 'next/server';
|
|
||||||
export const dynamic = 'force-dynamic';
|
|
||||||
import { compare } from 'bcryptjs';
|
|
||||||
|
|
||||||
export async function POST(request: NextRequest) {
|
|
||||||
try {
|
|
||||||
const { email, password } = await request.json();
|
|
||||||
|
|
||||||
// Basic validation
|
|
||||||
if (!email || !password) {
|
|
||||||
return NextResponse.json(
|
|
||||||
{ error: 'Email and password are required' },
|
|
||||||
{ status: 400 }
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
// In a real app, you would fetch user from database here
|
|
||||||
// For now, we'll simulate a user lookup
|
|
||||||
console.log('Attempting to login user with email:', email);
|
|
||||||
|
|
||||||
// Simulate database lookup
|
|
||||||
const mockStoredPassword = '$2b$10$examplehashedpassword'; // This would come from DB
|
|
||||||
|
|
||||||
// Compare passwords
|
|
||||||
const isValid = await compare(password, mockStoredPassword);
|
|
||||||
|
|
||||||
if (!isValid) {
|
|
||||||
return NextResponse.json(
|
|
||||||
{ error: 'Invalid email or password' },
|
|
||||||
{ status: 401 }
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
// Create a mock JWT token (in a real app, you would use a proper JWT library)
|
|
||||||
const token = `mock-jwt-token-${Date.now()}`;
|
|
||||||
|
|
||||||
// Set cookie
|
|
||||||
const response = NextResponse.json({ message: 'Login successful' });
|
|
||||||
response.cookies.set('auth_token', token, {
|
|
||||||
httpOnly: true,
|
|
||||||
maxAge: 15 * 60, // 15 minutes
|
|
||||||
path: '/',
|
|
||||||
secure: process.env.NODE_ENV === 'production',
|
|
||||||
sameSite: 'strict'
|
|
||||||
});
|
|
||||||
|
|
||||||
return response;
|
|
||||||
} catch (error) {
|
|
||||||
console.error('Login error:', error);
|
|
||||||
return NextResponse.json(
|
|
||||||
{ error: 'Internal server error' },
|
|
||||||
{ status: 500 }
|
|
||||||
);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@ -2,9 +2,16 @@ import { NextRequest, NextResponse } from 'next/server';
|
|||||||
export const dynamic = 'force-dynamic';
|
export const dynamic = 'force-dynamic';
|
||||||
import { prisma } from '@/lib/prisma';
|
import { prisma } from '@/lib/prisma';
|
||||||
import { sendVerificationEmail, generateVerificationCode, generateVerificationToken } from '@/lib/email';
|
import { sendVerificationEmail, generateVerificationCode, generateVerificationToken } from '@/lib/email';
|
||||||
|
import { rateLimit, clientIp, tooManyRequests } from '@/lib/rate-limit';
|
||||||
|
|
||||||
export async function POST(request: NextRequest) {
|
export async function POST(request: NextRequest) {
|
||||||
try {
|
try {
|
||||||
|
const ip = clientIp(request);
|
||||||
|
const limited = rateLimit(`resend-verification:${ip}`, 5, 15 * 60 * 1000);
|
||||||
|
if (!limited.ok) {
|
||||||
|
return tooManyRequests(limited.retryAfterSeconds);
|
||||||
|
}
|
||||||
|
|
||||||
const { email } = await request.json();
|
const { email } = await request.json();
|
||||||
|
|
||||||
if (!email) {
|
if (!email) {
|
||||||
|
|||||||
@ -3,10 +3,25 @@ export const dynamic = 'force-dynamic';
|
|||||||
import { hash } from 'bcryptjs';
|
import { hash } from 'bcryptjs';
|
||||||
import { prisma } from '@/lib/prisma';
|
import { prisma } from '@/lib/prisma';
|
||||||
import { sendPasswordResetEmail } from '@/lib/email';
|
import { sendPasswordResetEmail } from '@/lib/email';
|
||||||
|
import { rateLimit, clientIp, tooManyRequests } from '@/lib/rate-limit';
|
||||||
import crypto from 'crypto';
|
import crypto from 'crypto';
|
||||||
|
|
||||||
|
// Reset tokens are stored as SHA-256 hashes so a leaked DB dump cannot be
|
||||||
|
// used to take over accounts. The raw token only ever exists in the email.
|
||||||
|
function hashToken(token: string): string {
|
||||||
|
return crypto.createHash('sha256').update(token).digest('hex');
|
||||||
|
}
|
||||||
|
|
||||||
|
const MIN_PASSWORD_LENGTH = 8; // keep in sync with signup
|
||||||
|
|
||||||
export async function POST(request: NextRequest) {
|
export async function POST(request: NextRequest) {
|
||||||
try {
|
try {
|
||||||
|
const ip = clientIp(request);
|
||||||
|
const limited = rateLimit(`reset-password:${ip}`, 10, 15 * 60 * 1000);
|
||||||
|
if (!limited.ok) {
|
||||||
|
return tooManyRequests(limited.retryAfterSeconds);
|
||||||
|
}
|
||||||
|
|
||||||
const body = await request.json();
|
const body = await request.json();
|
||||||
const { email, token, newPassword } = body;
|
const { email, token, newPassword } = body;
|
||||||
|
|
||||||
@ -25,7 +40,7 @@ export async function POST(request: NextRequest) {
|
|||||||
await prisma.user.update({
|
await prisma.user.update({
|
||||||
where: { email },
|
where: { email },
|
||||||
data: {
|
data: {
|
||||||
passwordResetToken: resetToken,
|
passwordResetToken: hashToken(resetToken),
|
||||||
passwordResetExpires: resetExpires,
|
passwordResetExpires: resetExpires,
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
@ -37,16 +52,16 @@ export async function POST(request: NextRequest) {
|
|||||||
|
|
||||||
// CASE 2: Reset password (token + newPassword provided)
|
// CASE 2: Reset password (token + newPassword provided)
|
||||||
if (token && newPassword) {
|
if (token && newPassword) {
|
||||||
if (newPassword.length < 6) {
|
if (newPassword.length < MIN_PASSWORD_LENGTH) {
|
||||||
return NextResponse.json(
|
return NextResponse.json(
|
||||||
{ error: 'Password must be at least 6 characters' },
|
{ error: `Password must be at least ${MIN_PASSWORD_LENGTH} characters` },
|
||||||
{ status: 400 }
|
{ status: 400 }
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
const user = await (prisma.user as any).findFirst({
|
const user = await prisma.user.findFirst({
|
||||||
where: {
|
where: {
|
||||||
passwordResetToken: token,
|
passwordResetToken: hashToken(token),
|
||||||
passwordResetExpires: { gt: new Date() },
|
passwordResetExpires: { gt: new Date() },
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
@ -58,7 +73,7 @@ export async function POST(request: NextRequest) {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
const hashedPassword = await hash(newPassword, 10);
|
const hashedPassword = await hash(newPassword, 12);
|
||||||
|
|
||||||
await prisma.user.update({
|
await prisma.user.update({
|
||||||
where: { id: user.id },
|
where: { id: user.id },
|
||||||
|
|||||||
@ -4,9 +4,20 @@ import { prisma } from '@/lib/prisma';
|
|||||||
import { hash } from 'bcryptjs';
|
import { hash } from 'bcryptjs';
|
||||||
import { sendVerificationEmail, generateVerificationCode, generateVerificationToken } from '@/lib/email';
|
import { sendVerificationEmail, generateVerificationCode, generateVerificationToken } from '@/lib/email';
|
||||||
import { languageFromAcceptLanguage } from '@/lib/emailTemplates';
|
import { languageFromAcceptLanguage } from '@/lib/emailTemplates';
|
||||||
|
import { rateLimit, clientIp, tooManyRequests } from '@/lib/rate-limit';
|
||||||
|
|
||||||
|
// Intentionally loose — real validation happens via the verification email.
|
||||||
|
// This only rejects obvious garbage before it reaches the DB and SMTP.
|
||||||
|
const EMAIL_PATTERN = /^[^\s@]+@[^\s@]+\.[^\s@]+$/;
|
||||||
|
|
||||||
export async function POST(request: NextRequest) {
|
export async function POST(request: NextRequest) {
|
||||||
try {
|
try {
|
||||||
|
const ip = clientIp(request);
|
||||||
|
const limited = rateLimit(`signup:${ip}`, 10, 60 * 60 * 1000);
|
||||||
|
if (!limited.ok) {
|
||||||
|
return tooManyRequests(limited.retryAfterSeconds);
|
||||||
|
}
|
||||||
|
|
||||||
const body = await request.json();
|
const body = await request.json();
|
||||||
const { name, email, password } = body;
|
const { name, email, password } = body;
|
||||||
// Prefer the explicit language from the form (filled in by the signup
|
// Prefer the explicit language from the form (filled in by the signup
|
||||||
@ -21,6 +32,13 @@ export async function POST(request: NextRequest) {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (typeof email !== 'string' || !EMAIL_PATTERN.test(email) || email.length > 254) {
|
||||||
|
return NextResponse.json(
|
||||||
|
{ error: 'Please enter a valid email address' },
|
||||||
|
{ status: 400 }
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
if (password.length < 8) {
|
if (password.length < 8) {
|
||||||
return NextResponse.json(
|
return NextResponse.json(
|
||||||
{ error: 'Password must be at least 8 characters' },
|
{ error: 'Password must be at least 8 characters' },
|
||||||
|
|||||||
@ -1,12 +1,24 @@
|
|||||||
import { NextRequest, NextResponse } from 'next/server';
|
import { NextRequest, NextResponse } from 'next/server';
|
||||||
export const dynamic = 'force-dynamic';
|
export const dynamic = 'force-dynamic';
|
||||||
import { prisma } from '@/lib/prisma';
|
import { prisma } from '@/lib/prisma';
|
||||||
|
import { rateLimit, clientIp, tooManyRequests, RateLimitResult } from '@/lib/rate-limit';
|
||||||
|
|
||||||
// POST: Verify via 6-digit code
|
// POST: Verify via 6-digit code
|
||||||
export async function POST(request: NextRequest) {
|
export async function POST(request: NextRequest) {
|
||||||
try {
|
try {
|
||||||
const { email, code } = await request.json();
|
const { email, code } = await request.json();
|
||||||
|
|
||||||
|
// A 6-digit code has only 10^6 combinations — without attempt limiting
|
||||||
|
// it is brute-forceable in minutes. Limit per email AND per IP.
|
||||||
|
const ip = clientIp(request);
|
||||||
|
const perIp = rateLimit(`verify-email:ip:${ip}`, 10, 15 * 60 * 1000);
|
||||||
|
const perEmail: RateLimitResult = email
|
||||||
|
? rateLimit(`verify-email:email:${String(email).toLowerCase()}`, 5, 15 * 60 * 1000)
|
||||||
|
: { ok: true };
|
||||||
|
if (!perIp.ok || !perEmail.ok) {
|
||||||
|
return tooManyRequests(perIp.retryAfterSeconds ?? perEmail.retryAfterSeconds);
|
||||||
|
}
|
||||||
|
|
||||||
if (!email || !code) {
|
if (!email || !code) {
|
||||||
return NextResponse.json(
|
return NextResponse.json(
|
||||||
{ error: 'Email and code are required' },
|
{ error: 'Email and code are required' },
|
||||||
@ -97,6 +109,12 @@ export async function GET(request: NextRequest) {
|
|||||||
// the user actually clicks the button — survives scanner pre-fetches).
|
// the user actually clicks the button — survives scanner pre-fetches).
|
||||||
export async function PATCH(request: NextRequest) {
|
export async function PATCH(request: NextRequest) {
|
||||||
try {
|
try {
|
||||||
|
const ip = clientIp(request);
|
||||||
|
const limited = rateLimit(`verify-email-token:${ip}`, 10, 15 * 60 * 1000);
|
||||||
|
if (!limited.ok) {
|
||||||
|
return tooManyRequests(limited.retryAfterSeconds);
|
||||||
|
}
|
||||||
|
|
||||||
const { token } = await request.json();
|
const { token } = await request.json();
|
||||||
if (!token) {
|
if (!token) {
|
||||||
return NextResponse.json({ error: 'Token is required' }, { status: 400 });
|
return NextResponse.json({ error: 'Token is required' }, { status: 400 });
|
||||||
|
|||||||
@ -177,7 +177,17 @@ export async function PATCH(request: NextRequest) {
|
|||||||
refreshConnectionCache(rc, tMin, tMax)
|
refreshConnectionCache(rc, tMin, tMax)
|
||||||
.catch(e => console.error('[CACHE] Post-selection refresh failed:', e));
|
.catch(e => console.error('[CACHE] Post-selection refresh failed:', e));
|
||||||
|
|
||||||
return NextResponse.json({ success: true, connection: updated });
|
// Never echo accessToken/refreshToken back to the client
|
||||||
|
return NextResponse.json({
|
||||||
|
success: true,
|
||||||
|
connection: {
|
||||||
|
id: updated.id,
|
||||||
|
provider: updated.provider,
|
||||||
|
calendars: updated.calendars,
|
||||||
|
createdAt: updated.createdAt,
|
||||||
|
expiresAt: updated.expiresAt,
|
||||||
|
},
|
||||||
|
});
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error('Error updating calendar connection:', error);
|
console.error('Error updating calendar connection:', error);
|
||||||
return NextResponse.json(
|
return NextResponse.json(
|
||||||
|
|||||||
@ -76,7 +76,16 @@ export async function POST(req: Request) {
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
return NextResponse.json({ success: true, connection });
|
// Never echo accessToken (username:password) back to the client
|
||||||
|
return NextResponse.json({
|
||||||
|
success: true,
|
||||||
|
connection: {
|
||||||
|
id: connection.id,
|
||||||
|
provider: connection.provider,
|
||||||
|
calendars: connection.calendars,
|
||||||
|
createdAt: connection.createdAt,
|
||||||
|
},
|
||||||
|
});
|
||||||
} catch (error: any) {
|
} catch (error: any) {
|
||||||
console.error('Error connecting Synology Calendar:', error);
|
console.error('Error connecting Synology Calendar:', error);
|
||||||
return NextResponse.json(
|
return NextResponse.json(
|
||||||
|
|||||||
@ -8,6 +8,21 @@ export const dynamic = 'force-dynamic';
|
|||||||
// In-memory cache: { key: { data, fetchedAt } }
|
// In-memory cache: { key: { data, fetchedAt } }
|
||||||
const weatherCache = new Map<string, { data: any; fetchedAt: number }>();
|
const weatherCache = new Map<string, { data: any; fetchedAt: number }>();
|
||||||
const CACHE_TTL_MS = 15 * 60 * 1000; // 15 minutes
|
const CACHE_TTL_MS = 15 * 60 * 1000; // 15 minutes
|
||||||
|
const CACHE_MAX_ENTRIES = 500; // one entry per user/date-range — bound the Map
|
||||||
|
|
||||||
|
function evictStaleEntries() {
|
||||||
|
const now = Date.now();
|
||||||
|
for (const [key, entry] of weatherCache) {
|
||||||
|
if (now - entry.fetchedAt >= CACHE_TTL_MS) weatherCache.delete(key);
|
||||||
|
}
|
||||||
|
// Still over budget after TTL eviction: drop oldest entries
|
||||||
|
// (Map iterates in insertion order).
|
||||||
|
while (weatherCache.size >= CACHE_MAX_ENTRIES) {
|
||||||
|
const oldest = weatherCache.keys().next().value;
|
||||||
|
if (oldest === undefined) break;
|
||||||
|
weatherCache.delete(oldest);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
export async function GET(request: NextRequest) {
|
export async function GET(request: NextRequest) {
|
||||||
const session = await getServerSession(authOptions);
|
const session = await getServerSession(authOptions);
|
||||||
@ -67,6 +82,7 @@ export async function GET(request: NextRequest) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
const data = { hourly, timezone: raw.timezone };
|
const data = { hourly, timezone: raw.timezone };
|
||||||
|
evictStaleEntries();
|
||||||
weatherCache.set(cacheKey, { data, fetchedAt: Date.now() });
|
weatherCache.set(cacheKey, { data, fetchedAt: Date.now() });
|
||||||
|
|
||||||
return NextResponse.json(data);
|
return NextResponse.json(data);
|
||||||
|
|||||||
@ -1,4 +1,5 @@
|
|||||||
import nodemailer from 'nodemailer';
|
import nodemailer from 'nodemailer';
|
||||||
|
import { randomInt } from 'crypto';
|
||||||
import { getVerificationCopy, getResetCopy } from './emailTemplates';
|
import { getVerificationCopy, getResetCopy } from './emailTemplates';
|
||||||
|
|
||||||
let _transporter: nodemailer.Transporter | null = null;
|
let _transporter: nodemailer.Transporter | null = null;
|
||||||
@ -185,7 +186,9 @@ export async function sendPasswordResetEmail(email: string, token: string, langu
|
|||||||
}
|
}
|
||||||
|
|
||||||
export function generateVerificationCode(): string {
|
export function generateVerificationCode(): string {
|
||||||
return Math.floor(100000 + Math.random() * 900000).toString();
|
// CSPRNG-backed; Math.random() is predictable and must never be used
|
||||||
|
// for security tokens.
|
||||||
|
return randomInt(100000, 1000000).toString();
|
||||||
}
|
}
|
||||||
|
|
||||||
export function generateVerificationToken(): string {
|
export function generateVerificationToken(): string {
|
||||||
|
|||||||
75
src/lib/rate-limit.ts
Normal file
75
src/lib/rate-limit.ts
Normal file
@ -0,0 +1,75 @@
|
|||||||
|
/**
|
||||||
|
* Minimal in-memory sliding-window rate limiter for auth-sensitive routes.
|
||||||
|
*
|
||||||
|
* Works per Node.js process. If the app ever scales to multiple instances,
|
||||||
|
* swap the backing store for Redis — the call sites won't need to change.
|
||||||
|
*/
|
||||||
|
|
||||||
|
type WindowEntry = { timestamps: number[] };
|
||||||
|
|
||||||
|
const buckets = new Map<string, WindowEntry>();
|
||||||
|
|
||||||
|
// Purge stale buckets periodically so the Map can't grow without bound.
|
||||||
|
const CLEANUP_INTERVAL_MS = 10 * 60 * 1000;
|
||||||
|
let lastCleanup = Date.now();
|
||||||
|
|
||||||
|
function cleanup(windowMs: number) {
|
||||||
|
const now = Date.now();
|
||||||
|
if (now - lastCleanup < CLEANUP_INTERVAL_MS) return;
|
||||||
|
lastCleanup = now;
|
||||||
|
for (const [key, entry] of buckets) {
|
||||||
|
entry.timestamps = entry.timestamps.filter(t => now - t < windowMs);
|
||||||
|
if (entry.timestamps.length === 0) buckets.delete(key);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface RateLimitResult {
|
||||||
|
ok: boolean;
|
||||||
|
/** Seconds until the client may retry (only set when blocked). */
|
||||||
|
retryAfterSeconds?: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Record a hit for `key` and report whether it exceeds `limit` hits
|
||||||
|
* within the trailing `windowMs` window.
|
||||||
|
*/
|
||||||
|
export function rateLimit(key: string, limit: number, windowMs: number): RateLimitResult {
|
||||||
|
const now = Date.now();
|
||||||
|
cleanup(windowMs);
|
||||||
|
|
||||||
|
const entry = buckets.get(key) ?? { timestamps: [] };
|
||||||
|
entry.timestamps = entry.timestamps.filter(t => now - t < windowMs);
|
||||||
|
|
||||||
|
if (entry.timestamps.length >= limit) {
|
||||||
|
const oldest = entry.timestamps[0];
|
||||||
|
return {
|
||||||
|
ok: false,
|
||||||
|
retryAfterSeconds: Math.max(1, Math.ceil((oldest + windowMs - now) / 1000)),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
entry.timestamps.push(now);
|
||||||
|
buckets.set(key, entry);
|
||||||
|
return { ok: true };
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Best-effort client IP extraction behind a reverse proxy. */
|
||||||
|
export function clientIp(request: Request): string {
|
||||||
|
const forwarded = request.headers.get('x-forwarded-for');
|
||||||
|
if (forwarded) return forwarded.split(',')[0].trim();
|
||||||
|
return request.headers.get('x-real-ip') ?? 'unknown';
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Standard 429 payload so all auth routes respond consistently. */
|
||||||
|
export function tooManyRequests(retryAfterSeconds = 60) {
|
||||||
|
return new Response(
|
||||||
|
JSON.stringify({ error: 'Too many requests. Please try again later.' }),
|
||||||
|
{
|
||||||
|
status: 429,
|
||||||
|
headers: {
|
||||||
|
'Content-Type': 'application/json',
|
||||||
|
'Retry-After': String(retryAfterSeconds),
|
||||||
|
},
|
||||||
|
}
|
||||||
|
);
|
||||||
|
}
|
||||||
@ -20,8 +20,13 @@ export async function middleware(request: NextRequest) {
|
|||||||
secret: process.env.NEXTAUTH_SECRET,
|
secret: process.env.NEXTAUTH_SECRET,
|
||||||
});
|
});
|
||||||
|
|
||||||
// Redirect to login if not authenticated
|
|
||||||
if (!token && !request.nextUrl.pathname.startsWith('/auth')) {
|
if (!token && !request.nextUrl.pathname.startsWith('/auth')) {
|
||||||
|
// API callers get a proper 401 instead of a redirect to an HTML page —
|
||||||
|
// otherwise fetch() calls receive a 200 with the login page as body.
|
||||||
|
if (request.nextUrl.pathname.startsWith('/api')) {
|
||||||
|
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 });
|
||||||
|
}
|
||||||
|
// Browser navigation: redirect to login
|
||||||
const url = request.nextUrl.clone();
|
const url = request.nextUrl.clone();
|
||||||
url.pathname = '/auth/login';
|
url.pathname = '/auth/login';
|
||||||
url.searchParams.set('callbackUrl', request.nextUrl.pathname);
|
url.searchParams.set('callbackUrl', request.nextUrl.pathname);
|
||||||
|
|||||||
Loading…
Reference in New Issue
Block a user