Compare commits
17 commits
main
...
feat/M10-q
| Author | SHA1 | Date | |
|---|---|---|---|
| a9616ff122 | |||
| d6e71f915c | |||
| 5cbf543c16 | |||
| 4f9a6d2d9e | |||
| c87b6156ae | |||
| 3a90fa9d13 | |||
| c48e30df1f | |||
| 5c4ee150ea | |||
| 625221f9ee | |||
| 2a0c6a512d | |||
| e0bec8c55c | |||
| b4813e6e54 | |||
| 075cf28a5e | |||
| f0203fe314 | |||
| 414ef58aa3 | |||
| 0e3228d56f | |||
| 4af3b302b4 |
29 changed files with 2023 additions and 99 deletions
|
|
@ -98,6 +98,7 @@ Lees het relevante patroon vóór je begint. Nooit uit het hoofd schrijven.
|
|||
| Zustand optimistische update + rollback | `docs/patterns/zustand-optimistic.md` |
|
||||
| Float sort_order drag-and-drop | `docs/patterns/sort-order.md` |
|
||||
| Middleware (route protection) | `docs/patterns/middleware.md` |
|
||||
| QR-pairing (unauth-SSE + pre-auth cookie) | `docs/patterns/qr-login.md` |
|
||||
| Status-enum mapping (DB ↔ API) | `lib/task-status.ts` |
|
||||
| Client/server module-boundary | `*-server.ts` bevat DB-calls of node-only deps; `*.ts` is pure (client-safe). Nooit `import { ... } from '@/lib/foo-server'` in een client-component, anders krijg je `Module not found: 'dns'`/`'pg'`-style runtime fouten |
|
||||
|
||||
|
|
|
|||
170
__tests__/actions/pairing.test.ts
Normal file
170
__tests__/actions/pairing.test.ts
Normal file
|
|
@ -0,0 +1,170 @@
|
|||
import { describe, it, expect, vi, beforeEach } from 'vitest'
|
||||
|
||||
const { mockGetSession } = vi.hoisted(() => ({
|
||||
mockGetSession: vi.fn(),
|
||||
}))
|
||||
|
||||
vi.mock('@/lib/auth', () => ({
|
||||
getSession: mockGetSession,
|
||||
}))
|
||||
|
||||
vi.mock('@/lib/prisma', () => ({
|
||||
prisma: {
|
||||
loginPairing: {
|
||||
findUnique: vi.fn(),
|
||||
update: vi.fn(),
|
||||
},
|
||||
user: {
|
||||
findUnique: vi.fn(),
|
||||
},
|
||||
},
|
||||
}))
|
||||
|
||||
import { prisma } from '@/lib/prisma'
|
||||
import { hashToken } from '@/lib/auth/pairing'
|
||||
import {
|
||||
getPairingForApproval,
|
||||
approvePairing,
|
||||
cancelPairing,
|
||||
} from '@/actions/pairing'
|
||||
|
||||
const mockPrisma = prisma as unknown as {
|
||||
loginPairing: {
|
||||
findUnique: ReturnType<typeof vi.fn>
|
||||
update: ReturnType<typeof vi.fn>
|
||||
}
|
||||
user: { findUnique: ReturnType<typeof vi.fn> }
|
||||
}
|
||||
|
||||
const VALID_PAIRING_ID = 'cmohmk0a' + 'g008bs417mzik8x9w'.padEnd(17, 'a').slice(0, 17)
|
||||
const VALID_SECRET = 'A'.repeat(43) // ≥40 chars voor Zod min(40)
|
||||
const SESSION_USER = { userId: 'user-1', isDemo: false }
|
||||
const SESSION_DEMO = { userId: 'demo-1', isDemo: true }
|
||||
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks()
|
||||
mockPrisma.user.findUnique.mockResolvedValue({ username: 'lars' })
|
||||
})
|
||||
|
||||
function pendingPairing(secret = VALID_SECRET) {
|
||||
return {
|
||||
status: 'pending' as const,
|
||||
expires_at: new Date(Date.now() + 60_000),
|
||||
secret_hash: hashToken(secret),
|
||||
desktop_ua: 'TestUA/1.0',
|
||||
desktop_ip: '198.51.100.1',
|
||||
}
|
||||
}
|
||||
|
||||
describe('actions/pairing', () => {
|
||||
describe('getPairingForApproval', () => {
|
||||
it('ok-pad: pending + correct secret → desktop-info + username', async () => {
|
||||
mockGetSession.mockResolvedValue(SESSION_USER)
|
||||
mockPrisma.loginPairing.findUnique.mockResolvedValue(pendingPairing())
|
||||
|
||||
const res = await getPairingForApproval(VALID_PAIRING_ID, VALID_SECRET)
|
||||
expect(res).toEqual({
|
||||
ok: true,
|
||||
desktop_ua: 'TestUA/1.0',
|
||||
desktop_ip: '198.51.100.1',
|
||||
username: 'lars',
|
||||
})
|
||||
})
|
||||
|
||||
it('faalt zonder sessie', async () => {
|
||||
mockGetSession.mockResolvedValue({ userId: undefined, isDemo: false })
|
||||
const res = await getPairingForApproval(VALID_PAIRING_ID, VALID_SECRET)
|
||||
expect(res).toEqual({ ok: false, error: 'Niet ingelogd' })
|
||||
})
|
||||
|
||||
it('faalt op al-approved pairing', async () => {
|
||||
mockGetSession.mockResolvedValue(SESSION_USER)
|
||||
mockPrisma.loginPairing.findUnique.mockResolvedValue({
|
||||
...pendingPairing(),
|
||||
status: 'approved',
|
||||
})
|
||||
const res = await getPairingForApproval(VALID_PAIRING_ID, VALID_SECRET)
|
||||
expect(res).toEqual({ ok: false, error: 'Pairing al afgehandeld' })
|
||||
})
|
||||
|
||||
it('faalt op verlopen pairing', async () => {
|
||||
mockGetSession.mockResolvedValue(SESSION_USER)
|
||||
mockPrisma.loginPairing.findUnique.mockResolvedValue({
|
||||
...pendingPairing(),
|
||||
expires_at: new Date(Date.now() - 1000),
|
||||
})
|
||||
const res = await getPairingForApproval(VALID_PAIRING_ID, VALID_SECRET)
|
||||
expect(res).toEqual({ ok: false, error: 'Pairing verlopen' })
|
||||
})
|
||||
|
||||
it('faalt op verkeerd secret', async () => {
|
||||
mockGetSession.mockResolvedValue(SESSION_USER)
|
||||
mockPrisma.loginPairing.findUnique.mockResolvedValue(pendingPairing('echt'))
|
||||
const res = await getPairingForApproval(VALID_PAIRING_ID, VALID_SECRET)
|
||||
expect(res).toEqual({ ok: false, error: 'Ongeldig pairing-geheim' })
|
||||
})
|
||||
|
||||
it('faalt op ongeldige cuid (Zod)', async () => {
|
||||
mockGetSession.mockResolvedValue(SESSION_USER)
|
||||
const res = await getPairingForApproval('niet-cuid', VALID_SECRET)
|
||||
expect(res).toEqual({ ok: false, error: 'Ongeldige invoer' })
|
||||
expect(mockPrisma.loginPairing.findUnique).not.toHaveBeenCalled()
|
||||
})
|
||||
})
|
||||
|
||||
describe('approvePairing', () => {
|
||||
it('happy-pad: status pending→approved, user_id gezet, expires_at +5min', async () => {
|
||||
mockGetSession.mockResolvedValue(SESSION_USER)
|
||||
mockPrisma.loginPairing.findUnique.mockResolvedValue(pendingPairing())
|
||||
mockPrisma.loginPairing.update.mockResolvedValue({})
|
||||
|
||||
const res = await approvePairing(VALID_PAIRING_ID, VALID_SECRET)
|
||||
expect(res).toEqual({ ok: true })
|
||||
expect(mockPrisma.loginPairing.update).toHaveBeenCalledTimes(1)
|
||||
const arg = mockPrisma.loginPairing.update.mock.calls[0][0]
|
||||
expect(arg.where).toEqual({ id: VALID_PAIRING_ID })
|
||||
expect(arg.data.status).toBe('approved')
|
||||
expect(arg.data.user_id).toBe('user-1')
|
||||
const dt = new Date(arg.data.expires_at).getTime() - Date.now()
|
||||
expect(dt).toBeGreaterThan(295_000)
|
||||
expect(dt).toBeLessThan(305_000)
|
||||
})
|
||||
|
||||
it('demo-user wordt geblokkeerd, geen DB-write', async () => {
|
||||
mockGetSession.mockResolvedValue(SESSION_DEMO)
|
||||
const res = await approvePairing(VALID_PAIRING_ID, VALID_SECRET)
|
||||
expect(res).toEqual({ ok: false, error: 'Niet beschikbaar in demo-modus' })
|
||||
expect(mockPrisma.loginPairing.findUnique).not.toHaveBeenCalled()
|
||||
expect(mockPrisma.loginPairing.update).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('faalt op verkeerd secret zonder DB-write', async () => {
|
||||
mockGetSession.mockResolvedValue(SESSION_USER)
|
||||
mockPrisma.loginPairing.findUnique.mockResolvedValue(pendingPairing('echt'))
|
||||
const res = await approvePairing(VALID_PAIRING_ID, VALID_SECRET)
|
||||
expect(res).toEqual({ ok: false, error: 'Ongeldig pairing-geheim' })
|
||||
expect(mockPrisma.loginPairing.update).not.toHaveBeenCalled()
|
||||
})
|
||||
})
|
||||
|
||||
describe('cancelPairing', () => {
|
||||
it('happy-pad: status pending→cancelled', async () => {
|
||||
mockGetSession.mockResolvedValue(SESSION_USER)
|
||||
mockPrisma.loginPairing.findUnique.mockResolvedValue(pendingPairing())
|
||||
mockPrisma.loginPairing.update.mockResolvedValue({})
|
||||
|
||||
const res = await cancelPairing(VALID_PAIRING_ID, VALID_SECRET)
|
||||
expect(res).toEqual({ ok: true })
|
||||
const arg = mockPrisma.loginPairing.update.mock.calls[0][0]
|
||||
expect(arg.data.status).toBe('cancelled')
|
||||
})
|
||||
|
||||
it('demo-user wordt geblokkeerd, geen DB-write', async () => {
|
||||
mockGetSession.mockResolvedValue(SESSION_DEMO)
|
||||
const res = await cancelPairing(VALID_PAIRING_ID, VALID_SECRET)
|
||||
expect(res).toEqual({ ok: false, error: 'Niet beschikbaar in demo-modus' })
|
||||
expect(mockPrisma.loginPairing.findUnique).not.toHaveBeenCalled()
|
||||
expect(mockPrisma.loginPairing.update).not.toHaveBeenCalled()
|
||||
})
|
||||
})
|
||||
})
|
||||
158
__tests__/api/pair-claim.test.ts
Normal file
158
__tests__/api/pair-claim.test.ts
Normal file
|
|
@ -0,0 +1,158 @@
|
|||
import { describe, it, expect, vi, beforeEach } from 'vitest'
|
||||
|
||||
const { mockReadPairCookie, mockClearPairCookie, mockSession, mockGetIronSession } = vi.hoisted(
|
||||
() => ({
|
||||
mockReadPairCookie: vi.fn(),
|
||||
mockClearPairCookie: vi.fn(),
|
||||
mockSession: { userId: '', isDemo: false, paired: false, pairedExpiresAt: 0, save: vi.fn() },
|
||||
mockGetIronSession: vi.fn(),
|
||||
}),
|
||||
)
|
||||
|
||||
vi.mock('@/lib/auth/pair-cookie', () => ({
|
||||
readPairCookie: mockReadPairCookie,
|
||||
clearPairCookie: mockClearPairCookie,
|
||||
setPairCookie: vi.fn(),
|
||||
}))
|
||||
|
||||
vi.mock('iron-session', () => ({
|
||||
getIronSession: mockGetIronSession,
|
||||
}))
|
||||
|
||||
vi.mock('next/headers', () => ({
|
||||
cookies: vi.fn().mockResolvedValue({}),
|
||||
}))
|
||||
|
||||
vi.mock('@/lib/prisma', () => ({
|
||||
prisma: {
|
||||
loginPairing: {
|
||||
updateMany: vi.fn(),
|
||||
findFirst: vi.fn(),
|
||||
findUnique: vi.fn(),
|
||||
},
|
||||
},
|
||||
}))
|
||||
|
||||
import { prisma } from '@/lib/prisma'
|
||||
import { hashToken } from '@/lib/auth/pairing'
|
||||
import { POST } from '@/app/api/auth/pair/claim/route'
|
||||
|
||||
const mockPrisma = prisma as unknown as {
|
||||
loginPairing: {
|
||||
updateMany: ReturnType<typeof vi.fn>
|
||||
findFirst: ReturnType<typeof vi.fn>
|
||||
findUnique: ReturnType<typeof vi.fn>
|
||||
}
|
||||
}
|
||||
|
||||
const COOKIE_TOKEN = 'desktop-token-abc'
|
||||
const COOKIE_HASH = hashToken(COOKIE_TOKEN)
|
||||
const PAIRING_ID = 'cmohmk0qpair006c001'
|
||||
|
||||
function makePost(body: unknown): Request {
|
||||
return new Request('http://localhost:3000/api/auth/pair/claim', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: typeof body === 'string' ? body : JSON.stringify(body),
|
||||
})
|
||||
}
|
||||
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks()
|
||||
// Reset session-mock voor elke test
|
||||
mockSession.userId = ''
|
||||
mockSession.isDemo = false
|
||||
mockSession.paired = false
|
||||
mockSession.pairedExpiresAt = 0
|
||||
mockSession.save = vi.fn().mockResolvedValue(undefined)
|
||||
mockGetIronSession.mockResolvedValue(mockSession)
|
||||
})
|
||||
|
||||
describe('POST /api/auth/pair/claim', () => {
|
||||
it('200: schrijft iron-session, clear s4m_pair, retourneert {ok:true}', async () => {
|
||||
mockReadPairCookie.mockResolvedValue(COOKIE_TOKEN)
|
||||
mockPrisma.loginPairing.updateMany.mockResolvedValue({ count: 1 })
|
||||
mockPrisma.loginPairing.findUnique.mockResolvedValue({
|
||||
user_id: 'user-42',
|
||||
user: { is_demo: false },
|
||||
})
|
||||
|
||||
const res = await POST(makePost({ pairingId: PAIRING_ID }))
|
||||
expect(res.status).toBe(200)
|
||||
expect(await res.json()).toEqual({ ok: true })
|
||||
|
||||
// Atomic update aangeroepen met juiste WHERE
|
||||
expect(mockPrisma.loginPairing.updateMany).toHaveBeenCalledTimes(1)
|
||||
const where = mockPrisma.loginPairing.updateMany.mock.calls[0][0].where
|
||||
expect(where).toMatchObject({
|
||||
id: PAIRING_ID,
|
||||
status: 'approved',
|
||||
desktop_token_hash: COOKIE_HASH,
|
||||
})
|
||||
expect(where.expires_at).toMatchObject({ gt: expect.any(Date) })
|
||||
|
||||
// Iron-session payload
|
||||
expect(mockSession.userId).toBe('user-42')
|
||||
expect(mockSession.isDemo).toBe(false)
|
||||
expect(mockSession.paired).toBe(true)
|
||||
const dt = mockSession.pairedExpiresAt - Date.now()
|
||||
expect(dt).toBeGreaterThan(8 * 60 * 60 * 1000 - 5_000)
|
||||
expect(dt).toBeLessThan(8 * 60 * 60 * 1000 + 5_000)
|
||||
expect(mockSession.save).toHaveBeenCalledTimes(1)
|
||||
|
||||
expect(mockClearPairCookie).toHaveBeenCalledTimes(1)
|
||||
})
|
||||
|
||||
it('demo-user: isDemo doorgezet als vangnet', async () => {
|
||||
mockReadPairCookie.mockResolvedValue(COOKIE_TOKEN)
|
||||
mockPrisma.loginPairing.updateMany.mockResolvedValue({ count: 1 })
|
||||
mockPrisma.loginPairing.findUnique.mockResolvedValue({
|
||||
user_id: 'demo-1',
|
||||
user: { is_demo: true },
|
||||
})
|
||||
|
||||
const res = await POST(makePost({ pairingId: PAIRING_ID }))
|
||||
expect(res.status).toBe(200)
|
||||
expect(mockSession.isDemo).toBe(true)
|
||||
})
|
||||
|
||||
it('401 zonder s4m_pair-cookie', async () => {
|
||||
mockReadPairCookie.mockResolvedValue(null)
|
||||
const res = await POST(makePost({ pairingId: PAIRING_ID }))
|
||||
expect(res.status).toBe(401)
|
||||
expect(mockPrisma.loginPairing.updateMany).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('400 zonder body', async () => {
|
||||
mockReadPairCookie.mockResolvedValue(COOKIE_TOKEN)
|
||||
const res = await POST(makePost('not-json'))
|
||||
expect(res.status).toBe(400)
|
||||
})
|
||||
|
||||
it('400 zonder pairingId', async () => {
|
||||
mockReadPairCookie.mockResolvedValue(COOKIE_TOKEN)
|
||||
const res = await POST(makePost({}))
|
||||
expect(res.status).toBe(400)
|
||||
})
|
||||
|
||||
it('410 op tweede claim — pairing al consumed', async () => {
|
||||
mockReadPairCookie.mockResolvedValue(COOKIE_TOKEN)
|
||||
mockPrisma.loginPairing.updateMany.mockResolvedValue({ count: 0 })
|
||||
mockPrisma.loginPairing.findFirst.mockResolvedValue({ status: 'consumed' })
|
||||
|
||||
const res = await POST(makePost({ pairingId: PAIRING_ID }))
|
||||
expect(res.status).toBe(410)
|
||||
expect(mockClearPairCookie).toHaveBeenCalledTimes(1)
|
||||
expect(mockSession.save).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('401 op cookie/hash-mismatch (pairing bestaat niet voor deze cookie)', async () => {
|
||||
mockReadPairCookie.mockResolvedValue(COOKIE_TOKEN)
|
||||
mockPrisma.loginPairing.updateMany.mockResolvedValue({ count: 0 })
|
||||
mockPrisma.loginPairing.findFirst.mockResolvedValue(null)
|
||||
|
||||
const res = await POST(makePost({ pairingId: PAIRING_ID }))
|
||||
expect(res.status).toBe(401)
|
||||
expect(mockClearPairCookie).toHaveBeenCalledTimes(1)
|
||||
})
|
||||
})
|
||||
110
__tests__/api/pair-start.test.ts
Normal file
110
__tests__/api/pair-start.test.ts
Normal file
|
|
@ -0,0 +1,110 @@
|
|||
import { describe, it, expect, vi, beforeEach } from 'vitest'
|
||||
|
||||
const { cookieJar } = vi.hoisted(() => ({
|
||||
cookieJar: { set: vi.fn(), get: vi.fn(), delete: vi.fn() },
|
||||
}))
|
||||
|
||||
vi.mock('@/lib/prisma', () => ({
|
||||
prisma: {
|
||||
loginPairing: {
|
||||
create: vi.fn(),
|
||||
},
|
||||
},
|
||||
}))
|
||||
|
||||
vi.mock('next/headers', () => ({
|
||||
cookies: vi.fn().mockResolvedValue(cookieJar),
|
||||
}))
|
||||
|
||||
import { prisma } from '@/lib/prisma'
|
||||
import { POST } from '@/app/api/auth/pair/start/route'
|
||||
|
||||
const mockPrisma = prisma as unknown as {
|
||||
loginPairing: { create: ReturnType<typeof vi.fn> }
|
||||
}
|
||||
|
||||
function makePost(opts: { ip?: string; ua?: string } = {}): Request {
|
||||
const headers = new Headers()
|
||||
if (opts.ip !== undefined) headers.set('x-forwarded-for', opts.ip)
|
||||
if (opts.ua !== undefined) headers.set('user-agent', opts.ua)
|
||||
return new Request('http://localhost:3000/api/auth/pair/start', {
|
||||
method: 'POST',
|
||||
headers,
|
||||
})
|
||||
}
|
||||
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks()
|
||||
mockPrisma.loginPairing.create.mockResolvedValue({
|
||||
id: 'pair-1',
|
||||
expires_at: new Date('2026-04-27T20:30:00Z'),
|
||||
})
|
||||
})
|
||||
|
||||
describe('POST /api/auth/pair/start', () => {
|
||||
it('200 met body { pairingId, mobileSecret, expiresAt, qrUrl met fragment }', async () => {
|
||||
const res = await POST(makePost({ ip: '198.51.100.7', ua: 'TestUA/1.0' }))
|
||||
expect(res.status).toBe(200)
|
||||
const body = await res.json()
|
||||
expect(body.pairingId).toBe('pair-1')
|
||||
expect(body.mobileSecret).toMatch(/^[A-Za-z0-9_-]{43}$/)
|
||||
expect(body.qrUrl).toBe(
|
||||
`http://localhost:3000/m/pair#id=pair-1&s=${body.mobileSecret}`,
|
||||
)
|
||||
expect(body.expiresAt).toBe('2026-04-27T20:30:00.000Z')
|
||||
})
|
||||
|
||||
it('slaat alleen sha256-hashes op — geen plaintext mobileSecret of desktopToken', async () => {
|
||||
const res = await POST(makePost({ ip: '198.51.100.8' }))
|
||||
const body = await res.json()
|
||||
const arg = mockPrisma.loginPairing.create.mock.calls[0][0].data
|
||||
expect(arg.secret_hash).toMatch(/^[a-f0-9]{64}$/)
|
||||
expect(arg.desktop_token_hash).toMatch(/^[a-f0-9]{64}$/)
|
||||
expect(arg.secret_hash).not.toBe(body.mobileSecret)
|
||||
expect(arg.status).toBe('pending')
|
||||
// expires_at ~5 min in toekomst
|
||||
const dt = new Date(arg.expires_at).getTime() - Date.now()
|
||||
expect(dt).toBeGreaterThan(295_000)
|
||||
expect(dt).toBeLessThan(305_000)
|
||||
})
|
||||
|
||||
it('zet HttpOnly Path-scoped s4m_pair cookie met Max-Age 120', async () => {
|
||||
await POST(makePost({ ip: '198.51.100.9' }))
|
||||
expect(cookieJar.set).toHaveBeenCalledTimes(1)
|
||||
const [name, value, opts] = cookieJar.set.mock.calls[0]
|
||||
expect(name).toBe('s4m_pair')
|
||||
expect(value).toMatch(/^[A-Za-z0-9_-]{43}$/) // desktopToken
|
||||
expect(opts).toMatchObject({
|
||||
httpOnly: true,
|
||||
sameSite: 'lax',
|
||||
path: '/api/auth/pair',
|
||||
maxAge: 300,
|
||||
})
|
||||
})
|
||||
|
||||
it('slaat user-agent en IP op (afgekapt)', async () => {
|
||||
const longUa = 'A'.repeat(500)
|
||||
await POST(makePost({ ip: '198.51.100.10', ua: longUa }))
|
||||
const arg = mockPrisma.loginPairing.create.mock.calls[0][0].data
|
||||
expect(arg.desktop_ua).toBe('A'.repeat(255))
|
||||
expect(arg.desktop_ip).toBe('198.51.100.10')
|
||||
})
|
||||
|
||||
it('desktop_ip = null als x-forwarded-for ontbreekt', async () => {
|
||||
await POST(makePost({}))
|
||||
const arg = mockPrisma.loginPairing.create.mock.calls[0][0].data
|
||||
expect(arg.desktop_ip).toBeNull()
|
||||
})
|
||||
|
||||
it('11e POST binnen window levert 429', async () => {
|
||||
const ip = '198.51.100.99' // unieke IP zodat andere tests niet in de weg zitten
|
||||
for (let i = 0; i < 10; i++) {
|
||||
const ok = await POST(makePost({ ip }))
|
||||
expect(ok.status).toBe(200)
|
||||
}
|
||||
const blocked = await POST(makePost({ ip }))
|
||||
expect(blocked.status).toBe(429)
|
||||
const body = await blocked.json()
|
||||
expect(body.error).toMatch(/Te veel pogingen/i)
|
||||
})
|
||||
})
|
||||
84
__tests__/api/pair-stream.test.ts
Normal file
84
__tests__/api/pair-stream.test.ts
Normal file
|
|
@ -0,0 +1,84 @@
|
|||
import { describe, it, expect, vi, beforeEach } from 'vitest'
|
||||
|
||||
// Mock the cookie helper directly — easier than mocking next/headers + reasoning
|
||||
// over the cookies() async wrapper. The actual cookie wiring is tested in
|
||||
// pair-start.test.ts.
|
||||
const { mockReadPairCookie } = vi.hoisted(() => ({
|
||||
mockReadPairCookie: vi.fn(),
|
||||
}))
|
||||
|
||||
vi.mock('@/lib/auth/pair-cookie', () => ({
|
||||
readPairCookie: mockReadPairCookie,
|
||||
setPairCookie: vi.fn(),
|
||||
clearPairCookie: vi.fn(),
|
||||
}))
|
||||
|
||||
vi.mock('@/lib/prisma', () => ({
|
||||
prisma: {
|
||||
loginPairing: {
|
||||
findUnique: vi.fn(),
|
||||
},
|
||||
},
|
||||
}))
|
||||
|
||||
import { prisma } from '@/lib/prisma'
|
||||
import { hashToken } from '@/lib/auth/pairing'
|
||||
import type { NextRequest } from 'next/server'
|
||||
import { GET } from '@/app/api/auth/pair/stream/[pairingId]/route'
|
||||
|
||||
const mockPrisma = prisma as unknown as {
|
||||
loginPairing: { findUnique: ReturnType<typeof vi.fn> }
|
||||
}
|
||||
|
||||
function makeReq(): NextRequest {
|
||||
// Minimaal NextRequest-shape voor de auth-paden — we komen niet aan signal/url toe
|
||||
// omdat de auth-checks vóór de stream-setup falen.
|
||||
return { signal: new AbortController().signal } as unknown as NextRequest
|
||||
}
|
||||
|
||||
const params = (id: string) =>
|
||||
({ params: Promise.resolve({ pairingId: id }) }) as { params: Promise<{ pairingId: string }> }
|
||||
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks()
|
||||
})
|
||||
|
||||
describe('GET /api/auth/pair/stream/[pairingId]', () => {
|
||||
it('401 zonder s4m_pair-cookie', async () => {
|
||||
mockReadPairCookie.mockResolvedValue(null)
|
||||
const res = await GET(makeReq(), params('pair-x'))
|
||||
expect(res.status).toBe(401)
|
||||
const body = await res.json()
|
||||
expect(body.error).toMatch(/cookie/i)
|
||||
expect(mockPrisma.loginPairing.findUnique).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('404 als pairing onbekend is', async () => {
|
||||
mockReadPairCookie.mockResolvedValue('whatever-token')
|
||||
mockPrisma.loginPairing.findUnique.mockResolvedValue(null)
|
||||
const res = await GET(makeReq(), params('pair-onbekend'))
|
||||
expect(res.status).toBe(404)
|
||||
})
|
||||
|
||||
it('410 als pairing verlopen is', async () => {
|
||||
mockReadPairCookie.mockResolvedValue('correct-token')
|
||||
mockPrisma.loginPairing.findUnique.mockResolvedValue({
|
||||
desktop_token_hash: hashToken('correct-token'),
|
||||
status: 'pending',
|
||||
expires_at: new Date(Date.now() - 1000),
|
||||
})
|
||||
const res = await GET(makeReq(), params('pair-verlopen'))
|
||||
expect(res.status).toBe(410)
|
||||
})
|
||||
|
||||
it('401 als cookie hashed naar andere desktop_token_hash', async () => {
|
||||
mockReadPairCookie.mockResolvedValue('verkeerd-token')
|
||||
mockPrisma.loginPairing.findUnique.mockResolvedValue({
|
||||
desktop_token_hash: hashToken('echt-token'),
|
||||
status: 'pending',
|
||||
expires_at: new Date(Date.now() + 60_000),
|
||||
})
|
||||
const res = await GET(makeReq(), params('pair-mismatch'))
|
||||
expect(res.status).toBe(401)
|
||||
})
|
||||
})
|
||||
88
__tests__/lib/auth/pairing.test.ts
Normal file
88
__tests__/lib/auth/pairing.test.ts
Normal file
|
|
@ -0,0 +1,88 @@
|
|||
import { describe, it, expect } from 'vitest'
|
||||
|
||||
import {
|
||||
generateMobileSecret,
|
||||
generateDesktopToken,
|
||||
hashToken,
|
||||
verifyToken,
|
||||
isPairedSessionExpired,
|
||||
} from '@/lib/auth/pairing'
|
||||
|
||||
describe('lib/auth/pairing', () => {
|
||||
describe('generateMobileSecret / generateDesktopToken', () => {
|
||||
it('produceert 43-karakter base64url (32 bytes)', () => {
|
||||
// 32 bytes → ceil(32/3) * 4 = 44 chars zonder padding → 43 chars in base64url (geen '=')
|
||||
expect(generateMobileSecret()).toMatch(/^[A-Za-z0-9_-]{43}$/)
|
||||
expect(generateDesktopToken()).toMatch(/^[A-Za-z0-9_-]{43}$/)
|
||||
})
|
||||
|
||||
it('twee opeenvolgende calls leveren verschillende waardes', () => {
|
||||
const a = generateMobileSecret()
|
||||
const b = generateMobileSecret()
|
||||
expect(a).not.toBe(b)
|
||||
})
|
||||
|
||||
it('mobile en desktop generators delen geen state — paren zijn onafhankelijk', () => {
|
||||
const m1 = generateMobileSecret()
|
||||
const d1 = generateDesktopToken()
|
||||
const m2 = generateMobileSecret()
|
||||
const d2 = generateDesktopToken()
|
||||
expect(new Set([m1, d1, m2, d2]).size).toBe(4)
|
||||
})
|
||||
})
|
||||
|
||||
describe('hashToken', () => {
|
||||
it('is deterministisch — zelfde input → zelfde hash', () => {
|
||||
const t = 'voorbeeld-token'
|
||||
expect(hashToken(t)).toBe(hashToken(t))
|
||||
})
|
||||
|
||||
it('produceert 64-karakter hex (sha256)', () => {
|
||||
expect(hashToken('x')).toMatch(/^[a-f0-9]{64}$/)
|
||||
})
|
||||
|
||||
it('verschillende inputs → verschillende hashes', () => {
|
||||
expect(hashToken('a')).not.toBe(hashToken('b'))
|
||||
})
|
||||
})
|
||||
|
||||
describe('verifyToken', () => {
|
||||
it('true voor geldig (token, hashOf(token))', () => {
|
||||
const token = generateMobileSecret()
|
||||
expect(verifyToken(token, hashToken(token))).toBe(true)
|
||||
})
|
||||
|
||||
it('false voor onjuist token', () => {
|
||||
const realHash = hashToken('echt-token')
|
||||
expect(verifyToken('verkeerd-token', realHash)).toBe(false)
|
||||
})
|
||||
|
||||
it('false bij hash met afwijkende lengte', () => {
|
||||
expect(verifyToken('iets', 'abc')).toBe(false)
|
||||
})
|
||||
|
||||
it('false bij lege hash', () => {
|
||||
expect(verifyToken('iets', '')).toBe(false)
|
||||
})
|
||||
})
|
||||
|
||||
describe('isPairedSessionExpired', () => {
|
||||
it('false als paired niet gezet is (reguliere wachtwoord-sessie)', () => {
|
||||
expect(isPairedSessionExpired({})).toBe(false)
|
||||
})
|
||||
|
||||
it('false als pairedExpiresAt ontbreekt', () => {
|
||||
expect(isPairedSessionExpired({ paired: true })).toBe(false)
|
||||
})
|
||||
|
||||
it('false als de paired-sessie nog niet vervallen is', () => {
|
||||
const future = Date.now() + 60_000
|
||||
expect(isPairedSessionExpired({ paired: true, pairedExpiresAt: future })).toBe(false)
|
||||
})
|
||||
|
||||
it('true als paired én vervaltijd in het verleden ligt', () => {
|
||||
const past = Date.now() - 1_000
|
||||
expect(isPairedSessionExpired({ paired: true, pairedExpiresAt: past })).toBe(true)
|
||||
})
|
||||
})
|
||||
})
|
||||
143
actions/pairing.ts
Normal file
143
actions/pairing.ts
Normal file
|
|
@ -0,0 +1,143 @@
|
|||
'use server'
|
||||
|
||||
// ST-1005: Server Actions voor de mobiele zijde van de QR-pairing-flow (M10).
|
||||
//
|
||||
// Aangeroepen door de Client Component op /m/pair zodra die het #id=…&s=…
|
||||
// fragment uit de URL heeft geparsed. De mobiele gebruiker is hier al
|
||||
// geauthenticeerd via de bestaande iron-session (de page zit achter de
|
||||
// (app)/layout.tsx-guard).
|
||||
//
|
||||
// Volgt docs/patterns/server-action.md: getSession + Zod + demo-guard
|
||||
// (uitsluitend op approvePairing — read-only en cancel mag iedereen).
|
||||
|
||||
import { z } from 'zod'
|
||||
import { prisma } from '@/lib/prisma'
|
||||
import { getSession } from '@/lib/auth'
|
||||
import { verifyToken } from '@/lib/auth/pairing'
|
||||
|
||||
const APPROVED_TTL_MS = 5 * 60 * 1000
|
||||
|
||||
const inputSchema = z.object({
|
||||
pairingId: z.string().cuid(),
|
||||
mobileSecret: z.string().min(40), // 32 bytes base64url ≈ 43 chars
|
||||
})
|
||||
|
||||
type ActionFail = { ok: false; error: string }
|
||||
type ApprovalView = {
|
||||
ok: true
|
||||
desktop_ua: string | null
|
||||
desktop_ip: string | null
|
||||
username: string
|
||||
}
|
||||
|
||||
type PendingPairing = {
|
||||
status: string
|
||||
expires_at: Date
|
||||
secret_hash: string
|
||||
desktop_ua: string | null
|
||||
desktop_ip: string | null
|
||||
}
|
||||
|
||||
type LoadResult =
|
||||
| { kind: 'error'; error: string }
|
||||
| { kind: 'ok'; pairing: PendingPairing }
|
||||
|
||||
async function loadPendingPairing(
|
||||
pairingId: string,
|
||||
mobileSecret: string,
|
||||
): Promise<LoadResult> {
|
||||
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 { kind: 'error', error: 'Pairing niet gevonden' }
|
||||
if (pairing.expires_at < new Date()) return { kind: 'error', error: 'Pairing verlopen' }
|
||||
if (pairing.status !== 'pending') return { kind: 'error', error: 'Pairing al afgehandeld' }
|
||||
if (!verifyToken(mobileSecret, pairing.secret_hash)) {
|
||||
return { kind: 'error', error: 'Ongeldig pairing-geheim' }
|
||||
}
|
||||
return { kind: 'ok', pairing }
|
||||
}
|
||||
|
||||
export async function getPairingForApproval(
|
||||
pairingId: string,
|
||||
mobileSecret: string,
|
||||
): Promise<ApprovalView | ActionFail> {
|
||||
const session = await getSession()
|
||||
if (!session.userId) return { ok: false, error: 'Niet ingelogd' }
|
||||
|
||||
const parsed = inputSchema.safeParse({ pairingId, mobileSecret })
|
||||
if (!parsed.success) return { ok: false, error: 'Ongeldige invoer' }
|
||||
|
||||
const result = await loadPendingPairing(parsed.data.pairingId, parsed.data.mobileSecret)
|
||||
if (result.kind === 'error') return { ok: false, error: result.error }
|
||||
|
||||
const me = await prisma.user.findUnique({
|
||||
where: { id: session.userId },
|
||||
select: { username: true },
|
||||
})
|
||||
return {
|
||||
ok: true,
|
||||
desktop_ua: result.pairing.desktop_ua,
|
||||
desktop_ip: result.pairing.desktop_ip,
|
||||
username: me?.username ?? '',
|
||||
}
|
||||
}
|
||||
|
||||
export async function approvePairing(
|
||||
pairingId: string,
|
||||
mobileSecret: string,
|
||||
): Promise<{ ok: true } | ActionFail> {
|
||||
const session = await getSession()
|
||||
if (!session.userId) return { ok: false, error: 'Niet ingelogd' }
|
||||
if (session.isDemo) return { ok: false, error: 'Niet beschikbaar in demo-modus' }
|
||||
|
||||
const parsed = inputSchema.safeParse({ pairingId, mobileSecret })
|
||||
if (!parsed.success) return { ok: false, error: 'Ongeldige invoer' }
|
||||
|
||||
const result = await loadPendingPairing(parsed.data.pairingId, parsed.data.mobileSecret)
|
||||
if (result.kind === 'error') return { ok: false, error: result.error }
|
||||
|
||||
await prisma.loginPairing.update({
|
||||
where: { id: parsed.data.pairingId },
|
||||
data: {
|
||||
status: 'approved',
|
||||
user_id: session.userId,
|
||||
approved_at: new Date(),
|
||||
expires_at: new Date(Date.now() + APPROVED_TTL_MS),
|
||||
},
|
||||
})
|
||||
// Postgres-trigger emit pg_notify('scrum4me_pairing', …) automatisch — de
|
||||
// desktop-SSE in ST-1004 vangt het op. Geen revalidatePath nodig: deze page
|
||||
// heeft geen server-state om te ververversen, en de mobiele tab gaat naar
|
||||
// de "klaar"-state direct in de Client Component.
|
||||
return { ok: true }
|
||||
}
|
||||
|
||||
export async function cancelPairing(
|
||||
pairingId: string,
|
||||
mobileSecret: string,
|
||||
): Promise<{ ok: true } | ActionFail> {
|
||||
const session = await getSession()
|
||||
if (!session.userId) return { ok: false, error: 'Niet ingelogd' }
|
||||
// Cancel is een DB-write — onder de demo-write-block-regel.
|
||||
if (session.isDemo) return { ok: false, error: 'Niet beschikbaar in demo-modus' }
|
||||
|
||||
const parsed = inputSchema.safeParse({ pairingId, mobileSecret })
|
||||
if (!parsed.success) return { ok: false, error: 'Ongeldige invoer' }
|
||||
|
||||
const result = await loadPendingPairing(parsed.data.pairingId, parsed.data.mobileSecret)
|
||||
if (result.kind === 'error') return { ok: false, error: result.error }
|
||||
|
||||
await prisma.loginPairing.update({
|
||||
where: { id: parsed.data.pairingId },
|
||||
data: { status: 'cancelled' },
|
||||
})
|
||||
return { ok: true }
|
||||
}
|
||||
|
|
@ -2,6 +2,7 @@ import { redirect } from 'next/navigation'
|
|||
import { cookies } from 'next/headers'
|
||||
import { getIronSession } from 'iron-session'
|
||||
import { SessionData, sessionOptions } from '@/lib/session'
|
||||
import { isPairedSessionExpired } from '@/lib/auth/pairing'
|
||||
import { prisma } from '@/lib/prisma'
|
||||
import { productAccessFilter } from '@/lib/product-access'
|
||||
import { NavBar } from '@/components/shared/nav-bar'
|
||||
|
|
@ -18,6 +19,13 @@ export default async function AppLayout({ children }: { children: React.ReactNod
|
|||
redirect('/login')
|
||||
}
|
||||
|
||||
// ST-1002 (M10): paired-sessies (via QR-pairing) hebben een eigen kortere TTL.
|
||||
// Vervallen → vernietig en stuur naar /login.
|
||||
if (isPairedSessionExpired(session)) {
|
||||
session.destroy()
|
||||
redirect('/login')
|
||||
}
|
||||
|
||||
const [user, userRoles, accessibleProducts] = await Promise.all([
|
||||
prisma.user.findUnique({
|
||||
where: { id: session.userId },
|
||||
|
|
|
|||
26
app/(app)/m/pair/page.tsx
Normal file
26
app/(app)/m/pair/page.tsx
Normal file
|
|
@ -0,0 +1,26 @@
|
|||
// ST-1005: Mobiele bevestigingspagina voor de QR-pairing-flow (M10).
|
||||
//
|
||||
// Server Component achter de bestaande (app)/layout.tsx auth-guard — onbekende
|
||||
// mobielen worden eerst naar /login gestuurd. Bewust géén searchParams
|
||||
// uitlezen: het mobileSecret zit in het URL-fragment (#id=…&s=…), wat alleen
|
||||
// client-side leesbaar is. De Client Component PairConfirmation parseert
|
||||
// location.hash en doet de Server Action-calls.
|
||||
|
||||
import { PairConfirmation } from './pair-confirmation'
|
||||
|
||||
export const metadata = {
|
||||
title: 'Inloggen op desktop',
|
||||
}
|
||||
|
||||
export default function PairPage() {
|
||||
return (
|
||||
<main className="container mx-auto max-w-md py-12">
|
||||
<h1 className="text-2xl font-semibold">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>
|
||||
)
|
||||
}
|
||||
176
app/(app)/m/pair/pair-confirmation.tsx
Normal file
176
app/(app)/m/pair/pair-confirmation.tsx
Normal file
|
|
@ -0,0 +1,176 @@
|
|||
'use client'
|
||||
|
||||
// ST-1005: Mobiele bevestigings-island voor de QR-pairing-flow (M10).
|
||||
//
|
||||
// De QR-URL is /m/pair#id=…&s=… — de fragment wordt door browsers nooit naar
|
||||
// de server gestuurd, dus alleen client-side leesbaar via location.hash. Hier
|
||||
// halen we 'm op, doen via Server Action de bevestigings-roundtrip, en wissen
|
||||
// de hash zodra de approve gelukt is zodat back/forward de secret niet meer
|
||||
// onthult.
|
||||
|
||||
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
|
||||
mobileSecret: string
|
||||
desktop_ua: string | null
|
||||
desktop_ip: string | null
|
||||
username: string
|
||||
}
|
||||
| { kind: 'approved'; username: string }
|
||||
| { kind: 'cancelled' }
|
||||
|
||||
function parseHash(): { id: string; s: string } | null {
|
||||
if (typeof window === 'undefined') return null
|
||||
const raw = window.location.hash.replace(/^#/, '')
|
||||
if (!raw) return null
|
||||
const params = new URLSearchParams(raw)
|
||||
const id = params.get('id')
|
||||
const s = params.get('s')
|
||||
return id && s ? { id, s } : null
|
||||
}
|
||||
|
||||
function clearHash() {
|
||||
if (typeof window === 'undefined') return
|
||||
window.history.replaceState(null, '', window.location.pathname + window.location.search)
|
||||
}
|
||||
|
||||
export function PairConfirmation() {
|
||||
const [state, setState] = useState<State>({ kind: 'loading' })
|
||||
const [pending, startTransition] = useTransition()
|
||||
|
||||
useEffect(() => {
|
||||
const parsed = parseHash()
|
||||
if (!parsed) {
|
||||
queueMicrotask(() => {
|
||||
setState({ kind: 'invalid', error: 'Ongeldige of ontbrekende pairing-link' })
|
||||
})
|
||||
return
|
||||
}
|
||||
void getPairingForApproval(parsed.id, parsed.s).then((res) => {
|
||||
if (!res.ok) {
|
||||
setState({ kind: 'invalid', error: res.error })
|
||||
return
|
||||
}
|
||||
setState({
|
||||
kind: 'ready',
|
||||
pairingId: parsed.id,
|
||||
mobileSecret: parsed.s,
|
||||
desktop_ua: res.desktop_ua,
|
||||
desktop_ip: res.desktop_ip,
|
||||
username: res.username,
|
||||
})
|
||||
})
|
||||
}, [])
|
||||
|
||||
function onApprove() {
|
||||
if (state.kind !== 'ready') return
|
||||
startTransition(async () => {
|
||||
const res = await approvePairing(state.pairingId, state.mobileSecret)
|
||||
if (!res.ok) {
|
||||
toast.error(res.error)
|
||||
return
|
||||
}
|
||||
clearHash()
|
||||
setState({ kind: 'approved', username: state.username })
|
||||
})
|
||||
}
|
||||
|
||||
function onCancel() {
|
||||
if (state.kind !== 'ready') return
|
||||
startTransition(async () => {
|
||||
const res = await cancelPairing(state.pairingId, state.mobileSecret)
|
||||
if (!res.ok) {
|
||||
toast.error(res.error)
|
||||
return
|
||||
}
|
||||
clearHash()
|
||||
setState({ kind: 'cancelled' })
|
||||
})
|
||||
}
|
||||
|
||||
if (state.kind === 'loading') {
|
||||
return (
|
||||
<div className="text-muted-foreground mt-6 text-sm" aria-live="polite">
|
||||
Pairing controleren…
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
if (state.kind === 'invalid') {
|
||||
return (
|
||||
<div className="bg-error-container text-error-container-foreground border-error mt-6 rounded-md border-l-4 p-4">
|
||||
<p className="font-medium">Kan deze QR-code niet gebruiken</p>
|
||||
<p className="text-sm opacity-90">{state.error}</p>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
if (state.kind === 'approved') {
|
||||
return (
|
||||
<div className="bg-success-container text-success-container-foreground border-success mt-6 rounded-md border-l-4 p-4">
|
||||
<p className="font-medium">Klaar — je kunt deze tab sluiten.</p>
|
||||
<p className="text-sm opacity-90">
|
||||
Het apparaat met de QR-code is nu ingelogd als <strong>{state.username}</strong>.
|
||||
</p>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
if (state.kind === 'cancelled') {
|
||||
return (
|
||||
<div className="bg-surface-container-high text-foreground mt-6 rounded-md p-4">
|
||||
<p className="font-medium">Geannuleerd</p>
|
||||
<p className="text-muted-foreground text-sm">
|
||||
Er is geen sessie aangemaakt op het andere apparaat.
|
||||
</p>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="bg-card mt-6 rounded-md border p-4">
|
||||
<p>
|
||||
Wil je inloggen als <strong>{state.username}</strong> op dit apparaat?
|
||||
</p>
|
||||
<dl className="text-muted-foreground mt-3 space-y-1 text-sm">
|
||||
<div className="flex gap-2">
|
||||
<dt className="w-16 shrink-0">Browser:</dt>
|
||||
<dd className="font-mono text-xs">{state.desktop_ua ?? 'onbekend'}</dd>
|
||||
</div>
|
||||
<div className="flex gap-2">
|
||||
<dt className="w-16 shrink-0">IP:</dt>
|
||||
<dd className="font-mono text-xs">{state.desktop_ip ?? 'onbekend'}</dd>
|
||||
</div>
|
||||
</dl>
|
||||
<p className="text-muted-foreground mt-3 text-xs">
|
||||
Bevestig alleen als je deze QR-code zelf op een eigen scherm ziet — geen
|
||||
screenshot of foto van iemand anders.
|
||||
</p>
|
||||
<div className="mt-4 flex gap-2">
|
||||
<Button onClick={onApprove} disabled={pending} className="flex-1">
|
||||
Bevestig
|
||||
</Button>
|
||||
<Button
|
||||
onClick={onCancel}
|
||||
disabled={pending}
|
||||
variant="secondary"
|
||||
className="flex-1"
|
||||
>
|
||||
Annuleer
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
|
@ -1,6 +1,7 @@
|
|||
import Link from 'next/link'
|
||||
import { loginAction } from '@/actions/auth'
|
||||
import { AuthForm } from '@/components/auth/auth-form'
|
||||
import { QrLoginButton } from './qr-login-button'
|
||||
|
||||
export default function LoginPage() {
|
||||
return (
|
||||
|
|
@ -17,6 +18,14 @@ export default function LoginPage() {
|
|||
<div className="bg-surface-container-low rounded-xl p-6 space-y-4 border border-border">
|
||||
<AuthForm action={loginAction} submitLabel="Inloggen" />
|
||||
|
||||
{/* M10 — Inloggen via mobiel zonder wachtwoord */}
|
||||
<div className="flex items-center gap-2 py-1">
|
||||
<div className="border-border h-px flex-1 border-t" />
|
||||
<span className="text-muted-foreground text-xs">of</span>
|
||||
<div className="border-border h-px flex-1 border-t" />
|
||||
</div>
|
||||
<QrLoginButton />
|
||||
|
||||
<div className="text-center text-sm text-muted-foreground">
|
||||
Nog geen account?{' '}
|
||||
<Link href="/register" className="text-primary hover:underline font-medium">
|
||||
|
|
|
|||
209
app/(auth)/login/qr-login-button.tsx
Normal file
209
app/(auth)/login/qr-login-button.tsx
Normal file
|
|
@ -0,0 +1,209 @@
|
|||
'use client'
|
||||
|
||||
// ST-1007: Desktop-UI voor de QR-pairing-flow (M10).
|
||||
//
|
||||
// Klikt → POST /pair/start (cookie + body) → render QR die fragment-URL bevat
|
||||
// → EventSource luistert naar /pair/stream/[id] met s4m_pair-cookie → bij
|
||||
// approved-event POST /pair/claim → router.push('/dashboard').
|
||||
//
|
||||
// mobileSecret blijft in JS-memory en in het QR-fragment; wordt nooit naar
|
||||
// de server gestuurd vanuit deze browser. desktopToken zit alleen in de
|
||||
// HttpOnly s4m_pair-cookie. fetch en EventSource sturen die cookie automatisch
|
||||
// mee binnen de Path=/api/auth/pair-scope.
|
||||
|
||||
import { useEffect, useRef, useState } from 'react'
|
||||
import { useRouter } from 'next/navigation'
|
||||
import { QRCodeSVG } from 'qrcode.react'
|
||||
import { toast } from 'sonner'
|
||||
import { Button } from '@/components/ui/button'
|
||||
|
||||
type Phase =
|
||||
| { kind: 'idle' }
|
||||
| { kind: 'starting' }
|
||||
| { kind: 'showing'; pairingId: string; qrUrl: string; expiresAt: number }
|
||||
| { kind: 'expired' }
|
||||
| { kind: 'claiming' }
|
||||
|
||||
interface StartResponse {
|
||||
pairingId: string
|
||||
mobileSecret: string
|
||||
expiresAt: string
|
||||
qrUrl: string
|
||||
}
|
||||
|
||||
interface StreamMessage {
|
||||
op?: 'I' | 'U'
|
||||
status?: 'pending' | 'approved' | 'consumed' | 'cancelled'
|
||||
pairing_id?: string
|
||||
}
|
||||
|
||||
export function QrLoginButton() {
|
||||
const router = useRouter()
|
||||
const [phase, setPhase] = useState<Phase>({ kind: 'idle' })
|
||||
const sseRef = useRef<EventSource | null>(null)
|
||||
const [secondsLeft, setSecondsLeft] = useState(0)
|
||||
|
||||
async function start() {
|
||||
setPhase({ kind: 'starting' })
|
||||
try {
|
||||
const res = await fetch('/api/auth/pair/start', {
|
||||
method: 'POST',
|
||||
credentials: 'same-origin',
|
||||
})
|
||||
if (!res.ok) throw new Error(`pair/start ${res.status}`)
|
||||
const data = (await res.json()) as StartResponse
|
||||
setPhase({
|
||||
kind: 'showing',
|
||||
pairingId: data.pairingId,
|
||||
qrUrl: data.qrUrl,
|
||||
expiresAt: new Date(data.expiresAt).getTime(),
|
||||
})
|
||||
} catch {
|
||||
toast.error('Kon QR-code niet aanmaken — probeer opnieuw')
|
||||
setPhase({ kind: 'idle' })
|
||||
}
|
||||
}
|
||||
|
||||
// Open SSE-stream zodra we in 'showing' zijn
|
||||
useEffect(() => {
|
||||
if (phase.kind !== 'showing') return
|
||||
|
||||
const es = new EventSource(`/api/auth/pair/stream/${phase.pairingId}`, {
|
||||
withCredentials: true,
|
||||
})
|
||||
sseRef.current = es
|
||||
|
||||
const onMessage = async (ev: MessageEvent) => {
|
||||
let data: StreamMessage
|
||||
try {
|
||||
data = JSON.parse(ev.data) as StreamMessage
|
||||
} catch {
|
||||
return
|
||||
}
|
||||
if (data.status !== 'approved') return
|
||||
|
||||
// Approved! Sluit SSE en claim de sessie
|
||||
es.close()
|
||||
sseRef.current = null
|
||||
setPhase({ kind: 'claiming' })
|
||||
try {
|
||||
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) throw new Error(`pair/claim ${res.status}`)
|
||||
router.push('/dashboard')
|
||||
} catch {
|
||||
toast.error('Inloggen mislukt — probeer opnieuw')
|
||||
setPhase({ kind: 'idle' })
|
||||
}
|
||||
}
|
||||
|
||||
const onError = () => {
|
||||
// EventSource probeert zelf opnieuw te verbinden bij netwerk-glitches.
|
||||
// Geen actie nodig tenzij we definitief willen falen.
|
||||
}
|
||||
|
||||
// De server stuurt direct na connect een `event: state`-payload met de
|
||||
// huidige pairing-status (catch-up voor de race tussen pair/start en de
|
||||
// SSE-open: als de mobiel net daarvoor approvet komt de notify door
|
||||
// vóórdat onze LISTEN actief is en wordt 'ie verloren). EventSource
|
||||
// routeert events met `event: <name>` alleen naar listeners voor die
|
||||
// naam — niet naar 'message'. Dezelfde handler aan beide hangen vangt
|
||||
// de catch-up én reguliere notifies op.
|
||||
es.addEventListener('message', onMessage)
|
||||
es.addEventListener('state', onMessage as unknown as EventListener)
|
||||
es.addEventListener('error', onError)
|
||||
|
||||
return () => {
|
||||
es.removeEventListener('message', onMessage)
|
||||
es.removeEventListener('state', onMessage as unknown as EventListener)
|
||||
es.removeEventListener('error', onError)
|
||||
es.close()
|
||||
sseRef.current = null
|
||||
}
|
||||
}, [phase, router])
|
||||
|
||||
// Aftellen + auto-expire
|
||||
useEffect(() => {
|
||||
if (phase.kind !== 'showing') return
|
||||
|
||||
const tick = () => {
|
||||
const remaining = Math.max(0, Math.ceil((phase.expiresAt - Date.now()) / 1000))
|
||||
setSecondsLeft(remaining)
|
||||
if (remaining === 0) {
|
||||
sseRef.current?.close()
|
||||
sseRef.current = null
|
||||
setPhase({ kind: 'expired' })
|
||||
}
|
||||
}
|
||||
|
||||
tick()
|
||||
const id = setInterval(tick, 1000)
|
||||
return () => clearInterval(id)
|
||||
}, [phase])
|
||||
|
||||
if (phase.kind === 'idle' || phase.kind === 'starting') {
|
||||
return (
|
||||
<Button
|
||||
type="button"
|
||||
variant="outline"
|
||||
className="w-full"
|
||||
onClick={start}
|
||||
disabled={phase.kind === 'starting'}
|
||||
>
|
||||
{phase.kind === 'starting' ? 'Bezig…' : 'Inloggen via mobiel'}
|
||||
</Button>
|
||||
)
|
||||
}
|
||||
|
||||
if (phase.kind === 'expired') {
|
||||
return (
|
||||
<div className="space-y-3 text-center">
|
||||
<p className="text-muted-foreground text-sm">
|
||||
QR-code verlopen. Maak een nieuwe aan om opnieuw te proberen.
|
||||
</p>
|
||||
<Button type="button" variant="outline" className="w-full" onClick={start}>
|
||||
Vernieuwen
|
||||
</Button>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
if (phase.kind === 'claiming') {
|
||||
return (
|
||||
<div className="text-center text-sm" aria-live="polite">
|
||||
Inloggen…
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
// phase.kind === 'showing'
|
||||
const minutes = Math.floor(secondsLeft / 60)
|
||||
const seconds = String(secondsLeft % 60).padStart(2, '0')
|
||||
|
||||
return (
|
||||
<div className="space-y-3">
|
||||
<div className="bg-surface-container-low flex flex-col items-center gap-3 rounded-xl border border-border p-4">
|
||||
<QRCodeSVG
|
||||
value={phase.qrUrl}
|
||||
size={200}
|
||||
level="M"
|
||||
aria-label="QR-code voor inloggen via mobiel"
|
||||
/>
|
||||
<p className="text-muted-foreground text-xs" aria-live="polite">
|
||||
Vervalt over {minutes}:{seconds}
|
||||
</p>
|
||||
</div>
|
||||
<details className="text-muted-foreground text-xs">
|
||||
<summary className="cursor-pointer">Werkt scannen niet? Toon link</summary>
|
||||
<p className="mt-2 break-all font-mono text-[11px]">{phase.qrUrl}</p>
|
||||
</details>
|
||||
<p className="text-muted-foreground text-center text-xs">
|
||||
Scan met een telefoon waar je al ingelogd bent.
|
||||
</p>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
97
app/api/auth/pair/claim/route.ts
Normal file
97
app/api/auth/pair/claim/route.ts
Normal file
|
|
@ -0,0 +1,97 @@
|
|||
// ST-1006: POST /api/auth/pair/claim — desktop ruilt zijn pre-auth cookie
|
||||
// (s4m_pair) in voor een echte iron-session na een succesvolle approve op de
|
||||
// mobiele kant.
|
||||
//
|
||||
// Auth: alleen via de HttpOnly s4m_pair-cookie. Geen body-secret nodig — het
|
||||
// cookie-token is het bewijs. Body bevat alleen pairingId.
|
||||
//
|
||||
// Atomicity: één UPDATE met WHERE-clausule die status én token-hash én niet-
|
||||
// verlopen tegelijk eist. Concurrent dubbele claims: PostgreSQL row-locking
|
||||
// zorgt dat exact één caller count=1 ziet, de rest count=0 → 410.
|
||||
|
||||
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 — kortere TTL voor publieke desktops
|
||||
|
||||
interface ClaimBody {
|
||||
pairingId?: unknown
|
||||
}
|
||||
|
||||
export async function POST(request: Request) {
|
||||
const desktopToken = await readPairCookie()
|
||||
if (!desktopToken) {
|
||||
return Response.json({ error: 'Geen pairing-cookie' }, { status: 401 })
|
||||
}
|
||||
|
||||
let body: ClaimBody
|
||||
try {
|
||||
body = (await request.json()) as ClaimBody
|
||||
} catch {
|
||||
return Response.json({ error: 'Ongeldige JSON' }, { status: 400 })
|
||||
}
|
||||
const pairingId = typeof body?.pairingId === 'string' ? body.pairingId : null
|
||||
if (!pairingId) {
|
||||
return Response.json({ error: 'pairingId vereist' }, { status: 400 })
|
||||
}
|
||||
|
||||
const desktopTokenHash = hashToken(desktopToken)
|
||||
|
||||
// Atomic state-transitie: alleen rij die approved is + token-hash matcht +
|
||||
// niet verlopen wordt geconsumeerd.
|
||||
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) {
|
||||
// Disambigueer: bestaat de pairing wel met deze cookie? Zo ja → al consumed
|
||||
// of cancelled (410). Zo nee → cookie matcht geen pairing (401).
|
||||
const exists = await prisma.loginPairing.findFirst({
|
||||
where: { id: pairingId, desktop_token_hash: desktopTokenHash },
|
||||
select: { status: true },
|
||||
})
|
||||
await clearPairCookie()
|
||||
if (!exists) return Response.json({ error: 'Ongeldig' }, { status: 401 })
|
||||
return Response.json(
|
||||
{ error: `Pairing al ${exists.status}` },
|
||||
{ status: 410 },
|
||||
)
|
||||
}
|
||||
|
||||
// Haal user-info op voor de iron-session payload.
|
||||
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()
|
||||
|
||||
if (process.env.NODE_ENV !== 'production') {
|
||||
console.log(`[pair/claim] consumed pairingId=${pairingId}`)
|
||||
}
|
||||
|
||||
return Response.json({ ok: true })
|
||||
}
|
||||
74
app/api/auth/pair/start/route.ts
Normal file
74
app/api/auth/pair/start/route.ts
Normal file
|
|
@ -0,0 +1,74 @@
|
|||
// 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 { 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'
|
||||
|
||||
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 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,
|
||||
})
|
||||
}
|
||||
201
app/api/auth/pair/stream/[pairingId]/route.ts
Normal file
201
app/api/auth/pair/stream/[pairingId]/route.ts
Normal file
|
|
@ -0,0 +1,201 @@
|
|||
// ST-1004: Server-Sent Events stream voor de QR-pairing-flow (M10).
|
||||
//
|
||||
// De desktop opent deze stream direct na pair/start. Auth is via de HttpOnly
|
||||
// `s4m_pair`-cookie die diezelfde start-call zette — geen iron-session nodig
|
||||
// (de gebruiker is op dit punt nog niet geauthenticeerd) en geen secret in
|
||||
// query-parameters. De pairingId in het pad is niet vertrouwelijk.
|
||||
//
|
||||
// Bouwt voort op het LISTEN/NOTIFY-patroon uit ST-802
|
||||
// (app/api/realtime/solo/route.ts) maar op channel `scrum4me_pairing`.
|
||||
//
|
||||
// Output: text/event-stream met
|
||||
// - één `state`-event direct na connect met de huidige pairing-status (zo
|
||||
// mist de desktop geen approve die net vóór de SSE-open landde)
|
||||
// - `message`-events met de volledige notify-payload bij elke status-wijziging
|
||||
// - heartbeat-comments elke 25s om proxy-timeouts te voorkomen
|
||||
//
|
||||
// Sluit zelf na 240s als safety-net (Vercel kapt na maxDuration), of zodra
|
||||
// status `consumed` of `cancelled` doorkomt — desktop heeft dan geen reden
|
||||
// meer om te luisteren.
|
||||
|
||||
import { NextRequest } from 'next/server'
|
||||
import { Client } from 'pg'
|
||||
import { prisma } from '@/lib/prisma'
|
||||
import { verifyToken } from '@/lib/auth/pairing'
|
||||
import { readPairCookie } from '@/lib/auth/pair-cookie'
|
||||
|
||||
export const runtime = 'nodejs'
|
||||
export const dynamic = 'force-dynamic'
|
||||
export const maxDuration = 300
|
||||
|
||||
const CHANNEL = 'scrum4me_pairing'
|
||||
const HEARTBEAT_MS = 25_000
|
||||
const HARD_CLOSE_MS = 240_000
|
||||
|
||||
interface NotifyPayload {
|
||||
op: 'I' | 'U'
|
||||
pairing_id: string
|
||||
status: 'pending' | 'approved' | 'consumed' | 'cancelled'
|
||||
}
|
||||
|
||||
const TERMINAL_STATUSES = new Set(['consumed', 'cancelled'])
|
||||
|
||||
export async function GET(
|
||||
request: NextRequest,
|
||||
{ params }: { params: Promise<{ pairingId: string }> },
|
||||
) {
|
||||
const { pairingId } = await params
|
||||
|
||||
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 })
|
||||
}
|
||||
|
||||
const directUrl = process.env.DIRECT_URL ?? process.env.DATABASE_URL
|
||||
if (!directUrl || !directUrl.startsWith('postgres')) {
|
||||
// .env.local heeft DIRECT_URL=http://localhost:3000 (placeholder); dan
|
||||
// valt fallback ook nog terug op DATABASE_URL.
|
||||
const fallback = process.env.DATABASE_URL
|
||||
if (!fallback) {
|
||||
return Response.json(
|
||||
{ error: 'DATABASE_URL niet geconfigureerd' },
|
||||
{ status: 500 },
|
||||
)
|
||||
}
|
||||
}
|
||||
const connectionString =
|
||||
directUrl && directUrl.startsWith('postgres')
|
||||
? directUrl
|
||||
: process.env.DATABASE_URL!
|
||||
|
||||
const encoder = new TextEncoder()
|
||||
const pgClient = new Client({ connectionString })
|
||||
|
||||
let heartbeatTimer: ReturnType<typeof setInterval> | null = null
|
||||
let hardCloseTimer: ReturnType<typeof setTimeout> | null = null
|
||||
let closed = false
|
||||
|
||||
const stream = new ReadableStream({
|
||||
async start(controller) {
|
||||
const enqueue = (chunk: string) => {
|
||||
if (closed) return
|
||||
try {
|
||||
controller.enqueue(encoder.encode(chunk))
|
||||
} catch {
|
||||
// controller al gesloten — negeren
|
||||
}
|
||||
}
|
||||
|
||||
const cleanup = async (reason: string) => {
|
||||
if (closed) return
|
||||
closed = true
|
||||
if (heartbeatTimer) clearInterval(heartbeatTimer)
|
||||
if (hardCloseTimer) clearTimeout(hardCloseTimer)
|
||||
try {
|
||||
await pgClient.end()
|
||||
} catch {
|
||||
// ignore
|
||||
}
|
||||
try {
|
||||
controller.close()
|
||||
} catch {
|
||||
// already closed
|
||||
}
|
||||
if (process.env.NODE_ENV !== 'production') {
|
||||
console.log(`[pair/stream ${pairingId}] closed: ${reason}`)
|
||||
}
|
||||
}
|
||||
|
||||
try {
|
||||
await pgClient.connect()
|
||||
await pgClient.query(`LISTEN ${CHANNEL}`)
|
||||
} catch (err) {
|
||||
console.error('[pair/stream] pg connect/listen failed:', err)
|
||||
enqueue(
|
||||
`event: error\ndata: ${JSON.stringify({ message: 'pg connect failed' })}\n\n`,
|
||||
)
|
||||
await cleanup('pg connect failed')
|
||||
return
|
||||
}
|
||||
|
||||
pgClient.on('notification', (msg) => {
|
||||
if (!msg.payload) return
|
||||
let payload: NotifyPayload
|
||||
try {
|
||||
payload = JSON.parse(msg.payload) as NotifyPayload
|
||||
} catch {
|
||||
return
|
||||
}
|
||||
if (payload.pairing_id !== pairingId) return
|
||||
enqueue(`data: ${msg.payload}\n\n`)
|
||||
if (TERMINAL_STATUSES.has(payload.status)) {
|
||||
cleanup(`terminal status: ${payload.status}`)
|
||||
}
|
||||
})
|
||||
|
||||
pgClient.on('error', (err) => {
|
||||
console.error('[pair/stream] pg client error:', err)
|
||||
cleanup('pg error')
|
||||
})
|
||||
|
||||
// Initial state — dicht de race tussen pair/start en SSE-open. De
|
||||
// *eerste* findUnique (voor cookie-validatie) gebeurde vóór LISTEN
|
||||
// actief was; als de mobiel tussen die query en LISTEN approvet is
|
||||
// de pg_notify verloren (Postgres queuet niet) én is de eerder
|
||||
// gelezen status stale. Lees daarom de status hier opnieuw — nu LISTEN
|
||||
// wel actief is, dus alle approvals na dit punt komen via de notify-
|
||||
// handler door.
|
||||
const fresh = await prisma.loginPairing.findUnique({
|
||||
where: { id: pairingId },
|
||||
select: { status: true },
|
||||
})
|
||||
const currentStatus = fresh?.status ?? pairing.status
|
||||
|
||||
enqueue(
|
||||
`event: state\ndata: ${JSON.stringify({
|
||||
pairing_id: pairingId,
|
||||
status: currentStatus,
|
||||
})}\n\n`,
|
||||
)
|
||||
if (TERMINAL_STATUSES.has(currentStatus)) {
|
||||
await cleanup(`already-${currentStatus}`)
|
||||
return
|
||||
}
|
||||
|
||||
heartbeatTimer = setInterval(() => {
|
||||
enqueue(`: heartbeat\n\n`)
|
||||
}, HEARTBEAT_MS)
|
||||
|
||||
hardCloseTimer = setTimeout(() => {
|
||||
cleanup('hard close 240s')
|
||||
}, HARD_CLOSE_MS)
|
||||
|
||||
request.signal.addEventListener('abort', () => {
|
||||
cleanup('client aborted')
|
||||
})
|
||||
},
|
||||
})
|
||||
|
||||
return new Response(stream, {
|
||||
headers: {
|
||||
'Content-Type': 'text/event-stream; charset=utf-8',
|
||||
'Cache-Control': 'no-cache, no-transform',
|
||||
Connection: 'keep-alive',
|
||||
'X-Accel-Buffering': 'no',
|
||||
},
|
||||
})
|
||||
}
|
||||
|
|
@ -1,6 +1,6 @@
|
|||
'use client'
|
||||
|
||||
import { useRef } from 'react'
|
||||
import { useTransition } from 'react'
|
||||
import Link from 'next/link'
|
||||
import { Settings, Sun, Globe, LogOut } from 'lucide-react'
|
||||
import { logoutAction } from '@/actions/auth'
|
||||
|
|
@ -33,11 +33,20 @@ export function UserMenu({ userId, username, email, roles }: UserMenuProps) {
|
|||
const initials = username.slice(0, 2).toUpperCase()
|
||||
const roleLabels = roles.map((r) => ROLE_LABELS[r]).filter(Boolean)
|
||||
const subtitle = email?.trim() ? email.trim() : 'Lokaal account'
|
||||
const logoutFormRef = useRef<HTMLFormElement>(null)
|
||||
const [pendingLogout, startLogout] = useTransition()
|
||||
|
||||
// Server Action direct aanroepen — geen form/ref-dance. Eerdere implementatie
|
||||
// gebruikte een hidden form binnen DropdownMenuContent; die unmount op
|
||||
// onSelect en in deze base-ui-versie kwam de submit niet door.
|
||||
function handleLogout() {
|
||||
startLogout(async () => {
|
||||
await logoutAction()
|
||||
})
|
||||
}
|
||||
|
||||
return (
|
||||
<DropdownMenu>
|
||||
<DropdownMenuTrigger
|
||||
<DropdownMenuTrigger
|
||||
className="rounded-full focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-primary focus-visible:ring-offset-2 focus-visible:ring-offset-background"
|
||||
aria-label="Accountmenu openen"
|
||||
>
|
||||
|
|
@ -103,13 +112,14 @@ export function UserMenu({ userId, username, email, roles }: UserMenuProps) {
|
|||
<DropdownMenuSeparator />
|
||||
|
||||
<DropdownMenuItem
|
||||
onSelect={() => logoutFormRef.current?.requestSubmit()}
|
||||
onClick={handleLogout}
|
||||
onSelect={handleLogout}
|
||||
disabled={pendingLogout}
|
||||
className="cursor-pointer"
|
||||
>
|
||||
<LogOut className="mr-2 h-4 w-4" />
|
||||
<span>Uitloggen</span>
|
||||
<span>{pendingLogout ? 'Uitloggen…' : 'Uitloggen'}</span>
|
||||
</DropdownMenuItem>
|
||||
<form ref={logoutFormRef} action={logoutAction} className="hidden" />
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenu>
|
||||
)
|
||||
|
|
|
|||
94
docs/API.md
94
docs/API.md
|
|
@ -323,6 +323,100 @@ source.onmessage = (e) => console.log(JSON.parse(e.data))
|
|||
|
||||
---
|
||||
|
||||
## Auth — QR-pairing (M10)
|
||||
|
||||
Drie anonieme/cookie-geauthenticeerde endpoints voor de password-loze inlog
|
||||
via QR-pairing. Worden door de browser gebruikt (niet door Claude Code) —
|
||||
gedocumenteerd voor volledigheid en voor handmatige curl-tests.
|
||||
|
||||
**Cookie-mechaniek:** `pair/start` zet een korte `s4m_pair`-HttpOnly-cookie
|
||||
(`Path=/api/auth/pair`, `Max-Age=300`, `SameSite=Lax`, `Secure` in productie).
|
||||
`pair/stream` en `pair/claim` authenticeren tegen die cookie. Geheim materiaal
|
||||
zit nooit in URL-paden of querystrings — `mobileSecret` reist alleen via QR-
|
||||
fragment (`#s=…`) en POST-body, `desktopToken` alleen via cookie.
|
||||
|
||||
### `POST /api/auth/pair/start`
|
||||
|
||||
Anon. Maakt een nieuwe `LoginPairing` aan en zet de pre-auth cookie.
|
||||
|
||||
**Auth:** geen.
|
||||
**Body:** geen.
|
||||
**Rate-limit:** 10 per IP per minuut (zelfde patroon als `/login`).
|
||||
|
||||
**Response 200:**
|
||||
```json
|
||||
{
|
||||
"pairingId": "cmoh...",
|
||||
"mobileSecret": "<43-char base64url>",
|
||||
"expiresAt": "2026-04-27T20:30:00.000Z",
|
||||
"qrUrl": "https://.../m/pair#id=cmoh...&s=<mobileSecret>"
|
||||
}
|
||||
```
|
||||
Plus `Set-Cookie: s4m_pair=<desktopToken>; HttpOnly; Path=/api/auth/pair; Max-Age=300; SameSite=Lax`.
|
||||
|
||||
**Foutcodes:** `429` bij rate-limit overschreden.
|
||||
|
||||
**Voorbeeld:**
|
||||
```bash
|
||||
curl -i -X POST -c /tmp/jar http://localhost:3000/api/auth/pair/start
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### `GET /api/auth/pair/stream/:pairingId`
|
||||
|
||||
Server-Sent Events stream die de desktop opent direct na `pair/start` om op
|
||||
de approve-bevestiging van de mobiel te wachten.
|
||||
|
||||
**Auth:** `s4m_pair`-cookie. Werkt vanuit `EventSource` met `withCredentials: true`.
|
||||
**Path:** `pairingId` is niet vertrouwelijk; cookie is het bewijs.
|
||||
**Stream-duur:** maximaal 240s (Vercel-buffer onder de 300s `maxDuration`); sluit
|
||||
zodra status `consumed` of `cancelled` doorkomt.
|
||||
|
||||
**Events:**
|
||||
- `event: state` — eenmalig direct na connect, met `{ pairing_id, status }` (status van pairing op moment van connecten — voorkomt race wanneer approve net vóór SSE-open landt).
|
||||
- `data: {...}` — bij elke status-overgang. Payload:
|
||||
```json
|
||||
{ "op": "I" | "U", "pairing_id": "cmoh...", "status": "pending" | "approved" | "consumed" | "cancelled" }
|
||||
```
|
||||
- `: heartbeat` — SSE-comment elke 25s.
|
||||
|
||||
**Foutcodes:** `401` zonder/foute cookie, `404` als pairing onbekend, `410` als pairing verlopen.
|
||||
|
||||
**Voorbeeld:**
|
||||
```bash
|
||||
curl -N -i -b /tmp/jar http://localhost:3000/api/auth/pair/stream/<pairingId>
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### `POST /api/auth/pair/claim`
|
||||
|
||||
Cookie-auth. Atomisch consume van een approved pairing → schrijft de echte
|
||||
`scrum4me-session` cookie zodat de desktop is ingelogd.
|
||||
|
||||
**Auth:** `s4m_pair`-cookie.
|
||||
**Body:** `{ "pairingId": "cmoh..." }`.
|
||||
|
||||
**Response 200:** `{ "ok": true }` plus
|
||||
- `Set-Cookie: scrum4me-session=...; HttpOnly; SameSite=Lax` — paired-sessie met `paired: true` en `pairedExpiresAt = now + 8h` payload-velden.
|
||||
- `Set-Cookie: s4m_pair=...; Max-Age=0` — pre-auth cookie wordt gewist.
|
||||
|
||||
**Foutcodes:**
|
||||
- `400` bij ontbrekende of malformed body
|
||||
- `401` zonder cookie of bij hash-mismatch (cookie matcht geen pairing)
|
||||
- `410` als pairing al consumed/cancelled is (replay) of verlopen
|
||||
|
||||
**Voorbeeld:**
|
||||
```bash
|
||||
curl -i -X POST -b /tmp/jar -c /tmp/jar \
|
||||
-H "Content-Type: application/json" \
|
||||
-d '{"pairingId":"<pairingId>"}' \
|
||||
http://localhost:3000/api/auth/pair/claim
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Voorbeeldworkflow voor Claude Code
|
||||
|
||||
1. **Probe:** `GET /api/health?db=1` — bevestig dat de service en DB bereikbaar zijn.
|
||||
|
|
|
|||
95
docs/patterns/qr-login.md
Normal file
95
docs/patterns/qr-login.md
Normal file
|
|
@ -0,0 +1,95 @@
|
|||
# Patroon: QR-pairing via unauth-SSE + pre-auth cookie
|
||||
|
||||
Het M10 QR-login-mechanisme is herbruikbaar voor elke feature die **realtime-
|
||||
feedback wil tussen twee browsers/devices vóórdat de eindgebruiker is
|
||||
geauthenticeerd**. De typische vorm:
|
||||
|
||||
> "Apparaat A start een proces, krijgt een token. Apparaat B (bekend kanaal)
|
||||
> bevestigt iets. Apparaat A wil dat realtime weten en daarna iets claimen."
|
||||
|
||||
Voorbeelden waar dit zou kunnen passen: device-pairing voor 2FA-setup, login-
|
||||
op-TV via QR, "claim deze export"-flow, account-overdracht tussen sessies.
|
||||
|
||||
---
|
||||
|
||||
## Drie eindpunten
|
||||
|
||||
| Endpoint | Auth | Doel |
|
||||
|---|---|---|
|
||||
| `POST /api/.../start` | anon | maakt resource aan, retourneert mobile-secret in body, zet HttpOnly device-token cookie |
|
||||
| `GET /api/.../stream/[id]` | cookie | SSE die op LISTEN/NOTIFY wacht op statusverandering |
|
||||
| `POST /api/.../claim` | cookie | atomic state-transitie van "approved" → "consumed", wisselt cookie in voor échte sessie |
|
||||
|
||||
Plus een server-action-laag die door het tweede device wordt aangeroepen na
|
||||
het scannen / klikken van een link met fragment-secret.
|
||||
|
||||
---
|
||||
|
||||
## Vier security-uitgangspunten
|
||||
|
||||
1. **Twee gescheiden geheimen** — een voor het kanaal richting het tweede
|
||||
device (in QR-fragment), een voor het oorspronkelijke device (in HttpOnly
|
||||
cookie). Beide alleen als sha256-hash in DB.
|
||||
2. **Geen secret in URL.** Path en querystring lekken naar access logs,
|
||||
reverse proxies, observability. Geheimen reizen alleen via:
|
||||
- URL-fragment (`#…`) — browsers sturen die niet naar de server
|
||||
- HttpOnly cookies — meestal niet gelogd, en alleen leesbaar door server
|
||||
- POST-body — niet gelogd standaard
|
||||
3. **Atomic consume.** Het claim-endpoint doet één UPDATE met een composite
|
||||
WHERE op alle invarianten (status, hash, expiry). PostgreSQL row-locking
|
||||
garandeert dat concurrent dubbele claims slechts één caller succes geven.
|
||||
4. **Path-scoped cookie.** `Path=/api/.../...` zorgt dat de pre-auth cookie
|
||||
alleen naar pairing-routes gaat — niet naar de rest van de app.
|
||||
|
||||
---
|
||||
|
||||
## Sjabloon-bestanden
|
||||
|
||||
Ga voor M10 specifiek? Kopieer en pas aan:
|
||||
|
||||
- `lib/auth/pairing.ts` — secret/token generators + sha256 + timing-safe verify + expiry helper
|
||||
- `lib/auth/pair-cookie.ts` — set/read/clear van Path-scoped HttpOnly cookie
|
||||
- `app/api/auth/pair/start/route.ts` — anon POST, rate-limited, sets cookie
|
||||
- `app/api/auth/pair/stream/[id]/route.ts` — SSE met cookie-auth, LISTEN op eigen channel
|
||||
- `app/api/auth/pair/claim/route.ts` — atomic update + iron-session schrijven
|
||||
- `actions/pairing.ts` — Server Actions voor het tweede device
|
||||
- `app/(app)/m/pair/pair-confirmation.tsx` — Client island die `location.hash` parseert
|
||||
|
||||
Voor het tweede device zit de auth meestal al in de bestaande `(app)`-layout
|
||||
guard. De Client Component gebruikt `window.location.hash` (niet `useSearchParams`)
|
||||
om het secret op te pikken.
|
||||
|
||||
---
|
||||
|
||||
## TTL-richtlijn
|
||||
|
||||
Drie tijden in escalerende volgorde, alle korter dan de reguliere sessie:
|
||||
|
||||
- **Pending (cookie + DB-rij)** — *kort genoeg dat een verloren cookie/QR
|
||||
weinig schade aanricht*. M10: 5 minuten.
|
||||
- **Approved (na bevestiging)** — *kort genoeg dat een approved-maar-niet-
|
||||
geclaimde pairing niet eindeloos open blijft*. M10: 5 minuten extra.
|
||||
- **Resulterende sessie** — *kort genoeg voor publieke apparaten, lang genoeg
|
||||
voor een werkdag*. M10: 8 uur, plus `paired: true`-vlag voor toekomstige
|
||||
remote-revoke.
|
||||
|
||||
---
|
||||
|
||||
## Wanneer dit patroon NIET gebruiken
|
||||
|
||||
- Wanneer beide kanten al ingelogd zijn — dan is een normaal API-call met
|
||||
bestaande sessie eenvoudiger.
|
||||
- Wanneer realtime niet kritiek is — een korte poll (`setInterval` op een
|
||||
status-endpoint) is simpeler dan een SSE-stream.
|
||||
- Wanneer er één centraal apparaat is — gebruik dan een normale sessie; de
|
||||
twee-device-dans is alleen nodig om credentials van het ene apparaat naar
|
||||
het andere te brengen.
|
||||
|
||||
---
|
||||
|
||||
## Referenties
|
||||
|
||||
- Volledige flow + threat-model: `docs/scrum4me-architecture.md` § QR-pairing flow
|
||||
- Endpoint-contract: `docs/API.md` § Auth — QR-pairing
|
||||
- LISTEN/NOTIFY-pattern: `app/api/realtime/solo/route.ts` (M8 ST-802) — zelfde
|
||||
ReadableStream + heartbeat + hard-close + abort-cleanup, alleen ander channel
|
||||
|
|
@ -512,6 +512,85 @@ Uitloggen:
|
|||
|
||||
---
|
||||
|
||||
## 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
|
||||
|
||||
```mermaid
|
||||
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: scrum4me-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`.
|
||||
|
||||
---
|
||||
|
||||
## Projectstructuur
|
||||
|
||||
```
|
||||
|
|
|
|||
33
lib/auth/pair-cookie.ts
Normal file
33
lib/auth/pair-cookie.ts
Normal file
|
|
@ -0,0 +1,33 @@
|
|||
// ST-1002: HttpOnly pre-auth cookie voor de QR-pairing desktop-side.
|
||||
//
|
||||
// Wordt gezet door /api/auth/pair/start (ST-1003), gelezen door
|
||||
// /api/auth/pair/stream/[id] (ST-1004) en /api/auth/pair/claim (ST-1006),
|
||||
// en gewist op claim of cancel. Path-scoped naar /api/auth/pair zodat de
|
||||
// cookie niet naar andere routes lekt.
|
||||
|
||||
import { cookies } from 'next/headers'
|
||||
|
||||
const COOKIE_NAME = 's4m_pair'
|
||||
const MAX_AGE_SECONDS = 300 // gelijk aan pending-TTL van LoginPairing (5 min)
|
||||
const COOKIE_PATH = '/api/auth/pair'
|
||||
|
||||
export async function setPairCookie(desktopToken: string): Promise<void> {
|
||||
const jar = await cookies()
|
||||
jar.set(COOKIE_NAME, desktopToken, {
|
||||
httpOnly: true,
|
||||
secure: process.env.NODE_ENV === 'production',
|
||||
sameSite: 'lax',
|
||||
path: COOKIE_PATH,
|
||||
maxAge: MAX_AGE_SECONDS,
|
||||
})
|
||||
}
|
||||
|
||||
export async function readPairCookie(): Promise<string | null> {
|
||||
const jar = await cookies()
|
||||
return jar.get(COOKIE_NAME)?.value ?? null
|
||||
}
|
||||
|
||||
export async function clearPairCookie(): Promise<void> {
|
||||
const jar = await cookies()
|
||||
jar.delete({ name: COOKIE_NAME, path: COOKIE_PATH })
|
||||
}
|
||||
42
lib/auth/pairing.ts
Normal file
42
lib/auth/pairing.ts
Normal file
|
|
@ -0,0 +1,42 @@
|
|||
// ST-1002: Pure crypto-helpers voor de QR-pairing flow (M10).
|
||||
//
|
||||
// Twee gescheiden 256-bit geheimen per pairing:
|
||||
// mobileSecret — bewijs dat de mobiel komt vanaf het scan-kanaal (QR-fragment → POST-body)
|
||||
// desktopToken — bewijs dat de desktop is wie de pairing startte (HttpOnly cookie)
|
||||
//
|
||||
// In de DB staan alleen sha256-hashes van beide; de plaintext-waarden verlaten
|
||||
// alleen de desktop-JS (mobileSecret via QR-fragment, desktopToken via Set-Cookie)
|
||||
// en blijven nooit in URL-paden of access-logs.
|
||||
|
||||
import { createHash, randomBytes, timingSafeEqual } from 'crypto'
|
||||
|
||||
const SECRET_BYTES = 32
|
||||
|
||||
export function generateMobileSecret(): string {
|
||||
return randomBytes(SECRET_BYTES).toString('base64url')
|
||||
}
|
||||
|
||||
export function generateDesktopToken(): string {
|
||||
return randomBytes(SECRET_BYTES).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)
|
||||
}
|
||||
|
||||
// Geëxtraheerd zodat de Server Component (app/(app)/layout.tsx) Date.now() niet
|
||||
// rechtstreeks in render aanroept — de React Compiler markeert dat als impure.
|
||||
export function isPairedSessionExpired(session: {
|
||||
paired?: boolean
|
||||
pairedExpiresAt?: number
|
||||
}): boolean {
|
||||
if (!session.paired || !session.pairedExpiresAt) return false
|
||||
return session.pairedExpiresAt < Date.now()
|
||||
}
|
||||
|
|
@ -8,8 +8,9 @@ interface RateLimitConfig {
|
|||
}
|
||||
|
||||
const CONFIGS: Record<string, RateLimitConfig> = {
|
||||
login: { windowMs: 60_000, max: 10 }, // 10 attempts per minute
|
||||
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 }
|
||||
|
|
|
|||
|
|
@ -3,6 +3,10 @@ import { SessionOptions } from 'iron-session'
|
|||
export interface SessionData {
|
||||
userId: string
|
||||
isDemo: boolean
|
||||
// ST-1002 (M10) — gezet door /api/auth/pair/claim na een succesvolle QR-pairing.
|
||||
// Beide velden zijn optioneel zodat bestaande wachtwoord-sessies onveranderd blijven.
|
||||
paired?: boolean
|
||||
pairedExpiresAt?: number // unix ms
|
||||
}
|
||||
|
||||
export const sessionOptions: SessionOptions = {
|
||||
|
|
|
|||
14
package-lock.json
generated
14
package-lock.json
generated
|
|
@ -1,12 +1,12 @@
|
|||
{
|
||||
"name": "scrum4me",
|
||||
"version": "0.3.1",
|
||||
"version": "0.4.0",
|
||||
"lockfileVersion": 3,
|
||||
"requires": true,
|
||||
"packages": {
|
||||
"": {
|
||||
"name": "scrum4me",
|
||||
"version": "0.3.1",
|
||||
"version": "0.4.0",
|
||||
"hasInstallScript": true,
|
||||
"dependencies": {
|
||||
"@base-ui/react": "^1.4.1",
|
||||
|
|
@ -27,6 +27,7 @@
|
|||
"next-themes": "^0.4.6",
|
||||
"pg": "^8.20.0",
|
||||
"prisma": "^7.8.0",
|
||||
"qrcode.react": "^4.2.0",
|
||||
"react": "19.2.4",
|
||||
"react-dom": "19.2.4",
|
||||
"shadcn": "^4.4.0",
|
||||
|
|
@ -13763,6 +13764,15 @@
|
|||
],
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/qrcode.react": {
|
||||
"version": "4.2.0",
|
||||
"resolved": "https://registry.npmjs.org/qrcode.react/-/qrcode.react-4.2.0.tgz",
|
||||
"integrity": "sha512-QpgqWi8rD9DsS9EP3z7BT+5lY5SFhsqGjpgW5DY/i3mK4M9DTBNz3ErMi8BWYEfI3L0d8GIbGmcdFAS1uIRGjA==",
|
||||
"license": "ISC",
|
||||
"peerDependencies": {
|
||||
"react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/qs": {
|
||||
"version": "6.15.1",
|
||||
"resolved": "https://registry.npmjs.org/qs/-/qs-6.15.1.tgz",
|
||||
|
|
|
|||
|
|
@ -14,7 +14,8 @@
|
|||
"postinstall": "prisma generate --generator client",
|
||||
"db:erd": "prisma generate",
|
||||
"db:erd:watch": "chokidar \"prisma/schema.prisma\" -c \"npm run db:erd\"",
|
||||
"db:insert-milestone": "tsx scripts/insert-milestone.ts"
|
||||
"db:insert-milestone": "tsx scripts/insert-milestone.ts",
|
||||
"seed": "prisma db seed"
|
||||
},
|
||||
"dependencies": {
|
||||
"@base-ui/react": "^1.4.1",
|
||||
|
|
@ -35,6 +36,7 @@
|
|||
"next-themes": "^0.4.6",
|
||||
"pg": "^8.20.0",
|
||||
"prisma": "^7.8.0",
|
||||
"qrcode.react": "^4.2.0",
|
||||
"react": "19.2.4",
|
||||
"react-dom": "19.2.4",
|
||||
"shadcn": "^4.4.0",
|
||||
|
|
|
|||
|
|
@ -0,0 +1,67 @@
|
|||
-- CreateTable
|
||||
CREATE TABLE "login_pairings" (
|
||||
"id" TEXT NOT NULL,
|
||||
"secret_hash" TEXT NOT NULL,
|
||||
"desktop_token_hash" TEXT NOT NULL,
|
||||
"status" TEXT NOT NULL,
|
||||
"user_id" TEXT,
|
||||
"desktop_ua" VARCHAR(255),
|
||||
"desktop_ip" VARCHAR(45),
|
||||
"created_at" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
"expires_at" TIMESTAMP(3) NOT NULL,
|
||||
"approved_at" TIMESTAMP(3),
|
||||
"consumed_at" TIMESTAMP(3),
|
||||
|
||||
CONSTRAINT "login_pairings_pkey" PRIMARY KEY ("id")
|
||||
);
|
||||
|
||||
-- CreateIndex
|
||||
CREATE INDEX "login_pairings_expires_at_idx" ON "login_pairings"("expires_at");
|
||||
|
||||
-- CreateIndex
|
||||
CREATE INDEX "login_pairings_status_expires_at_idx" ON "login_pairings"("status", "expires_at");
|
||||
|
||||
-- AddForeignKey
|
||||
ALTER TABLE "login_pairings" ADD CONSTRAINT "login_pairings_user_id_fkey" FOREIGN KEY ("user_id") REFERENCES "users"("id") ON DELETE SET NULL ON UPDATE CASCADE;
|
||||
|
||||
-- ST-1001: Postgres LISTEN/NOTIFY voor QR-pairing flow.
|
||||
--
|
||||
-- AFTER INSERT/UPDATE-trigger op login_pairings emit een JSON-payload op het
|
||||
-- `scrum4me_pairing`-kanaal. De SSE-route /api/auth/pair/stream/[pairingId]
|
||||
-- (ST-1004) abonneert op dit kanaal en filtert per pairing_id.
|
||||
--
|
||||
-- DELETE wordt niet ondersteund — pairings gaan naar status='consumed' of
|
||||
-- 'cancelled', niet weg. Een eventuele cleanup-job die rijen wel deleten zou
|
||||
-- kan dat zonder dit kanaal te bereiken.
|
||||
--
|
||||
-- Payload shape:
|
||||
-- { op: 'I' | 'U',
|
||||
-- pairing_id: text,
|
||||
-- status: text }
|
||||
--
|
||||
-- Channel-name is hardcoded analoog aan `scrum4me_changes` uit ST-801. Bij
|
||||
-- wijziging deze migratie én app/api/auth/pair/stream/[pairingId]/route.ts
|
||||
-- bijwerken.
|
||||
|
||||
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'
|
||||
END,
|
||||
'pairing_id', NEW.id,
|
||||
'status', NEW.status
|
||||
);
|
||||
|
||||
PERFORM pg_notify('scrum4me_pairing', payload::text);
|
||||
RETURN NEW;
|
||||
END;
|
||||
$$ LANGUAGE plpgsql;
|
||||
|
||||
DROP TRIGGER IF EXISTS login_pairings_notify ON login_pairings;
|
||||
CREATE TRIGGER login_pairings_notify
|
||||
AFTER INSERT OR UPDATE ON login_pairings
|
||||
FOR EACH ROW EXECUTE FUNCTION notify_pairing_change();
|
||||
|
|
@ -65,6 +65,7 @@ model User {
|
|||
todos Todo[]
|
||||
product_members ProductMember[]
|
||||
assigned_stories Story[] @relation("StoryAssignee")
|
||||
login_pairings LoginPairing[]
|
||||
|
||||
@@index([active_product_id])
|
||||
@@map("users")
|
||||
|
|
@ -247,3 +248,22 @@ model Todo {
|
|||
@@index([user_id, product_id])
|
||||
@@map("todos")
|
||||
}
|
||||
|
||||
model LoginPairing {
|
||||
id String @id @default(cuid())
|
||||
secret_hash String
|
||||
desktop_token_hash String
|
||||
status String
|
||||
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)
|
||||
created_at DateTime @default(now())
|
||||
expires_at DateTime
|
||||
approved_at DateTime?
|
||||
consumed_at DateTime?
|
||||
|
||||
@@index([expires_at])
|
||||
@@index([status, expires_at])
|
||||
@@map("login_pairings")
|
||||
}
|
||||
|
|
|
|||
|
|
@ -68,14 +68,14 @@ const MILESTONE_SPRINT_STATUS: Record<string, ParsedMilestone['sprint_status']>
|
|||
M1: 'COMPLETED',
|
||||
M2: 'COMPLETED',
|
||||
M3: 'COMPLETED',
|
||||
'M3.5': 'ACTIVE',
|
||||
'M3.5': 'COMPLETED',
|
||||
M4: 'COMPLETED',
|
||||
M5: 'COMPLETED',
|
||||
M6: 'COMPLETED',
|
||||
M7: 'COMPLETED',
|
||||
M8: 'COMPLETED',
|
||||
M9: 'COMPLETED',
|
||||
M10: 'COMPLETED',
|
||||
M10: 'ACTIVE',
|
||||
}
|
||||
|
||||
const MILESTONE_KEY = /^(?:M[\d.]+|PBI-\d+)$/
|
||||
|
|
|
|||
|
|
@ -189,93 +189,6 @@ async function main() {
|
|||
}
|
||||
}
|
||||
|
||||
|
||||
// Solo board demo data — claimed stories for demo user + 1 unassigned for the sheet
|
||||
const activeSprint = await prisma.sprint.findFirst({
|
||||
where: { product_id: product.id, status: 'ACTIVE' },
|
||||
})
|
||||
|
||||
if (activeSprint) {
|
||||
const soloPbi = await prisma.pbi.create({
|
||||
data: {
|
||||
product_id: product.id,
|
||||
title: 'Solo Demo',
|
||||
description: 'Voorbeeldtaken voor het Solo bord.',
|
||||
priority: 3,
|
||||
sort_order: 99,
|
||||
},
|
||||
})
|
||||
|
||||
const soloData = [
|
||||
{
|
||||
title: 'Gebruikersauthenticatie opzetten',
|
||||
tasks: [
|
||||
{ title: 'JWT middleware schrijven', status: 'TO_DO' as const, priority: 1 },
|
||||
{ title: 'Login endpoint testen', status: 'TO_DO' as const, priority: 2 },
|
||||
],
|
||||
assignee_id: demo.id,
|
||||
sortOrder: 1,
|
||||
},
|
||||
{
|
||||
title: 'REST API endpoints implementeren',
|
||||
tasks: [
|
||||
{ title: 'Route handlers aanmaken', status: 'IN_PROGRESS' as const, priority: 2 },
|
||||
{ title: 'Zod-validatie toevoegen', status: 'TO_DO' as const, priority: 3 },
|
||||
],
|
||||
assignee_id: demo.id,
|
||||
sortOrder: 2,
|
||||
},
|
||||
{
|
||||
title: 'Database schema migreren',
|
||||
tasks: [
|
||||
{ title: 'Prisma schema bijwerken', status: 'DONE' as const, priority: 2 },
|
||||
{ title: 'Migratietest uitvoeren', status: 'DONE' as const, priority: 3 },
|
||||
],
|
||||
assignee_id: demo.id,
|
||||
sortOrder: 3,
|
||||
},
|
||||
{
|
||||
title: 'Frontend unit tests schrijven',
|
||||
tasks: [
|
||||
{ title: 'Vitest opzetten', status: 'TO_DO' as const, priority: 3 },
|
||||
],
|
||||
assignee_id: null,
|
||||
sortOrder: 4,
|
||||
},
|
||||
]
|
||||
|
||||
for (const s of soloData) {
|
||||
const story = await prisma.story.create({
|
||||
data: {
|
||||
pbi_id: soloPbi.id,
|
||||
product_id: product.id,
|
||||
sprint_id: activeSprint.id,
|
||||
title: s.title,
|
||||
priority: 2,
|
||||
sort_order: 90 + s.sortOrder,
|
||||
status: 'IN_SPRINT',
|
||||
assignee_id: s.assignee_id,
|
||||
},
|
||||
})
|
||||
|
||||
for (let i = 0; i < s.tasks.length; i++) {
|
||||
const t = s.tasks[i]
|
||||
await prisma.task.create({
|
||||
data: {
|
||||
story_id: story.id,
|
||||
sprint_id: activeSprint.id,
|
||||
title: t.title,
|
||||
priority: t.priority,
|
||||
sort_order: i + 1.0,
|
||||
status: t.status,
|
||||
},
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
console.log(' Solo demo stories created (3 claimed, 1 unassigned)')
|
||||
}
|
||||
|
||||
console.log('\nSeeding complete!')
|
||||
console.log('Demo user: username=demo password=demo1234')
|
||||
console.log('Main user: username=lars password=scrum4me123')
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue