MVP
This commit is contained in:
38
src/lib/cookieConfig.ts
Normal file
38
src/lib/cookieConfig.ts
Normal file
@@ -0,0 +1,38 @@
|
||||
/**
|
||||
* Cookie configuration helpers
|
||||
* Automatically uses secure settings in production
|
||||
*/
|
||||
|
||||
const isProduction = process.env.NODE_ENV === 'production';
|
||||
|
||||
/**
|
||||
* Get cookie options for authentication cookies
|
||||
*/
|
||||
export function getAuthCookieOptions() {
|
||||
return {
|
||||
httpOnly: true,
|
||||
secure: isProduction, // HTTPS only in production
|
||||
sameSite: 'lax' as const,
|
||||
maxAge: 60 * 60 * 24 * 7, // 7 days
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Get cookie options for CSRF tokens
|
||||
* Note: httpOnly is false so client-side JavaScript can read the token
|
||||
*/
|
||||
export function getCsrfCookieOptions() {
|
||||
return {
|
||||
httpOnly: false, // Client needs to read this token
|
||||
secure: isProduction, // HTTPS only in production
|
||||
sameSite: 'lax' as const,
|
||||
maxAge: 60 * 60 * 24, // 24 hours
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if running in production
|
||||
*/
|
||||
export function isProductionEnvironment(): boolean {
|
||||
return isProduction;
|
||||
}
|
||||
79
src/lib/csrf.ts
Normal file
79
src/lib/csrf.ts
Normal file
@@ -0,0 +1,79 @@
|
||||
import { cookies } from 'next/headers';
|
||||
import { v4 as uuidv4 } from 'uuid';
|
||||
import { getCsrfCookieOptions } from './cookieConfig';
|
||||
|
||||
const CSRF_TOKEN_COOKIE = 'csrf_token';
|
||||
const CSRF_TOKEN_HEADER = 'x-csrf-token';
|
||||
|
||||
/**
|
||||
* Generate a new CSRF token and set it as a cookie
|
||||
*/
|
||||
export function generateCsrfToken(): string {
|
||||
const token = uuidv4();
|
||||
|
||||
cookies().set(CSRF_TOKEN_COOKIE, token, getCsrfCookieOptions());
|
||||
|
||||
return token;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the CSRF token from cookies
|
||||
*/
|
||||
export function getCsrfToken(): string | undefined {
|
||||
return cookies().get(CSRF_TOKEN_COOKIE)?.value;
|
||||
}
|
||||
|
||||
/**
|
||||
* Validate CSRF token from request header against cookie
|
||||
*/
|
||||
export function validateCsrfToken(headerToken: string | null): boolean {
|
||||
if (!headerToken) {
|
||||
return false;
|
||||
}
|
||||
|
||||
const cookieToken = getCsrfToken();
|
||||
|
||||
if (!cookieToken) {
|
||||
return false;
|
||||
}
|
||||
|
||||
// Constant-time comparison to prevent timing attacks
|
||||
return cookieToken === headerToken;
|
||||
}
|
||||
|
||||
/**
|
||||
* CSRF Protection middleware for API routes
|
||||
*/
|
||||
export function csrfProtection(request: Request): { valid: boolean; error?: string } {
|
||||
const method = request.method;
|
||||
|
||||
// Only protect state-changing methods
|
||||
if (['GET', 'HEAD', 'OPTIONS'].includes(method)) {
|
||||
return { valid: true };
|
||||
}
|
||||
|
||||
const headerToken = request.headers.get(CSRF_TOKEN_HEADER);
|
||||
|
||||
if (!validateCsrfToken(headerToken)) {
|
||||
return {
|
||||
valid: false,
|
||||
error: 'Invalid or missing CSRF token'
|
||||
};
|
||||
}
|
||||
|
||||
return { valid: true };
|
||||
}
|
||||
|
||||
/**
|
||||
* Get CSRF token for client-side use
|
||||
* This should be called from a GET endpoint
|
||||
*/
|
||||
export function getOrCreateCsrfToken(): string {
|
||||
let token = getCsrfToken();
|
||||
|
||||
if (!token) {
|
||||
token = generateCsrfToken();
|
||||
}
|
||||
|
||||
return token;
|
||||
}
|
||||
138
src/lib/rateLimit.ts
Normal file
138
src/lib/rateLimit.ts
Normal file
@@ -0,0 +1,138 @@
|
||||
/**
|
||||
* Simple in-memory rate limiter
|
||||
* For production with multiple servers, consider using Redis/Upstash
|
||||
*/
|
||||
|
||||
interface RateLimitEntry {
|
||||
count: number;
|
||||
resetAt: number;
|
||||
}
|
||||
|
||||
// Store rate limit data in memory
|
||||
const rateLimitStore = new Map<string, RateLimitEntry>();
|
||||
|
||||
// Cleanup old entries every 5 minutes
|
||||
setInterval(() => {
|
||||
const now = Date.now();
|
||||
for (const [key, entry] of rateLimitStore.entries()) {
|
||||
if (entry.resetAt < now) {
|
||||
rateLimitStore.delete(key);
|
||||
}
|
||||
}
|
||||
}, 5 * 60 * 1000);
|
||||
|
||||
export interface RateLimitConfig {
|
||||
/**
|
||||
* Maximum number of requests allowed in the window
|
||||
*/
|
||||
maxRequests: number;
|
||||
|
||||
/**
|
||||
* Time window in seconds
|
||||
*/
|
||||
windowSeconds: number;
|
||||
|
||||
/**
|
||||
* Unique identifier for this rate limiter (e.g., 'login', 'signup')
|
||||
*/
|
||||
name: string;
|
||||
}
|
||||
|
||||
export interface RateLimitResult {
|
||||
success: boolean;
|
||||
limit: number;
|
||||
remaining: number;
|
||||
reset: number;
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if a request should be rate limited
|
||||
*
|
||||
* @param identifier - Unique identifier for the client (e.g., IP address, email)
|
||||
* @param config - Rate limit configuration
|
||||
* @returns RateLimitResult
|
||||
*/
|
||||
export function rateLimit(
|
||||
identifier: string,
|
||||
config: RateLimitConfig
|
||||
): RateLimitResult {
|
||||
const key = `${config.name}:${identifier}`;
|
||||
const now = Date.now();
|
||||
const windowMs = config.windowSeconds * 1000;
|
||||
|
||||
let entry = rateLimitStore.get(key);
|
||||
|
||||
// Create new entry if doesn't exist or expired
|
||||
if (!entry || entry.resetAt < now) {
|
||||
entry = {
|
||||
count: 0,
|
||||
resetAt: now + windowMs,
|
||||
};
|
||||
rateLimitStore.set(key, entry);
|
||||
}
|
||||
|
||||
// Increment counter
|
||||
entry.count++;
|
||||
|
||||
const remaining = Math.max(0, config.maxRequests - entry.count);
|
||||
const success = entry.count <= config.maxRequests;
|
||||
|
||||
return {
|
||||
success,
|
||||
limit: config.maxRequests,
|
||||
remaining,
|
||||
reset: entry.resetAt,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Get client identifier from request (IP address)
|
||||
*/
|
||||
export function getClientIdentifier(request: Request): string {
|
||||
// Try to get real IP from headers (for proxies/load balancers)
|
||||
const forwardedFor = request.headers.get('x-forwarded-for');
|
||||
if (forwardedFor) {
|
||||
return forwardedFor.split(',')[0].trim();
|
||||
}
|
||||
|
||||
const realIp = request.headers.get('x-real-ip');
|
||||
if (realIp) {
|
||||
return realIp;
|
||||
}
|
||||
|
||||
// Fallback (this won't work well in production behind a proxy)
|
||||
return 'unknown';
|
||||
}
|
||||
|
||||
/**
|
||||
* Predefined rate limit configurations
|
||||
*/
|
||||
export const RateLimits = {
|
||||
// Login: 5 attempts per 15 minutes
|
||||
LOGIN: {
|
||||
name: 'login',
|
||||
maxRequests: 5,
|
||||
windowSeconds: 15 * 60,
|
||||
},
|
||||
|
||||
// Signup: 3 accounts per hour
|
||||
SIGNUP: {
|
||||
name: 'signup',
|
||||
maxRequests: 3,
|
||||
windowSeconds: 60 * 60,
|
||||
},
|
||||
|
||||
// API: 100 requests per minute
|
||||
API: {
|
||||
name: 'api',
|
||||
maxRequests: 100,
|
||||
windowSeconds: 60,
|
||||
},
|
||||
|
||||
// Password reset: 3 attempts per hour
|
||||
PASSWORD_RESET: {
|
||||
name: 'password-reset',
|
||||
maxRequests: 3,
|
||||
windowSeconds: 60 * 60,
|
||||
},
|
||||
};
|
||||
@@ -5,7 +5,7 @@ if (!process.env.STRIPE_SECRET_KEY) {
|
||||
}
|
||||
|
||||
export const stripe = new Stripe(process.env.STRIPE_SECRET_KEY, {
|
||||
apiVersion: '2024-11-20.acacia',
|
||||
apiVersion: '2025-09-30.clover',
|
||||
typescript: true,
|
||||
});
|
||||
|
||||
@@ -36,10 +36,10 @@ export const STRIPE_PLANS = {
|
||||
interval: 'month',
|
||||
features: [
|
||||
'50 QR-Codes',
|
||||
'Branding (Logo, Farben)',
|
||||
'Branding (Farben)',
|
||||
'Detaillierte Analytics (Datum, Gerät, Stadt)',
|
||||
'CSV-Export',
|
||||
'Passwortschutz',
|
||||
'SVG/PNG Download',
|
||||
],
|
||||
limits: {
|
||||
dynamicQRCodes: 50,
|
||||
@@ -57,16 +57,14 @@ export const STRIPE_PLANS = {
|
||||
interval: 'month',
|
||||
features: [
|
||||
'500 QR-Codes',
|
||||
'Team-Zugänge (bis 3 User)',
|
||||
'API-Zugang',
|
||||
'Benutzerdefinierte Domains',
|
||||
'White-Label',
|
||||
'Alles von Pro',
|
||||
'Bulk QR-Generierung (bis 1,000)',
|
||||
'Prioritäts-Support',
|
||||
],
|
||||
limits: {
|
||||
dynamicQRCodes: 500,
|
||||
staticQRCodes: -1,
|
||||
teamMembers: 3,
|
||||
teamMembers: 1,
|
||||
},
|
||||
priceId: process.env.STRIPE_PRICE_ID_BUSINESS_MONTHLY,
|
||||
priceIdYearly: process.env.STRIPE_PRICE_ID_BUSINESS_YEARLY,
|
||||
|
||||
Reference in New Issue
Block a user