docs: spec for sub-project A — auth & roles
This commit is contained in:
339
docs/superpowers/specs/2026-05-20-auth-and-roles-design.md
Normal file
339
docs/superpowers/specs/2026-05-20-auth-and-roles-design.md
Normal file
@@ -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 <noreply@example.com>"
|
||||
```
|
||||
|
||||
### 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 -- <email>` (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 `<AuthBoundary>` wrapper. Niet ingelogd → redirect naar `/login?next=<originele path>`. Sysadmin-only routes worden bewaakt door `<RoleGuard role="sysadmin">`.
|
||||
|
||||
### 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)
|
||||
Reference in New Issue
Block a user