Scrum4Me/docs/architecture/qr-pairing.md

4.1 KiB


QR-pairing flow (M10)

Password-loze inlog op een (publieke) desktop. Mobiel — al ingelogd — bevestigt door een QR te scannen die de desktop toont. Geen wachtwoord op het publieke toetsenbord, geen credentials op de draad, demo-accounts geblokkeerd, paired- sessie heeft eigen kortere TTL (8 u) + paired-vlag.

Sequence

sequenceDiagram
  participant D as Desktop (anon)
  participant S as Server
  participant M as Mobiel (ingelogd)

  D->>S: POST /api/auth/pair/start
  S->>S: maak LoginPairing { secret_hash, desktop_token_hash, status=pending, expires=+5min }
  S-->>D: 200 { pairingId, mobileSecret, qrUrl }<br/>Set-Cookie: s4m_pair=desktopToken
  D->>D: render QR met qrUrl (#id=…&s=mobileSecret)
  D->>S: GET /api/auth/pair/stream/[pairingId]<br/>Cookie: s4m_pair
  S->>S: LISTEN scrum4me_pairing
  S-->>D: event: state { status: 'pending' }

  Note over M: Gebruiker scant QR
  M->>M: location.hash → mobileSecret
  M->>S: getPairingForApproval(pairingId, mobileSecret)
  S-->>M: { desktop_ua, desktop_ip, username }
  M->>M: toont bevestigingskaart
  Note over M: Tap "Bevestig"
  M->>S: approvePairing(pairingId, mobileSecret)
  S->>S: status pending→approved, expires +5min<br/>pg_notify scrum4me_pairing
  S-->>D: data { status: 'approved' }

  D->>S: POST /api/auth/pair/claim<br/>Cookie: s4m_pair, body: { pairingId }
  S->>S: atomic UPDATE WHERE status=approved AND token-hash<br/>→ status=consumed
  S->>S: getIronSession.save { userId, paired: true, pairedExpiresAt }
  S-->>D: 200, Set-Cookie: session<br/>+ s4m_pair cleared
  D->>D: redirect /dashboard

Threat-model

Aanval Mitigatie
Replay van een geconsumeerde pairing Atomic updateMany WHERE status='approved' — concurrent dubbele claim ziet count=0 → 410
Phishing-QR ingesloten op een vreemde site Mobiele bevestigingspagina toont desktop-UA + IP; gebruiker moet expliciet tappen; waarschuwing onder de kaart
Demo-account misbruik approvePairing early-return op session.isDemo — pairing blijft pending
Brute-force van pairings Rate-limit 10 starts per IP per minuut; pairingId is CUID (lange entropy)
Secret-leak via DB-dump DB bevat alleen sha256-hashes; plaintext geheimen verlaten desktop alleen via QR-fragment + POST-body (mobile) of HttpOnly cookie (desktop)
Long-lived sessie op publieke desktop Paired-sessie krijgt 8u TTL i.p.v. reguliere; paired: true markeert 'm voor toekomstige remote-revoke

TTL-rationale

  • Pending: 5 min. Genoeg voor menselijke handeling (telefoon pakken, scannen, bevestigen) — kort genoeg dat een verloren QR een klein attack-window heeft.
  • Approved (na bump): nogmaals 5 min. Klant claim moet binnen redelijke tijd plaatsvinden; voorkomt dat een approved-maar-onclaimed pairing eindeloos open blijft.
  • Paired-sessie: 8 uur. Korter dan de reguliere wachtwoord-sessie omdat de use-case publieke apparaten zijn waar je niet wil dat de sessie 's nachts blijft hangen.

Waarom geen secret in URL

Servers loggen URL-paden en querystrings standaard — nginx, Vercel access logs, observability-stacks (Sentry, Datadog), reverse proxies, CDN's. Een geheim in ?s=… belandt onbedoeld in al die logs. Twee technieken voorkomen dit:

  1. URL-fragment voor mobileSecret. Het deel achter de # wordt door browsers nooit naar de server gestuurd in HTTP-requests. De mobiele Client Component leest window.location.hash en POST't de waarde in een body — ook niet in een URL.
  2. HttpOnly cookie voor desktopToken. Cookie-headers worden meestal NIET in toegangslogs gelogd (in tegenstelling tot URLs). De cookie is bovendien Path=/api/auth/pair-scoped, dus verlaat die route nooit.

Twee gescheiden hashes (secret_hash voor mobiel-bewijs, desktop_token_hash voor desktop-bewijs) zorgen dat een ene server-side compromis niet automatisch de andere kant compromitteert.

Dit patroon is herbruikbaar — zie docs/patterns/qr-login.md.