Add more security and audit
Some checks failed
Build, Push & Deploy / Build & Push image (push) Failing after 56s
Build, Push & Deploy / Deploy naar VPS (push) Has been skipped
Build & Push / Build & Push image (push) Successful in 1m2s

This commit is contained in:
2026-02-28 14:47:33 +01:00
parent db87ea447a
commit 07bcfede75
11 changed files with 386 additions and 76 deletions

View File

@@ -30,6 +30,10 @@ MICROSOFT_CLIENT_SECRET=xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx
# Naam van de scholengroep — verschijnt op de loginpagina # Naam van de scholengroep — verschijnt op de loginpagina
ORG_NAME=GO! Scholengroep 2 ORG_NAME=GO! Scholengroep 2
# Redis wachtwoord — beschermt de rate limiter state
# Genereer met: python3 -c "import secrets; print(secrets.token_hex(24))"
REDIS_PASSWORD=verander_dit_redis_wachtwoord
# Docker image uit de Gitea registry (wordt ingevuld door CI/CD) # Docker image uit de Gitea registry (wordt ingevuld door CI/CD)
# Lokaal builden: laat leeg of zet op 'leerdoelen-backend:local' # Lokaal builden: laat leeg of zet op 'leerdoelen-backend:local'
BACKEND_IMAGE=gitea.jouwdomein.be/jouw-org/leerdoelen-tracker:latest BACKEND_IMAGE=gitea.jouwdomein.be/jouw-org/leerdoelen-tracker:latest

89
SECURITY.md Normal file
View File

