diff --git a/.env.production.save b/.env.production.save deleted file mode 100644 index 00b6db2..0000000 --- a/.env.production.save +++ /dev/null @@ -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 diff --git a/.gitignore b/.gitignore index 907f892..81b7580 100644 --- a/.gitignore +++ b/.gitignore @@ -40,4 +40,12 @@ prisma/dev.db-journal # Next.js .next/ -certificates \ No newline at end of file +certificates + +# Design reference files (not tracked) +docs/design/ + +# Dokumentationen (nicht tracken) +APP_ERKLÄRUNG.md +.env.production.save +.env*.save diff --git a/package.json b/package.json index ce04262..461dded 100644 --- a/package.json +++ b/package.json @@ -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": { diff --git a/src/app/api/auth/login/route.ts b/src/app/api/auth/login/route.ts deleted file mode 100644 index 5f7f8a5..0000000 --- a/src/app/api/auth/login/route.ts +++ /dev/null @@ -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 } - ); - } -} \ No newline at end of file diff --git a/src/app/api/auth/resend-verification/route.ts b/src/app/api/auth/resend-verification/route.ts index fad455e..8d84a66 100644 --- a/src/app/api/auth/resend-verification/route.ts +++ b/src/app/api/auth/resend-verification/route.ts @@ -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) { diff --git a/src/app/api/auth/reset-password/route.ts b/src/app/api/auth/reset-password/route.ts index 435068e..f16b6df 100644 --- a/src/app/api/auth/reset-password/route.ts +++ b/src/app/api/auth/reset-password/route.ts @@ -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 }, diff --git a/src/app/api/auth/signup/route.ts b/src/app/api/auth/signup/route.ts index c1c4ccd..2636094 100644 --- a/src/app/api/auth/signup/route.ts +++ b/src/app/api/auth/signup/route.ts @@ -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' }, diff --git a/src/app/api/auth/verify-email/route.ts b/src/app/api/auth/verify-email/route.ts index 8fc5613..c6e2410 100644 --- a/src/app/api/auth/verify-email/route.ts +++ b/src/app/api/auth/verify-email/route.ts @@ -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 }); diff --git a/src/app/api/calendar/connections/route.ts b/src/app/api/calendar/connections/route.ts index 2c71f70..d5d4fe0 100644 --- a/src/app/api/calendar/connections/route.ts +++ b/src/app/api/calendar/connections/route.ts @@ -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( diff --git a/src/app/api/calendar/synology/connect/route.ts b/src/app/api/calendar/synology/connect/route.ts index 986a565..28173f3 100644 --- a/src/app/api/calendar/synology/connect/route.ts +++ b/src/app/api/calendar/synology/connect/route.ts @@ -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( diff --git a/src/app/api/weather/route.ts b/src/app/api/weather/route.ts index 6fcadbd..1bab420 100644 --- a/src/app/api/weather/route.ts +++ b/src/app/api/weather/route.ts @@ -8,6 +8,21 @@ export const dynamic = 'force-dynamic'; // In-memory cache: { key: { data, fetchedAt } } const weatherCache = new Map(); 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); diff --git a/src/lib/email.ts b/src/lib/email.ts index de9e995..8903497 100644 --- a/src/lib/email.ts +++ b/src/lib/email.ts @@ -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 { diff --git a/src/lib/rate-limit.ts b/src/lib/rate-limit.ts new file mode 100644 index 0000000..a1f75bd --- /dev/null +++ b/src/lib/rate-limit.ts @@ -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(); + +// 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), + }, + } + ); +} diff --git a/src/middleware.ts b/src/middleware.ts index 9200cc0..002c897 100644 --- a/src/middleware.ts +++ b/src/middleware.ts @@ -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);