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

10
.gitignore vendored
View File

@ -40,4 +40,12 @@ prisma/dev.db-journal
# Next.js
.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",
"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",
"main": "index.js",
"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';
import { prisma } from '@/lib/prisma';
import { sendVerificationEmail, generateVerificationCode, generateVerificationToken } from '@/lib/email';
import { rateLimit, clientIp, tooManyRequests } from '@/lib/rate-limit';
export async function POST(request: NextRequest) {
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();
if (!email) {

View File

@ -3,10 +3,25 @@ export const dynamic = 'force-dynamic';
import { hash } from 'bcryptjs';
import { prisma } from '@/lib/prisma';
import { sendPasswordResetEmail } from '@/lib/email';
import { rateLimit, clientIp, tooManyRequests } from '@/lib/rate-limit';
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) {
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 { email, token, newPassword } = body;
@ -25,7 +40,7 @@ export async function POST(request: NextRequest) {
await prisma.user.update({
where: { email },
data: {
passwordResetToken: resetToken,
passwordResetToken: hashToken(resetToken),
passwordResetExpires: resetExpires,
},
});
@ -37,16 +52,16 @@ export async function POST(request: NextRequest) {
// CASE 2: Reset password (token + newPassword provided)
if (token && newPassword) {
if (newPassword.length < 6) {
if (newPassword.length < MIN_PASSWORD_LENGTH) {
return NextResponse.json(
{ error: 'Password must be at least 6 characters' },
{ error: `Password must be at least ${MIN_PASSWORD_LENGTH} characters` },
{ status: 400 }
);
}
const user = await (prisma.user as any).findFirst({
const user = await prisma.user.findFirst({
where: {
passwordResetToken: token,
passwordResetToken: hashToken(token),
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({
where: { id: user.id },

View File

@ -4,9 +4,20 @@ import { prisma } from '@/lib/prisma';
import { hash } from 'bcryptjs';
import { sendVerificationEmail, generateVerificationCode, generateVerificationToken } from '@/lib/email';
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) {
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 { name, email, password } = body;
// 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) {
return NextResponse.json(
{ error: 'Password must be at least 8 characters' },

View File

@ -1,12 +1,24 @@
import { NextRequest, NextResponse } from 'next/server';
export const dynamic = 'force-dynamic';
import { prisma } from '@/lib/prisma';
import { rateLimit, clientIp, tooManyRequests, RateLimitResult } from '@/lib/rate-limit';
// POST: Verify via 6-digit code
export async function POST(request: NextRequest) {
try {
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) {
return NextResponse.json(
{ 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).
export async function PATCH(request: NextRequest) {
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();
if (!token) {
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)
.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) {
console.error('Error updating calendar connection:', error);
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) {
console.error('Error connecting Synology Calendar:', error);
return NextResponse.json(

View File

@ -8,6 +8,21 @@ export const dynamic = 'force-dynamic';
// In-memory cache: { key: { data, fetchedAt } }
const weatherCache = new Map<string, { data: any; fetchedAt: number }>();
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) {
const session = await getServerSession(authOptions);
@ -67,6 +82,7 @@ export async function GET(request: NextRequest) {
}
const data = { hourly, timezone: raw.timezone };
evictStaleEntries();
weatherCache.set(cacheKey, { data, fetchedAt: Date.now() });
return NextResponse.json(data);

View File

@ -1,4 +1,5 @@
import nodemailer from 'nodemailer';
import { randomInt } from 'crypto';
import { getVerificationCopy, getResetCopy } from './emailTemplates';
let _transporter: nodemailer.Transporter | null = null;
@ -185,7 +186,9 @@ export async function sendPasswordResetEmail(email: string, token: string, langu
}
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 {

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,
});
// Redirect to login if not authenticated
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();
url.pathname = '/auth/login';
url.searchParams.set('callbackUrl', request.nextUrl.pathname);