Add more security and audit
This commit is contained in:
@@ -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
89
SECURITY.md
Normal 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
|
||||||
@@ -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 . .
|
||||||
|
|||||||
160
backend/app.py
160
backend/app.py
@@ -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)
|
||||||
|
|||||||
@@ -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:
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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
|
||||||
|
|
||||||
|
|||||||
@@ -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()
|
||||||
|
|
||||||
|
|||||||
@@ -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:
|
||||||
|
|||||||
@@ -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.
|
||||||
|
|||||||
123
nginx/nginx.conf
123
nginx/nginx.conf
@@ -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;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user