From dce6809893509efa01c84fbc239f4cfd6c4398e0 Mon Sep 17 00:00:00 2001 From: Bert Hausmans Date: Wed, 20 May 2026 22:16:41 +0200 Subject: [PATCH] =?UTF-8?q?docs:=20spec=20for=20sub-project=20A=20?= =?UTF-8?q?=E2=80=94=20auth=20&=20roles?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../specs/2026-05-20-auth-and-roles-design.md | 339 ++++++++++++++++++ 1 file changed, 339 insertions(+) create mode 100644 docs/superpowers/specs/2026-05-20-auth-and-roles-design.md diff --git a/docs/superpowers/specs/2026-05-20-auth-and-roles-design.md b/docs/superpowers/specs/2026-05-20-auth-and-roles-design.md new file mode 100644 index 0000000..0d54534 --- /dev/null +++ b/docs/superpowers/specs/2026-05-20-auth-and-roles-design.md @@ -0,0 +1,339 @@ +# Sub-project A — Authentication & Roles + +**Datum:** 2026-05-20 +**Status:** Draft, ready for review +**Parent app:** Flashcard webapplicatie (zie `2026-05-20-flashcard-app-design.md`) +**Scope:** Eerste van drie subprojecten in de multi-user uitbreiding (A→B→C). + +--- + +## 1. Doel + +De applicatie achter een login zetten en een systeembeheerder-rol introduceren. Lessen, kaarten, sessies en statistieken blijven in deze fase ongewijzigd qua eigenaarschap — dat komt in sub-project B. Sub-project A levert: + +- Self-service registratie + admin-invites +- E-mailverificatie verplicht voor login +- Wachtwoord-reset +- Profielbeheer (naam, email, wachtwoord) +- Sysadmin-rol met gebruikersbeheer +- Alle bestaande endpoints achter `requireAuth` + +## 2. Uitgangspunten + +- **Single-source-of-truth blijft SQLite (better-sqlite3).** Drizzle wordt uitgebreid met `users`, `sessions_auth`, `auth_tokens`. +- **Schone start:** bij introductie wordt de bestaande database gewist en opnieuw gemigreerd; geen migratiepad nodig voor bestaande lessen/kaarten/sessies (die hebben nog geen eigenaar). +- **Eerste registratie** wordt automatisch sysadmin. Daarna ook open self-service, maar nieuwe accounts hebben rol `user`. +- **Geen 2FA**, geen social login, geen audit log (v1). +- Alle authentication state is **server-side** via DB-backed sessions + HttpOnly cookies. Geen JWT. + +## 3. Functionele eisen + +### 3.1 Self-service registratie + +- `/register` accepteert `email` (uniek, normaliseerd lowercase), `displayName`, `password` (min 8 chars). +- Systeem maakt user-row met `password_hash`, `email_verified_at = null`, `is_active = true`. +- Als er nog geen users zijn: rol = `sysadmin`. Anders rol = `user`. +- Verificatie-token wordt gegenereerd en per e-mail verzonden. +- Login is geblokkeerd tot `email_verified_at` is gezet. + +### 3.2 Email verificatie + +- `/verify-email?token=…` accepteert het token. +- Token is single-use, geldig 24 uur. +- Bij succes: `email_verified_at = now()`; token wordt gemarkeerd als gebruikt. +- "Resend verification" beschikbaar vanaf de login-pagina als de gebruiker probeert in te loggen zonder verificatie. + +### 3.3 Login / logout + +- `/login` accepteert `email` + `password`. +- Bij succes: server maakt `sessions_auth` row + zet `flashcard_sid` cookie (HttpOnly, SameSite=Lax, Secure in prod). 30 dagen geldig met rolling `last_used_at` update. +- Bij ontbrekende verificatie: 403 met code `EMAIL_NOT_VERIFIED`. +- Bij `is_active = false`: 403 met code `ACCOUNT_DISABLED`. +- `/logout` invalidatert de sessie in de DB en wist de cookie. + +### 3.4 Wachtwoord vergeten / resetten + +- `/forgot-password` accepteert `email`. Response is **altijd** `200 OK` met generieke melding (geen account-enumeration). +- Als de user bestaat én actief is: reset-token aanmaken, mail sturen. +- Token TTL: 1 uur. Single-use. +- `/reset-password?token=…&password=…` zet nieuw `password_hash` en markeert token gebruikt. Alle bestaande `sessions_auth` van de user worden geïnvalideerd (alle apparaten uitloggen). + +### 3.5 Profiel + +- `GET /api/auth/me` retourneert huidige user (zonder hash). +- `PATCH /api/auth/profile` met `{ displayName?, email? }`. E-mail wijzigen vereist verificatie van het nieuwe adres (token + mail naar nieuwe adres; `email_verified_at` blijft op oud adres tot bevestiging — eenvoudigste flow: zet `pending_email` veld en wissel pas bij verificatie). +- `POST /api/auth/change-password` met `{ currentPassword, newPassword }`. Bij succes: invalideer alle andere sessies behalve de huidige. + +### 3.6 Invite door admin + +- Admin gaat naar `/admin/users`, kiest "Uitnodigen", vult `email` + `role` in. +- Systeem maakt user-row met `password_hash = null`, `email_verified_at = null`, `is_active = true`, en genereert een invite-token (TTL 24u, mailtype `invite`). +- E-mail verstuurt naar het opgegeven adres met link naar `/accept-invite?token=…`. +- Acceptatie: gebruiker vult `displayName` + `password` in. Bij succes: `password_hash` gezet, `email_verified_at = now()`, token gebruikt. Gebruiker wordt automatisch ingelogd. +- Admin kan een open invite intrekken (DELETE op user die nog geen `password_hash` heeft). + +### 3.7 Sysadmin: gebruikersbeheer + +- `GET /api/admin/users` met query `?q=` (zoeken op email/displayName), `?role=`, `?active=`, paginatie. +- `PATCH /api/admin/users/:id` met `{ role?, isActive?, displayName? }`. Sysadmin kan rollen wijzigen, accounts (de)activeren, naam corrigeren. +- `POST /api/admin/users/:id/send-reset` triggert een reset-mail. +- Sysadmin kan niet zichzelf op `is_active=false` zetten of zijn eigen rol verlagen tenzij er minstens één andere actieve sysadmin is. Dit voorkomt lock-out. + +### 3.8 Rate limiting + +- `express-rate-limit` (in-memory, per IP): + - `/login` — 10 per 15 min + - `/register` — 5 per 15 min + - `/forgot-password` — 5 per 15 min + - `/verify-email`, `/reset-password`, `/accept-invite` — 20 per 15 min (genoeg voor user die per ongeluk dubbelklikt) +- Headers `RateLimit-*` worden meegestuurd voor zichtbaarheid. + +### 3.9 CSRF + +- Double-submit cookie pattern: + - Bij login wordt naast `flashcard_sid` ook een `flashcard_csrf` cookie gezet (zelfde TTL, niet HttpOnly). + - Mutatie-endpoints (POST/PATCH/DELETE) eisen header `X-CSRF-Token` met dezelfde waarde. + - SameSite=Lax dekt het meeste; CSRF-token is extra defense-in-depth. +- API client haalt de waarde uit de cookie en stuurt hem standaard mee. + +## 4. Datamodel (Drizzle / SQLite) + +```ts +users { + id: integer pk autoIncrement + email: text not null unique // lowercase, normalized + display_name: text not null + password_hash: text // nullable: invite-pending users hebben nog geen hash + role: text ('user' | 'sysadmin') not null default 'user' + is_active: integer (boolean) not null default true + email_verified_at: integer (unix sec) + pending_email: text // nullable; gebruikt bij email-wijziging + created_at: integer not null default unixepoch() + updated_at: integer not null default unixepoch() +} + +sessions_auth { + id: text pk // 32-byte hex random + user_id: integer fk → users.id on delete cascade not null + created_at: integer not null default unixepoch() + expires_at: integer not null + last_used_at: integer not null + user_agent: text + ip: text +} +INDEX sessions_auth (user_id), (expires_at) + +auth_tokens { + id: integer pk autoIncrement + user_id: integer fk → users.id on delete cascade not null + token_hash: text not null // sha256 hex + purpose: text ('verify_email' | 'password_reset' | 'invite' | 'change_email') not null + payload: text // bv. nieuw email-adres bij change_email + expires_at: integer not null + used_at: integer + created_at: integer not null default unixepoch() +} +INDEX auth_tokens (token_hash), (user_id, purpose) +``` + +## 5. Backend architectuur + +### 5.1 Bibliotheken (toevoegingen) + +| Lib | Reden | +|---|---| +| `bcryptjs` | Password hashing, geen native build deps | +| `nodemailer` | SMTP transport (Mailpit dev, SES prod) | +| `cookie-parser` | Cookies parsen in express middleware | +| `express-rate-limit` | Brute-force preventie | + +### 5.2 Mappenstructuur + +``` +packages/backend/src/ + services/ + auth/ + passwords.ts # hash, verify + tokens.ts # random, sha256, lookup, mark-used + sessions.ts # create, validate, invalidate, rolling-refresh + email.ts # nodemailer transport + send helpers + templates.ts # html strings: verify, reset, invite, change-email + users.ts # CRUD voor admin user-management + middleware/ + auth.ts # requireAuth, requireRole, currentUserOrNull + csrf.ts # ensureCsrfToken, verifyCsrf + rate-limit.ts # named limiters + routes/ + auth.ts # register, login, logout, verify, forgot, reset, profile, change-pw, accept-invite, me + admin-users.ts # sysadmin user management + lib/ + cookies.ts # naming + options helpers (sid + csrf) +``` + +### 5.3 Configuratie (env-vars) + +``` +DB_PATH=./data/flashcard.db +PORT=3000 +SESSION_SECRET=<32-byte hex> # NOTE: ook met server-side sessions houden we een secret aan voor toekomstige uitbreiding (signed cookies) +COOKIE_SECURE=true # false in dev +COOKIE_DOMAIN= # leeg = host-only +APP_URL=http://localhost:5173 # gebruikt in mailtemplates +SMTP_HOST=localhost +SMTP_PORT=1025 +SMTP_USER= +SMTP_PASS= +SMTP_FROM="Flashcard " +``` + +### 5.4 Middleware-volgorde + +``` +1. cookie-parser +2. body-parser (express.json) +3. csrf.ensureCsrfToken (zet cookie bij eerste GET) +4. auth.currentUserOrNull (alleen leest; geen redirect) +5. routes + - /api/auth/* public except /logout (requireAuth) + - /api/admin/* requireAuth + requireRole('sysadmin') + verifyCsrf op mutaties + - /api/lessons|cards|sessions|stats/* requireAuth + verifyCsrf op mutaties +6. error handler +``` + +### 5.5 Bootstrap van eerste sysadmin + +Geen aparte bootstrap-script. De allereerste `POST /api/auth/register` op een lege users-tabel krijgt automatisch `role='sysadmin'`. Wel: verificatie-email moet alsnog bevestigd worden. Voor het oplossen van "geen sysadmin meer" bestaat een CLI-script `npm -w @flashcard/backend run promote -- ` (klein, optioneel). + +## 6. Frontend + +### 6.1 Nieuwe routes + +| Route | Pagina | Toegang | +|---|---|---| +| `/login` | LoginPage | publiek | +| `/register` | RegisterPage | publiek | +| `/verify-email` | VerifyEmailPage (leest `?token=` uit URL) | publiek | +| `/forgot-password` | ForgotPasswordPage | publiek | +| `/reset-password` | ResetPasswordPage (`?token=`) | publiek | +| `/accept-invite` | AcceptInvitePage (`?token=`) | publiek | +| `/profile` | ProfilePage | auth | +| `/admin/users` | AdminUsersPage | sysadmin | + +### 6.2 Bestaande routes + +Alle bestaande routes (dashboard, admin/lessons, practice, stats, settings) staan achter een nieuwe `` wrapper. Niet ingelogd → redirect naar `/login?next=`. Sysadmin-only routes worden bewaakt door ``. + +### 6.3 Layout-uitbreidingen + +- **User menu** rechtsboven (initialen-avatar). Dropdown bevat: + - Naam + email (read-only) + - "Profiel" link + - "Systeembeheer" (alleen voor sysadmin) + - "Uitloggen" +- Sysadmin krijgt extra link "👑 Systeem" in de top-nav. + +### 6.4 Stores (Zustand) + +Nieuwe `authStore`: +- `user: User | null` +- `csrfToken: string | null` +- `loading: boolean` +- `hydrate()` — eenmalig bij app-boot: `GET /api/auth/me` +- `login(email, password)` / `logout()` / `register(...)` +- `refreshMe()` + +API-client wordt aangepast om bij elke mutatie de CSRF-header mee te sturen vanuit `document.cookie`. + +### 6.5 UI-stijl + +We blijven bij de huidige design system (brand-purple primary, success-green, glassmorphic surfaces). Auth-pagina's gebruiken de bestaande `.btn-primary`, `.input-field`, `.surface` utility classes. Geen aparte styling. + +## 7. E-mailtemplates + +Vier templates (HTML + plaintext): + +1. **verify_email** — onderwerp "Bevestig je e-mailadres", link `${APP_URL}/verify-email?token=…` +2. **password_reset** — "Reset je wachtwoord", link `${APP_URL}/reset-password?token=…`, TTL 1u in tekst +3. **invite** — "Je bent uitgenodigd voor Flashcard", link `${APP_URL}/accept-invite?token=…` +4. **change_email_confirm** — "Bevestig je nieuwe e-mailadres", link `${APP_URL}/verify-email?token=…` + +Alle templates leesbaar zonder HTML rendering (plaintext alt). + +## 8. Foutafhandeling + +Naast bestaande error codes: + +| Code | Status | Wanneer | +|---|---|---| +| `EMAIL_TAKEN` | 409 | Registreren met bestaand e-mail | +| `EMAIL_NOT_VERIFIED` | 403 | Login zonder bevestiging | +| `ACCOUNT_DISABLED` | 403 | Login op gedeactiveerd account | +| `INVALID_CREDENTIALS` | 401 | Verkeerd wachtwoord of niet-bestaand e-mail (zelfde generieke message naar buiten) | +| `INVALID_TOKEN` | 400 | Verlopen of onbestaand token (verify/reset/invite) | +| `RATE_LIMITED` | 429 | Rate limit overschreden | +| `CSRF_MISMATCH` | 403 | CSRF-token ontbreekt of klopt niet | +| `LAST_SYSADMIN` | 409 | Poging zichzelf te verlagen/deactiveren als enige sysadmin | + +## 9. Test-strategie + +### 9.1 Backend unit (Vitest) + +- `passwords.ts`: hash + verify roundtrip; verkeerde hash faalt +- `tokens.ts`: generate, lookup, used/expired afgewezen, single-use +- `sessions.ts`: create, validate, expired, rolling-refresh, invalidate +- `email.ts`: stub transport, assert template + recipient + +### 9.2 Backend integration (Vitest + supertest) + +Volledige flows met in-memory DB en mail-stub: + +- register → verify → login → /me +- register → login zonder verify → 403 +- forgot → reset → login met nieuw wachtwoord +- invite → accept-invite → login +- admin invite + admin send-reset +- admin promote/demote user +- last-sysadmin guard +- rate limit triggert na N pogingen + +### 9.3 E2E (Playwright, Mailpit-gebaseerd) + +- **Registreer**: vul form in, lees verificatielink uit Mailpit HTTP API, klik, log in, zie dashboard +- **Forgot password**: vul email in, lees mail, reset, log in met nieuw wachtwoord +- **Admin invite**: log in als sysadmin, nodig user uit, lees mail, accepteer, log in als nieuwe user + +Mailpit draait in dev in docker-compose; tests hebben aparte port (1026/8026) of gebruiken dev-mailpit. + +## 10. Deployment / dev setup + +### 10.1 docker-compose voor Mailpit (dev) + +Nieuwe `docker-compose.yml` in repo-root: + +```yaml +services: + mailpit: + image: axllent/mailpit:latest + ports: + - "1025:1025" # SMTP + - "8025:8025" # web UI + restart: unless-stopped +``` + +Niet verplicht (auth werkt ook zonder, het is een SMTP-server), maar `npm run dev` gaat aanvragen dat Mailpit draait. Of dev mailtransport faalt zacht — we kiezen voor **stub-mailer fallback**: als `SMTP_HOST` ontbreekt of onbereikbaar is, loggen we de e-mail content + link naar console. + +### 10.2 Productie + +In productie zet `SMTP_HOST=email-smtp.eu-west-1.amazonaws.com` (of relevante region), `SMTP_PORT=587`, `SMTP_USER`/`SMTP_PASS` = SES SMTP credentials. `COOKIE_SECURE=true`. + +## 11. Out of scope (volgt in B en C) + +- Ownership op lessen/kaarten/sessies (user_id, visibility) +- Marketplace / sharing +- Avatar uploads +- 2FA / TOTP +- OAuth (Google/etc.) +- Audit log +- Email-changing met dubbele confirmatie via beide mailadressen (alleen single-confirmation naar nieuw adres in v1) +- Profielfoto's +- User self-delete (alleen admin-deactivate)