@@ -0,0 +1,89 @@
# Security beleid
## Kwetsbaarheden melden
Gevonden een beveiligingsprobleem? Stuur een e-mail naar de systeembeheerder van jouw scholengroep.
Voeg zo veel mogelijk detail toe: stappen om te reproduceren, impact, en eventueel een proof-of-concept.
Publiceer kwetsbaarheden **niet** publiek voordat ze zijn opgelost.
---
## Beveiligingsmaatregelen in deze applicatie
### Authenticatie
- Primaire login via Microsoft Entra ID (Azure AD) — geen wachtwoorden opgeslagen voor gewone gebruikers
- Superadmin wachtwoord gehasht met **scrypt** (sterk geheugenintensief algoritme)
- OAuth2 state parameter validatie — beschermt tegen CSRF in OAuth flow
- `?next=` redirect parameter gevalideerd — beschermt tegen open redirect aanvallen
- Session cookies: `HttpOnly`, `Secure` (HTTPS), `SameSite=Lax`
### Rate limiting
| Endpoint | Limiet |
|----------|--------|
| Alle `/auth/*` routes | 10 per minuut per IP |
| Superadmin login | 10/min + 30/uur per IP |
| API endpoints | 120 per minuut per IP |
| Doelen upload | 5 per minuut per IP |
| Setup endpoint | 5 per minuut per IP |
Rate limiting via **Redis** (persistent over meerdere workers).
Nginx voegt een extra laag rate limiting toe vóór Flask.
### HTTP Security headers
Via Flask-Talisman + Nginx:
- `Content-Security-Policy` — nonce-based, geen unsafe-inline scripts
- `Strict-Transport-Security` — HSTS 1 jaar, incl. subdomains
- `X-Frame-Options: DENY` — clickjacking preventie
- `X-Content-Type-Options: nosniff`
- `Referrer-Policy: strict-origin-when-cross-origin`
- `Permissions-Policy` — geen toegang tot camera/microfoon/locatie
- `form-action: 'self'` — voorkomt form hijacking
- `base-uri: 'self'` — voorkomt base tag injection
- `object-src: 'none'` — geen Flash of plugins
### Autorisatie
- Rolgebaseerde toegangscontrole (superadmin → scholengroep_ict → school_ict → director → teacher)
- Elke API route heeft expliciete rolauthenticatie decorator
- School-isolatie: gebruikers kunnen enkel data van hun eigen school zien
- Auditlog van alle beheerhandelingen
### Database
- Parameterized queries via SQLAlchemy ORM — geen raw SQL met gebruikersinput
- Non-root database gebruiker
- PostgreSQL container niet publiek blootgesteld (intern Docker netwerk)
### Infrastructuur
- Flask draait als non-root gebruiker (`appuser`) in Docker container
- Read-only volume mount voor doelen JSON bestanden
- Redis beveiligd met wachtwoord
- Backend enkel bereikbaar via `127.0.0.1` (niet publiek)
- Nginx als reverse proxy met request size limiting en timeouts (Slowloris bescherming)
---
## Dependency updates
Controleer regelmatig op kwetsbaarheden in dependencies:
```bash
pip install pip-audit
pip-audit -r backend/requirements.txt
```
Python base image: pin op specifieke patch versie in `Dockerfile`.
Controleer updates op: https://hub.docker.com/_/python
---
## Checklist voor nieuwe deployment
- [ ] `SECRET_KEY` gegenereerd met `python3 -c "import secrets; print(secrets.token_hex(32))"`
- [ ] `POSTGRES_PASSWORD` sterk en uniek
- [ ] `REDIS_PASSWORD` ingesteld
- [ ] `BASE_URL` correct ingesteld op HTTPS URL
- [ ] SSL/TLS certificaat aanwezig (Let's Encrypt via Certbot)
- [ ] Microsoft Entra ID app registratie correct geconfigureerd
- [ ] Superadmin wachtwoord ingesteld via `/auth/setup` (min. 12 tekens)
- [ ] `/auth/setup` endpoint niet meer toegankelijk na setup (wordt automatisch geblokkeerd)
- [ ] Firewall: enkel poorten 80 en 443 publiek open

View File

@@ -1,4 +1,7 @@
FROM python:3.12-slim # Pin op een specifieke patch versie voor reproduceerbare builds.
# Controleer regelmatig op https://hub.docker.com/_/python voor updates.
# Bij elke Python security patch: versienummer hier bijwerken + opnieuw builden.
FROM python:3.12.9-slim
WORKDIR /app WORKDIR /app
@@ -8,9 +11,10 @@ RUN apt-get update && apt-get install -y --no-install-recommends \
gcc \ gcc \
&& rm -rf /var/lib/apt/lists/* && rm -rf /var/lib/apt/lists/*
# Python dependencies # Python dependencies — upgrade pip zelf ook voor security fixes
COPY requirements.txt . COPY requirements.txt .
RUN pip install --no-cache-dir -r requirements.txt RUN pip install --no-cache-dir --upgrade pip && \
pip install --no-cache-dir -r requirements.txt
# App code + entrypoint (chmod als root, vóór USER switch) # App code + entrypoint (chmod als root, vóór USER switch)
COPY . . COPY . .

View File

@@ -1,65 +1,168 @@
import os import os
from flask import Flask import logging
from flask import Flask, jsonify, request
from flask_sqlalchemy import SQLAlchemy from flask_sqlalchemy import SQLAlchemy
from flask_login import LoginManager from flask_login import LoginManager
from flask_migrate import Migrate from flask_migrate import Migrate
from flask_limiter import Limiter
from flask_limiter.util import get_remote_address
from flask_talisman import Talisman
from werkzeug.middleware.proxy_fix import ProxyFix from werkzeug.middleware.proxy_fix import ProxyFix
db = SQLAlchemy() logger = logging.getLogger(__name__)
db = SQLAlchemy()
login_manager = LoginManager() login_manager = LoginManager()
migrate = Migrate() migrate = Migrate()
limiter = Limiter(key_func=get_remote_address, default_limits=[])
def create_app(): def create_app():
app = Flask(__name__, template_folder='templates', static_folder='static') app = Flask(__name__, template_folder='templates', static_folder='static')
# Config # ── Config ────────────────────────────────────────────────────────────────
app.config['SECRET_KEY'] = os.environ['SECRET_KEY'] app.config['SECRET_KEY'] = os.environ['SECRET_KEY']
app.config['SQLALCHEMY_DATABASE_URI'] = os.environ['DATABASE_URL'] app.config['SQLALCHEMY_DATABASE_URI'] = os.environ['DATABASE_URL']
app.config['SQLALCHEMY_TRACK_MODIFICATIONS'] = False app.config['SQLALCHEMY_TRACK_MODIFICATIONS'] = False
app.config['BASE_URL'] = os.environ.get('BASE_URL', 'http://localhost') app.config['BASE_URL'] = os.environ.get('BASE_URL', 'http://localhost')
app.config['ORG_NAME'] = os.environ.get('ORG_NAME', 'GO! Scholengroep') app.config['ORG_NAME'] = os.environ.get('ORG_NAME', 'GO! Scholengroep')
# OAuth2 config (voor later) # OAuth2
app.config['MICROSOFT_CLIENT_ID'] = os.environ.get('MICROSOFT_CLIENT_ID') app.config['MICROSOFT_CLIENT_ID'] = os.environ.get('MICROSOFT_CLIENT_ID')
app.config['MICROSOFT_CLIENT_SECRET'] = os.environ.get('MICROSOFT_CLIENT_SECRET') app.config['MICROSOFT_CLIENT_SECRET'] = os.environ.get('MICROSOFT_CLIENT_SECRET')
app.config['MICROSOFT_TENANT_ID'] = os.environ.get('MICROSOFT_TENANT_ID', 'common') app.config['MICROSOFT_TENANT_ID'] = os.environ.get('MICROSOFT_TENANT_ID', 'common')
app.config['GOOGLE_CLIENT_ID'] = os.environ.get('GOOGLE_CLIENT_ID')
app.config['GOOGLE_CLIENT_SECRET'] = os.environ.get('GOOGLE_CLIENT_SECRET')
# ProxyFix: Flask zit achter nginx als reverse proxy. # Session cookie beveiliging
# x_for=1, x_proto=1 zorgt dat Flask de echte client IP en https ziet. is_https = app.config['BASE_URL'].startswith('https')
app.config['SESSION_COOKIE_SECURE'] = is_https
app.config['SESSION_COOKIE_HTTPONLY'] = True
app.config['SESSION_COOKIE_SAMESITE'] = 'Lax' # Lax ipv Strict: compatibel met OAuth redirect
app.config['REMEMBER_COOKIE_SECURE'] = is_https
app.config['REMEMBER_COOKIE_HTTPONLY'] = True
app.config['REMEMBER_COOKIE_SAMESITE'] = 'Lax'
app.config['REMEMBER_COOKIE_DURATION'] = 86400 * 8 # 8 dagen max (was: onbeperkt)
# ── Rate limit handler ───────────────────────────────────────────────────
def _rate_limit_handler(e):
logger.warning(
f"Rate limit overschreden: {request.method} {request.path} "
f"van {request.remote_addr}"
)
return jsonify({
'error': 'Te veel verzoeken. Probeer later opnieuw.',
'retry_after': e.retry_after,
}), 429
# ── ProxyFix (Flask zit achter nginx) ────────────────────────────────────
app.wsgi_app = ProxyFix(app.wsgi_app, x_for=1, x_proto=1, x_host=1) app.wsgi_app = ProxyFix(app.wsgi_app, x_for=1, x_proto=1, x_host=1)
# Extensions # ── Rate limiter ──────────────────────────────────────────────────────────
redis_url = os.environ.get('REDIS_URL', '')
if not redis_url:
logger.warning(
"REDIS_URL niet ingesteld — rate limiter gebruikt in-memory storage. "
"Dit werkt NIET correct met meerdere gunicorn workers! "
"Stel REDIS_URL in voor productie."
)
redis_url = 'memory://'
limiter.init_app(
app,
storage_uri=redis_url,
strategy='fixed-window-elastic-expiry', # robuuster dan fixed-window
on_breach=_rate_limit_handler,
)
# ── Security headers via Talisman ─────────────────────────────────────────
# CSP: strikte whitelist — geen inline scripts, geen externe resources buiten cdnjs
# CSP: nonce-based voor scripts (Talisman injecteert {{ csp_nonce() }} in templates)
# unsafe-inline is uitgeschakeld voor scripts — gebruik {{ csp_nonce() }} in <script> tags
csp = {
'default-src': ["'self'"],
'script-src': ["'self'", 'cdnjs.cloudflare.com'], # nonce wordt auto toegevoegd
'style-src': ["'self'", "'unsafe-inline'"], # inline styles in templates (aanvaardbaar)
'img-src': ["'self'", 'data:'],
'font-src': ["'self'"],
'connect-src': ["'self'"],
'form-action': ["'self'"], # voorkomt form hijacking
'base-uri': ["'self'"], # voorkomt base tag injection
'frame-ancestors': ["'none'"], # clickjacking preventie
'object-src': ["'none'"], # geen Flash/plugins
}
Talisman(
app,
force_https=is_https,
strict_transport_security=is_https,
strict_transport_security_max_age=31536000, # 1 jaar HSTS
strict_transport_security_include_subdomains=True,
strict_transport_security_preload=False, # alleen aanvragen als je zeker bent
content_security_policy=csp,
content_security_policy_nonce_in=['script-src'], # nonce auto toegevoegd aan script tags
x_content_type_options=True,
x_frame_options='DENY',
referrer_policy='strict-origin-when-cross-origin',
feature_policy={ # moderne vervanger van Permissions-Policy
'geolocation': ''none'',
'microphone': ''none'',
'camera': ''none'',
}
)
# ── Extensions ────────────────────────────────────────────────────────────
db.init_app(app) db.init_app(app)
migrate.init_app(app, db) migrate.init_app(app, db)
login_manager.init_app(app) login_manager.init_app(app)
login_manager.login_view = 'auth.login' login_manager.login_view = 'auth.login'
login_manager.login_message = 'Gelieve in te loggen.' login_manager.login_message = 'Gelieve in te loggen.'
# Import models (zodat Flask-Migrate ze kent) # Import models zodat Flask-Migrate ze kent
from models import User, School, SchoolYear, Class, TeacherClass, Assessment from models import User, School, SchoolYear, Class, TeacherClass, Assessment, AuditLog
@login_manager.user_loader @login_manager.user_loader
def load_user(user_id): def load_user(user_id):
return User.query.get(int(user_id)) return User.query.get(int(user_id))
# Blueprints registreren @login_manager.unauthorized_handler
from routes.auth import auth_bp def unauthorized():
from routes.api import api_bp if request.is_json or request.path.startswith('/api/') or request.path.startswith('/admin/'):
return jsonify({'error': 'Niet ingelogd'}), 401
from flask import redirect, url_for
return redirect(url_for('auth.login'))
# ── Globale error handlers ───────────────────────────────────────────────
from flask_limiter.errors import RateLimitExceeded
@app.errorhandler(RateLimitExceeded)
def handle_rate_limit(e):
return _rate_limit_handler(e)
@app.errorhandler(404)
def not_found(e):
if request.is_json or request.path.startswith(('/api/', '/admin/')):
return jsonify({'error': 'Niet gevonden'}), 404
from flask import redirect, url_for
return redirect(url_for('auth.login'))
@app.errorhandler(500)
def server_error(e):
logger.error(f"500 fout: {e}", exc_info=True)
if request.is_json or request.path.startswith(('/api/', '/admin/')):
return jsonify({'error': 'Serverfout — probeer later opnieuw'}), 500
return redirect(url_for('auth.login'))
# ── Blueprints ────────────────────────────────────────────────────────────
from routes.auth import auth_bp
from routes.api import api_bp
from routes.admin import admin_bp from routes.admin import admin_bp
from routes.pages import pages_bp from routes.pages import pages_bp
app.register_blueprint(auth_bp, url_prefix='/auth') app.register_blueprint(auth_bp, url_prefix='/auth')
app.register_blueprint(api_bp, url_prefix='/api') app.register_blueprint(api_bp, url_prefix='/api')
app.register_blueprint(admin_bp, url_prefix='/admin') app.register_blueprint(admin_bp, url_prefix='/admin')
app.register_blueprint(pages_bp) app.register_blueprint(pages_bp)
# ── Auditlog cleanup (1 jaar bewaren) ───────────────────────────────────── # ── CLI commando's ────────────────────────────────────────────────────────
@app.cli.command('cleanup-audit') @app.cli.command('cleanup-audit')
def cleanup_audit(): def cleanup_audit():
"""Verwijder auditlog entries ouder dan 1 jaar. Voer uit via cron of handmatig.""" """Verwijder auditlog entries ouder dan 1 jaar."""
from models import AuditLog from models import AuditLog
from datetime import datetime, timedelta from datetime import datetime, timedelta
cutoff = datetime.utcnow() - timedelta(days=365) cutoff = datetime.utcnow() - timedelta(days=365)
@@ -73,4 +176,5 @@ def create_app():
app = create_app() app = create_app()
if __name__ == '__main__': if __name__ == '__main__':
app.run(debug=True, host='0.0.0.0', port=5000) # Nooit debug=True in productie — gebruik gunicorn via entrypoint.sh
app.run(debug=False, host='127.0.0.1', port=5000)

View File

@@ -106,7 +106,8 @@ class User(UserMixin, db.Model):
def is_teacher(self): return self.role == 'teacher' def is_teacher(self): return self.role == 'teacher'
def set_password(self, password): def set_password(self, password):
self.password_hash = generate_password_hash(password) # scrypt is het sterkste algoritme in werkzeug — veel meer weerstand tegen brute force
self.password_hash = generate_password_hash(password, method='scrypt')
def check_password(self, password): def check_password(self, password):
if not self.password_hash: if not self.password_hash:

View File

@@ -1,12 +1,14 @@
flask==3.0.3 flask==3.1.1
flask-login==0.6.3 flask-login==0.6.3
flask-sqlalchemy==3.1.1 flask-sqlalchemy==3.1.1
flask-migrate==4.0.7 flask-migrate==4.1.0
flask-cors==4.0.1 flask-limiter==3.9.0 # rate limiting
psycopg2-binary==2.9.9 flask-talisman==1.1.0 # security headers (CSP, HSTS, X-Frame-Options, ...)
gunicorn==22.0.0 psycopg2-binary==2.9.10
python-dotenv==1.0.1 gunicorn==23.0.0
werkzeug==3.0.3 python-dotenv==1.1.0
sqlalchemy==2.0.31 werkzeug==3.1.3
authlib==1.3.1 # OAuth2 (Microsoft + Google, later) sqlalchemy==2.0.41
authlib==1.4.1
requests==2.32.3 requests==2.32.3
redis==5.2.1 # backend voor flask-limiter in productie

View File

@@ -303,8 +303,7 @@ def add_scholengroep_ict():
db.session.add(user) db.session.add(user)
db.session.flush() db.session.flush()
audit_log('user.create', 'user', target_type='user', target_id=str(user.id), audit_log('user.create', 'user', target_type='user', target_id=str(user.id),
detail={'email': email, 'role': user.role, 'school_id': school_id}, detail={'email': email, 'role': user.role})
school_id=school_id)
db.session.commit() db.session.commit()
return jsonify({'user': user.to_dict()}), 201 return jsonify({'user': user.to_dict()}), 201

View File

@@ -2,10 +2,10 @@ from datetime import datetime
from flask import Blueprint, jsonify, request from flask import Blueprint, jsonify, request
from flask_login import login_required, current_user from flask_login import login_required, current_user
from models import User, SchoolYear, Assessment, School, Class, AuditLog from models import User, SchoolYear, Assessment, School, Class, AuditLog
from app import db
from services.doelen import load_index, load_vak, is_valid_vak_id from services.doelen import load_index, load_vak, is_valid_vak_id
from services.audit import audit_log from services.audit import audit_log
from functools import wraps from functools import wraps
from app import db, limiter
api_bp = Blueprint('api', __name__) api_bp = Blueprint('api', __name__)
@@ -71,6 +71,7 @@ def get_assessments():
@api_bp.route('/assessments', methods=['POST']) @api_bp.route('/assessments', methods=['POST'])
@login_required @login_required
@limiter.limit('120 per minute') # max 2 per seconde per gebruiker
def save_assessment(): def save_assessment():
data = request.get_json() or {} data = request.get_json() or {}
vak_id = (data.get('vak_id') or '').strip() vak_id = (data.get('vak_id') or '').strip()
@@ -81,6 +82,9 @@ def save_assessment():
return jsonify({'error': 'vak_id en goal_id zijn verplicht'}), 400 return jsonify({'error': 'vak_id en goal_id zijn verplicht'}), 400
if status not in ('groen', 'oranje', 'roze', ''): if status not in ('groen', 'oranje', 'roze', ''):
return jsonify({'error': 'Ongeldige status — gebruik groen, oranje, roze of leeg'}), 400 return jsonify({'error': 'Ongeldige status — gebruik groen, oranje, roze of leeg'}), 400
# Sanitiseer input — voorkomt oversized data in DB
if len(vak_id) > 100 or len(goal_id) > 50:
return jsonify({'error': 'Ongeldige invoer'}), 400
if not current_user.school_id: if not current_user.school_id:
return jsonify({'error': 'Account is nog niet gekoppeld aan een school'}), 400 return jsonify({'error': 'Account is nog niet gekoppeld aan een school'}), 400
@@ -283,8 +287,8 @@ def get_audit_log():
if not current_user.is_school_ict: if not current_user.is_school_ict:
return jsonify({'error': 'Geen toegang'}), 403 return jsonify({'error': 'Geen toegang'}), 403
page = int(request.args.get('page', 1)) page = max(1, int(request.args.get('page', 1)))
per_page = int(request.args.get('per_page', 50)) per_page = min(100, max(1, int(request.args.get('per_page', 50)))) # max 100 per pagina
category = request.args.get('category') category = request.args.get('category')
search = request.args.get('search', '').strip() search = request.args.get('search', '').strip()

View File

@@ -15,19 +15,33 @@ import os
import secrets import secrets
import logging import logging
from datetime import datetime from datetime import datetime
from urllib.parse import urlencode from urllib.parse import urlencode, urlparse
import requests import requests
from services.audit import audit_log from services.audit import audit_log
from app import db, limiter
from flask import (Blueprint, render_template, request, redirect, from flask import (Blueprint, render_template, request, redirect,
url_for, flash, jsonify, session, current_app) url_for, flash, jsonify, session, current_app)
from flask_login import login_user, logout_user, login_required, current_user from flask_login import login_user, logout_user, login_required, current_user
from models import User, School from models import User, School
from app import db
logger = logging.getLogger(__name__) logger = logging.getLogger(__name__)
auth_bp = Blueprint('auth', __name__) auth_bp = Blueprint('auth', __name__)
def _safe_next_url(next_url: str | None) -> str:
"""Valideer dat de next-redirect intern blijft — voorkomt open redirect aanvallen."""
if not next_url:
return url_for('pages.dashboard')
parsed = urlparse(next_url)
# Weiger externe URLs (heeft netloc) of protocol-relative URLs
if parsed.netloc or parsed.scheme:
return url_for('pages.dashboard')
# Zorg dat het pad begint met /
if not next_url.startswith('/'):
return url_for('pages.dashboard')
return next_url
ENTRA_AUTHORITY = "https://login.microsoftonline.com/common" ENTRA_AUTHORITY = "https://login.microsoftonline.com/common"
ENTRA_AUTH_URL = f"{ENTRA_AUTHORITY}/oauth2/v2.0/authorize" ENTRA_AUTH_URL = f"{ENTRA_AUTHORITY}/oauth2/v2.0/authorize"
ENTRA_TOKEN_URL = f"{ENTRA_AUTHORITY}/oauth2/v2.0/token" ENTRA_TOKEN_URL = f"{ENTRA_AUTHORITY}/oauth2/v2.0/token"
@@ -117,6 +131,7 @@ def logout():
@auth_bp.route('/microsoft') @auth_bp.route('/microsoft')
@limiter.limit('20 per minute')
def microsoft_login(): def microsoft_login():
if not _entra_client_id(): if not _entra_client_id():
flash('Microsoft login is niet geconfigureerd.', 'error') flash('Microsoft login is niet geconfigureerd.', 'error')
@@ -138,6 +153,7 @@ def microsoft_login():
@auth_bp.route('/callback') @auth_bp.route('/callback')
@limiter.limit('20 per minute')
def microsoft_callback(): def microsoft_callback():
error = request.args.get('error') error = request.args.get('error')
if error: if error:
@@ -220,10 +236,11 @@ def microsoft_callback():
db.session.commit() db.session.commit()
logger.info(f"Entra login: {email} (nieuw: {is_new}, school_id: {user.school_id})") logger.info(f"Entra login: {email} (nieuw: {is_new}, school_id: {user.school_id})")
return redirect(request.args.get('next') or url_for('pages.dashboard')) return redirect(_safe_next_url(request.args.get('next')))
@auth_bp.route('/setup', methods=['GET', 'POST']) @auth_bp.route('/setup', methods=['GET', 'POST'])
@limiter.limit('5 per minute')
def setup(): def setup():
admin = User.query.filter_by(role='superadmin').first() admin = User.query.filter_by(role='superadmin').first()
if admin and admin.password_hash: if admin and admin.password_hash:
@@ -252,7 +269,7 @@ def setup():
first_name='Super', last_name='Admin') first_name='Super', last_name='Admin')
db.session.add(admin) db.session.add(admin)
admin.set_password(password) admin.set_password(password) # pbkdf2:sha256 — zie models.py voor hash methode
db.session.commit() db.session.commit()
if request.is_json: if request.is_json:
@@ -264,6 +281,7 @@ def setup():
@auth_bp.route('/superadmin-login', methods=['POST']) @auth_bp.route('/superadmin-login', methods=['POST'])
@limiter.limit('10 per minute; 30 per hour')
def superadmin_login(): def superadmin_login():
"""Fallback login ENKEL voor de superadmin — niet voor gewone gebruikers.""" """Fallback login ENKEL voor de superadmin — niet voor gewone gebruikers."""
if current_user.is_authenticated: if current_user.is_authenticated:

View File

@@ -18,6 +18,17 @@ services:
timeout: 5s timeout: 5s
retries: 5 retries: 5
redis:
image: redis:7-alpine
container_name: leerdoelen_redis
restart: unless-stopped
command: redis-server --save "" --appendonly no --maxmemory 64mb --maxmemory-policy allkeys-lru --requirepass ${REDIS_PASSWORD:-changeme_redis}
healthcheck:
test: ["CMD", "redis-cli", "-a", "${REDIS_PASSWORD:-changeme_redis}", "ping"]
interval: 10s
timeout: 3s
retries: 3
backend: backend:
# In productie: image uit de Gitea registry (gezet door CI/CD pipeline) # In productie: image uit de Gitea registry (gezet door CI/CD pipeline)
# Lokaal ontwikkelen: verander naar 'build: ./backend' # Lokaal ontwikkelen: verander naar 'build: ./backend'
@@ -40,6 +51,7 @@ services:
GOOGLE_CLIENT_SECRET: ${GOOGLE_CLIENT_SECRET:-} GOOGLE_CLIENT_SECRET: ${GOOGLE_CLIENT_SECRET:-}
BASE_URL: ${BASE_URL:-http://localhost} BASE_URL: ${BASE_URL:-http://localhost}
ORG_NAME: ${ORG_NAME:-GO! Scholengroep} ORG_NAME: ${ORG_NAME:-GO! Scholengroep}
REDIS_URL: redis://:${REDIS_PASSWORD:-changeme_redis}@redis:6379/0
volumes: volumes:
- ./doelen:/app/doelen:ro # JSON doelen bestanden (read-only) - ./doelen:/app/doelen:ro # JSON doelen bestanden (read-only)
ports: ports:
@@ -47,6 +59,8 @@ services:
depends_on: depends_on:
db: db:
condition: service_healthy condition: service_healthy
redis:
condition: service_healthy
# Nginx container verwijderd — SSL offloading gebeurt door de host nginx. # Nginx container verwijderd — SSL offloading gebeurt door de host nginx.
# Flask is bereikbaar op 127.0.0.1:${APP_PORT} van de host. # Flask is bereikbaar op 127.0.0.1:${APP_PORT} van de host.

View File

@@ -8,56 +8,127 @@ http {
# Logging # Logging
access_log /var/log/nginx/access.log; access_log /var/log/nginx/access.log;
error_log /var/log/nginx/error.log; error_log /var/log/nginx/error.log warn;
# Verberg nginx versie
server_tokens off;
# Gzip # Gzip
gzip on; gzip on;
gzip_types text/plain text/css application/json application/javascript text/xml application/xml; gzip_types text/plain text/css application/json application/javascript text/xml application/xml;
gzip_min_length 1000;
# Rate limiting # ── Rate limiting zones ────────────────────────────────────────────────────
limit_req_zone $binary_remote_addr zone=login:10m rate=10r/m; # Auth endpoints: login, OAuth flow, superadmin — streng
limit_req_zone $binary_remote_addr zone=api:10m rate=60r/m; limit_req_zone $binary_remote_addr zone=auth:10m rate=10r/m;
# API endpoints — soepeler voor normale interactie
limit_req_zone $binary_remote_addr zone=api:10m rate=120r/m;
# Upload endpoint — apart beperkt
limit_req_zone $binary_remote_addr zone=upload:10m rate=5r/m;
# Algemeen — vangt alles op wat niet specifiek gelimiteerd is
limit_req_zone $binary_remote_addr zone=general:10m rate=60r/m;
# Geef 429 terug bij rate limit (ipv standaard 503)
limit_req_status 429;
upstream flask { upstream flask {
server backend:5000; server backend:5000;
keepalive 32;
} }
server { server {
listen 80; listen 80;
server_name _; server_name _;
# Security headers # ── Security headers ───────────────────────────────────────────────────
add_header X-Frame-Options "SAMEORIGIN" always; # Talisman (Flask) voegt HSTS en CSP toe — nginx vult aan met de rest
add_header X-Content-Type-Options "nosniff" always; add_header X-Frame-Options "DENY" always;
add_header X-XSS-Protection "1; mode=block" always; add_header X-Content-Type-Options "nosniff" always;
add_header X-XSS-Protection "1; mode=block" always;
add_header Referrer-Policy "strict-origin-when-cross-origin" always;
add_header Permissions-Policy "geolocation=(), microphone=(), camera=()" always;
# Maximale upload grootte (voor doelen JSON bestanden)
client_max_body_size 10M; client_max_body_size 10M;
# Rate limiting op login # Timeouts — voorkomt Slowloris aanvallen
location /auth/login { client_body_timeout 12s;
limit_req zone=login burst=5 nodelay; client_header_timeout 12s;
proxy_pass http://flask; send_timeout 10s;
proxy_set_header Host $host; keepalive_timeout 65s;
proxy_set_header X-Real-IP $remote_addr;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; # ── Auth endpoints — strenge rate limiting ─────────────────────────────
# Dekt login, OAuth start, OAuth callback en superadmin login
location ~ ^/auth/ {
limit_req zone=auth burst=8 nodelay;
limit_req_log_level warn;
proxy_pass http://flask;
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_http_version 1.1;
proxy_set_header Connection "";
} }
# Rate limiting op API # ── Upload endpoint — extra streng ─────────────────────────────────────
location /admin/doelen/upload {
limit_req zone=upload burst=2 nodelay;
client_max_body_size 50M; # grotere bestanden toegestaan hier
proxy_pass http://flask;
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_read_timeout 120s;
}
# ── API endpoints ──────────────────────────────────────────────────────
location /api/ { location /api/ {
limit_req zone=api burst=20 nodelay; limit_req zone=api burst=30 nodelay;
proxy_pass http://flask;
proxy_set_header Host $host; proxy_pass http://flask;
proxy_set_header X-Real-IP $remote_addr; proxy_set_header Host $host;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; 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_http_version 1.1;
proxy_set_header Connection "";
} }
# Alle andere requests # ── Admin endpoints ────────────────────────────────────────────────────
location /admin/ {
limit_req zone=general burst=20 nodelay;
proxy_pass http://flask;
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_http_version 1.1;
proxy_set_header Connection "";
}
# ── Alle andere requests ───────────────────────────────────────────────
location / { location / {
proxy_pass http://flask; limit_req zone=general burst=20 nodelay;
proxy_set_header Host $host;
proxy_set_header X-Real-IP $remote_addr; proxy_pass http://flask;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; 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_http_version 1.1;
proxy_set_header Connection "";
proxy_read_timeout 60s; proxy_read_timeout 60s;
} }
# ── Blokkeer toegang tot verborgen bestanden (.git, .env, ...) ─────────
location ~ /\. {
deny all;
return 404;
}
} }
} }