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/
|
||||
|
||||
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",
|
||||
"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": {
|
||||
|
||||
@ -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';
|
||||
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) {
|
||||
|
||||
@ -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 },
|
||||
|
||||
@ -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' },
|
||||
|
||||
@ -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 });
|
||||
|
||||
@ -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(
|
||||
|
||||
@ -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(
|
||||
|
||||
@ -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);
|
||||
|
||||
@ -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
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,
|
||||
});
|
||||
|
||||
// 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);
|
||||
|
||||
Loading…
Reference in New Issue
Block a user