Compare commits
5 commits
main
...
feat/ST-10
| Author | SHA1 | Date | |
|---|---|---|---|
| a35edc97f5 | |||
| 8ef4edecc8 | |||
| 7bfb2a786a | |||
| 308ff57789 | |||
| 842957fe77 |
6 changed files with 1030 additions and 0 deletions
31
CLAUDE.md
31
CLAUDE.md
|
|
@ -133,6 +133,37 @@ SESSION_SECRET="" # openssl rand -base64 32
|
|||
|
||||
---
|
||||
|
||||
## Branch & PR Strategy (STRICT — kostenbeheersing)
|
||||
|
||||
> **Core rule: één branch per milestone, PR alleen na gebruikerstest**
|
||||
|
||||
Elke `git push` naar een feature-branch triggert een Vercel preview-deployment. Op het huidige Hobby-account zijn die schaars en kosten geld; we minimaliseren preview-builds tot er werkelijk iets te reviewen valt.
|
||||
|
||||
### Wel doen
|
||||
|
||||
- Eén branch voor de hele milestone — `feat/M{N}-{slug}` (bv. `feat/M10-qr-login`); voor losse stories zonder milestone blijft `feat/ST-XXX-{slug}` geldig
|
||||
- Commits accumuleren lokaal volgens de Commit Strategy hieronder — één commit per stap, ST-code in de titel
|
||||
- Pushen + PR openen **pas nadat de gebruiker de milestone handmatig heeft getest en goedgekeurd** — vraag expliciet om bevestiging vóór `git push`
|
||||
- Tussentijdse "klaar voor jouw test"-momenten markeren met een lokale tag of een berichtje in chat, niet met een push
|
||||
|
||||
### Niet doen
|
||||
|
||||
- Pushen na elke story of commit
|
||||
- Een PR per story openen tijdens de implementatie
|
||||
- "Just-in-case" pushen om backup te hebben — gebruik `git stash`, een lokale tag, of meerdere lokale branches
|
||||
- `--force-push` om eerdere preview-builds "weg te toveren" (kost dezelfde build opnieuw bij hercreatie)
|
||||
|
||||
### Uitzonderingen
|
||||
|
||||
- Een **planning-PR** zonder code-wijzigingen (alleen docs in `docs/plans/` of `docs/`) mag direct gepusht worden — die triggert geen functional regressie en is goedkoop te bouwen
|
||||
- Een **bugfix-hotfix** op `main` met aantoonbare productie-impact mag direct gepusht worden
|
||||
|
||||
### Wanneer aanpassen
|
||||
|
||||
Zodra het Vercel-account naar Pro (of andere omgeving zonder per-build-kosten) gaat: vervang deze regel door "branch + PR per story" zoals oorspronkelijk in dit document stond. Werk deze sectie bij én documenteer de wijziging in `docs/agent-instruction-audit.md`.
|
||||
|
||||
---
|
||||
|
||||
## Commit Strategy (STRICT)
|
||||
|
||||
> **Core rule: één commit = één verantwoordelijkheid**
|
||||
|
|
|
|||
885
docs/plans/M10-qr-pairing-login.md
Normal file
885
docs/plans/M10-qr-pairing-login.md
Normal file
|
|
@ -0,0 +1,885 @@
|
|||
# M10 — Password-loze inlog via QR-pairing
|
||||
|
||||
Inloggen op een (publieke) desktop zonder wachtwoord: desktop toont QR, telefoon (al-ingelogd) scant en bevestigt expliciet, desktop is binnen 1–2 s ingelogd. Bouwt voort op M8 LISTEN/NOTIFY-infra met eigen channel `scrum4me_pairing`.
|
||||
|
||||
**Beveiligingsuitgangspunt:** geheim materiaal nooit in URL-paden, querystrings, access logs of browsergeschiedenis.
|
||||
- `mobileSecret` reist alleen via QR-fragment (`#s=…`) → mobile `location.hash` → POST-body
|
||||
- `desktopToken` reist alleen via HttpOnly cookie `s4m_pair` met `Path=/api/auth/pair`, `Max-Age=120`, `SameSite=Lax`
|
||||
- Twee gescheiden hashes in DB scheiden mobiel-bewijs (`secret_hash`) van desktop-bewijs (`desktop_token_hash`)
|
||||
|
||||
Backlog-entries: zie [scrum4me-backlog.md § M10](../scrum4me-backlog.md#m10-password-loze-inlog-via-qr-pairing).
|
||||
Functional spec: zie [scrum4me-functional-spec.md § F-01b](../scrum4me-functional-spec.md#f-01b-inloggen-via-mobiel-qr-pairing).
|
||||
|
||||
**Implementatie-volgorde** (commit-strategy uit CLAUDE.md):
|
||||
|
||||
1. **DB-laag** — ST-1001 (schema + trigger)
|
||||
2. **Auth helpers + sessie-uitbreiding** — ST-1002
|
||||
3. **API-laag** — ST-1003 (start), ST-1004 (SSE), ST-1006 (claim)
|
||||
4. **Server actions + mobile UI** — ST-1005
|
||||
5. **Desktop UI** — ST-1007
|
||||
6. **Documentatie + acceptatietest** — ST-1008
|
||||
|
||||
ST-1006 staat bij de API-laag (niet bij UI) omdat het een Route Handler is; ST-1005 levert tegelijk de server actions en de mobiele bevestigingspagina omdat die strak gekoppeld zijn.
|
||||
|
||||
---
|
||||
|
||||
## ST-1001 — LoginPairing schema + Postgres-trigger
|
||||
|
||||
**Bestanden**
|
||||
- `prisma/schema.prisma` — nieuw model `LoginPairing` + back-relation op `User`
|
||||
- `prisma/migrations/<timestamp>_add_login_pairing/migration.sql` — model + trigger
|
||||
- `vendor/scrum4me`-submodule in repo `scrum4me-mcp` — schema-sync ná merge
|
||||
|
||||
**Stappen**
|
||||
|
||||
1. **Schema-uitbreiding**:
|
||||
|
||||
```prisma
|
||||
model LoginPairing {
|
||||
id String @id @default(cuid())
|
||||
secret_hash String // sha256 hex van mobileSecret
|
||||
desktop_token_hash String // sha256 hex van desktopToken (HttpOnly cookie)
|
||||
status String // 'pending' | 'approved' | 'consumed' | 'cancelled'
|
||||
user_id String?
|
||||
user User? @relation(fields: [user_id], references: [id], onDelete: SetNull)
|
||||
desktop_ua String? @db.VarChar(255)
|
||||
desktop_ip String? @db.VarChar(45) // IPv6 max
|
||||
created_at DateTime @default(now())
|
||||
expires_at DateTime
|
||||
approved_at DateTime?
|
||||
consumed_at DateTime?
|
||||
|
||||
@@index([expires_at])
|
||||
@@index([status, expires_at])
|
||||
@@map("login_pairings")
|
||||
}
|
||||
```
|
||||
|
||||
Op `User`: `login_pairings LoginPairing[]` toevoegen.
|
||||
|
||||
2. **Migratie-SQL** voegt naast de tabel ook trigger toe (mirror van `notify_solo_change` in `prisma/migrations/20260426230316_add_solo_realtime_triggers/migration.sql`):
|
||||
|
||||
```sql
|
||||
CREATE OR REPLACE FUNCTION notify_pairing_change() RETURNS trigger AS $$
|
||||
DECLARE payload jsonb;
|
||||
BEGIN
|
||||
payload := jsonb_build_object(
|
||||
'op', CASE TG_OP WHEN 'INSERT' THEN 'I' WHEN 'UPDATE' THEN 'U' ELSE 'D' END,
|
||||
'pairing_id', COALESCE(NEW.id, OLD.id),
|
||||
'status', COALESCE(NEW.status, OLD.status)
|
||||
);
|
||||
PERFORM pg_notify('scrum4me_pairing', payload::text);
|
||||
RETURN COALESCE(NEW, OLD);
|
||||
END;
|
||||
$$ LANGUAGE plpgsql;
|
||||
|
||||
CREATE TRIGGER login_pairings_notify
|
||||
AFTER INSERT OR UPDATE ON login_pairings
|
||||
FOR EACH ROW EXECUTE FUNCTION notify_pairing_change();
|
||||
```
|
||||
|
||||
3. `npx prisma migrate dev --name add_login_pairing`.
|
||||
|
||||
**Aandachtspunten**
|
||||
- `desktop_ip` houdt op 45 tekens om IPv6 te accommoderen (`xxxx:xxxx:…:255.255.255.255`).
|
||||
- Geen index op `user_id` nodig voor v1 — er is geen lookup-pad "geef alle pairings van user X" (komt pas bij remote-revoke in M+1).
|
||||
- Trigger emit ook bij DELETE niet nodig — pairings worden niet gedelete'd, ze gaan naar `consumed`/`cancelled`.
|
||||
- `vendor/scrum4me`-submodule in scrum4me-mcp moet ná merge op `main` direct gesynced worden, anders breekt de wekelijkse drift-check (`trig_015FFUnxjz9WMuhhWNGBQKFD`). Dit was ook een aandachtspunt bij ST-901.
|
||||
|
||||
**Verificatie**
|
||||
- `npx prisma migrate dev` slaagt
|
||||
- `npx prisma validate` zonder fouten
|
||||
- `psql $DIRECT_URL -c "LISTEN scrum4me_pairing;"` toont payload bij `INSERT INTO login_pairings(...) VALUES(...)`
|
||||
- `prisma studio` toont tabel met beide hash-kolommen `NOT NULL`
|
||||
|
||||
---
|
||||
|
||||
## ST-1002 — Pairing-helpers + sessie-uitbreiding + pre-auth-cookie
|
||||
|
||||
**Bestanden**
|
||||
- `lib/auth/pairing.ts` — nieuw, secret/token-generatie en hash-helpers
|
||||
- `lib/auth/pair-cookie.ts` — nieuw, set/read/clear van `s4m_pair`-cookie
|
||||
- `lib/session.ts` — `SessionData` uitbreiden met `paired` en `pairedExpiresAt`
|
||||
- `app/(app)/layout.tsx` — extra guard op vervallen paired-sessie
|
||||
- `__tests__/lib/auth/pairing.test.ts` — nieuw
|
||||
|
||||
**Stappen**
|
||||
|
||||
1. **`lib/auth/pairing.ts`**:
|
||||
|
||||
```ts
|
||||
import { createHash, randomBytes, timingSafeEqual } from 'crypto'
|
||||
|
||||
export function generateMobileSecret(): string {
|
||||
return randomBytes(32).toString('base64url')
|
||||
}
|
||||
|
||||
export function generateDesktopToken(): string {
|
||||
return randomBytes(32).toString('base64url')
|
||||
}
|
||||
|
||||
export function hashToken(token: string): string {
|
||||
return createHash('sha256').update(token).digest('hex')
|
||||
}
|
||||
|
||||
export function verifyToken(token: string, hash: string): boolean {
|
||||
const a = Buffer.from(hashToken(token), 'hex')
|
||||
const b = Buffer.from(hash, 'hex')
|
||||
if (a.length !== b.length) return false
|
||||
return timingSafeEqual(a, b)
|
||||
}
|
||||
```
|
||||
|
||||
Twee aparte generators (niet één functie met arg) voorkomen dat dezelfde geheim per ongeluk twee keer wordt gebruikt.
|
||||
|
||||
2. **`lib/auth/pair-cookie.ts`**:
|
||||
|
||||
```ts
|
||||
import { cookies } from 'next/headers'
|
||||
|
||||
const COOKIE_NAME = 's4m_pair'
|
||||
const MAX_AGE = 120 // 2 min, gelijk aan pending-TTL van pairing
|
||||
|
||||
export async function setPairCookie(desktopToken: string) {
|
||||
const jar = await cookies()
|
||||
jar.set(COOKIE_NAME, desktopToken, {
|
||||
httpOnly: true,
|
||||
secure: process.env.NODE_ENV === 'production',
|
||||
sameSite: 'lax',
|
||||
path: '/api/auth/pair',
|
||||
maxAge: MAX_AGE,
|
||||
})
|
||||
}
|
||||
|
||||
export async function readPairCookie(): Promise<string | null> {
|
||||
const jar = await cookies()
|
||||
return jar.get(COOKIE_NAME)?.value ?? null
|
||||
}
|
||||
|
||||
export async function clearPairCookie() {
|
||||
const jar = await cookies()
|
||||
jar.delete({ name: COOKIE_NAME, path: '/api/auth/pair' })
|
||||
}
|
||||
```
|
||||
|
||||
`Path=/api/auth/pair` zorgt dat de cookie alleen naar pair-endpoints wordt gestuurd — niet naar elke route.
|
||||
|
||||
3. **`lib/session.ts`** — `SessionData` interface:
|
||||
|
||||
```ts
|
||||
export interface SessionData {
|
||||
userId: string
|
||||
isDemo: boolean
|
||||
paired?: boolean // true als sessie is aangemaakt via QR-pairing
|
||||
pairedExpiresAt?: number // unix ms
|
||||
}
|
||||
```
|
||||
|
||||
Bestaande sessies blijven werken — beide velden zijn optioneel.
|
||||
|
||||
4. **`app/(app)/layout.tsx`** — guard ná de bestaande `if (!session.userId) redirect('/login')`:
|
||||
|
||||
```ts
|
||||
if (session.paired && session.pairedExpiresAt && session.pairedExpiresAt < Date.now()) {
|
||||
session.destroy()
|
||||
redirect('/login?notice=paired-expired')
|
||||
}
|
||||
```
|
||||
|
||||
Hergebruikt het bestaande `notice`-querystring-patroon van M9's `<NoticeToast />` voor de melding "Je sessie is verlopen, log opnieuw in".
|
||||
|
||||
5. **Tests** — `__tests__/lib/auth/pairing.test.ts`:
|
||||
- `generateMobileSecret()` produceert 43-karakter base64url (32 bytes)
|
||||
- `hashToken` is deterministisch
|
||||
- `verifyToken` is true voor geldig paar, false voor ongeldig
|
||||
- Twee verschillende `generateMobileSecret()`-calls geven verschillende waardes
|
||||
- Cookie helpers: HttpOnly bit gezet (via Next.js cookie-store mock)
|
||||
|
||||
**Aandachtspunten**
|
||||
- Geen middleware nodig voor de paired-expiry-check; layout-guard is voldoende. Middleware komt pas in beeld als `proxy.ts` herzien wordt (uit scope hier).
|
||||
- `cookies().delete({ name, path })` moet **dezelfde path** specificeren als bij set, anders blijft de cookie staan.
|
||||
- `crypto.randomBytes` is sync en blocking — voor 32 bytes ruim < 1ms; geen async-variant nodig.
|
||||
|
||||
**Verificatie**
|
||||
- `npm run lint && npx tsc --noEmit && npm test && npm run build` groen
|
||||
- Handmatig: in DevTools Application-tab is de cookie zichtbaar als HttpOnly + Path scoped
|
||||
- `document.cookie` op de pagina laat de cookie *niet* zien
|
||||
|
||||
---
|
||||
|
||||
## ST-1003 — `POST /api/auth/pair/start` (anon, sets pre-auth cookie)
|
||||
|
||||
**Bestanden**
|
||||
- `app/api/auth/pair/start/route.ts` — nieuw
|
||||
- `lib/rate-limit.ts` — checken of bestaand (uit ST-608); anders helper toevoegen
|
||||
- `__tests__/api/pair-start.test.ts` — nieuw
|
||||
|
||||
**Stappen**
|
||||
|
||||
1. Route Handler (vrij van `authenticateApiRequest` — dit is anon):
|
||||
|
||||
```ts
|
||||
import { NextRequest } from 'next/server'
|
||||
import { prisma } from '@/lib/prisma'
|
||||
import {
|
||||
generateMobileSecret, generateDesktopToken, hashToken,
|
||||
} from '@/lib/auth/pairing'
|
||||
import { setPairCookie } from '@/lib/auth/pair-cookie'
|
||||
import { rateLimit } from '@/lib/rate-limit'
|
||||
|
||||
export const runtime = 'nodejs'
|
||||
|
||||
const PENDING_TTL_MS = 2 * 60 * 1000
|
||||
|
||||
export async function POST(request: NextRequest) {
|
||||
const ip = request.headers.get('x-forwarded-for')?.split(',')[0]?.trim() ?? null
|
||||
const ua = request.headers.get('user-agent')?.slice(0, 255) ?? null
|
||||
|
||||
// Rate-limit per IP — 10/min (zelfde patroon als ST-608)
|
||||
const limited = await rateLimit(`pair-start:${ip ?? 'anon'}`, 10, 60_000)
|
||||
if (limited) {
|
||||
return Response.json({ error: 'Te veel verzoeken' }, { status: 429 })
|
||||
}
|
||||
|
||||
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: ip,
|
||||
expires_at: new Date(Date.now() + PENDING_TTL_MS),
|
||||
},
|
||||
select: { id: true, expires_at: true },
|
||||
})
|
||||
|
||||
await setPairCookie(desktopToken)
|
||||
|
||||
const origin = request.nextUrl.origin
|
||||
const qrUrl = `${origin}/m/pair#id=${pairing.id}&s=${mobileSecret}`
|
||||
|
||||
return Response.json({
|
||||
pairingId: pairing.id,
|
||||
mobileSecret,
|
||||
expiresAt: pairing.expires_at.toISOString(),
|
||||
qrUrl,
|
||||
})
|
||||
}
|
||||
```
|
||||
|
||||
2. **Rate-limit helper** — als `lib/rate-limit.ts` nog niet bestaat:
|
||||
- In-memory Map keyed op `key`, met sliding-window van timestamps; thread-safe genoeg voor v1 single-instance Vercel Functions.
|
||||
- Wordt ook door `actions/auth.ts` (login) gebruikt; verifieer met grep of die al bestaat — zo ja, hergebruik exact.
|
||||
|
||||
3. **Tests** — `__tests__/api/pair-start.test.ts`:
|
||||
- 200 response bevat `pairingId`, `mobileSecret`, `qrUrl` met fragment-syntax (`#id=…&s=…`)
|
||||
- `Set-Cookie`-header bevat `s4m_pair=...; HttpOnly; SameSite=Lax; Path=/api/auth/pair; Max-Age=120`
|
||||
- Database-rij heeft `secret_hash`, `desktop_token_hash`, geen plaintext
|
||||
- 11e call binnen 60s → 429
|
||||
- `desktop_ua` en `desktop_ip` worden opgeslagen bij aanwezigheid, anders `null`
|
||||
|
||||
**Aandachtspunten**
|
||||
- `mobileSecret` mag in de JSON-response — die is HTTPS-encrypted en wordt niet door browsers naar logs geschreven. Kritisch is dat het niet in **request**-URLs of **server**-toegangslogs belandt.
|
||||
- `qrUrl` gebruikt `#` (fragment), niet `?`. Browsers strippen het fragment voordat ze de mobile pair-page ophalen — maar dat is uitvoerig getest in ST-1005 server-side: de page leest de URL niet, alleen de client component leest `window.location.hash`.
|
||||
- Geen idempotency-key nodig: een tweede start vanuit dezelfde tab maakt simpelweg een nieuwe pairing en cookie aan; oude cookie wordt overschreven.
|
||||
- Vercel-edge-of-fluid: `runtime: 'nodejs'` expliciet (niet 'edge') want we gebruiken `crypto.randomBytes`.
|
||||
|
||||
**Verificatie**
|
||||
- `curl -i -X POST http://localhost:3000/api/auth/pair/start --cookie-jar /tmp/jar` retourneert JSON + `Set-Cookie`
|
||||
- Body bevat `qrUrl` met `#id=…&s=…`
|
||||
- Rij in `login_pairings` heeft beide hashes ingevuld
|
||||
- Tests groen
|
||||
|
||||
---
|
||||
|
||||
## ST-1004 — SSE-route `/api/auth/pair/stream/[pairingId]` (cookie-auth)
|
||||
|
||||
**Bestanden**
|
||||
- `app/api/auth/pair/stream/[pairingId]/route.ts` — nieuw
|
||||
- `__tests__/api/pair-stream.test.ts` — nieuw (auth-test, geen full SSE-test)
|
||||
|
||||
**Stappen**
|
||||
|
||||
1. **Routebestand** — exacte structuur uit `app/api/realtime/solo/route.ts` (incl. heartbeat, hard-close, abort-handler), maar:
|
||||
- Geen iron-session check; auth via `s4m_pair`-cookie:
|
||||
|
||||
```ts
|
||||
const desktopToken = await readPairCookie()
|
||||
if (!desktopToken) return Response.json({ error: 'Geen pairing-cookie' }, { status: 401 })
|
||||
|
||||
const pairing = await prisma.loginPairing.findUnique({
|
||||
where: { id: pairingId },
|
||||
select: { desktop_token_hash: true, status: true, expires_at: true },
|
||||
})
|
||||
if (!pairing) return Response.json({ error: 'Pairing niet gevonden' }, { status: 404 })
|
||||
if (pairing.expires_at < new Date()) return Response.json({ error: 'Pairing verlopen' }, { status: 410 })
|
||||
if (!verifyToken(desktopToken, pairing.desktop_token_hash)) {
|
||||
return Response.json({ error: 'Ongeldige cookie' }, { status: 401 })
|
||||
}
|
||||
```
|
||||
|
||||
- Channel = `'scrum4me_pairing'`
|
||||
- Filter `shouldEmit`: `payload.pairing_id === pairingId`
|
||||
- Auto-close ook bij payload `status` ∈ `{'consumed', 'cancelled'}` — niet alleen na 240 s
|
||||
- Geen sprint-resolve, geen userId-filter — eenvoudiger dan solo-route
|
||||
|
||||
2. **Initial-state-event** vlak na verbinden: query de pairing-status één keer uit DB en stuur als `event: state\ndata: {"status":"pending"}` zodat de desktop niet hoeft te wachten op het eerste pg_notify (handig als pairing al `approved` is voordat de SSE opent — race-conditie).
|
||||
|
||||
3. **Tests**:
|
||||
- GET zonder cookie → 401
|
||||
- GET met cookie maar onbekende `pairingId` → 404
|
||||
- GET met verlopen pairing → 410
|
||||
- GET met cookie die hasht naar een andere `desktop_token_hash` → 401
|
||||
|
||||
Geen full-stream-test — vereist een Postgres-event-mock die het niet waard is voor v1. Manuele test dekt dit (zie verificatie).
|
||||
|
||||
**Aandachtspunten**
|
||||
- `pairingId` in het URL-pad is OK — niet vertrouwelijk. De cookie is het bewijs.
|
||||
- `EventSource` op de client kan geen custom headers; cookie is daarom de enige praktische auth-methode. `withCredentials: true` op de client is verplicht.
|
||||
- Bij browser-tab-sluiten wordt `request.signal.abort` getriggered → cleanup zoals in solo-route.
|
||||
- Vermijd Prisma in de notification-handler; gebruik alleen `pg.Client` zoals solo-route. Status-check hierboven is de enige Prisma-call (vóór de stream start).
|
||||
|
||||
**Verificatie**
|
||||
- `curl -N --cookie /tmp/jar http://localhost:3000/api/auth/pair/stream/<id>` blijft open en print `: heartbeat` elke 25 s
|
||||
- Andere terminal: `psql $DIRECT_URL -c "UPDATE login_pairings SET status='approved' WHERE id='<id>'"` → curl-uitvoer toont event binnen 1 s
|
||||
- Manuele 401-test: `curl -N` zonder cookie → JSON 401
|
||||
|
||||
---
|
||||
|
||||
## ST-1005 — Server actions + mobiele bevestigingspagina
|
||||
|
||||
**Bestanden**
|
||||
- `actions/pairing.ts` — nieuw, drie Server Actions
|
||||
- `app/(app)/m/pair/page.tsx` — nieuw, Server Component
|
||||
- `app/(app)/m/pair/pair-confirmation.tsx` — nieuw, Client Component
|
||||
- `__tests__/actions/pairing.test.ts` — nieuw
|
||||
|
||||
**Stappen**
|
||||
|
||||
1. **`actions/pairing.ts`** — volgt `docs/patterns/server-action.md`:
|
||||
|
||||
```ts
|
||||
'use server'
|
||||
|
||||
import { revalidatePath } from 'next/cache'
|
||||
import { z } from 'zod'
|
||||
import { prisma } from '@/lib/prisma'
|
||||
import { getSession } from '@/lib/auth'
|
||||
import { verifyToken } from '@/lib/auth/pairing'
|
||||
|
||||
const inputSchema = z.object({
|
||||
pairingId: z.string().cuid(),
|
||||
mobileSecret: z.string().min(40), // base64url van 32 bytes ≈ 43 chars
|
||||
})
|
||||
|
||||
export async function getPairingForApproval(pairingId: string, mobileSecret: string) {
|
||||
const session = await getSession()
|
||||
if (!session.userId) return { ok: false, error: 'Niet ingelogd' } as const
|
||||
|
||||
const parsed = inputSchema.safeParse({ pairingId, mobileSecret })
|
||||
if (!parsed.success) return { ok: false, error: 'Ongeldige invoer' } as const
|
||||
|
||||
const pairing = await prisma.loginPairing.findUnique({
|
||||
where: { id: pairingId },
|
||||
select: {
|
||||
status: true, expires_at: true, secret_hash: true,
|
||||
desktop_ua: true, desktop_ip: true,
|
||||
},
|
||||
})
|
||||
if (!pairing) return { ok: false, error: 'Pairing niet gevonden' } as const
|
||||
if (pairing.expires_at < new Date()) return { ok: false, error: 'Pairing verlopen' } as const
|
||||
if (pairing.status !== 'pending') return { ok: false, error: 'Pairing al afgehandeld' } as const
|
||||
if (!verifyToken(mobileSecret, pairing.secret_hash)) {
|
||||
return { ok: false, error: 'Ongeldig pairing-geheim' } as const
|
||||
}
|
||||
|
||||
const me = await prisma.user.findUnique({
|
||||
where: { id: session.userId },
|
||||
select: { username: true },
|
||||
})
|
||||
return {
|
||||
ok: true,
|
||||
desktop_ua: pairing.desktop_ua,
|
||||
desktop_ip: pairing.desktop_ip,
|
||||
username: me?.username ?? '',
|
||||
} as const
|
||||
}
|
||||
|
||||
export async function approvePairing(pairingId: string, mobileSecret: string) {
|
||||
const session = await getSession()
|
||||
if (!session.userId) return { ok: false, error: 'Niet ingelogd' } as const
|
||||
if (session.isDemo) return { ok: false, error: 'Niet beschikbaar in demo-modus' } as const
|
||||
|
||||
const parsed = inputSchema.safeParse({ pairingId, mobileSecret })
|
||||
if (!parsed.success) return { ok: false, error: 'Ongeldige invoer' } as const
|
||||
|
||||
const pairing = await prisma.loginPairing.findUnique({
|
||||
where: { id: pairingId },
|
||||
select: { status: true, expires_at: true, secret_hash: true },
|
||||
})
|
||||
if (!pairing) return { ok: false, error: 'Pairing niet gevonden' } as const
|
||||
if (pairing.expires_at < new Date()) return { ok: false, error: 'Pairing verlopen' } as const
|
||||
if (pairing.status !== 'pending') return { ok: false, error: 'Pairing al afgehandeld' } as const
|
||||
if (!verifyToken(mobileSecret, pairing.secret_hash)) {
|
||||
return { ok: false, error: 'Ongeldig pairing-geheim' } as const
|
||||
}
|
||||
|
||||
const APPROVED_TTL_MS = 5 * 60 * 1000
|
||||
await prisma.loginPairing.update({
|
||||
where: { id: pairingId },
|
||||
data: {
|
||||
status: 'approved',
|
||||
user_id: session.userId,
|
||||
approved_at: new Date(),
|
||||
expires_at: new Date(Date.now() + APPROVED_TTL_MS),
|
||||
},
|
||||
})
|
||||
// Trigger emit pg_notify automatisch — geen revalidatePath nodig
|
||||
return { ok: true } as const
|
||||
}
|
||||
|
||||
export async function cancelPairing(pairingId: string, mobileSecret: string) {
|
||||
// Vergelijkbaar met approvePairing maar status='cancelled', geen user_id; demo mag annuleren.
|
||||
}
|
||||
```
|
||||
|
||||
2. **`app/(app)/m/pair/page.tsx`** — Server Component, achter bestaande `(app)/layout.tsx` auth-guard:
|
||||
|
||||
```tsx
|
||||
import { PairConfirmation } from './pair-confirmation'
|
||||
|
||||
export default function PairPage() {
|
||||
return (
|
||||
<main className="container mx-auto max-w-md py-12">
|
||||
<h1 className="text-h2">Inloggen op desktop</h1>
|
||||
<p className="text-muted-foreground mt-2">
|
||||
Bevestig hieronder dat je wilt inloggen op het apparaat dat de QR-code toont.
|
||||
</p>
|
||||
<PairConfirmation />
|
||||
</main>
|
||||
)
|
||||
}
|
||||
```
|
||||
|
||||
Geen searchParams! De page leest de URL überhaupt niet — alleen het client-island doet dat client-side via `window.location.hash`.
|
||||
|
||||
3. **`app/(app)/m/pair/pair-confirmation.tsx`** — Client Component:
|
||||
|
||||
```tsx
|
||||
'use client'
|
||||
|
||||
import { useEffect, useState, useTransition } from 'react'
|
||||
import { Button } from '@/components/ui/button'
|
||||
import { toast } from 'sonner'
|
||||
import { getPairingForApproval, approvePairing, cancelPairing } from '@/actions/pairing'
|
||||
|
||||
type State =
|
||||
| { kind: 'loading' }
|
||||
| { kind: 'invalid'; error: string }
|
||||
| { kind: 'ready'; pairingId: string; secret: string; ua: string | null; ip: string | null; username: string }
|
||||
| { kind: 'success' }
|
||||
| { kind: 'error'; error: string }
|
||||
|
||||
function parseHash(): { id: string; s: string } | null {
|
||||
if (typeof window === 'undefined') return null
|
||||
const hash = window.location.hash.replace(/^#/, '')
|
||||
const params = new URLSearchParams(hash)
|
||||
const id = params.get('id'); const s = params.get('s')
|
||||
return id && s ? { id, s } : null
|
||||
}
|
||||
|
||||
export function PairConfirmation() {
|
||||
const [state, setState] = useState<State>({ kind: 'loading' })
|
||||
const [pending, startTransition] = useTransition()
|
||||
|
||||
useEffect(() => {
|
||||
const parsed = parseHash()
|
||||
if (!parsed) {
|
||||
setState({ kind: 'invalid', error: 'Ongeldige pairing-link' })
|
||||
return
|
||||
}
|
||||
getPairingForApproval(parsed.id, parsed.s).then((res) => {
|
||||
if (!res.ok) setState({ kind: 'invalid', error: res.error })
|
||||
else setState({
|
||||
kind: 'ready',
|
||||
pairingId: parsed.id, secret: parsed.s,
|
||||
ua: res.desktop_ua, ip: res.desktop_ip, username: res.username,
|
||||
})
|
||||
})
|
||||
}, [])
|
||||
|
||||
function onApprove() {
|
||||
if (state.kind !== 'ready') return
|
||||
startTransition(async () => {
|
||||
const res = await approvePairing(state.pairingId, state.secret)
|
||||
if (!res.ok) { toast.error(res.error); return }
|
||||
// Wist secret uit URL zodat back/forward 'm niet onthult
|
||||
if (typeof window !== 'undefined') {
|
||||
window.history.replaceState(null, '', window.location.pathname)
|
||||
}
|
||||
setState({ kind: 'success' })
|
||||
})
|
||||
}
|
||||
|
||||
function onCancel() {
|
||||
if (state.kind !== 'ready') return
|
||||
startTransition(async () => {
|
||||
await cancelPairing(state.pairingId, state.secret)
|
||||
setState({ kind: 'invalid', error: 'Pairing geannuleerd' })
|
||||
})
|
||||
}
|
||||
|
||||
// Render-logica per state — kort:
|
||||
// loading → spinner
|
||||
// invalid → foutmelding + link "Terug naar dashboard"
|
||||
// ready → kaart met UA/IP/username + Bevestig/Annuleer
|
||||
// success → "Klaar — je kunt deze tab sluiten"
|
||||
// … (volledige JSX in implementation)
|
||||
}
|
||||
```
|
||||
|
||||
4. **Tests** — `__tests__/actions/pairing.test.ts`:
|
||||
- `getPairingForApproval` met `pending`-pairing → `ok: true` + ua/ip/username
|
||||
- `getPairingForApproval` met al-`approved` → `ok: false`
|
||||
- `getPairingForApproval` met verlopen → `ok: false`
|
||||
- `getPairingForApproval` met verkeerd secret → `ok: false`
|
||||
- `approvePairing` als demo-user → `ok: false` + DB onveranderd
|
||||
- `approvePairing` happy path → status `approved`, `user_id` gezet, `expires_at` bumped
|
||||
- `cancelPairing` happy path → status `cancelled`
|
||||
|
||||
**Aandachtspunten**
|
||||
- `getPairingForApproval` mág door demo-users worden aangeroepen (om iets te zien); alleen `approvePairing` blokkeert demo's.
|
||||
- `pair-confirmation.tsx` gebruikt **geen** `useSearchParams` — die kan in Next.js 16 hash niet lezen, en we willen het ook niet via routing zien.
|
||||
- Na approve `window.history.replaceState` zonder de `#hash` zorgt dat browser-back de secret niet opnieuw onthult.
|
||||
- `revalidatePath` in `approvePairing` is niet nodig — de desktop hoort het via SSE, en de mobiele page heeft geen server-state om te ververversen.
|
||||
|
||||
**Verificatie**
|
||||
- `npm run lint && npx tsc --noEmit && npm test && npm run build` groen
|
||||
- Handmatig: log in op telefoon-emulator → bezoek `/m/pair#id=…&s=…` → kaart toont UA/IP → Bevestig → succes-state, URL is `/m/pair` zonder hash
|
||||
|
||||
---
|
||||
|
||||
## ST-1006 — `POST /api/auth/pair/claim` (cookie-auth, schrijft iron-session)
|
||||
|
||||
**Bestanden**
|
||||
- `app/api/auth/pair/claim/route.ts` — nieuw
|
||||
- `__tests__/api/pair-claim.test.ts` — nieuw
|
||||
|
||||
**Stappen**
|
||||
|
||||
1. Route Handler:
|
||||
|
||||
```ts
|
||||
import { NextRequest } from 'next/server'
|
||||
import { getIronSession } from 'iron-session'
|
||||
import { cookies } from 'next/headers'
|
||||
import { prisma } from '@/lib/prisma'
|
||||
import { SessionData, sessionOptions } from '@/lib/session'
|
||||
import { hashToken } from '@/lib/auth/pairing'
|
||||
import { readPairCookie, clearPairCookie } from '@/lib/auth/pair-cookie'
|
||||
|
||||
export const runtime = 'nodejs'
|
||||
|
||||
const PAIRED_TTL_MS = 8 * 60 * 60 * 1000 // 8 uur
|
||||
|
||||
export async function POST(request: NextRequest) {
|
||||
const desktopToken = await readPairCookie()
|
||||
if (!desktopToken) return Response.json({ error: 'Geen pairing-cookie' }, { status: 401 })
|
||||
|
||||
const body = await request.json().catch(() => null) as { pairingId?: string } | null
|
||||
const pairingId = body?.pairingId
|
||||
if (!pairingId) return Response.json({ error: 'pairingId vereist' }, { status: 400 })
|
||||
|
||||
const desktopTokenHash = hashToken(desktopToken)
|
||||
|
||||
// Atomic: WHERE status='approved' AND token-hash + expiry → consumed, RETURNING user_id
|
||||
const updated = await prisma.loginPairing.updateMany({
|
||||
where: {
|
||||
id: pairingId,
|
||||
status: 'approved',
|
||||
desktop_token_hash: desktopTokenHash,
|
||||
expires_at: { gt: new Date() },
|
||||
},
|
||||
data: { status: 'consumed', consumed_at: new Date() },
|
||||
})
|
||||
if (updated.count !== 1) {
|
||||
// Was het wél een geldige cookie maar al consumed? → 410. Anders → 401.
|
||||
const exists = await prisma.loginPairing.findFirst({
|
||||
where: { id: pairingId, desktop_token_hash: desktopTokenHash },
|
||||
select: { status: true, expires_at: true },
|
||||
})
|
||||
await clearPairCookie()
|
||||
if (!exists) return Response.json({ error: 'Ongeldig' }, { status: 401 })
|
||||
if (exists.status === 'consumed') return Response.json({ error: 'Al gebruikt' }, { status: 410 })
|
||||
return Response.json({ error: 'Niet beschikbaar' }, { status: 410 })
|
||||
}
|
||||
|
||||
const pairing = await prisma.loginPairing.findUnique({
|
||||
where: { id: pairingId },
|
||||
select: { user_id: true, user: { select: { is_demo: true } } },
|
||||
})
|
||||
if (!pairing?.user_id) {
|
||||
await clearPairCookie()
|
||||
return Response.json({ error: 'Pairing zonder user' }, { status: 500 })
|
||||
}
|
||||
|
||||
const session = await getIronSession<SessionData>(await cookies(), sessionOptions)
|
||||
session.userId = pairing.user_id
|
||||
session.isDemo = pairing.user?.is_demo ?? false
|
||||
session.paired = true
|
||||
session.pairedExpiresAt = Date.now() + PAIRED_TTL_MS
|
||||
await session.save()
|
||||
|
||||
await clearPairCookie()
|
||||
return Response.json({ ok: true })
|
||||
}
|
||||
```
|
||||
|
||||
2. **Tests**:
|
||||
- 200 + iron-session cookie + clear `s4m_pair` na succes
|
||||
- 410 op tweede claim met dezelfde cookie
|
||||
- 401 zonder cookie
|
||||
- 401 met cookie die hasht naar andere pairing
|
||||
- paired-sessie bevat `paired: true` en `pairedExpiresAt` rond `now + 8h`
|
||||
|
||||
**Aandachtspunten**
|
||||
- `updateMany` gebruiken (niet `update`) want we hebben een composite WHERE met `status` + `desktop_token_hash` + `expires_at`; `update` kan alleen op unique keys.
|
||||
- Het WHERE-criterium garandeert atomiciteit: PostgreSQL UPDATE met meerdere predicates is row-level locked; concurrent dubbele claim resulteert in `count = 1` voor één caller en `count = 0` voor de ander.
|
||||
- `clearPairCookie` ook bij faalpaden, anders blijft 'm na expiry hangen (cosmetisch — `Max-Age=120` regelt het ook).
|
||||
- De `session.isDemo` check overneemt: als de approver een demo-user is — wat ST-1005 al blokkeert — komen we hier niet eens, maar `is_demo` doorzetten is een extra vangnet.
|
||||
|
||||
**Verificatie**
|
||||
- Handmatig: na approve in mobiele tab, POST naar `/api/auth/pair/claim` met de cookie van start → 200 + `Set-Cookie: scrum4me-session=...`
|
||||
- `curl -X POST` zonder cookie → 401
|
||||
- Tweede claim → 410
|
||||
|
||||
---
|
||||
|
||||
## ST-1007 — Desktop UI: QR-render + SSE-listener op `/login`
|
||||
|
||||
**Bestanden**
|
||||
- `app/login/page.tsx` — bestaand, knop "Inloggen via mobiel" toevoegen
|
||||
- `app/login/qr-login-button.tsx` — nieuw, Client Component
|
||||
- `package.json` — `qrcode.react` toevoegen
|
||||
- `__tests__/components/qr-login-button.test.tsx` — minimale render-test
|
||||
|
||||
**Stappen**
|
||||
|
||||
1. **Dependency**: `npm install qrcode.react` — direct in `dependencies` per CLAUDE.md-conventie.
|
||||
|
||||
2. **`qr-login-button.tsx`**:
|
||||
|
||||
```tsx
|
||||
'use client'
|
||||
|
||||
import { useEffect, useRef, useState } from 'react'
|
||||
import { useRouter } from 'next/navigation'
|
||||
import { QRCodeSVG } from 'qrcode.react'
|
||||
import { Button } from '@/components/ui/button'
|
||||
import { toast } from 'sonner'
|
||||
|
||||
type Phase =
|
||||
| { kind: 'idle' }
|
||||
| { kind: 'starting' }
|
||||
| { kind: 'showing'; pairingId: string; qrUrl: string; expiresAt: number }
|
||||
| { kind: 'expired'; pairingId: string }
|
||||
| { kind: 'claiming' }
|
||||
|
||||
export function QrLoginButton() {
|
||||
const router = useRouter()
|
||||
const [phase, setPhase] = useState<Phase>({ kind: 'idle' })
|
||||
const sseRef = useRef<EventSource | null>(null)
|
||||
|
||||
async function start() {
|
||||
setPhase({ kind: 'starting' })
|
||||
const res = await fetch('/api/auth/pair/start', {
|
||||
method: 'POST', credentials: 'same-origin',
|
||||
})
|
||||
if (!res.ok) { toast.error('Kon QR-code niet aanmaken'); setPhase({ kind: 'idle' }); return }
|
||||
const data = await res.json() as { pairingId: string; qrUrl: string; expiresAt: string }
|
||||
setPhase({
|
||||
kind: 'showing',
|
||||
pairingId: data.pairingId,
|
||||
qrUrl: data.qrUrl,
|
||||
expiresAt: new Date(data.expiresAt).getTime(),
|
||||
})
|
||||
}
|
||||
|
||||
// SSE-koppeling
|
||||
useEffect(() => {
|
||||
if (phase.kind !== 'showing') return
|
||||
const es = new EventSource(`/api/auth/pair/stream/${phase.pairingId}`, {
|
||||
withCredentials: true,
|
||||
})
|
||||
sseRef.current = es
|
||||
|
||||
es.addEventListener('message', async (ev) => {
|
||||
const data = JSON.parse(ev.data) as { status?: string }
|
||||
if (data.status === 'approved') {
|
||||
es.close()
|
||||
setPhase({ kind: 'claiming' })
|
||||
const res = await fetch('/api/auth/pair/claim', {
|
||||
method: 'POST',
|
||||
credentials: 'same-origin',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ pairingId: phase.pairingId }),
|
||||
})
|
||||
if (!res.ok) {
|
||||
toast.error('Inloggen mislukt')
|
||||
setPhase({ kind: 'idle' }); return
|
||||
}
|
||||
router.push('/dashboard')
|
||||
}
|
||||
})
|
||||
es.addEventListener('error', () => { /* silent — laat reconnecten */ })
|
||||
|
||||
return () => { es.close() }
|
||||
}, [phase, router])
|
||||
|
||||
// Aftellende timer + auto-expire
|
||||
useEffect(() => {
|
||||
if (phase.kind !== 'showing') return
|
||||
const t = setInterval(() => {
|
||||
if (Date.now() > phase.expiresAt) {
|
||||
sseRef.current?.close()
|
||||
setPhase({ kind: 'expired', pairingId: phase.pairingId })
|
||||
clearInterval(t)
|
||||
}
|
||||
}, 1000)
|
||||
return () => clearInterval(t)
|
||||
}, [phase])
|
||||
|
||||
// Render: knop / QR + countdown / "Vernieuwen" — JSX hier weggelaten voor brevity
|
||||
}
|
||||
```
|
||||
|
||||
3. **`app/login/page.tsx`** — knop toevoegen onder of naast het wachtwoord-formulier:
|
||||
|
||||
```tsx
|
||||
<div className="my-4 flex items-center gap-2">
|
||||
<div className="border-border h-px flex-1 border-t" />
|
||||
<span className="text-muted-foreground text-sm">of</span>
|
||||
<div className="border-border h-px flex-1 border-t" />
|
||||
</div>
|
||||
<QrLoginButton />
|
||||
```
|
||||
|
||||
MD3-tokens uit `docs/scrum4me-styling.md`; geen willekeurige Tailwind-kleuren.
|
||||
|
||||
4. **A11y**: QR-component krijgt `aria-label="QR-code voor mobiel inloggen"` en de URL wordt visueel als kopieer-bare tekst onder de QR getoond zodat screenreaders en gebruikers met cameraproblemen de URL handmatig kunnen openen.
|
||||
|
||||
**Aandachtspunten**
|
||||
- `EventSource({ withCredentials: true })` is verplicht zodat de browser de `s4m_pair`-cookie meestuurt; standaard verstuurt EventSource geen credentials.
|
||||
- Cleanup-volgorde: `es.close()` eerst, dán fetch claim. Anders kan een tweede `approved`-event tussen close-en-claim binnenkomen en een dubbele claim triggeren (server vangt het op met 410, maar netter is het netjes te sluiten).
|
||||
- `qrcode.react` exporteert zowel `QRCodeSVG` als `QRCodeCanvas`. Kies SVG: schaalbaarder voor printen/screenshots en kleinere bundle.
|
||||
- Geen `next/image` — QR is dynamisch en client-side.
|
||||
|
||||
**Verificatie**
|
||||
- `npm run lint && npx tsc --noEmit && npm test && npm run build` groen
|
||||
- Handmatige twee-browser-test: A toont QR → B (ingelogd op mobile-emulator) opent QR-URL en bevestigt → A redirect naar `/dashboard` met `session.paired === true`
|
||||
- DevTools Network-tab: geen URL bevat `s=` (alleen `Set-Cookie`/`Cookie` headers)
|
||||
- Speel met een verlopen QR: na 2 min toont knop "Vernieuwen", `Vernieuwen` start nieuwe pairing
|
||||
|
||||
---
|
||||
|
||||
## ST-1008 — Documentatie + acceptatietest
|
||||
|
||||
**Bestanden**
|
||||
- `docs/API.md` — drie nieuwe endpoints
|
||||
- `docs/scrum4me-architecture.md` — sectie "QR-pairing flow" + threat-model
|
||||
- `docs/patterns/qr-login.md` — nieuw pattern-doc
|
||||
- `CLAUDE.md` — verwijzing naar het pattern-doc in de patterns-tabel
|
||||
- `__tests__/integration/qr-pairing-e2e.test.ts` — optioneel, alleen als de test-infra het toelaat
|
||||
|
||||
**Stappen**
|
||||
|
||||
1. **`docs/API.md`** — drie endpoints documenteren met request/response, foutcodes (400/401/403/404/410/422/429), curl-voorbeelden inclusief `--cookie-jar`. Voeg een sectie *"Cookie-mechaniek"* toe die uitlegt dat `s4m_pair` een tijdelijke pre-auth cookie is, anders dan de iron-session cookie.
|
||||
|
||||
2. **`docs/scrum4me-architecture.md`** — sectie *"QR-pairing flow"* met:
|
||||
- Sequence-diagram (mermaid of ASCII analoog aan M8)
|
||||
- Threat-model:
|
||||
- **Replay**: atomic `updateMany` met `status='approved'` voorkomt dubbele claim
|
||||
- **Phishing-QR**: mobiele bevestigingspagina toont UA + IP zodat gebruiker een vreemd apparaat herkent; expliciete tap vereist
|
||||
- **Demo-block**: `approvePairing` early return op `session.isDemo`
|
||||
- **Rate-limit**: 10 starts per IP per minuut
|
||||
- **Secret-hashing**: alleen sha256-hashes in DB; secrets verlaten desktop alleen via QR-fragment + POST-body
|
||||
- **TTL-rationale**: 2 min pending vs. 5 min approved vs. 8 u paired-sessie — verschillen verklaren
|
||||
- **Subsectie "Waarom geen secret in URL"**: fragment-eigenschap (browsers sturen `#…` niet naar server); HttpOnly cookie voor desktop-bewijs; geen secret in access logs / reverse-proxy logs / observability / browsergeschiedenis
|
||||
|
||||
3. **`docs/patterns/qr-login.md`** — herbruikbaar patroon: "unauth-SSE-via-pre-auth-cookie". Toekomstige features die een pre-auth flow met realtime-updates willen kunnen dit kopiëren (bv. "ontvang webhook live", "long-running export"). Inclusief:
|
||||
- Wanneer dit patroon te gebruiken (er moet realtime-feedback zijn vóór de gebruiker is geauthenticeerd)
|
||||
- Verwijzingen naar `lib/auth/pair-cookie.ts` als sjabloon
|
||||
- Risico's en mitigaties
|
||||
|
||||
4. **`CLAUDE.md`** — in de *Implementatiepatronen*-tabel een rij `| QR-pairing (unauth-SSE + pre-auth cookie) | docs/patterns/qr-login.md |`.
|
||||
|
||||
5. **Acceptatie-scenario's** (handmatig, eventueel automatiseerbaar in v2):
|
||||
1. Happy path — twee browsers, end-to-end binnen 2 minuten ingelogd
|
||||
2. Demo-block — mobiel ingelogd als demo-user, scant QR → kan niet bevestigen
|
||||
3. Replay — claim de pairing twee keer → tweede call 410
|
||||
4. Expiry tijdens pending — wacht 3 min na start, scan dan → mobiel toont "Pairing verlopen"
|
||||
5. Expiry tussen approve en claim — approve, wacht 6 min, claim → 410
|
||||
6. Ontbrekende cookie op SSE/claim — verwijder `s4m_pair` in DevTools, herhaal → 401
|
||||
7. **Secret niet in access logs** — controleer Vercel runtime-logs (via `mcp__a1fa0fcf-…__get_runtime_logs`) en lokale dev-logs; zoek op de secret-string en op `s=`-substrings; verwacht: 0 hits
|
||||
|
||||
**Aandachtspunten**
|
||||
- Zorg dat de runtime-logs MCP-controle in `docs/scrum4me-test-plan.md` belandt zodat hij bij elke release herhaalbaar is.
|
||||
- `docs/patterns/qr-login.md` mag refereren naar bestaande pattern-docs (iron-session, route-handler) zonder ze te dupliceren.
|
||||
|
||||
**Verificatie**
|
||||
- `npm run lint && npx tsc --noEmit && npm test && npm run build` groen
|
||||
- Alle zeven scenario's handmatig groen, beschreven in een test-rapport-sectie
|
||||
- `vendor/scrum4me`-submodule in scrum4me-mcp gesynced ná schema-merge
|
||||
|
||||
---
|
||||
|
||||
## Branch- en commit-strategie
|
||||
|
||||
Per [CLAUDE.md → Branch & PR Strategy](../../CLAUDE.md#branch--pr-strategy-strict--kostenbeheersing): **één branch voor de hele milestone**, PR pas na handmatige acceptatie door de gebruiker. Reden: elke push triggert een Vercel preview-build, en op het Hobby-account zijn die schaars.
|
||||
|
||||
**Branch:** `feat/M10-qr-login` — afgesplitst van `main` na merge van de planning-PR (#11). Alle ST-1001..ST-1008-werk landt op deze branch.
|
||||
|
||||
**Commits** in chronologische volgorde, één per stap, ST-code in de titel. Voorbeeld-progressie:
|
||||
|
||||
```
|
||||
feat(ST-1001): add LoginPairing model
|
||||
feat(ST-1001): add pg_notify trigger on scrum4me_pairing channel
|
||||
feat(ST-1002): add pairing helpers and pre-auth cookie
|
||||
feat(ST-1002): extend SessionData with paired flag
|
||||
feat(ST-1002): guard expired paired sessions in app layout
|
||||
feat(ST-1003): add /api/auth/pair/start with rate-limit and pre-auth cookie
|
||||
feat(ST-1004): add SSE /api/auth/pair/stream with cookie auth
|
||||
feat(ST-1005): add pairing server actions
|
||||
feat(ST-1005): add mobile pair confirmation page with hash-fragment client island
|
||||
feat(ST-1006): add /api/auth/pair/claim with atomic consume
|
||||
chore(ST-1007): add qrcode.react dependency
|
||||
feat(ST-1007): add QR login button on /login with SSE listener
|
||||
docs(ST-1008): document QR-pairing endpoints in API.md
|
||||
docs(ST-1008): add QR-pairing flow and threat-model to architecture
|
||||
docs(ST-1008): add qr-login pattern doc
|
||||
```
|
||||
|
||||
**Push + PR**: pas nadat ST-1008-acceptatie-scenario 1 (happy path, end-to-end op localhost) handmatig groen is bevonden door de gebruiker. Tussentijdse "klaar voor jouw test"-momenten markeren we lokaal — niet met een push.
|
||||
|
||||
**Pre-merge gates** (uit CLAUDE.md DoD):
|
||||
- `npm run lint && npm test && npm run build` groen op CI
|
||||
- Schema-wijziging in ST-1001 → wekelijkse drift-check `trig_015FFUnxjz9WMuhhWNGBQKFD` mag niet rood staan; `vendor/scrum4me`-submodule in scrum4me-mcp meebewegen na merge
|
||||
|
||||
**Wanneer dit aanpassen:** zodra het Vercel-account naar Pro gaat — zie CLAUDE.md.
|
||||
|
||||
---
|
||||
|
||||
## Reseed-stap (eenmalig vóór ST-1001-implementatie)
|
||||
|
||||
De backlog-markdown bevat ST-1001..1008, maar de live database heeft die rijen nog niet. Voordat `mcp__scrum4me__get_claude_context` ze als next-story kan teruggeven:
|
||||
|
||||
```
|
||||
npx prisma db seed
|
||||
```
|
||||
|
||||
Verifieer dat M10 en zijn 8 stories als `status=OPEN` in de DB staan. Daarna geeft `mcp__scrum4me__implement_next_story product_id=cmohfotr70000jwrt0hw4q020` automatisch ST-1001 als startpunt.
|
||||
|
||||
> **Let op:** seed kan bestaande dev-data overschrijven. Doe dit op een dev-DB, niet productie. Voor productie volstaat het om de stories handmatig of via een eenmalige migratie-script in te voegen — buiten scope hier.
|
||||
|
|
@ -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,73 @@ Eén "actief Product Backlog" per gebruiker — persistent in DB. De NavBar word
|
|||
|
||||
---
|
||||
|
||||
### M10: Password-loze inlog via QR-pairing
|
||||
|
||||
**Implementatieplan:** [docs/plans/M10-qr-pairing-login.md](plans/M10-qr-pairing-login.md)
|
||||
|
||||
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=<desktopToken>; 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/<pairingId>', { 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
|
||||
|
|
|
|||
|
|
@ -54,6 +54,45 @@ Gebruikers kunnen een account aanmaken en inloggen met gebruikersnaam en wachtwo
|
|||
|
||||
---
|
||||
|
||||
### F-01b: Inloggen via mobiel (QR-pairing)
|
||||
|
||||
**Prioriteit:** v1 — Belangrijk
|
||||
**Persona:** Lars (publieke demo-laptops), Dina (klantapparatuur)
|
||||
|
||||
**Omschrijving:**
|
||||
Een gebruiker kan op een (publieke of gedeelde) desktop inloggen zonder zijn wachtwoord te typen, door op zijn al-ingelogde mobiele apparaat een QR-code te scannen die de desktop toont. Na een expliciete tap op "Bevestig" op de mobiel raakt de desktop binnen 1–2 seconden ingelogd. De flow is bedoeld om typen op vreemde toetsenborden, shoulder-surfing en autofill-history te vermijden.
|
||||
|
||||
**Verloop:**
|
||||
1. Op het login-scherm klikt de desktop-gebruiker op *"Inloggen via mobiel"*. De server maakt een eenmalige pairing-rij aan (status `pending`, vervalt na 2 minuten) met twee gescheiden geheimen: `mobileSecret` (voor de mobiel) en `desktopToken` (HttpOnly cookie voor de desktop).
|
||||
2. De desktop toont een QR-code. De code bevat een URL met `mobileSecret` in het URL-fragment (`#s=…`) — dit fragment wordt door browsers nooit naar servers gestuurd, dus belandt niet in access logs of analytics.
|
||||
3. De gebruiker scant met zijn telefoon. De OS-camera opent de URL in de mobiele Scrum4Me-tab. Een Client Component leest het fragment en POST't `mobileSecret` in de body naar de approve-endpoint.
|
||||
4. De mobiele bevestigingspagina toont *"Inloggen op {browser-omschrijving} ({IP}) als {jouw-gebruikersnaam}?"* met een Bevestig- en Annuleer-knop. Na een tap op Bevestig wordt de pairing approved (status `approved`, vervaltijd verlengd naar 5 minuten); de desktop ontvangt dit binnen 1–2 seconden via een SSE-stream die geauthenticeerd is met het HttpOnly desktop-cookie.
|
||||
5. De desktop claimt de sessie atomisch (eenmalig consumeerbaar), krijgt zijn iron-session cookie en wordt naar `/dashboard` doorgestuurd.
|
||||
|
||||
**Acceptatiecriteria:**
|
||||
- [ ] Knop "Inloggen via mobiel" zichtbaar op `/login` naast het wachtwoord-formulier
|
||||
- [ ] QR-code vernieuwt automatisch na 2 minuten via een "Vernieuwen"-knop
|
||||
- [ ] Mobiele bevestigingspagina toont browser/UA en best-effort IP van de desktop
|
||||
- [ ] Demo-gebruiker kan niet als approver fungeren — duidelijke foutmelding "Niet beschikbaar in demo-modus"
|
||||
- [ ] Paired-sessie heeft een eigen TTL van 8 uur (korter dan reguliere wachtwoord-login) en is herkenbaar via een `paired`-vlag in het session-payload
|
||||
- [ ] Een tweede claim met dezelfde pairing geeft `410 Gone` (one-time use)
|
||||
- [ ] `mobileSecret` komt nergens in een GET-URL voor (alleen in URL-fragment of POST-body); `desktopToken` staat alleen in een HttpOnly cookie met `Path=/api/auth/pair`, `Max-Age=120`, `SameSite=Lax`
|
||||
- [ ] In `nginx`/Vercel access logs is geen secret-materiaal terug te vinden (acceptatietest)
|
||||
|
||||
**Randgevallen:**
|
||||
- QR vervalt voordat mobiel scant → mobiele pagina toont "Pairing verlopen, vraag een nieuwe QR-code op"; desktop toont "Vernieuwen"-knop
|
||||
- Pairing approved maar desktop claimt niet binnen 5 minuten → atomic update faalt; pairing-rij wordt automatisch genegeerd; gebruiker start opnieuw
|
||||
- Gebruiker scant een phishing-QR vanaf een willekeurige website → mobiele bevestiging toont onbekende UA/IP; expliciete bevestiging vereist; de gebruiker kan annuleren
|
||||
- Gebruiker is op de mobiel niet ingelogd → middleware-guard van `/m/pair` redirectt naar `/login` met return-URL
|
||||
- Gebruiker logt zichzelf uit op de mobiel terwijl de pairing nog `pending` is → approve faalt op auth-check
|
||||
|
||||
**Data:**
|
||||
- Nieuw: `login_pairings` (id, secret_hash, desktop_token_hash, status, user_id?, desktop_ua?, desktop_ip?, created_at, expires_at, approved_at?, consumed_at?)
|
||||
- Postgres-trigger op `login_pairings` publiceert via `pg_notify('scrum4me_pairing', …)`
|
||||
- Sessie-payload: nieuwe optionele `paired: boolean` en `pairedExpiresAt: number`
|
||||
|
||||
---
|
||||
|
||||
### F-02: Roltoewijzing
|
||||
|
||||
**Prioriteit:** v1 — Fundament voor v2
|
||||
|
|
|
|||
|
|
@ -36,6 +36,7 @@ Elke repository heeft een `TODO.md` die hij bijhoudt zolang een project actief i
|
|||
- Elke avond binnen één minuut weten welke taak het meest urgent is per project
|
||||
- Claude Code laten oppakken wat open staat zonder zelf de context te hoeven herstellen
|
||||
- Achteraf kunnen zien wat er gedaan is en hoe (implementatieplan, commit, testresultaat)
|
||||
- Op klantbezoek of bij familie zijn side projects kunnen demonstreren op een geleende laptop, zonder dat hij zijn wachtwoord op een vreemd toetsenbord hoeft te typen — door zijn telefoon (waar hij al ingelogd is) een QR-code op het scherm te laten scannen
|
||||
|
||||
### Frustraties om te vermijden
|
||||
|
||||
|
|
|
|||
|
|
@ -44,6 +44,8 @@ const MILESTONE_PRIORITY: Record<string, 1 | 2 | 3 | 4> = {
|
|||
M6: 4,
|
||||
M7: 4,
|
||||
M8: 4,
|
||||
M9: 4,
|
||||
M10: 4,
|
||||
}
|
||||
|
||||
const MILESTONE_GOAL: Record<string, string> = {
|
||||
|
|
@ -57,6 +59,8 @@ const MILESTONE_GOAL: Record<string, string> = {
|
|||
M6: 'Foutafhandeling, toegankelijkheid, CI/CD, beveiliging',
|
||||
M7: 'MCP-server voor Claude Code',
|
||||
M8: 'Realtime updates voor Solo Paneel',
|
||||
M9: 'Actief Product Backlog — persistent gekozen product',
|
||||
M10: 'Password-loze inlog via QR-pairing',
|
||||
}
|
||||
|
||||
const MILESTONE_SPRINT_STATUS: Record<string, ParsedMilestone['sprint_status']> = {
|
||||
|
|
@ -70,6 +74,8 @@ const MILESTONE_SPRINT_STATUS: Record<string, ParsedMilestone['sprint_status']>
|
|||
M6: 'COMPLETED',
|
||||
M7: 'COMPLETED',
|
||||
M8: 'COMPLETED',
|
||||
M9: 'COMPLETED',
|
||||
M10: 'COMPLETED',
|
||||
}
|
||||
|
||||
const MILESTONE_KEY = /^(?:M[\d.]+|PBI-\d+)$/
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue