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:
mARTin-B78 2026-07-04 18:11:44 +02:00
parent 8c247e9115
commit f6c4a21215
14 changed files with 196 additions and 90 deletions

View File

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

8
.gitignore vendored
View File

@ -41,3 +41,11 @@ prisma/dev.db-journal
.next/ .next/
certificates certificates
# Design reference files (not tracked)
docs/design/
# Dokumentationen (nicht tracken)
APP_ERKLÄRUNG.md
.env.production.save
.env*.save

View File

@ -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": {

View File

@ -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 }
);
}
}

View File

@ -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) {

View File

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

View File

@ -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' },

View File

@ -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 });

View File

@ -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(

View File

@ -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(

View File

@ -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);

View File

@ -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
View 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),
},
}
);
}

View File

@ -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);