diff --git a/docs/scrum4me-backlog.md b/docs/scrum4me-backlog.md index 089d3ff..5308972 100644 --- a/docs/scrum4me-backlog.md +++ b/docs/scrum4me-backlog.md @@ -26,6 +26,7 @@ De MVP is klaar wanneer Lars — de primaire persona — de volledige cyclus kan | M7: MCP-server voor Claude Code | Native MCP-laag bovenop Scrum4Me-DB (aparte repo `scrum4me-mcp`) | ST-701 – ST-710 | | M8: Realtime Solo Paneel | Live updates voor stories/tasks via SSE + Postgres LISTEN/NOTIFY | ST-801 – ST-806 | | M9: Actief Product Backlog | Persistente actieve PB-keuze, gesplitste navigatie, disabled-states | ST-901 – ST-907 | +| M10: Password-loze inlog via QR-pairing | Mobiel als bevestigingskanaal voor desktop-login zonder wachtwoord | ST-1001 – ST-1008 | --- ## Backlog @@ -586,6 +587,71 @@ Eén "actief Product Backlog" per gebruiker — persistent in DB. De NavBar word --- +### M10: Password-loze inlog via QR-pairing + +Inloggen op een (publieke) desktop zonder wachtwoord: de desktop toont een QR-code, de gebruiker scant met een telefoon waar hij al ingelogd is, bevestigt expliciet, en de desktop is binnen 1–2 seconden ingelogd. Bouwt voort op de Postgres LISTEN/NOTIFY-infra van M8 (eigen kanaal `scrum4me_pairing`). Geen wachtwoord ingetypt op het publieke apparaat, geen credentials op de draad, demo-accounts geblokkeerd, paired-sessie heeft eigen kortere TTL (8 u) + `paired`-vlag voor toekomstige remote-revoke. + +**Beveiligingsuitgangspunt:** `mobileSecret` reist alleen via QR-fragment (`#s=…`) → `location.hash` op de mobiel → POST-body. Desktop-SSE en claim authenticeren via een **HttpOnly pre-auth cookie** (`s4m_pair`, `Path=/api/auth/pair`, `Max-Age=120`, `SameSite=Lax`). Twee gescheiden hashes in DB (`secret_hash` voor mobiel-bewijs, `desktop_token_hash` voor desktop-bewijs) zodat geheim materiaal niet in URL-paden, querystrings, access logs, reverse-proxy logs, observability of browsergeschiedenis kan belanden. + +Volledige flow + threat-model: `docs/patterns/qr-login.md` (op te leveren in ST-1008). + +- [ ] **ST-1001** LoginPairing schema + Postgres-trigger + - **Schema:** `LoginPairing { id, secret_hash, desktop_token_hash, status, user_id?, desktop_ua?, desktop_ip?, created_at, expires_at, approved_at?, consumed_at? }`; back-relation `User.login_pairings`; `@@index([expires_at])`, `@@index([status, expires_at])`; `status` als string (`pending|approved|consumed|cancelled`); twee hash-kolommen scheiden mobiel-bewijs van desktop-bewijs + - **Trigger:** `notify_pairing_change()` + `AFTER INSERT/UPDATE` op `login_pairings`; `pg_notify('scrum4me_pairing', payload)` met `{ pairing_id, status, op }`; analoog aan `notify_solo_change` uit ST-801 + - **Migratie:** `prisma migrate dev --name add_login_pairing` + - Done when: migratie slaagt; `psql $DIRECT_URL -c "LISTEN scrum4me_pairing;"` levert payload bij INSERT op `login_pairings`; beide hash-kolommen zijn `NOT NULL` + +- [ ] **ST-1002** Pairing-helpers + sessie-uitbreiding + pre-auth-cookie + - **`lib/auth/pairing.ts`:** `generateMobileSecret()` en `generateDesktopToken()` (beide 32 bytes → base64url, los gegenereerd zodat ze elkaar niet onthullen), `hashToken(t)` (sha256-hex), `verifyToken(t, hash)` (timing-safe compare) + - **`lib/auth/pair-cookie.ts`:** `setPairCookie(response, desktopToken)` (`HttpOnly`, `Secure` in prod, `SameSite=Lax`, `Path=/api/auth/pair`, `Max-Age=120`); `readPairCookie(request)` returnt `desktopToken | null`; `clearPairCookie(response)` op claim/cancel + - **`SessionData` in `lib/session.ts`:** voeg optionele `paired?: boolean` en `pairedExpiresAt?: number` toe + - **`app/(app)/layout.tsx`:** extra guard — als `session.paired && session.pairedExpiresAt < Date.now()` → `session.destroy()` + `redirect('/login')` + - Done when: helpers hebben unit-tests; paired-sessie verloopt zichtbaar na vervaltijd; cookie wordt nooit door client-JS gelezen (HttpOnly-test) + +- [ ] **ST-1003** `POST /api/auth/pair/start` — pairing aanmaken (anon) + - Route Handler zonder auth; leest UA + best-effort IP (`x-forwarded-for`); genereert los `mobileSecret` + `desktopToken`; insert `LoginPairing` met beide hashes, `status='pending'`, `expires_at = now() + 2 min` + - **Response body:** `{ pairingId, mobileSecret, expiresAt, qrUrl }` — `qrUrl = ${origin}/m/pair#id=…&s=…` (fragment, geen querystring) + - **Response header:** `Set-Cookie: s4m_pair=; HttpOnly; Secure; SameSite=Lax; Path=/api/auth/pair; Max-Age=120` + - **Rate-limit:** patroon ST-608 (max 10 starts per IP per minuut) + - Done when: curl POST levert pairingId+mobileSecret in body en `s4m_pair`-cookie in header; 11e call binnen 60s geeft 429; rij in `login_pairings` zonder plaintext secret of desktop-token + +- [ ] **ST-1004** SSE-route `/api/auth/pair/stream/[pairingId]` (cookie-auth) + - `runtime: 'nodejs'`, `maxDuration: 300`; pairingId in pad (niet sensitief), auth via `s4m_pair`-cookie: sha256(cookie) matcht `desktop_token_hash` van pairing met `pairingId` en `expires_at > now()`; anders 401 + - **Geen query-parameters met geheim materiaal.** Browser stuurt cookie automatisch mee. + - Hergebruik LISTEN/NOTIFY-pattern uit `app/api/realtime/solo/route.ts` op kanaal `scrum4me_pairing`; filter notifications op `pairing_id` + - Auto-close bij status `consumed`/`cancelled` of na 240 s; heartbeat 25 s + - Done when: SSE-verbinding zonder `s4m_pair`-cookie geeft 401; met geldige cookie levert event binnen 1s na approve; stream sluit na consume; pairingId in URL is OK (niet vertrouwelijk) + +- [ ] **ST-1005** Server actions + mobiele bevestigingspagina + - **`actions/pairing.ts`:** `getPairingForApproval(pairingId, mobileSecret)`, `approvePairing(pairingId, mobileSecret)` (demo-blokkade, hash-vergelijk tegen `secret_hash`, status pending→approved, bumpt `expires_at` +5 min, zet `user_id` + `approved_at`), `cancelPairing(pairingId, mobileSecret)` + - **`app/(app)/m/pair/page.tsx`:** Server Component achter de bestaande `(app)/layout.tsx` auth-guard; **leest géén query-params** — alleen statische uitleg + een client-island + - **`app/(app)/m/pair/pair-confirmation.tsx`:** Client Component die bij mount `window.location.hash` parseert (`#id=…&s=…`), via Server Action `getPairingForApproval` de UA/IP/username ophaalt, dan toont *"Inloggen op {ua} ({ip}) als {jouw-username}?"* met Bevestig/Annuleer-knoppen die `approvePairing`/`cancelPairing` aanroepen; succes-state *"Klaar — je kunt deze tab sluiten"*. Wist `location.hash` na approve zodat back/forward de secret niet onthult + - Demo-modus: approve geeft Nederlandse foutmelding (consistent ST-604) + - Done when: ingelogde mobiel ziet bevestigingspagina met UA + IP; secret komt nooit in een GET-URL voor; tap "Bevestig" zet status approved; demo-user ziet foutmelding en pairing blijft `pending` + +- [ ] **ST-1006** `POST /api/auth/pair/claim` — desktop-cookie zetten (cookie-auth) + - Auth via `s4m_pair`-cookie (geen body-secret nodig); atomic update: `UPDATE login_pairings SET status='consumed', consumed_at=now() WHERE id=$1 AND status='approved' AND desktop_token_hash=$2 AND expires_at > now() RETURNING user_id` + - Bij rij geretourneerd: `getIronSession` → `session.userId = user.id; session.isDemo = user.is_demo; session.paired = true; session.pairedExpiresAt = Date.now() + 8h`; clear `s4m_pair`-cookie; anders 410 (al consumed) / 404 / 401 + - Logging alleen `pairingId`, nooit cookie-waarde of mobileSecret + - Done when: claim met geldige cookie schrijft iron-session cookie en retourneert 200; tweede claim 410; ontbrekende/foute cookie 401; `s4m_pair` is na succes geclear'd + +- [ ] **ST-1007** Desktop UI: QR-render + SSE-listener op `/login` + - **Dependency:** `qrcode.react` (client SVG; mobileSecret blijft op desktop in JS-geheugen) + - **`app/login/qr-login-button.tsx`:** Client Component; klik → POST `pair/start` (`credentials: 'same-origin'` zodat `s4m_pair`-cookie wordt geaccepteerd) → render QR met `qrUrl` (fragment-URL) → open `EventSource('/api/auth/pair/stream/', { withCredentials: true })` → bij `approved` event POST `pair/claim` (cookie-only) → bij succes `router.push('/dashboard')`; aftellende timer (2 min); bij timeout "Vernieuwen"-knop; cleanup bij unmount/redirect + - **`app/login/page.tsx`:** knop "Inloggen via mobiel" naast bestaande wachtwoord-form (MD3-tokens uit `docs/scrum4me-styling.md`) + - A11y: QR heeft alt-tekst met de URL voor screenreaders/copy-paste (de hash-suffix is onderdeel van die alt-tekst, niet van de page-URL die in browsergeschiedenis komt) + - Done when: end-to-end happy path werkt op localhost (twee browsers): A toont QR → B scant + bevestigt → A redirect naar `/dashboard` met `session.paired === true`; QR vernieuwt na expiry; geen secret zichtbaar in DevTools Network-tab onder URL-kolommen + +- [ ] **ST-1008** Documentatie + acceptatietest + - **`docs/API.md`:** drie nieuwe endpoints (start/stream/claim) met request/response, cookie-mechaniek, foutcodes (400/401/403/404/410/422/429), curl-voorbeelden inclusief `--cookie-jar` + - **`docs/scrum4me-architecture.md`:** sectie "QR-pairing flow" met sequence-diagram + threat-model; expliciete subsectie *"Waarom geen secret in URL"* — fragments worden niet naar server gestuurd; SSE/claim authenticeren via HttpOnly cookie zodat secret-materiaal niet in access logs / reverse-proxy logs / observability-tools / browsergeschiedenis kan belanden + - **`docs/patterns/qr-login.md`:** nieuw pattern-doc voor toekomstige features die hetzelfde unauth-SSE-via-pre-auth-cookie-patroon willen hergebruiken + - **`CLAUDE.md`:** verwijzing naar het nieuwe pattern-doc in de patterns-tabel + - **Acceptatietest:** zeven scenario's handmatig: happy path, demo-block, replay, expiry tijdens pending, expiry tussen approve+claim, ontbrekende cookie op SSE/claim, secret niet aanwezig in `nginx`/Vercel access logs (controle via runtime-logs MCP-tool) + - Done when: docs gepubliceerd; alle zeven scenario's groen + +--- + ## v2 Backlog (na MVP) - [ ] Uitnodigingsflow voor teams — e-mailuitnodiging of link-gebaseerd; nu kunnen alleen admins met toegang tot het systeem Developers toevoegen via gebruikersnaam