Files
flashcards/docs/superpowers/specs/2026-05-20-auth-and-roles-design.md

14 KiB

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)

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:

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)