* feat(ST-1110.3): add proxy.ts demo-guard for non-GET API routes
* feat(ST-1110.3+4): demo-guard proxy + block demo in QR-pairing
- proxy.ts: gebruik unsealData ipv getIronSession (middleware-compatibel)
- pair/start: isDemo-check via cookies() guard
- pair/claim: check pairing.user.is_demo na DB-read; 403 + clearPairCookie
* feat(ST-1110.5): unify demo write-button pattern to disabled+tooltip
Convert all !isDemo && <Button> patterns to <DemoTooltip show={isDemo}>
<Button disabled={isDemo}> so demo visitors see app capabilities.
Affects: pbi-list, story-panel, story-dialog, task-list, sprint-backlog,
token-manager, product-list, activate-product-button, leave-product-button,
settings page.
* test(ST-1110.6): proxy demo-guard coverage — 403 for demo+non-GET on /api/*
* docs(ST-1110.7): document three-layer demo-readonly policy and mirror plan
82 lines
2.6 KiB
TypeScript
82 lines
2.6 KiB
TypeScript
// ST-1003: POST /api/auth/pair/start — anonieme endpoint die een nieuwe
|
|
// LoginPairing aanmaakt voor de QR-pairing-flow (M10).
|
|
//
|
|
// Genereert twee gescheiden 256-bit geheimen:
|
|
// - mobileSecret → komt in JSON-body terug zodat de desktop het in een
|
|
// QR-fragment kan plaatsen (wordt nooit naar onze server gestuurd)
|
|
// - desktopToken → wordt als HttpOnly cookie gezet zodat alleen deze
|
|
// browser de SSE-stream en claim mag uitvoeren
|
|
//
|
|
// Rate-limit: 10 pogingen per IP per minuut (lib/rate-limit.ts → 'pair-start').
|
|
|
|
import { getIronSession } from 'iron-session'
|
|
import { cookies } from 'next/headers'
|
|
import { prisma } from '@/lib/prisma'
|
|
import {
|
|
generateMobileSecret,
|
|
generateDesktopToken,
|
|
hashToken,
|
|
} from '@/lib/auth/pairing'
|
|
import { setPairCookie } from '@/lib/auth/pair-cookie'
|
|
import { checkRateLimit } from '@/lib/rate-limit'
|
|
import { SessionData, sessionOptions } from '@/lib/session'
|
|
|
|
export const runtime = 'nodejs'
|
|
|
|
const PENDING_TTL_MS = 5 * 60 * 1000 // 5 min — komt overeen met s4m_pair Max-Age
|
|
|
|
const UA_MAX = 255 // matcht VarChar(255) op login_pairings.desktop_ua
|
|
const IP_MAX = 45 // matcht VarChar(45) — IPv6 max length
|
|
|
|
function getClientIp(request: Request): string {
|
|
return (
|
|
request.headers.get('x-forwarded-for')?.split(',')[0]?.trim() ||
|
|
request.headers.get('x-real-ip') ||
|
|
'unknown'
|
|
)
|
|
}
|
|
|
|
export async function POST(request: Request) {
|
|
const session = await getIronSession<SessionData>(await cookies(), sessionOptions)
|
|
if (session.isDemo) {
|
|
return Response.json({ error: 'Niet beschikbaar in demo-modus' }, { status: 403 })
|
|
}
|
|
|
|
const ip = getClientIp(request)
|
|
if (!checkRateLimit(`pair-start:${ip}`)) {
|
|
return Response.json(
|
|
{ error: 'Te veel pogingen. Probeer het over een minuut opnieuw.' },
|
|
{ status: 429 },
|
|
)
|
|
}
|
|
|
|
const ua = request.headers.get('user-agent')?.slice(0, UA_MAX) ?? null
|
|
const ipStored = ip === 'unknown' ? null : ip.slice(0, IP_MAX)
|
|
|
|
const mobileSecret = generateMobileSecret()
|
|
const desktopToken = generateDesktopToken()
|
|
|
|
const pairing = await prisma.loginPairing.create({
|
|
data: {
|
|
secret_hash: hashToken(mobileSecret),
|
|
desktop_token_hash: hashToken(desktopToken),
|
|
status: 'pending',
|
|
desktop_ua: ua,
|
|
desktop_ip: ipStored,
|
|
expires_at: new Date(Date.now() + PENDING_TTL_MS),
|
|
},
|
|
select: { id: true, expires_at: true },
|
|
})
|
|
|
|
await setPairCookie(desktopToken)
|
|
|
|
const origin = new URL(request.url).origin
|
|
const qrUrl = `${origin}/m/pair#id=${pairing.id}&s=${mobileSecret}`
|
|
|
|
return Response.json({
|
|
pairingId: pairing.id,
|
|
mobileSecret,
|
|
expiresAt: pairing.expires_at.toISOString(),
|
|
qrUrl,
|
|
})
|
|
}
|