Verbeter anatomie en plaatsing van avataraccessoires (v0.4.46-beta) #1
18 changed files with 560 additions and 276 deletions
|
|
@ -19,6 +19,12 @@ jobs:
|
|||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
|
||||
- name: Dependencies en securitytests
|
||||
run: |
|
||||
npm ci
|
||||
npm test
|
||||
npm audit --omit=dev --audit-level=moderate
|
||||
|
||||
- name: Image-tags bepalen
|
||||
id: meta
|
||||
run: |
|
||||
|
|
@ -27,7 +33,7 @@ jobs:
|
|||
echo "image=${IMAGE}" >> "$GITHUB_OUTPUT"
|
||||
echo "sha_tag=${IMAGE}:dev-${GITHUB_SHA::7}" >> "$GITHUB_OUTPUT"
|
||||
echo "dev_tag=${IMAGE}:dev" >> "$GITHUB_OUTPUT"
|
||||
echo "app_version=${VERSION}-beta" >> "$GITHUB_OUTPUT"
|
||||
echo "app_version=${VERSION}" >> "$GITHUB_OUTPUT"
|
||||
|
||||
# Platte docker build/push i.p.v. buildx: betrouwbaarder tegen een
|
||||
# HTTP-registry. De runner-host moet 10.0.20.22:3000 als
|
||||
|
|
@ -48,7 +54,10 @@ jobs:
|
|||
run: |
|
||||
echo "${{ secrets.TEST_SSH_KEY }}" > deploy_key
|
||||
chmod 600 deploy_key
|
||||
scp -o StrictHostKeyChecking=no -o UserKnownHostsFile=/dev/null -o BatchMode=yes -o ConnectTimeout=15 \
|
||||
-i deploy_key deploy/compose.deploy.yaml deploy/nginx.conf \
|
||||
${{ secrets.TEST_USER }}@${{ secrets.TEST_HOST }}:${{ secrets.TEST_DEPLOY_PATH }}/
|
||||
ssh -o StrictHostKeyChecking=no -o UserKnownHostsFile=/dev/null -o BatchMode=yes -o ConnectTimeout=15 \
|
||||
-i deploy_key ${{ secrets.TEST_USER }}@${{ secrets.TEST_HOST }} \
|
||||
"set -e && cd ${{ secrets.TEST_DEPLOY_PATH }} && echo '${{ secrets.REGISTRY_TOKEN }}' | docker login ${{ vars.REGISTRY }} -u '${{ secrets.REGISTRY_USER }}' --password-stdin && sed -i 's|^IMAGE=.*|IMAGE=${{ steps.meta.outputs.dev_tag }}|' .env && docker compose -f compose.deploy.yaml pull app && docker compose -f compose.deploy.yaml up -d && docker image prune -f"
|
||||
"set -e && cd ${{ secrets.TEST_DEPLOY_PATH }} && echo '${{ secrets.REGISTRY_TOKEN }}' | docker login ${{ vars.REGISTRY }} -u '${{ secrets.REGISTRY_USER }}' --password-stdin && sed -i 's|^IMAGE=.*|IMAGE=${{ steps.meta.outputs.dev_tag }}|' .env && docker compose -f compose.deploy.yaml pull app && docker compose -f compose.deploy.yaml up -d --force-recreate app web && docker image prune -f"
|
||||
rm -f deploy_key
|
||||
|
|
|
|||
|
|
@ -19,6 +19,12 @@ jobs:
|
|||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
|
||||
- name: Dependencies en securitytests
|
||||
run: |
|
||||
npm ci
|
||||
npm test
|
||||
npm audit --omit=dev --audit-level=moderate
|
||||
|
||||
- name: Image-tags bepalen
|
||||
id: meta
|
||||
run: |
|
||||
|
|
@ -46,7 +52,10 @@ jobs:
|
|||
run: |
|
||||
echo "${{ secrets.PROD_SSH_KEY }}" > deploy_key
|
||||
chmod 600 deploy_key
|
||||
scp -o StrictHostKeyChecking=no -o UserKnownHostsFile=/dev/null -o BatchMode=yes -o ConnectTimeout=15 \
|
||||
-i deploy_key deploy/compose.deploy.yaml deploy/nginx.conf \
|
||||
${{ secrets.PROD_USER }}@${{ secrets.PROD_HOST }}:${{ secrets.PROD_DEPLOY_PATH }}/
|
||||
ssh -o StrictHostKeyChecking=no -o UserKnownHostsFile=/dev/null -o BatchMode=yes -o ConnectTimeout=15 \
|
||||
-i deploy_key ${{ secrets.PROD_USER }}@${{ secrets.PROD_HOST }} \
|
||||
"set -e && cd ${{ secrets.PROD_DEPLOY_PATH }} && echo '${{ secrets.REGISTRY_TOKEN }}' | docker login ${{ vars.REGISTRY }} -u '${{ secrets.REGISTRY_USER }}' --password-stdin && sed -i 's|^IMAGE=.*|IMAGE=${{ steps.meta.outputs.version_tag }}|' .env && docker compose -f compose.deploy.yaml pull app && docker compose -f compose.deploy.yaml up -d && docker image prune -f"
|
||||
"set -e && cd ${{ secrets.PROD_DEPLOY_PATH }} && echo '${{ secrets.REGISTRY_TOKEN }}' | docker login ${{ vars.REGISTRY }} -u '${{ secrets.REGISTRY_USER }}' --password-stdin && sed -i 's|^IMAGE=.*|IMAGE=${{ steps.meta.outputs.version_tag }}|' .env && docker compose -f compose.deploy.yaml pull app && docker compose -f compose.deploy.yaml up -d --force-recreate app web && docker image prune -f"
|
||||
rm -f deploy_key
|
||||
|
|
|
|||
36
README.md
36
README.md
|
|
@ -35,8 +35,9 @@ App op http://localhost:3000, healthcheck op `/healthz`, DB-check op `/readyz`.
|
|||
serveert de HTML én biedt de `/api`, en praat met de DB.
|
||||
- **Forgejo container registry**: CI bouwt de image één keer en pusht die; beide
|
||||
VM's pullen exact dezelfde geteste image. Geen build op de productie-VM.
|
||||
- **Nginx blijft ervoor** als reverse proxy / TLS op elke VM; de app luistert
|
||||
alleen op `127.0.0.1:<APP_PORT>`.
|
||||
- **Pangolin/Traefik verzorgt de publieke HTTPS-ingang**. De meegeleverde nginx
|
||||
vormt de interne proxylaag en is standaard alleen via `127.0.0.1:8081`
|
||||
bereikbaar. De Fastify-app vertrouwt precies twee proxy-hops.
|
||||
|
||||
## Eenmalige setup
|
||||
|
||||
|
|
@ -107,28 +108,29 @@ POSTGRES_USER=teach
|
|||
POSTGRES_PASSWORD=<sterk-wachtwoord>
|
||||
DATABASE_URL=postgres://teach:<sterk-wachtwoord>@db:5432/teach
|
||||
APP_PORT=3000
|
||||
WEB_BIND_IP=127.0.0.1
|
||||
WEB_PORT=8081
|
||||
TRUST_PROXY_HOPS=2
|
||||
SUPER_USER=beheerder
|
||||
SUPER_PASS=<uniek-sterk-eerste-wachtwoord>
|
||||
EOF
|
||||
```
|
||||
|
||||
CI werkt bij elke deploy de `IMAGE=`-regel bij, pullt en herstart.
|
||||
|
||||
### 4. Nginx reverse proxy (per VM)
|
||||
```nginx
|
||||
server {
|
||||
listen 443 ssl;
|
||||
server_name digibord.familiebesselink.nl; # test-VM eigen subdomein
|
||||
### 4. Pangolin/Traefik publiceren
|
||||
|
||||
# ssl_certificate ... (bestaande config)
|
||||
Publiceer `http://127.0.0.1:8081` via Pangolin/Traefik en laat daar TLS
|
||||
beëindigen. De interne nginx behoudt `X-Forwarded-Proto: https`, waarna de app
|
||||
Secure/HttpOnly/SameSite-cookies, HSTS en overige securityheaders gebruikt.
|
||||
|
||||
location / {
|
||||
proxy_pass http://127.0.0.1:3000;
|
||||
proxy_set_header Host $host;
|
||||
proxy_set_header X-Real-IP $remote_addr;
|
||||
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
|
||||
proxy_set_header X-Forwarded-Proto $scheme;
|
||||
}
|
||||
}
|
||||
```
|
||||
- Laat poort 8081 niet rechtstreeks vanaf internet of het LAN benaderbaar zijn.
|
||||
- Pas `TRUST_PROXY_HOPS` alleen aan wanneer de proxyketen werkelijk verandert.
|
||||
- Bij een aparte Traefik-container kan een gedeeld intern Docker-netwerk nodig
|
||||
zijn; zet `WEB_BIND_IP` niet ruimer dan noodzakelijk.
|
||||
- `SUPER_PASS` is alleen nodig bij een lege database, wordt nooit gelogd en
|
||||
moet via de beveiligde deploy-omgeving worden aangeleverd.
|
||||
- De overgang naar v0.3.03-beta trekt bestaande browsersessies bewust in.
|
||||
|
||||
## Workflow / branching
|
||||
|
||||
|
|
|
|||
2
VERSION
2
VERSION
|
|
@ -1 +1 @@
|
|||
0.3.02-beta
|
||||
0.3.03-beta
|
||||
|
|
|
|||
|
|
@ -18,6 +18,8 @@ services:
|
|||
APP_VERSION: dev
|
||||
SUPER_USER: ${SUPER_USER:-beheerder}
|
||||
SUPER_PASS: ${SUPER_PASS:-}
|
||||
TRUST_PROXY_HOPS: 0
|
||||
COOKIE_SECURE: "false"
|
||||
ports:
|
||||
- "3000:3000"
|
||||
restart: unless-stopped
|
||||
|
|
|
|||
62
db/006_security_boundaries.sql
Normal file
62
db/006_security_boundaries.sql
Normal file
|
|
@ -0,0 +1,62 @@
|
|||
-- v0.3.03: tenant-isolatie en security-invarianten ook in PostgreSQL afdwingen.
|
||||
|
||||
CREATE INDEX IF NOT EXISTS idx_sessions_expires ON sessions(expires_at);
|
||||
|
||||
-- Leerlingwachtwoorden worden voortaan alleen eenmalig in de API-respons getoond.
|
||||
ALTER TABLE users DROP COLUMN IF EXISTS password_plain;
|
||||
|
||||
-- Ruim eventuele historische tenant-kruisingen veilig op vóór de triggers actief worden.
|
||||
UPDATE users u SET class_id = NULL
|
||||
WHERE class_id IS NOT NULL AND NOT EXISTS (
|
||||
SELECT 1 FROM classes c WHERE c.id = u.class_id AND c.school_id = u.school_id
|
||||
);
|
||||
|
||||
DELETE FROM class_teachers ct
|
||||
WHERE NOT EXISTS (
|
||||
SELECT 1 FROM classes c
|
||||
JOIN users u ON u.id = ct.user_id AND u.school_id = c.school_id
|
||||
WHERE c.id = ct.class_id
|
||||
AND (u.role = 'teacher' OR EXISTS (
|
||||
SELECT 1 FROM user_roles ur WHERE ur.user_id = u.id AND ur.role = 'teacher'
|
||||
))
|
||||
);
|
||||
|
||||
CREATE OR REPLACE FUNCTION teach_validate_user_class_school()
|
||||
RETURNS trigger LANGUAGE plpgsql AS $$
|
||||
BEGIN
|
||||
IF NEW.class_id IS NOT NULL AND NOT EXISTS (
|
||||
SELECT 1 FROM classes c WHERE c.id = NEW.class_id AND c.school_id = NEW.school_id
|
||||
) THEN
|
||||
RAISE EXCEPTION 'leerling en klas moeten bij dezelfde school horen';
|
||||
END IF;
|
||||
RETURN NEW;
|
||||
END;
|
||||
$$;
|
||||
|
||||
DROP TRIGGER IF EXISTS trg_users_class_school ON users;
|
||||
CREATE TRIGGER trg_users_class_school
|
||||
BEFORE INSERT OR UPDATE OF class_id, school_id ON users
|
||||
FOR EACH ROW EXECUTE FUNCTION teach_validate_user_class_school();
|
||||
|
||||
CREATE OR REPLACE FUNCTION teach_validate_class_teacher()
|
||||
RETURNS trigger LANGUAGE plpgsql AS $$
|
||||
BEGIN
|
||||
IF NOT EXISTS (
|
||||
SELECT 1
|
||||
FROM classes c
|
||||
JOIN users u ON u.id = NEW.user_id AND u.school_id = c.school_id
|
||||
WHERE c.id = NEW.class_id
|
||||
AND (u.role = 'teacher' OR EXISTS (
|
||||
SELECT 1 FROM user_roles ur WHERE ur.user_id = u.id AND ur.role = 'teacher'
|
||||
))
|
||||
) THEN
|
||||
RAISE EXCEPTION 'groepsleiding en klas moeten bij dezelfde school horen';
|
||||
END IF;
|
||||
RETURN NEW;
|
||||
END;
|
||||
$$;
|
||||
|
||||
DROP TRIGGER IF EXISTS trg_class_teachers_tenant ON class_teachers;
|
||||
CREATE TRIGGER trg_class_teachers_tenant
|
||||
BEFORE INSERT OR UPDATE ON class_teachers
|
||||
FOR EACH ROW EXECUTE FUNCTION teach_validate_class_teacher();
|
||||
|
|
@ -22,6 +22,9 @@ services:
|
|||
# APP_VERSION komt uit de image zelf (build-arg); hier niet overschrijven.
|
||||
SUPER_USER: ${SUPER_USER:-beheerder}
|
||||
SUPER_PASS: ${SUPER_PASS:-}
|
||||
# Pangolin/Traefik -> nginx -> app: twee vertrouwde proxy-hops.
|
||||
TRUST_PROXY_HOPS: ${TRUST_PROXY_HOPS:-2}
|
||||
COOKIE_SECURE: "true"
|
||||
ports:
|
||||
# Alleen op localhost van de VM; nginx zit ervoor als reverse proxy
|
||||
- "127.0.0.1:${APP_PORT:-3000}:3000"
|
||||
|
|
@ -32,8 +35,8 @@ services:
|
|||
depends_on:
|
||||
- app
|
||||
ports:
|
||||
# Bereikbaar in de browser op http://<vm-ip>:8081
|
||||
- "8081:80"
|
||||
# Alleen lokaal bereikbaar; Pangolin/Traefik publiceert deze endpoint met TLS.
|
||||
- "${WEB_BIND_IP:-127.0.0.1}:${WEB_PORT:-8081}:80"
|
||||
volumes:
|
||||
- ./nginx.conf:/etc/nginx/conf.d/default.conf:ro
|
||||
restart: unless-stopped
|
||||
|
|
|
|||
|
|
@ -1,6 +1,13 @@
|
|||
# Behoud het publieke schema van Pangolin/Traefik; val lokaal terug op nginx.
|
||||
map $http_x_forwarded_proto $teach_forwarded_proto {
|
||||
default $http_x_forwarded_proto;
|
||||
"" $scheme;
|
||||
}
|
||||
|
||||
server {
|
||||
listen 80;
|
||||
server_name _;
|
||||
server_tokens off;
|
||||
|
||||
# Wat groter zodat grote borden/afbeeldingen niet geweigerd worden
|
||||
client_max_body_size 25m;
|
||||
|
|
@ -11,6 +18,6 @@ server {
|
|||
proxy_set_header Host $host;
|
||||
proxy_set_header X-Real-IP $remote_addr;
|
||||
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
|
||||
proxy_set_header X-Forwarded-Proto $scheme;
|
||||
proxy_set_header X-Forwarded-Proto $teach_forwarded_proto;
|
||||
}
|
||||
}
|
||||
|
|
|
|||
289
package-lock.json
generated
289
package-lock.json
generated
|
|
@ -8,7 +8,10 @@
|
|||
"name": "teach",
|
||||
"version": "0.1.0",
|
||||
"dependencies": {
|
||||
"@fastify/static": "^8.0.1",
|
||||
"@fastify/cookie": "^11.1.1",
|
||||
"@fastify/helmet": "^13.1.0",
|
||||
"@fastify/rate-limit": "^11.1.0",
|
||||
"@fastify/static": "^10.1.0",
|
||||
"fastify": "^5.1.0",
|
||||
"pg": "^8.13.1"
|
||||
},
|
||||
|
|
@ -53,6 +56,39 @@
|
|||
"fast-uri": "^3.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@fastify/cookie": {
|
||||
"version": "11.1.1",
|
||||
"resolved": "https://registry.npmjs.org/@fastify/cookie/-/cookie-11.1.1.tgz",
|
||||
"integrity": "sha512-sJ0NXzGVYjUB4OynPZRsIcQ1mKSP4rW45xLCN0aelRq5Vl37xVVbz5kJ6Y0a9m2T0mCUjYCuvlUA9QlTafrZWw==",
|
||||
"funding": [
|
||||
{
|
||||
"type": "github",
|
||||
"url": "https://github.com/sponsors/fastify"
|
||||
},
|
||||
{
|
||||
"type": "opencollective",
|
||||
"url": "https://opencollective.com/fastify"
|
||||
}
|
||||
],
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"cookie": "^2.0.0",
|
||||
"fastify-plugin": "^6.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@fastify/cookie/node_modules/cookie": {
|
||||
"version": "2.0.1",
|
||||
"resolved": "https://registry.npmjs.org/cookie/-/cookie-2.0.1.tgz",
|
||||
"integrity": "sha512-yuToqVvRrj6pfDXREyQAAv8SkAEk/8GS3jQRTiUMm66TVtBYmqQeoEjL2Lmq8Rpo6271vH76InTChTitEAm65w==",
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">=22"
|
||||
},
|
||||
"funding": {
|
||||
"type": "opencollective",
|
||||
"url": "https://opencollective.com/express"
|
||||
}
|
||||
},
|
||||
"node_modules/@fastify/error": {
|
||||
"version": "4.2.0",
|
||||
"resolved": "https://registry.npmjs.org/@fastify/error/-/error-4.2.0.tgz",
|
||||
|
|
@ -104,6 +140,26 @@
|
|||
],
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/@fastify/helmet": {
|
||||
"version": "13.1.0",
|
||||
"resolved": "https://registry.npmjs.org/@fastify/helmet/-/helmet-13.1.0.tgz",
|
||||
"integrity": "sha512-SvVOU0IrzYJW1BvSkfq9G1WUdW3dnaRUvg6m0BtgGMBmML62No0VmSu087jecH58SFbicbREgZTPJ89mAguupA==",
|
||||
"funding": [
|
||||
{
|
||||
"type": "github",
|
||||
"url": "https://github.com/sponsors/fastify"
|
||||
},
|
||||
{
|
||||
"type": "opencollective",
|
||||
"url": "https://opencollective.com/fastify"
|
||||
}
|
||||
],
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"fastify-plugin": "^6.0.0",
|
||||
"helmet": "^8.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@fastify/merge-json-schemas": {
|
||||
"version": "0.2.1",
|
||||
"resolved": "https://registry.npmjs.org/@fastify/merge-json-schemas/-/merge-json-schemas-0.2.1.tgz",
|
||||
|
|
@ -143,6 +199,27 @@
|
|||
"ipaddr.js": "^2.1.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@fastify/rate-limit": {
|
||||
"version": "11.1.0",
|
||||
"resolved": "https://registry.npmjs.org/@fastify/rate-limit/-/rate-limit-11.1.0.tgz",
|
||||
"integrity": "sha512-BeJ9tizLvmTXGD7deYU5G04OtHhwk5uHxbpEPVp09gKvUBIXmau/4Bshxhu9ci54MvVWfGjCEx4RzvsTntojwA==",
|
||||
"funding": [
|
||||
{
|
||||
"type": "github",
|
||||
"url": "https://github.com/sponsors/fastify"
|
||||
},
|
||||
{
|
||||
"type": "opencollective",
|
||||
"url": "https://opencollective.com/fastify"
|
||||
}
|
||||
],
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@lukeed/ms": "^2.0.2",
|
||||
"fastify-plugin": "^6.0.0",
|
||||
"toad-cache": "^3.7.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@fastify/send": {
|
||||
"version": "4.1.0",
|
||||
"resolved": "https://registry.npmjs.org/@fastify/send/-/send-4.1.0.tgz",
|
||||
|
|
@ -167,9 +244,9 @@
|
|||
}
|
||||
},
|
||||
"node_modules/@fastify/static": {
|
||||
"version": "8.3.0",
|
||||
"resolved": "https://registry.npmjs.org/@fastify/static/-/static-8.3.0.tgz",
|
||||
"integrity": "sha512-yKxviR5PH1OKNnisIzZKmgZSus0r2OZb8qCSbqmw34aolT4g3UlzYfeBRym+HJ1J471CR8e2ldNub4PubD1coA==",
|
||||
"version": "10.1.0",
|
||||
"resolved": "https://registry.npmjs.org/@fastify/static/-/static-10.1.0.tgz",
|
||||
"integrity": "sha512-iK/8TvRM/EgNOyQL+EpWu+x3aR6o4GWt+UI+27zmE7w6t/6d80mXqOtWLdEKQ13vL/g1Jry0ae2icj6GP7tGzA==",
|
||||
"funding": [
|
||||
{
|
||||
"type": "github",
|
||||
|
|
@ -183,20 +260,12 @@
|
|||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@fastify/accept-negotiator": "^2.0.0",
|
||||
"@fastify/error": "^4.0.0",
|
||||
"@fastify/send": "^4.0.0",
|
||||
"content-disposition": "^0.5.4",
|
||||
"fastify-plugin": "^5.0.0",
|
||||
"content-disposition": "^2.0.1",
|
||||
"fastify-plugin": "^6.0.0",
|
||||
"fastq": "^1.17.1",
|
||||
"glob": "^11.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@isaacs/cliui": {
|
||||
"version": "9.0.0",
|
||||
"resolved": "https://registry.npmjs.org/@isaacs/cliui/-/cliui-9.0.0.tgz",
|
||||
"integrity": "sha512-AokJm4tuBHillT+FpMtxQ60n8ObyXBatq7jD2/JA9dxbDDokKQm8KMht5ibGzLVU9IJDIKK4TPKgMHEYMn3lMg==",
|
||||
"license": "BlueOak-1.0.0",
|
||||
"engines": {
|
||||
"node": ">=18"
|
||||
"glob": "^13.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@lukeed/ms": {
|
||||
|
|
@ -304,15 +373,16 @@
|
|||
}
|
||||
},
|
||||
"node_modules/content-disposition": {
|
||||
"version": "0.5.4",
|
||||
"resolved": "https://registry.npmjs.org/content-disposition/-/content-disposition-0.5.4.tgz",
|
||||
"integrity": "sha512-FveZTNuGw04cxlAiWbzi6zTAL/lhehaWbTtgluJh4/E95DqMwTmha3KZN1aAWA8cFIhHzMZUvLevkw5Rqk+tSQ==",
|
||||
"version": "2.0.1",
|
||||
"resolved": "https://registry.npmjs.org/content-disposition/-/content-disposition-2.0.1.tgz",
|
||||
"integrity": "sha512-e+H0ZXHSWYrENhQzw1LPuP4oF5MzVKmDU6d3hxlvaPEYLLg62MxtQNPRx4SYSuYJSBUgnQIG4HIN2tEtNv7Dog==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"safe-buffer": "5.2.1"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">= 0.6"
|
||||
"node": ">=18"
|
||||
},
|
||||
"funding": {
|
||||
"type": "opencollective",
|
||||
"url": "https://opencollective.com/express"
|
||||
}
|
||||
},
|
||||
"node_modules/cookie": {
|
||||
|
|
@ -328,20 +398,6 @@
|
|||
"url": "https://opencollective.com/express"
|
||||
}
|
||||
},
|
||||
"node_modules/cross-spawn": {
|
||||
"version": "7.0.6",
|
||||
"resolved": "https://registry.npmjs.org/cross-spawn/-/cross-spawn-7.0.6.tgz",
|
||||
"integrity": "sha512-uV2QOWP2nWzsy2aMp8aRibhi9dlzF5Hgh5SHaB9OiTGEyDTiJJyx0uy51QXdyWbtAHNua4XJzUKca3OzKUd3vA==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"path-key": "^3.1.0",
|
||||
"shebang-command": "^2.0.0",
|
||||
"which": "^2.0.1"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">= 8"
|
||||
}
|
||||
},
|
||||
"node_modules/depd": {
|
||||
"version": "2.0.0",
|
||||
"resolved": "https://registry.npmjs.org/depd/-/depd-2.0.0.tgz",
|
||||
|
|
@ -461,9 +517,9 @@
|
|||
}
|
||||
},
|
||||
"node_modules/fastify-plugin": {
|
||||
"version": "5.1.0",
|
||||
"resolved": "https://registry.npmjs.org/fastify-plugin/-/fastify-plugin-5.1.0.tgz",
|
||||
"integrity": "sha512-FAIDA8eovSt5qcDgcBvDuX/v0Cjz0ohGhENZ/wpc3y+oZCY2afZ9Baqql3g/lC+OHRnciQol4ww7tuthOb9idw==",
|
||||
"version": "6.0.0",
|
||||
"resolved": "https://registry.npmjs.org/fastify-plugin/-/fastify-plugin-6.0.0.tgz",
|
||||
"integrity": "sha512-fZOty7z3O7vOliF6d8bHE3wiEh1KcNnKEQensSgTk9C1DvN6nRLS++XVd86v33Hw/8u9Un8A1zDrQ8ujcQDHEg==",
|
||||
"funding": [
|
||||
{
|
||||
"type": "github",
|
||||
|
|
@ -499,44 +555,33 @@
|
|||
"node": ">=20"
|
||||
}
|
||||
},
|
||||
"node_modules/foreground-child": {
|
||||
"version": "3.3.1",
|
||||
"resolved": "https://registry.npmjs.org/foreground-child/-/foreground-child-3.3.1.tgz",
|
||||
"integrity": "sha512-gIXjKqtFuWEgzFRJA9WCQeSJLZDjgJUOMCMzxtvFq/37KojM1BFGufqsCy0r4qSQmYLsZYMeyRqzIWOMup03sw==",
|
||||
"license": "ISC",
|
||||
"node_modules/glob": {
|
||||
"version": "13.0.6",
|
||||
"resolved": "https://registry.npmjs.org/glob/-/glob-13.0.6.tgz",
|
||||
"integrity": "sha512-Wjlyrolmm8uDpm/ogGyXZXb1Z+Ca2B8NbJwqBVg0axK9GbBeoS7yGV6vjXnYdGm6X53iehEuxxbyiKp8QmN4Vw==",
|
||||
"license": "BlueOak-1.0.0",
|
||||
"dependencies": {
|
||||
"cross-spawn": "^7.0.6",
|
||||
"signal-exit": "^4.0.1"
|
||||
"minimatch": "^10.2.2",
|
||||
"minipass": "^7.1.3",
|
||||
"path-scurry": "^2.0.2"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=14"
|
||||
"node": "18 || 20 || >=22"
|
||||
},
|
||||
"funding": {
|
||||
"url": "https://github.com/sponsors/isaacs"
|
||||
}
|
||||
},
|
||||
"node_modules/glob": {
|
||||
"version": "11.1.0",
|
||||
"resolved": "https://registry.npmjs.org/glob/-/glob-11.1.0.tgz",
|
||||
"integrity": "sha512-vuNwKSaKiqm7g0THUBu2x7ckSs3XJLXE+2ssL7/MfTGPLLcrJQ/4Uq1CjPTtO5cCIiRxqvN6Twy1qOwhL0Xjcw==",
|
||||
"deprecated": "Old versions of glob are not supported, and contain widely publicized security vulnerabilities, which have been fixed in the current version. Please update. Support for old versions may be purchased (at exorbitant rates) by contacting i@izs.me",
|
||||
"license": "BlueOak-1.0.0",
|
||||
"dependencies": {
|
||||
"foreground-child": "^3.3.1",
|
||||
"jackspeak": "^4.1.1",
|
||||
"minimatch": "^10.1.1",
|
||||
"minipass": "^7.1.2",
|
||||
"package-json-from-dist": "^1.0.0",
|
||||
"path-scurry": "^2.0.0"
|
||||
},
|
||||
"bin": {
|
||||
"glob": "dist/esm/bin.mjs"
|
||||
},
|
||||
"node_modules/helmet": {
|
||||
"version": "8.3.0",
|
||||
"resolved": "https://registry.npmjs.org/helmet/-/helmet-8.3.0.tgz",
|
||||
"integrity": "sha512-Qgpiaws3Sm30Av8Eah6sjMCZZwjlBu+E68rhpCWBshY1lb09HtLwj5GviX0OyQIn+ulUS0iX0AxN5n3tLZzz1w==",
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": "20 || >=22"
|
||||
"node": ">=18.0.0"
|
||||
},
|
||||
"funding": {
|
||||
"url": "https://github.com/sponsors/isaacs"
|
||||
"url": "https://github.com/sponsors/EvanHahn"
|
||||
}
|
||||
},
|
||||
"node_modules/http-errors": {
|
||||
|
|
@ -574,27 +619,6 @@
|
|||
"node": ">= 10"
|
||||
}
|
||||
},
|
||||
"node_modules/isexe": {
|
||||
"version": "2.0.0",
|
||||
"resolved": "https://registry.npmjs.org/isexe/-/isexe-2.0.0.tgz",
|
||||
"integrity": "sha512-RHxMLp9lnKHGHRng9QFhRCMbYAcVpn69smSGcq3f36xjgVVWThj4qqLbTLlq7Ssj8B+fIQ1EuCEGI2lKsyQeIw==",
|
||||
"license": "ISC"
|
||||
},
|
||||
"node_modules/jackspeak": {
|
||||
"version": "4.2.3",
|
||||
"resolved": "https://registry.npmjs.org/jackspeak/-/jackspeak-4.2.3.tgz",
|
||||
"integrity": "sha512-ykkVRwrYvFm1nb2AJfKKYPr0emF6IiXDYUaFx4Zn9ZuIH7MrzEZ3sD5RlqGXNRpHtvUHJyOnCEFxOlNDtGo7wg==",
|
||||
"license": "BlueOak-1.0.0",
|
||||
"dependencies": {
|
||||
"@isaacs/cliui": "^9.0.0"
|
||||
},
|
||||
"engines": {
|
||||
"node": "20 || >=22"
|
||||
},
|
||||
"funding": {
|
||||
"url": "https://github.com/sponsors/isaacs"
|
||||
}
|
||||
},
|
||||
"node_modules/json-schema-ref-resolver": {
|
||||
"version": "3.0.0",
|
||||
"resolved": "https://registry.npmjs.org/json-schema-ref-resolver/-/json-schema-ref-resolver-3.0.0.tgz",
|
||||
|
|
@ -658,9 +682,9 @@
|
|||
"license": "MIT"
|
||||
},
|
||||
"node_modules/lru-cache": {
|
||||
"version": "11.5.1",
|
||||
"resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-11.5.1.tgz",
|
||||
"integrity": "sha512-RPimw/7aMdv2oqRrxKwvZXcPfwBrn/JZ2xYcY9Hus/6LaS3VOAKVWKWgNLCFSiOm1ESXinjsDlidVU7JlnCN2A==",
|
||||
"version": "11.5.2",
|
||||
"resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-11.5.2.tgz",
|
||||
"integrity": "sha512-4pfM1Ff0x50o0tQwb5ucw/RzNyD0/YJME6IVcStalZuMWxdt3sR3huStTtxz4PUmvZfRguvDejasvQ2kifR11g==",
|
||||
"license": "BlueOak-1.0.0",
|
||||
"engines": {
|
||||
"node": "20 || >=22"
|
||||
|
|
@ -711,21 +735,6 @@
|
|||
"node": ">=14.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/package-json-from-dist": {
|
||||
"version": "1.0.1",
|
||||
"resolved": "https://registry.npmjs.org/package-json-from-dist/-/package-json-from-dist-1.0.1.tgz",
|
||||
"integrity": "sha512-UEZIS3/by4OC8vL3P2dTXRETpebLI2NiI5vIrjaD/5UtrkFX/tNbwjTSRAGC/+7CAo2pIcBaRgWmcBBHcsaCIw==",
|
||||
"license": "BlueOak-1.0.0"
|
||||
},
|
||||
"node_modules/path-key": {
|
||||
"version": "3.1.1",
|
||||
"resolved": "https://registry.npmjs.org/path-key/-/path-key-3.1.1.tgz",
|
||||
"integrity": "sha512-ojmeN0qd+y0jszEtoY48r0Peq5dwMEkIlCOu6Q5f41lfkswXuKtYrhgoTpLnyIcHm24Uhqx+5Tqm2InSwLhE6Q==",
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">=8"
|
||||
}
|
||||
},
|
||||
"node_modules/path-scurry": {
|
||||
"version": "2.0.2",
|
||||
"resolved": "https://registry.npmjs.org/path-scurry/-/path-scurry-2.0.2.tgz",
|
||||
|
|
@ -972,26 +981,6 @@
|
|||
"integrity": "sha512-q1b3N5QkRUWUl7iyylaaj3kOpIT0N2i9MqIEQXP73GVsN9cw3fdx8X63cEmWhJGi2PPCF23Ijp7ktmd39rawIA==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/safe-buffer": {
|
||||
"version": "5.2.1",
|
||||
"resolved": "https://registry.npmjs.org/safe-buffer/-/safe-buffer-5.2.1.tgz",
|
||||
"integrity": "sha512-rp3So07KcdmmKbGvgaNxQSJr7bGVSVk5S9Eq1F+ppbRo70+YeaDxkw5Dd8NPN+GD6bjnYm2VuPuCXmpuYvmCXQ==",
|
||||
"funding": [
|
||||
{
|
||||
"type": "github",
|
||||
"url": "https://github.com/sponsors/feross"
|
||||
},
|
||||
{
|
||||
"type": "patreon",
|
||||
"url": "https://www.patreon.com/feross"
|
||||
},
|
||||
{
|
||||
"type": "consulting",
|
||||
"url": "https://feross.org/support"
|
||||
}
|
||||
],
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/safe-regex2": {
|
||||
"version": "5.1.1",
|
||||
"resolved": "https://registry.npmjs.org/safe-regex2/-/safe-regex2-5.1.1.tgz",
|
||||
|
|
@ -1063,39 +1052,6 @@
|
|||
"integrity": "sha512-E5LDX7Wrp85Kil5bhZv46j8jOeboKq5JMmYM3gVGdGH8xFpPWXUMsNrlODCrkoxMEeNi/XZIwuRvY4XNwYMJpw==",
|
||||
"license": "ISC"
|
||||
},
|
||||
"node_modules/shebang-command": {
|
||||
"version": "2.0.0",
|
||||
"resolved": "https://registry.npmjs.org/shebang-command/-/shebang-command-2.0.0.tgz",
|
||||
"integrity": "sha512-kHxr2zZpYtdmrN1qDjrrX/Z1rR1kG8Dx+gkpK1G4eXmvXswmcE1hTWBWYUzlraYw1/yZp6YuDY77YtvbN0dmDA==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"shebang-regex": "^3.0.0"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=8"
|
||||
}
|
||||
},
|
||||
"node_modules/shebang-regex": {
|
||||
"version": "3.0.0",
|
||||
"resolved": "https://registry.npmjs.org/shebang-regex/-/shebang-regex-3.0.0.tgz",
|
||||
"integrity": "sha512-7++dFhtcx3353uBaq8DDR4NuxBetBzC7ZQOhmTQInHEd6bSrXdiEyzCvG07Z44UYdLShWUyXt5M/yhz8ekcb1A==",
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">=8"
|
||||
}
|
||||
},
|
||||
"node_modules/signal-exit": {
|
||||
"version": "4.1.0",
|
||||
"resolved": "https://registry.npmjs.org/signal-exit/-/signal-exit-4.1.0.tgz",
|
||||
"integrity": "sha512-bzyZ1e88w9O1iNJbKnOlvYTrWPDl46O1bG0D3XInv+9tkPrxrN8jUUTiFlDkkmKWgn1M6CfIA13SuGqOa9Korw==",
|
||||
"license": "ISC",
|
||||
"engines": {
|
||||
"node": ">=14"
|
||||
},
|
||||
"funding": {
|
||||
"url": "https://github.com/sponsors/isaacs"
|
||||
}
|
||||
},
|
||||
"node_modules/sonic-boom": {
|
||||
"version": "4.2.1",
|
||||
"resolved": "https://registry.npmjs.org/sonic-boom/-/sonic-boom-4.2.1.tgz",
|
||||
|
|
@ -1159,21 +1115,6 @@
|
|||
"node": ">=0.6"
|
||||
}
|
||||
},
|
||||
"node_modules/which": {
|
||||
"version": "2.0.2",
|
||||
"resolved": "https://registry.npmjs.org/which/-/which-2.0.2.tgz",
|
||||
"integrity": "sha512-BLI3Tl1TW3Pvl70l3yq3Y64i+awpwXqsGBYWkkqMtnbXgrMD+yj7rhW0kuEDxzJaYXGjEW5ogapKNMEKNMjibA==",
|
||||
"license": "ISC",
|
||||
"dependencies": {
|
||||
"isexe": "^2.0.0"
|
||||
},
|
||||
"bin": {
|
||||
"node-which": "bin/node-which"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">= 8"
|
||||
}
|
||||
},
|
||||
"node_modules/xtend": {
|
||||
"version": "4.0.2",
|
||||
"resolved": "https://registry.npmjs.org/xtend/-/xtend-4.0.2.tgz",
|
||||
|
|
|
|||
|
|
@ -9,10 +9,14 @@
|
|||
},
|
||||
"scripts": {
|
||||
"start": "node src/server.js",
|
||||
"dev": "node --watch src/server.js"
|
||||
"dev": "node --watch src/server.js",
|
||||
"test": "node --test"
|
||||
},
|
||||
"dependencies": {
|
||||
"@fastify/static": "^8.0.1",
|
||||
"@fastify/cookie": "^11.1.1",
|
||||
"@fastify/helmet": "^13.1.0",
|
||||
"@fastify/rate-limit": "^11.1.0",
|
||||
"@fastify/static": "^10.1.0",
|
||||
"fastify": "^5.1.0",
|
||||
"pg": "^8.13.1"
|
||||
}
|
||||
|
|
|
|||
|
|
@ -140,6 +140,9 @@
|
|||
<div class="am-h3" data-i18n="stPassword"></div>
|
||||
<div class="am-reset" id="ownPwBlock">
|
||||
<div id="settingsMsg" class="formMsg"></div>
|
||||
<div class="lrow">
|
||||
<input type="password" id="ownPwCurrent" maxlength="64" autocomplete="current-password">
|
||||
</div>
|
||||
<div class="lrow">
|
||||
<input type="password" id="ownPw" maxlength="64" autocomplete="new-password">
|
||||
<button class="tbtn ghost pwtoggle" type="button" id="btnOwnPwToggle" data-i18n="pwShow"></button>
|
||||
|
|
|
|||
|
|
@ -2,7 +2,7 @@
|
|||
"use strict";
|
||||
/* version — shown until /api/version resolves (or if the fetch fails, e.g. offline).
|
||||
Kept in sync by hand with the VERSION file at the repo root on every release. */
|
||||
const VERSION = "0.3.02-beta";
|
||||
const VERSION = "0.3.03-beta";
|
||||
(function(){
|
||||
const tag = document.getElementById("verTag");
|
||||
tag.textContent = "v"+VERSION;
|
||||
|
|
@ -125,7 +125,7 @@ const I18N = {
|
|||
vwTbar:"Menubalk", vwTop:"Boven", vwLeft:"Links", vwRight:"Rechts",
|
||||
vwMotion:"Animaties", vwGlass:"Glas-effect",
|
||||
vwNote:"Deze voorkeuren gelden voor dit apparaat.",
|
||||
pwChanged:"Wachtwoord gewijzigd ✓",
|
||||
pwChanged:"Wachtwoord gewijzigd ✓", pwCurrentPh:"huidig wachtwoord",
|
||||
namePh:"naam", passPh:"wachtwoord",
|
||||
guestNote:"Zonder inloggen wordt niets bewaard. Log in met je schoolaccount om alles op de server te bewaren.",
|
||||
storedNote:"Je werk staat op de school-server en is op elk digibord beschikbaar.",
|
||||
|
|
@ -265,7 +265,7 @@ const I18N = {
|
|||
vwTbar:"Menu bar", vwTop:"Top", vwLeft:"Left", vwRight:"Right",
|
||||
vwMotion:"Animations", vwGlass:"Glass effect",
|
||||
vwNote:"These preferences apply to this device.",
|
||||
pwChanged:"Password changed ✓",
|
||||
pwChanged:"Password changed ✓", pwCurrentPh:"current password",
|
||||
namePh:"name", passPh:"password",
|
||||
guestNote:"Without logging in nothing is saved. Log in with your school account to keep everything on the server.",
|
||||
storedNote:"Your work is stored on the school server and available on any board.",
|
||||
|
|
@ -324,6 +324,7 @@ function applyI18n(){
|
|||
document.getElementById("liCode").placeholder = T("codePh");
|
||||
document.getElementById("liPassNew").placeholder = T("pwNewPh");
|
||||
document.getElementById("liPassNew2").placeholder = T("pwConfirmPh");
|
||||
document.getElementById("ownPwCurrent").placeholder = T("pwCurrentPh");
|
||||
document.getElementById("ownPw").placeholder = T("pwNewPh");
|
||||
document.getElementById("ownPw2").placeholder = T("pwConfirmPh");
|
||||
document.getElementById("btnLiPassToggle").textContent = T("pwShow");
|
||||
|
|
@ -348,14 +349,14 @@ document.getElementById("btnFs").addEventListener("click", ()=>{
|
|||
Rollen: super (overkoepelend), admin (school), teacher (groepsleiding),
|
||||
pupil (leerling). Zonder inloggen wordt niets bewaard.
|
||||
==========================================================*/
|
||||
let TOKEN = null;
|
||||
try{ TOKEN = localStorage.getItem("teach.token"); }catch(e){}
|
||||
// Oude bearer-tokens worden bewust verwijderd; sessies lopen via een HttpOnly-cookie.
|
||||
try{ localStorage.removeItem("teach.token"); }catch(e){}
|
||||
let currentUser = null; /* {id, username, displayName, role, schoolId, classId} */
|
||||
|
||||
async function api(path, opts={}){
|
||||
const headers = { "Content-Type": "application/json" };
|
||||
if(TOKEN) headers.Authorization = "Bearer " + TOKEN;
|
||||
const r = await fetch("/api" + path, {
|
||||
credentials: "same-origin",
|
||||
method: opts.method || (opts.body ? "POST" : "GET"),
|
||||
headers,
|
||||
body: opts.body ? JSON.stringify(opts.body) : undefined
|
||||
|
|
@ -443,8 +444,6 @@ async function hydrateFromServer(){
|
|||
}
|
||||
function resetToGuest(){
|
||||
currentUser = null;
|
||||
TOKEN = null;
|
||||
try{ localStorage.removeItem("teach.token"); }catch(e){}
|
||||
stopPupilView();
|
||||
THEMES = {nl:[],en:[]};
|
||||
GENERAL_EXTRA = {nl:[],en:[]};
|
||||
|
|
@ -527,8 +526,6 @@ loginWrap.addEventListener("click", e=>{ if(e.target===loginWrap) loginWrap.clas
|
|||
settingsWrap.addEventListener("click", e=>{ if(e.target===settingsWrap) settingsWrap.classList.remove("open"); });
|
||||
|
||||
async function afterLogin(res){
|
||||
TOKEN = res.token;
|
||||
try{ localStorage.setItem("teach.token", TOKEN); }catch(e){}
|
||||
currentUser = res.user;
|
||||
updateUserUI();
|
||||
loginWrap.classList.remove("open");
|
||||
|
|
@ -568,7 +565,7 @@ function pwToggle(btnId, ...fieldIds){
|
|||
});
|
||||
}
|
||||
pwToggle("btnLiPassToggle", "liPassNew", "liPassNew2");
|
||||
pwToggle("btnOwnPwToggle", "ownPw", "ownPw2");
|
||||
pwToggle("btnOwnPwToggle", "ownPwCurrent", "ownPw", "ownPw2");
|
||||
|
||||
/* account activeren met een koppelcode */
|
||||
document.getElementById("btnLink").addEventListener("click", async ()=>{
|
||||
|
|
@ -596,13 +593,15 @@ document.getElementById("btnLogout").addEventListener("click", async ()=>{
|
|||
/* eigen wachtwoord wijzigen (niet voor leerlingen) */
|
||||
document.getElementById("btnOwnPw").addEventListener("click", async ()=>{
|
||||
const btn = document.getElementById("btnOwnPw");
|
||||
const currentPassword = document.getElementById("ownPwCurrent").value;
|
||||
const pw = document.getElementById("ownPw").value;
|
||||
const pw2 = document.getElementById("ownPw2").value;
|
||||
settingsMsg.classList.remove("ok");
|
||||
if(pw !== pw2){ settingsMsg.textContent = T("errPwMatch"); return; }
|
||||
await withBusy(btn, async ()=>{
|
||||
try{
|
||||
await api("/auth/password", { method:"PATCH", body:{ password: pw } });
|
||||
await api("/auth/password", { method:"PATCH", body:{ currentPassword, password: pw } });
|
||||
document.getElementById("ownPwCurrent").value = "";
|
||||
document.getElementById("ownPw").value = "";
|
||||
document.getElementById("ownPw2").value = "";
|
||||
settingsMsg.textContent = T("pwChanged"); settingsMsg.classList.add("ok");
|
||||
|
|
|
|||
162
src/api.js
162
src/api.js
|
|
@ -12,19 +12,38 @@
|
|||
import {
|
||||
hashPassword, verifyPassword, createSession, userFromRequest,
|
||||
newLinkCode, pupilPassword, staffPassword, checkPassword, publicUser,
|
||||
SESSION_COOKIE, hashSessionToken, sessionTokenFromRequest, sessionDaysForRole,
|
||||
} from './auth.js';
|
||||
import { PERMISSIONS, CREATABLE_ROLES, can } from './permissions.js';
|
||||
|
||||
export default async function api(app) {
|
||||
const pool = app.pg;
|
||||
// Voorkomt meetbaar snellere antwoorden voor niet-bestaande gebruikers.
|
||||
const dummyPasswordHash = await hashPassword(newLinkCode());
|
||||
const cookieSecure = process.env.COOKIE_SECURE !== 'false' && process.env.NODE_ENV === 'production';
|
||||
const cookieOptions = { path: '/', httpOnly: true, secure: cookieSecure, sameSite: 'strict' };
|
||||
const setSessionCookie = (reply, token, role) => reply.setCookie(SESSION_COOKIE, token, {
|
||||
...cookieOptions, maxAge: sessionDaysForRole(role) * 24 * 60 * 60,
|
||||
});
|
||||
const clearSessionCookie = (reply) => reply.clearCookie(SESSION_COOKIE, { path: '/' });
|
||||
|
||||
// ---- helpers --------------------------------------------------------------
|
||||
const fail = (reply, code, msg) => { reply.code(code); return { error: msg }; };
|
||||
|
||||
app.decorateRequest('user', null);
|
||||
app.addHook('preHandler', async (req) => {
|
||||
app.addHook('preHandler', async (req, reply) => {
|
||||
if (!['GET','HEAD','OPTIONS'].includes(req.method) && req.headers.origin) {
|
||||
const expectedOrigin = `${req.protocol}://${req.headers.host}`;
|
||||
if (req.headers.origin !== expectedOrigin) return fail(reply, 403, 'ongeldige origin');
|
||||
}
|
||||
req.user = await userFromRequest(pool, req);
|
||||
});
|
||||
app.addHook('onResponse', async (req, reply) => {
|
||||
if ([401, 403, 429].includes(reply.statusCode)) {
|
||||
req.log.warn({ securityEvent: true, status: reply.statusCode, method: req.method,
|
||||
url: req.url, ip: req.ip, userId: req.user?.id ?? null }, 'security-relevant response');
|
||||
}
|
||||
});
|
||||
const need = (req, reply, roles) => {
|
||||
if (!req.user) { reply.code(401); throw new Error('niet ingelogd'); }
|
||||
if (roles && !roles.some((r) => req.user.allRoles.includes(r))) { reply.code(403); throw new Error('geen rechten'); }
|
||||
|
|
@ -62,25 +81,33 @@ export default async function api(app) {
|
|||
});
|
||||
|
||||
// ---- auth -------------------------------------------------------------------
|
||||
app.post('/auth/login', async (req, reply) => {
|
||||
app.post('/auth/login', {
|
||||
config: { rateLimit: { max: 8, timeWindow: '10 minutes', ban: 3 } },
|
||||
}, async (req, reply) => {
|
||||
const { schoolId, username, password } = req.body ?? {};
|
||||
if (!username || !password) return fail(reply, 400, 'naam en wachtwoord verplicht');
|
||||
if (typeof username !== 'string' || typeof password !== 'string'
|
||||
|| !username.trim() || username.length > 30 || password.length > 128)
|
||||
return fail(reply, 400, 'ongeldige naam of wachtwoord');
|
||||
const r = schoolId
|
||||
? await pool.query('SELECT * FROM users WHERE school_id = $1 AND lower(username) = lower($2)', [schoolId, username])
|
||||
: await pool.query('SELECT * FROM users WHERE school_id IS NULL AND lower(username) = lower($1)', [username]);
|
||||
? await pool.query('SELECT * FROM users WHERE school_id = $1 AND lower(username) = lower($2)', [schoolId, username.trim()])
|
||||
: await pool.query('SELECT * FROM users WHERE school_id IS NULL AND lower(username) = lower($1)', [username.trim()]);
|
||||
const u = r.rows[0];
|
||||
if (!u || !(await verifyPassword(password, u.password_hash)))
|
||||
return fail(reply, 401, 'onbekende naam of verkeerd wachtwoord');
|
||||
const passwordOk = await verifyPassword(password, u?.password_hash || dummyPasswordHash);
|
||||
if (!u || !passwordOk) return fail(reply, 401, 'onbekende naam of verkeerd wachtwoord');
|
||||
// account is nu "in gebruik": leerling-wachtwoord niet langer inzichtelijk
|
||||
await pool.query('UPDATE users SET last_login_at = now(), password_plain = NULL WHERE id = $1', [u.id]);
|
||||
const token = await createSession(pool, u.id);
|
||||
return { token, user: publicUser(u) };
|
||||
await pool.query('UPDATE users SET last_login_at = now() WHERE id = $1', [u.id]);
|
||||
const token = await createSession(pool, u.id, u.role);
|
||||
setSessionCookie(reply, token, u.role);
|
||||
return { user: publicUser(u) };
|
||||
});
|
||||
|
||||
// Account activeren met een koppelcode (groepsleiding/beheerders)
|
||||
app.post('/auth/link', async (req, reply) => {
|
||||
app.post('/auth/link', {
|
||||
config: { rateLimit: { max: 5, timeWindow: '15 minutes', ban: 3 } },
|
||||
}, async (req, reply) => {
|
||||
const { code, password } = req.body ?? {};
|
||||
if (!code) return fail(reply, 400, 'koppelcode verplicht');
|
||||
if (typeof code !== 'string' || code.length > 20 || typeof password !== 'string' || password.length > 128)
|
||||
return fail(reply, 400, 'ongeldige koppelcode of wachtwoord');
|
||||
const r = await pool.query('SELECT * FROM users WHERE link_code = $1', [code.trim().toUpperCase()]);
|
||||
const u = r.rows[0];
|
||||
if (!u) return fail(reply, 404, 'koppelcode onbekend of al gebruikt');
|
||||
|
|
@ -88,13 +115,15 @@ export default async function api(app) {
|
|||
if (err) return fail(reply, 400, err);
|
||||
const hash = await hashPassword(password);
|
||||
await pool.query('UPDATE users SET password_hash = $1, link_code = NULL, last_login_at = now() WHERE id = $2', [hash, u.id]);
|
||||
const token = await createSession(pool, u.id);
|
||||
return { token, user: publicUser({ ...u, password_hash: hash, link_code: null }) };
|
||||
const token = await createSession(pool, u.id, u.role);
|
||||
setSessionCookie(reply, token, u.role);
|
||||
return { user: publicUser({ ...u, password_hash: hash, link_code: null }) };
|
||||
});
|
||||
|
||||
app.post('/auth/logout', async (req) => {
|
||||
const h = req.headers.authorization || '';
|
||||
if (h.startsWith('Bearer ')) await pool.query('DELETE FROM sessions WHERE token = $1', [h.slice(7)]);
|
||||
app.post('/auth/logout', async (req, reply) => {
|
||||
const token = sessionTokenFromRequest(req);
|
||||
if (token) await pool.query('DELETE FROM sessions WHERE token = $1', [hashSessionToken(token)]);
|
||||
clearSessionCookie(reply);
|
||||
return { ok: true };
|
||||
});
|
||||
|
||||
|
|
@ -104,13 +133,20 @@ export default async function api(app) {
|
|||
});
|
||||
|
||||
// Eigen wachtwoord wijzigen - niet voor leerlingen
|
||||
app.patch('/auth/password', async (req, reply) => {
|
||||
app.patch('/auth/password', {
|
||||
config: { rateLimit: { max: 5, timeWindow: '15 minutes' } },
|
||||
}, async (req, reply) => {
|
||||
need(req, reply, PERMISSIONS['password.own.change']);
|
||||
const { password } = req.body ?? {};
|
||||
const { currentPassword, password } = req.body ?? {};
|
||||
if (typeof currentPassword !== 'string' || typeof password !== 'string') return fail(reply, 400, 'ongeldig wachtwoord');
|
||||
if (!(await verifyPassword(currentPassword, req.user.password_hash))) return fail(reply, 401, 'huidig wachtwoord onjuist');
|
||||
const err = checkPassword(password, req.user.role);
|
||||
if (err) return fail(reply, 400, err);
|
||||
await pool.query('UPDATE users SET password_hash = $1 WHERE id = $2',
|
||||
[await hashPassword(password), req.user.id]);
|
||||
const token = sessionTokenFromRequest(req);
|
||||
await pool.query('DELETE FROM sessions WHERE user_id = $1 AND token <> $2',
|
||||
[req.user.id, token ? hashSessionToken(token) : '']);
|
||||
return { ok: true };
|
||||
});
|
||||
|
||||
|
|
@ -120,9 +156,12 @@ export default async function api(app) {
|
|||
const r = await pool.query('SELECT data FROM users WHERE id = $1', [req.user.id]);
|
||||
return { data: r.rows[0]?.data ?? {} };
|
||||
});
|
||||
app.put('/me/data', async (req, reply) => {
|
||||
app.put('/me/data', { bodyLimit: 21 * 1024 * 1024 }, async (req, reply) => {
|
||||
need(req, reply);
|
||||
await pool.query('UPDATE users SET data = $1 WHERE id = $2', [req.body ?? {}, req.user.id]);
|
||||
const data = req.body ?? {};
|
||||
if (!data || typeof data !== 'object' || Array.isArray(data)) return fail(reply, 400, 'ongeldige borddata');
|
||||
if (Buffer.byteLength(JSON.stringify(data), 'utf8') > 20 * 1024 * 1024) return fail(reply, 413, 'borddata te groot');
|
||||
await pool.query('UPDATE users SET data = $1 WHERE id = $2', [data, req.user.id]);
|
||||
return { ok: true };
|
||||
});
|
||||
|
||||
|
|
@ -130,7 +169,7 @@ export default async function api(app) {
|
|||
app.post('/admin/schools', async (req, reply) => {
|
||||
need(req, reply, PERMISSIONS['schools.manage']);
|
||||
const { name } = req.body ?? {};
|
||||
if (!name) return fail(reply, 400, 'naam verplicht');
|
||||
if (!name || typeof name !== 'string' || name.trim().length > 80) return fail(reply, 400, 'ongeldige naam');
|
||||
const r = await pool.query('INSERT INTO schools (name) VALUES ($1) RETURNING id, name', [name.trim()]);
|
||||
return { school: { id: Number(r.rows[0].id), name: r.rows[0].name } };
|
||||
});
|
||||
|
|
@ -152,8 +191,9 @@ export default async function api(app) {
|
|||
const users = [];
|
||||
for (const p of pupils) {
|
||||
const pw = pupilPassword();
|
||||
await pool.query('UPDATE users SET password_plain = $1, password_hash = $2, last_login_at = NULL WHERE id = $3',
|
||||
[pw, await hashPassword(pw), p.id]);
|
||||
await pool.query('UPDATE users SET password_hash = $1, last_login_at = NULL WHERE id = $2',
|
||||
[await hashPassword(pw), p.id]);
|
||||
await pool.query('DELETE FROM sessions WHERE user_id = $1', [p.id]);
|
||||
users.push({ id: Number(p.id), username: p.username, displayName: p.display_name || p.username, password: pw });
|
||||
}
|
||||
return { users };
|
||||
|
|
@ -175,7 +215,7 @@ export default async function api(app) {
|
|||
need(req, reply, PERMISSIONS['classes.manage']);
|
||||
const { name, school } = req.body ?? {};
|
||||
const schoolId = req.user.allRoles.includes('super') ? school : req.user.school_id;
|
||||
if (!name || !schoolId) return fail(reply, 400, 'naam en school verplicht');
|
||||
if (!name || typeof name !== 'string' || name.trim().length > 80 || !schoolId) return fail(reply, 400, 'ongeldige naam of school');
|
||||
const r = await pool.query('INSERT INTO classes (school_id, name) VALUES ($1,$2) RETURNING id, name', [schoolId, name.trim()]);
|
||||
return { class: { id: Number(r.rows[0].id), name: r.rows[0].name } };
|
||||
});
|
||||
|
|
@ -192,8 +232,18 @@ export default async function api(app) {
|
|||
app.post('/admin/classes/:id/teachers', async (req, reply) => {
|
||||
need(req, reply, PERMISSIONS['classes.manage']);
|
||||
const { userId, remove } = req.body ?? {};
|
||||
if (remove) await pool.query('DELETE FROM class_teachers WHERE class_id = $1 AND user_id = $2', [req.params.id, userId]);
|
||||
else await pool.query('INSERT INTO class_teachers (class_id, user_id) VALUES ($1,$2) ON CONFLICT DO NOTHING', [req.params.id, userId]);
|
||||
if (!userId) return fail(reply, 400, 'gebruiker verplicht');
|
||||
const c = (await pool.query('SELECT * FROM classes WHERE id = $1', [req.params.id])).rows[0];
|
||||
const u = (await pool.query(`SELECT u.*, EXISTS (
|
||||
SELECT 1 FROM user_roles ur WHERE ur.user_id = u.id AND ur.role = 'teacher'
|
||||
) AS extra_teacher FROM users u WHERE u.id = $1`, [userId])).rows[0];
|
||||
if (!c || !u) return fail(reply, 404, 'klas of gebruiker onbekend');
|
||||
if (!req.user.allRoles.includes('super') && Number(c.school_id) !== Number(req.user.school_id))
|
||||
return fail(reply, 403, 'geen rechten');
|
||||
if (Number(c.school_id) !== Number(u.school_id)) return fail(reply, 400, 'klas en groepsleiding moeten bij dezelfde school horen');
|
||||
if (u.role !== 'teacher' && !u.extra_teacher) return fail(reply, 400, 'gebruiker is geen groepsleiding');
|
||||
if (remove) await pool.query('DELETE FROM class_teachers WHERE class_id = $1 AND user_id = $2', [c.id, u.id]);
|
||||
else await pool.query('INSERT INTO class_teachers (class_id, user_id) VALUES ($1,$2) ON CONFLICT DO NOTHING', [c.id, u.id]);
|
||||
return { ok: true };
|
||||
});
|
||||
|
||||
|
|
@ -212,8 +262,9 @@ export default async function api(app) {
|
|||
const users = [];
|
||||
for (const p of pupils) {
|
||||
const pw = pupilPassword();
|
||||
await pool.query('UPDATE users SET password_plain = $1, password_hash = $2, last_login_at = NULL WHERE id = $3',
|
||||
[pw, await hashPassword(pw), p.id]);
|
||||
await pool.query('UPDATE users SET password_hash = $1, last_login_at = NULL WHERE id = $2',
|
||||
[await hashPassword(pw), p.id]);
|
||||
await pool.query('DELETE FROM sessions WHERE user_id = $1', [p.id]);
|
||||
users.push({ id: Number(p.id), username: p.username, displayName: p.display_name || p.username, password: pw });
|
||||
}
|
||||
return { users };
|
||||
|
|
@ -246,7 +297,6 @@ export default async function api(app) {
|
|||
} else {
|
||||
rows = (await pool.query('SELECT * FROM users WHERE school_id = $1 ORDER BY role, lower(username)', [req.user.school_id])).rows;
|
||||
}
|
||||
const canSeePw = can(req.user.allRoles, 'users.manage');
|
||||
// extra stafrollen per gebruiker erbij ophalen (in één keer, niet per rij)
|
||||
const roleRows = rows.length
|
||||
? (await pool.query('SELECT user_id, role FROM user_roles WHERE user_id = ANY($1)', [rows.map((u) => u.id)])).rows
|
||||
|
|
@ -259,9 +309,6 @@ export default async function api(app) {
|
|||
return { users: rows.map((u) => ({
|
||||
...publicUser({ ...u, extra_roles: extraByUser.get(String(u.id)) || [] }),
|
||||
linkCode: u.link_code || null,
|
||||
// leerlingwachtwoord is alleen inzichtelijk zolang het account nog niet
|
||||
// gebruikt is (na de eerste keer inloggen wordt password_plain gewist)
|
||||
password: canSeePw && u.role === 'pupil' ? u.password_plain : undefined,
|
||||
everLoggedIn: !!u.last_login_at,
|
||||
})) };
|
||||
});
|
||||
|
|
@ -279,13 +326,18 @@ export default async function api(app) {
|
|||
if (!/^[a-zA-Z0-9_.-]{2,30}$/.test(username)) return fail(reply, 400, 'ongeldige gebruikersnaam');
|
||||
try {
|
||||
if (role === 'pupil') {
|
||||
if (b.classId != null) {
|
||||
const c = (await pool.query('SELECT * FROM classes WHERE id = $1', [b.classId])).rows[0];
|
||||
if (!c || Number(c.school_id) !== Number(schoolId)) return fail(reply, 400, 'klas hoort niet bij deze school');
|
||||
if (teacherOnly(req) && !(await classOwnedByTeacher(c.id, req.user.id))) return fail(reply, 403, 'geen rechten voor deze klas');
|
||||
}
|
||||
const pw = b.password || pupilPassword();
|
||||
const err = checkPassword(pw, 'pupil');
|
||||
if (err) return fail(reply, 400, err);
|
||||
const r = await pool.query(
|
||||
`INSERT INTO users (username, role, school_id, class_id, display_name, password_hash, password_plain)
|
||||
VALUES ($1,'pupil',$2,$3,$4,$5,$6) RETURNING *`,
|
||||
[username, schoolId, b.classId ?? null, b.displayName || username, await hashPassword(pw), pw]);
|
||||
`INSERT INTO users (username, role, school_id, class_id, display_name, password_hash)
|
||||
VALUES ($1,'pupil',$2,$3,$4,$5) RETURNING *`,
|
||||
[username, schoolId, b.classId ?? null, b.displayName || username, await hashPassword(pw)]);
|
||||
return { user: { ...publicUser(r.rows[0]), password: pw } };
|
||||
}
|
||||
// groepsleiding/beheer: ofwel meteen een wachtwoord (zelf gekozen of
|
||||
|
|
@ -320,16 +372,28 @@ export default async function api(app) {
|
|||
if (!u) return fail(reply, 404, 'gebruiker onbekend');
|
||||
if (!sameSchool(req, u)) return fail(reply, 403, 'geen rechten');
|
||||
if (teacherOnly(req) && u.role !== 'pupil') return fail(reply, 403, 'groepsleiding beheert alleen leerlingen');
|
||||
if (teacherOnly(req) && !(u.class_id && (await classOwnedByTeacher(u.class_id, req.user.id))))
|
||||
return fail(reply, 403, 'groepsleiding beheert alleen leerlingen uit eigen klassen');
|
||||
if (u.role === 'super' && !req.user.allRoles.includes('super')) return fail(reply, 403, 'geen rechten');
|
||||
const b = req.body ?? {};
|
||||
if (b.displayName) await pool.query('UPDATE users SET display_name = $1 WHERE id = $2', [b.displayName, u.id]);
|
||||
if (b.classId !== undefined && u.role === 'pupil')
|
||||
if (b.displayName) {
|
||||
if (typeof b.displayName !== 'string' || b.displayName.trim().length > 80) return fail(reply, 400, 'ongeldige weergavenaam');
|
||||
await pool.query('UPDATE users SET display_name = $1 WHERE id = $2', [b.displayName.trim(), u.id]);
|
||||
}
|
||||
if (b.classId !== undefined && u.role === 'pupil') {
|
||||
if (b.classId !== null) {
|
||||
const c = (await pool.query('SELECT * FROM classes WHERE id = $1', [b.classId])).rows[0];
|
||||
if (!c || Number(c.school_id) !== Number(u.school_id)) return fail(reply, 400, 'klas hoort niet bij deze school');
|
||||
if (teacherOnly(req) && !(await classOwnedByTeacher(c.id, req.user.id))) return fail(reply, 403, 'geen rechten voor deze klas');
|
||||
}
|
||||
await pool.query('UPDATE users SET class_id = $1 WHERE id = $2', [b.classId, u.id]);
|
||||
}
|
||||
if (b.password && u.role === 'pupil') {
|
||||
const err = checkPassword(b.password, 'pupil');
|
||||
if (err) return fail(reply, 400, err);
|
||||
await pool.query('UPDATE users SET password_plain = $1, password_hash = $2, last_login_at = NULL WHERE id = $3',
|
||||
[b.password, await hashPassword(b.password), u.id]);
|
||||
await pool.query('UPDATE users SET password_hash = $1, last_login_at = NULL WHERE id = $2',
|
||||
[await hashPassword(b.password), u.id]);
|
||||
await pool.query('DELETE FROM sessions WHERE user_id = $1', [u.id]);
|
||||
}
|
||||
// groepsleiding/beheer: direct een nieuw wachtwoord zetten (zelf gekozen
|
||||
// of gegenereerd) zonder de koppelcode-activatie opnieuw te doorlopen.
|
||||
|
|
@ -338,11 +402,13 @@ export default async function api(app) {
|
|||
const err = checkPassword(pw, u.role);
|
||||
if (err) return fail(reply, 400, err);
|
||||
await pool.query('UPDATE users SET password_hash = $1, link_code = NULL WHERE id = $2', [await hashPassword(pw), u.id]);
|
||||
await pool.query('DELETE FROM sessions WHERE user_id = $1', [u.id]);
|
||||
return { ok: true, password: pw };
|
||||
}
|
||||
if (b.newLinkCode && u.role !== 'pupil' && can(req.user.allRoles, 'users.staffCredentials')) {
|
||||
const code = newLinkCode();
|
||||
await pool.query('UPDATE users SET link_code = $1, password_hash = NULL WHERE id = $2', [code, u.id]);
|
||||
await pool.query('DELETE FROM sessions WHERE user_id = $1', [u.id]);
|
||||
return { ok: true, linkCode: code };
|
||||
}
|
||||
if (b.role && can(req.user.allRoles, 'users.role.change') && ['super', 'admin', 'teacher'].includes(b.role) && u.role !== 'pupil')
|
||||
|
|
@ -370,6 +436,8 @@ export default async function api(app) {
|
|||
if (Number(u.id) === Number(req.user.id)) return fail(reply, 400, 'je kunt jezelf niet verwijderen');
|
||||
if (!sameSchool(req, u)) return fail(reply, 403, 'geen rechten');
|
||||
if (teacherOnly(req) && u.role !== 'pupil') return fail(reply, 403, 'geen rechten');
|
||||
if (teacherOnly(req) && !(u.class_id && (await classOwnedByTeacher(u.class_id, req.user.id))))
|
||||
return fail(reply, 403, 'groepsleiding beheert alleen leerlingen uit eigen klassen');
|
||||
if (u.role === 'super' && !req.user.allRoles.includes('super')) return fail(reply, 403, 'geen rechten');
|
||||
await pool.query('DELETE FROM users WHERE id = $1', [u.id]);
|
||||
return { ok: true };
|
||||
|
|
@ -415,7 +483,8 @@ export default async function api(app) {
|
|||
app.put('/assignments/class/:id', async (req, reply) => {
|
||||
need(req, reply, PERMISSIONS['assignments.manage']);
|
||||
const { boardId } = req.body ?? {};
|
||||
if (!boardId) return fail(reply, 400, 'boardId verplicht');
|
||||
if (!boardId || typeof boardId !== 'string' || boardId.length > 100) return fail(reply, 400, 'ongeldige boardId');
|
||||
if (!findBoardById(req.user.data, boardId)) return fail(reply, 400, 'bord onbekend of niet van deze gebruiker');
|
||||
const c = (await pool.query('SELECT * FROM classes WHERE id = $1', [req.params.id])).rows[0];
|
||||
if (!c) return fail(reply, 404, 'klas onbekend');
|
||||
if (!sameSchool(req, c)) return fail(reply, 403, 'geen rechten');
|
||||
|
|
@ -453,7 +522,8 @@ export default async function api(app) {
|
|||
app.put('/assignments/pupil/:id', async (req, reply) => {
|
||||
need(req, reply, PERMISSIONS['assignments.manage']);
|
||||
const { boardId } = req.body ?? {};
|
||||
if (!boardId) return fail(reply, 400, 'boardId verplicht');
|
||||
if (!boardId || typeof boardId !== 'string' || boardId.length > 100) return fail(reply, 400, 'ongeldige boardId');
|
||||
if (!findBoardById(req.user.data, boardId)) return fail(reply, 400, 'bord onbekend of niet van deze gebruiker');
|
||||
const u = (await pool.query('SELECT * FROM users WHERE id = $1', [req.params.id])).rows[0];
|
||||
if (!u) return fail(reply, 404, 'gebruiker onbekend');
|
||||
if (!(await pupilAccessible(req, u))) return fail(reply, 403, 'geen rechten');
|
||||
|
|
@ -478,6 +548,7 @@ export default async function api(app) {
|
|||
// doorkomen zodra de leerling-pagina opnieuw ophaalt (polling).
|
||||
app.get('/my/assignment', async (req, reply) => {
|
||||
need(req, reply);
|
||||
if (req.user.role !== 'pupil') return fail(reply, 403, 'alleen voor leerlingen');
|
||||
let a = (await pool.query('SELECT * FROM assignments WHERE pupil_id = $1', [req.user.id])).rows[0];
|
||||
if (!a && req.user.class_id) {
|
||||
a = (await pool.query('SELECT * FROM assignments WHERE class_id = $1', [req.user.class_id])).rows[0];
|
||||
|
|
@ -495,10 +566,13 @@ export async function bootstrapSuper(pool, log) {
|
|||
const r = await pool.query("SELECT count(*)::int AS n FROM users WHERE role = 'super'");
|
||||
if (r.rows[0].n > 0) return;
|
||||
const username = process.env.SUPER_USER || 'beheerder';
|
||||
const password = process.env.SUPER_PASS || newLinkCode();
|
||||
const password = process.env.SUPER_PASS;
|
||||
if (!password) throw new Error('SUPER_PASS is verplicht bij het aanmaken van de eerste super-beheerder');
|
||||
const passwordError = checkPassword(password, 'super');
|
||||
if (passwordError) throw new Error(`ongeldige SUPER_PASS: ${passwordError}`);
|
||||
await pool.query(
|
||||
`INSERT INTO users (username, role, display_name, password_hash)
|
||||
VALUES ($1, 'super', $1, $2)`,
|
||||
[username, await hashPassword(password)]);
|
||||
log.warn(`Eerste super-beheerder aangemaakt: ${username} / ${password} - wijzig dit wachtwoord direct!`);
|
||||
log.warn(`Eerste super-beheerder aangemaakt: ${username}. Het wachtwoord wordt niet gelogd.`);
|
||||
}
|
||||
|
|
|
|||
48
src/auth.js
48
src/auth.js
|
|
@ -1,6 +1,8 @@
|
|||
// Auth-helpers: wachtwoord-hashing (scrypt, ingebouwd in Node), sessietokens
|
||||
// en koppelcodes. Geen externe dependencies nodig.
|
||||
import { scrypt, randomBytes, timingSafeEqual } from 'node:crypto';
|
||||
import { scrypt, randomBytes, timingSafeEqual, createHash } from 'node:crypto';
|
||||
|
||||
export const SESSION_COOKIE = 'teach_session';
|
||||
|
||||
export function hashPassword(password) {
|
||||
return new Promise((resolve, reject) => {
|
||||
|
|
@ -14,7 +16,7 @@ export function hashPassword(password) {
|
|||
|
||||
export function verifyPassword(password, stored) {
|
||||
return new Promise((resolve) => {
|
||||
if (!stored || !stored.includes(':')) return resolve(false);
|
||||
if (typeof password !== 'string' || password.length > 128 || !stored || !stored.includes(':')) return resolve(false);
|
||||
const [salt, hex] = stored.split(':');
|
||||
scrypt(password, salt, 32, (err, buf) => {
|
||||
if (err) return resolve(false);
|
||||
|
|
@ -28,6 +30,14 @@ export function newToken() {
|
|||
return randomBytes(32).toString('hex');
|
||||
}
|
||||
|
||||
export function hashSessionToken(token) {
|
||||
return createHash('sha256').update(token).digest('hex');
|
||||
}
|
||||
|
||||
export function sessionTokenFromRequest(req) {
|
||||
return req.cookies?.[SESSION_COOKIE] || null;
|
||||
}
|
||||
|
||||
// Leesbare eenmalige koppelcode, bv. "K7FP-3RZM"
|
||||
export function newLinkCode() {
|
||||
const chars = 'ABCDEFGHJKLMNPQRSTUVWXYZ23456789';
|
||||
|
|
@ -37,10 +47,10 @@ export function newLinkCode() {
|
|||
|
||||
// Eenvoudig leerlingwachtwoord dat een juf kan voorlezen, bv. "vis73"
|
||||
export function pupilPassword() {
|
||||
const words = ['vis','kat','zon','maan','boot','ster','boom','bal','kip','roos'];
|
||||
const w = words[randomBytes(1)[0] % words.length];
|
||||
const n = 10 + (randomBytes(1)[0] % 90);
|
||||
return `${w}${n}`;
|
||||
const words = ['vis','kat','zon','maan','boot','ster','boom','bal','kip','roos','beer','muis','peer','koek','wolf','haan'];
|
||||
const pickWord = () => words[randomBytes(1)[0] % words.length];
|
||||
const n = 100 + (randomBytes(2).readUInt16BE() % 900);
|
||||
return `${pickWord()}-${pickWord()}-${pickWord()}-${n}`;
|
||||
}
|
||||
|
||||
// Willekeurig wachtwoord voor groepsleiding/beheerders, altijd met minstens
|
||||
|
|
@ -50,13 +60,14 @@ export function staffPassword() {
|
|||
const digits = '23456789';
|
||||
const all = letters + digits;
|
||||
const pick = (set, n) => Array.from(randomBytes(n)).map((b) => set[b % set.length]).join('');
|
||||
return `${pick(letters, 1)}${pick(digits, 1)}${pick(all, 6)}`;
|
||||
return `${pick(letters, 1)}${pick(digits, 1)}${pick(all, 12)}`;
|
||||
}
|
||||
|
||||
// Wachtwoordeisen: leerlingen kort en eenvoudig (worden voorgelezen door
|
||||
// groepsleiding), groepsleiding/beheer strenger (eigen, onthouden wachtwoord).
|
||||
export function checkPassword(password, role) {
|
||||
if (!password) return 'wachtwoord verplicht';
|
||||
if (typeof password !== 'string' || !password) return 'wachtwoord verplicht';
|
||||
if (password.length > 128) return 'wachtwoord: maximaal 128 tekens';
|
||||
if (role === 'pupil') {
|
||||
if (password.length < 4) return 'wachtwoord: minimaal 4 tekens';
|
||||
return null;
|
||||
|
|
@ -67,26 +78,33 @@ export function checkPassword(password, role) {
|
|||
return null;
|
||||
}
|
||||
|
||||
const SESSION_DAYS = 30;
|
||||
export function sessionDaysForRole(role) {
|
||||
if (role === 'super' || role === 'admin') return 1;
|
||||
if (role === 'teacher') return 7;
|
||||
return 30;
|
||||
}
|
||||
|
||||
export async function createSession(pool, userId) {
|
||||
export async function createSession(pool, userId, role) {
|
||||
const token = newToken();
|
||||
await pool.query('DELETE FROM sessions WHERE expires_at <= now()');
|
||||
await pool.query(`DELETE FROM sessions WHERE token IN (
|
||||
SELECT token FROM sessions WHERE user_id = $1 ORDER BY created_at DESC OFFSET 9
|
||||
)`, [userId]);
|
||||
await pool.query(
|
||||
`INSERT INTO sessions (token, user_id, expires_at)
|
||||
VALUES ($1, $2, now() + interval '${SESSION_DAYS} days')`,
|
||||
[token, userId],
|
||||
VALUES ($1, $2, now() + ($3 * interval '1 day'))`,
|
||||
[hashSessionToken(token), userId, sessionDaysForRole(role)],
|
||||
);
|
||||
return token;
|
||||
}
|
||||
|
||||
export async function userFromRequest(pool, req) {
|
||||
const h = req.headers.authorization || '';
|
||||
const token = h.startsWith('Bearer ') ? h.slice(7) : null;
|
||||
const token = sessionTokenFromRequest(req);
|
||||
if (!token) return null;
|
||||
const r = await pool.query(
|
||||
`SELECT u.* FROM sessions s JOIN users u ON u.id = s.user_id
|
||||
WHERE s.token = $1 AND s.expires_at > now()`,
|
||||
[token],
|
||||
[hashSessionToken(token)],
|
||||
);
|
||||
const u = r.rows[0];
|
||||
if (!u) return null;
|
||||
|
|
|
|||
|
|
@ -2,6 +2,9 @@ import { fileURLToPath } from 'node:url';
|
|||
import { dirname, join } from 'node:path';
|
||||
import Fastify from 'fastify';
|
||||
import fastifyStatic from '@fastify/static';
|
||||
import fastifyCookie from '@fastify/cookie';
|
||||
import fastifyHelmet from '@fastify/helmet';
|
||||
import fastifyRateLimit from '@fastify/rate-limit';
|
||||
import pg from 'pg';
|
||||
import api, { bootstrapSuper } from './api.js';
|
||||
import { runMigrations } from './migrate.js';
|
||||
|
|
@ -10,14 +13,16 @@ const __dirname = dirname(fileURLToPath(import.meta.url));
|
|||
|
||||
const PORT = Number(process.env.PORT ?? 3000);
|
||||
const HOST = process.env.HOST ?? '0.0.0.0';
|
||||
const TRUST_PROXY_HOPS = Math.max(0, Number(process.env.TRUST_PROXY_HOPS ?? 2));
|
||||
const IS_PRODUCTION = process.env.NODE_ENV === 'production';
|
||||
|
||||
const app = Fastify({
|
||||
logger: true,
|
||||
trustProxy: true, // achter nginx reverse proxy
|
||||
// borden bevatten tekeningen/foto's als base64 in de JSON; de Fastify-standaard
|
||||
// van 1 MB weigerde die stilletjes (413) waardoor opslaan leek te lukken maar
|
||||
// niets bewaarde. Ruime limiet + eerlijke foutmelding client-side (core.js).
|
||||
bodyLimit: 25 * 1024 * 1024,
|
||||
// Pangolin/Traefik en nginx vormen normaal twee vertrouwde proxy-hops.
|
||||
// Vertrouw nooit willekeurige X-Forwarded-* headers van directe clients.
|
||||
trustProxy: TRUST_PROXY_HOPS,
|
||||
// Alleen de borddata-route krijgt expliciet een ruimere limiet.
|
||||
bodyLimit: 1024 * 1024,
|
||||
});
|
||||
|
||||
// --- Database pool -----------------------------------------------------------
|
||||
|
|
@ -44,6 +49,34 @@ app.get('/readyz', async (req, reply) => {
|
|||
}
|
||||
});
|
||||
|
||||
// --- HTTP-beveiliging --------------------------------------------------------
|
||||
await app.register(fastifyCookie);
|
||||
await app.register(fastifyRateLimit, { global: false });
|
||||
await app.register(fastifyHelmet, {
|
||||
global: true,
|
||||
hsts: IS_PRODUCTION ? { maxAge: 31536000, includeSubDomains: true } : false,
|
||||
contentSecurityPolicy: {
|
||||
directives: {
|
||||
defaultSrc: ["'self'"],
|
||||
scriptSrc: ["'self'"],
|
||||
styleSrc: ["'self'", "'unsafe-inline'"],
|
||||
imgSrc: ["'self'", 'data:', 'blob:', 'https:'],
|
||||
mediaSrc: ["'self'", 'blob:', 'https:'],
|
||||
connectSrc: ["'self'", 'https://api.arasaac.org'],
|
||||
frameSrc: ['https://www.youtube.com', 'https://player.vimeo.com'],
|
||||
objectSrc: ["'none'"],
|
||||
baseUri: ["'self'"],
|
||||
formAction: ["'self'"],
|
||||
frameAncestors: ["'none'"],
|
||||
upgradeInsecureRequests: IS_PRODUCTION ? [] : null,
|
||||
},
|
||||
},
|
||||
referrerPolicy: { policy: 'strict-origin-when-cross-origin' },
|
||||
});
|
||||
app.addHook('onSend', async (_req, reply) => {
|
||||
reply.header('Permissions-Policy', 'camera=(), microphone=(), geolocation=(), payment=(), usb=(), fullscreen=(self)');
|
||||
});
|
||||
|
||||
// --- API ---------------------------------------------------------------------
|
||||
// Inloggen, rollen, gebruikersbeheer en per-gebruiker data. Zie src/api.js.
|
||||
app.register(api, { prefix: '/api' });
|
||||
|
|
|
|||
61
test/api-security.test.js
Normal file
61
test/api-security.test.js
Normal file
|
|
@ -0,0 +1,61 @@
|
|||
import test from 'node:test';
|
||||
import assert from 'node:assert/strict';
|
||||
import Fastify from 'fastify';
|
||||
import cookie from '@fastify/cookie';
|
||||
import rateLimit from '@fastify/rate-limit';
|
||||
import api from '../src/api.js';
|
||||
import { hashPassword } from '../src/auth.js';
|
||||
|
||||
async function makeApp() {
|
||||
const passwordHash = await hashPassword('Veilig123');
|
||||
const calls = [];
|
||||
const pool = {
|
||||
async query(sql, params=[]) {
|
||||
calls.push({sql, params});
|
||||
if (sql.includes('FROM users WHERE school_id = $1')) return {rows:[{
|
||||
id: 7, username: 'docent', display_name: 'Docent', role: 'teacher',
|
||||
school_id: 2, class_id: null, password_hash: passwordHash, data: {},
|
||||
}]};
|
||||
return {rows:[]};
|
||||
},
|
||||
};
|
||||
const app = Fastify({trustProxy: 2});
|
||||
await app.register(cookie);
|
||||
await app.register(rateLimit, {global:false});
|
||||
app.decorate('pg', pool);
|
||||
await app.register(api, {prefix:'/api'});
|
||||
await app.ready();
|
||||
return {app, calls};
|
||||
}
|
||||
|
||||
test('login zet HttpOnly-cookie en bewaart alleen een tokenhash', async () => {
|
||||
const oldNodeEnv = process.env.NODE_ENV;
|
||||
process.env.NODE_ENV = 'production';
|
||||
const {app, calls} = await makeApp();
|
||||
const res = await app.inject({method:'POST', url:'/api/auth/login', payload:{
|
||||
schoolId:2, username:'docent', password:'Veilig123',
|
||||
}});
|
||||
assert.equal(res.statusCode, 200, res.body);
|
||||
assert.equal('token' in res.json(), false);
|
||||
const setCookie = res.headers['set-cookie'];
|
||||
assert.match(setCookie, /teach_session=/);
|
||||
assert.match(setCookie, /HttpOnly/);
|
||||
assert.match(setCookie, /Secure/);
|
||||
assert.match(setCookie, /SameSite=Strict/);
|
||||
const rawToken = /teach_session=([^;]+)/.exec(setCookie)[1];
|
||||
const insert = calls.find(c=>c.sql.includes('INSERT INTO sessions'));
|
||||
assert.ok(insert);
|
||||
assert.notEqual(insert.params[0], rawToken);
|
||||
assert.match(insert.params[0], /^[a-f0-9]{64}$/);
|
||||
await app.close();
|
||||
if (oldNodeEnv === undefined) delete process.env.NODE_ENV; else process.env.NODE_ENV = oldNodeEnv;
|
||||
});
|
||||
|
||||
test('state-changing request met vreemde Origin wordt geweigerd', async () => {
|
||||
const {app} = await makeApp();
|
||||
const res = await app.inject({method:'POST', url:'/api/auth/logout', headers:{
|
||||
origin:'https://aanvaller.example', host:'teach.example',
|
||||
}, payload:{}});
|
||||
assert.equal(res.statusCode, 403);
|
||||
await app.close();
|
||||
});
|
||||
36
test/auth.test.js
Normal file
36
test/auth.test.js
Normal file
|
|
@ -0,0 +1,36 @@
|
|||
import test from 'node:test';
|
||||
import assert from 'node:assert/strict';
|
||||
import {
|
||||
hashPassword, verifyPassword, newToken, hashSessionToken,
|
||||
pupilPassword, staffPassword, checkPassword, sessionDaysForRole,
|
||||
} from '../src/auth.js';
|
||||
|
||||
test('wachtwoordhash verifieert alleen het juiste wachtwoord', async () => {
|
||||
const hash = await hashPassword('Veilig123');
|
||||
assert.equal(await verifyPassword('Veilig123', hash), true);
|
||||
assert.equal(await verifyPassword('verkeerd', hash), false);
|
||||
assert.equal(hash.includes('Veilig123'), false);
|
||||
});
|
||||
|
||||
test('sessietokens zijn sterk en worden gehasht opgeslagen', () => {
|
||||
const token = newToken();
|
||||
assert.match(token, /^[a-f0-9]{64}$/);
|
||||
assert.match(hashSessionToken(token), /^[a-f0-9]{64}$/);
|
||||
assert.notEqual(hashSessionToken(token), token);
|
||||
});
|
||||
|
||||
test('gegenereerde wachtwoorden voldoen aan het beleid', () => {
|
||||
const pupil = pupilPassword();
|
||||
const staff = staffPassword();
|
||||
assert.equal(checkPassword(pupil, 'pupil'), null);
|
||||
assert.equal(checkPassword(staff, 'teacher'), null);
|
||||
assert.match(pupil, /^[a-z]+-[a-z]+-[a-z]+-\d{3}$/);
|
||||
assert.ok(staff.length >= 14);
|
||||
});
|
||||
|
||||
test('sessieduur wordt korter bij meer privileges', () => {
|
||||
assert.equal(sessionDaysForRole('super'), 1);
|
||||
assert.equal(sessionDaysForRole('admin'), 1);
|
||||
assert.equal(sessionDaysForRole('teacher'), 7);
|
||||
assert.equal(sessionDaysForRole('pupil'), 30);
|
||||
});
|
||||
21
test/security-config.test.js
Normal file
21
test/security-config.test.js
Normal file
|
|
@ -0,0 +1,21 @@
|
|||
import test from 'node:test';
|
||||
import assert from 'node:assert/strict';
|
||||
import { readFile } from 'node:fs/promises';
|
||||
|
||||
test('productieconfig gebruikt secure cookies en begrensde proxy trust', async () => {
|
||||
const compose = await readFile('deploy/compose.deploy.yaml', 'utf8');
|
||||
assert.match(compose, /COOKIE_SECURE: "true"/);
|
||||
assert.match(compose, /TRUST_PROXY_HOPS:/);
|
||||
assert.match(compose, /WEB_BIND_IP:-127\.0\.0\.1/);
|
||||
});
|
||||
|
||||
test('nginx behoudt het publieke forwarded protocol', async () => {
|
||||
const nginx = await readFile('deploy/nginx.conf', 'utf8');
|
||||
assert.match(nginx, /http_x_forwarded_proto/);
|
||||
assert.match(nginx, /X-Forwarded-Proto \$teach_forwarded_proto/);
|
||||
});
|
||||
|
||||
test('client bewaart geen bearer-token meer', async () => {
|
||||
const core = await readFile('public/js/core.js', 'utf8');
|
||||
assert.doesNotMatch(core, /headers\.Authorization|localStorage\.setItem\("teach\.token"/);
|
||||
});
|
||||
Loading…
Reference in a new issue