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
/registeraccepteertemail(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_atis 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
/loginaccepteertemail+password.- Bij succes: server maakt
sessions_authrow + zetflashcard_sidcookie (HttpOnly, SameSite=Lax, Secure in prod). 30 dagen geldig met rollinglast_used_atupdate. - Bij ontbrekende verificatie: 403 met code
EMAIL_NOT_VERIFIED. - Bij
is_active = false: 403 met codeACCOUNT_DISABLED. /logoutinvalidatert de sessie in de DB en wist de cookie.
3.4 Wachtwoord vergeten / resetten
/forgot-passwordaccepteertemail. Response is altijd200 OKmet 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 nieuwpassword_hashen markeert token gebruikt. Alle bestaandesessions_authvan de user worden geïnvalideerd (alle apparaten uitloggen).
3.5 Profiel
GET /api/auth/meretourneert huidige user (zonder hash).PATCH /api/auth/profilemet{ displayName?, email? }. E-mail wijzigen vereist verificatie van het nieuwe adres (token + mail naar nieuwe adres;email_verified_atblijft op oud adres tot bevestiging — eenvoudigste flow: zetpending_emailveld en wissel pas bij verificatie).POST /api/auth/change-passwordmet{ currentPassword, newPassword }. Bij succes: invalideer alle andere sessies behalve de huidige.
3.6 Invite door admin
- Admin gaat naar
/admin/users, kiest "Uitnodigen", vultemail+rolein. - Systeem maakt user-row met
password_hash = null,email_verified_at = null,is_active = true, en genereert een invite-token (TTL 24u, mailtypeinvite). - E-mail verstuurt naar het opgegeven adres met link naar
/accept-invite?token=…. - Acceptatie: gebruiker vult
displayName+passwordin. Bij succes:password_hashgezet,email_verified_at = now(), token gebruikt. Gebruiker wordt automatisch ingelogd. - Admin kan een open invite intrekken (DELETE op user die nog geen
password_hashheeft).
3.7 Sysadmin: gebruikersbeheer
GET /api/admin/usersmet query?q=(zoeken op email/displayName),?role=,?active=, paginatie.PATCH /api/admin/users/:idmet{ role?, isActive?, displayName? }. Sysadmin kan rollen wijzigen, accounts (de)activeren, naam corrigeren.POST /api/admin/users/:id/send-resettriggert een reset-mail.- Sysadmin kan niet zichzelf op
is_active=falsezetten 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_sidook eenflashcard_csrfcookie gezet (zelfde TTL, niet HttpOnly). - Mutatie-endpoints (POST/PATCH/DELETE) eisen header
X-CSRF-Tokenmet dezelfde waarde. - SameSite=Lax dekt het meeste; CSRF-token is extra defense-in-depth.
- Bij login wordt naast
- 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 | nullcsrfToken: string | nullloading: booleanhydrate()— eenmalig bij app-boot:GET /api/auth/melogin(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):
- verify_email — onderwerp "Bevestig je e-mailadres", link
${APP_URL}/verify-email?token=… - password_reset — "Reset je wachtwoord", link
${APP_URL}/reset-password?token=…, TTL 1u in tekst - invite — "Je bent uitgenodigd voor Flashcard", link
${APP_URL}/accept-invite?token=… - 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 faalttokens.ts: generate, lookup, used/expired afgewezen, single-usesessions.ts: create, validate, expired, rolling-refresh, invalidateemail.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)