POST /api/auth/pair/start (anon, runtime: 'nodejs'):
- Geen authenticateApiRequest — desktop heeft nog geen sessie
- Genereert los mobileSecret + desktopToken via lib/auth/pairing
- Persisteert alleen sha256-hashes in login_pairings; status='pending', expires_at = now + 2 min
- Slaat user-agent + best-effort IP op (afgekapt op kolom-grootte)
- Set-Cookie via setPairCookie helper: HttpOnly, Path=/api/auth/pair, Max-Age=120, SameSite=Lax
- Response body: { pairingId, mobileSecret, expiresAt, qrUrl } met qrUrl = origin/m/pair#id=…&s=…
→ secret reist alleen via fragment (#…), nooit in querystring of access logs
Rate-limit: 'pair-start' expliciet aan lib/rate-limit.ts CONFIGS toegevoegd
voor self-documentatie (10/min, gelijk aan login).
Tests __tests__/api/pair-start.test.ts (6 cases):
- 200 met body-shape (pairingId, mobileSecret 43-char base64url, qrUrl met
fragment, expiresAt ISO)
- alleen hashes in DB, geen plaintext
- cookie set met juiste opties
- UA + IP afgekapt op kolom-grootte
- IP=null als x-forwarded-for ontbreekt
- 11e POST levert 429 met NL foutmelding
Quality gates: lint 0 errors, tsc clean (na prisma generate), vitest 117/117.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
37 lines
1.1 KiB
TypeScript
37 lines
1.1 KiB
TypeScript
// Simple in-memory rate limiter.
|
|
// Note: resets on server restart and does not share state across multiple processes.
|
|
// Suitable for MVP; replace with Redis for production scale-out.
|
|
|
|
interface RateLimitConfig {
|
|
windowMs: number
|
|
max: number
|
|
}
|
|
|
|
const CONFIGS: Record<string, RateLimitConfig> = {
|
|
login: { windowMs: 60_000, max: 10 }, // 10 attempts per minute
|
|
register: { windowMs: 3_600_000, max: 5 }, // 5 attempts per hour
|
|
'pair-start': { windowMs: 60_000, max: 10 }, // 10 QR-pairings per minute (M10)
|
|
}
|
|
|
|
const DEFAULT_CONFIG: RateLimitConfig = { windowMs: 60_000, max: 10 }
|
|
|
|
const store = new Map<string, { count: number; resetAt: number }>()
|
|
|
|
export function checkRateLimit(key: string): boolean {
|
|
const prefix = key.split(':')[0]
|
|
const config = CONFIGS[prefix] ?? DEFAULT_CONFIG
|
|
const now = Date.now()
|
|
const entry = store.get(key)
|
|
|
|
if (!entry || now > entry.resetAt) {
|
|
store.set(key, { count: 1, resetAt: now + config.windowMs })
|
|
return true
|
|
}
|
|
|
|
if (entry.count >= config.max) {
|
|
return false
|
|
}
|
|
|
|
entry.count++
|
|
return true
|
|
}
|