Files
Pivot/blueprints/auth.py
T
2026-10-14 12:29:29 +02:00

327 lines
14 KiB
Python

from flask import Blueprint, render_template, request, session, redirect, url_for, flash, current_app, make_response
from werkzeug.security import check_password_hash
import time
import functools
from authlib.integrations.flask_client import OAuth
from config import Config
from tenancy import (alle_scholen, school_op_code, school_voor_email, scholen_voor_email, school_voor_host, in_school,
huidige_school)
import rechten
from rechten import recht_vereist, ADMIN, GEBRUIKER
auth_bp = Blueprint('auth', __name__)
oauth = OAuth()
SCHOOL_COOKIE = 'pivot_school' # onthoudt de laatst gekozen school op dit toestel
GOOGLE_METADATA = 'https://accounts.google.com/.well-known/openid-configuration'
def _google_client(school=None):
"""
Google-login client: die van de school als die een eigen OAuth-client heeft,
anders de gedeelde client uit config.ini. Geeft (naam, client) of (None, None).
"""
if school and school.heeft('GOOGLE_SSO_CLIENTID', 'GOOGLE_SSO_CLIENTSECRET'):
naam = f"google_{school.code}_{''.join(c for c in school.versie if c.isdigit())}"
cid, secret = school.get('GOOGLE_SSO_CLIENTID'), school.get('GOOGLE_SSO_CLIENTSECRET')
elif Config.SSO_CLIENT_ID and Config.SSO_CLIENT_SECRET:
naam, cid, secret = 'google', Config.SSO_CLIENT_ID, Config.SSO_CLIENT_SECRET
else:
return None, None
client = oauth.create_client(naam)
if client is None:
client = oauth.register(name=naam, client_id=cid, client_secret=secret,
server_metadata_url=GOOGLE_METADATA,
client_kwargs={'scope': 'openid email profile'})
return naam, client
def gekozen_school():
"""School die vastligt via de hostnaam, of anders de laatst gekozen school (cookie)."""
return school_voor_host(request.host) or school_op_code(request.cookies.get(SCHOOL_COOKIE))
def _nummer(school):
"""Sorteersleutel: laagste officiële instellingsnummer eerst; scholen zonder nummer achteraan."""
nr = ''.join(c for c in str(school.get('INSTELLINGSNUMMER') or '') if c.isdigit())
return (0, int(nr), school.naam.lower()) if nr else (1, 0, school.naam.lower())
def standaard_school(scholen=None):
"""De school met het laagste instellingsnummer (voor wie nog nooit aangemeld was)."""
scholen = alle_scholen() if scholen is None else list(scholen)
return min(scholen, key=_nummer) if scholen else None
def kies_school(gebruiker=None, toegang=None, ook=()):
"""
De school die na het aanmelden geladen wordt (het aanmeldscherm vraagt geen school meer):
1) de laatst actieve school van de gebruiker,
2) voor een nieuwe gebruiker: de school van zijn e-maildomein (+ eventueel de scholen in `ook`),
3) de laatst gekozen school op dit toestel (cookie), 4) als laatste redmiddel: het laagste instellingsnummer.
Telkens enkel scholen waarvoor toegang(school) True is (als `toegang` gegeven is).
"""
ok = toegang or (lambda s: True)
domein = _volgens_domein((gebruiker or {}).get('Emailadres'))
for s in [school_op_code((gebruiker or {}).get('Laatste_school')), *domein, *ook,
school_op_code(request.cookies.get(SCHOOL_COOKIE))]:
if s and ok(s):
return s
return standaard_school(s for s in alle_scholen() if ok(s))
def _volgens_domein(email):
"""Scholen met het e-maildomein (een domein kan door meerdere scholen gedeeld worden): de laatst gekozen
school op dit toestel eerst, dan op instellingsnummer."""
lijst = sorted(scholen_voor_email(email), key=_nummer)
cookie = request.cookies.get(SCHOOL_COOKIE)
return sorted(lijst, key=lambda s: s.code != cookie)
def is_superbeheerder(email):
return (email or '').lower() in Config.SUPERBEHEERDERS
def _login_ok(school, user, naam, groep, superbeheerder=False, gebruiker=None):
"""
Zet de sessie. `gebruiker` is de rij uit de centrale tabel Gebruikers (of None voor een
superbeheerder uit config.ini of een leerkracht zonder PIVOT-account).
Administrators (Veiligheidsgroep 1) zijn superbeheerder: alle rechten in alle scholen.
"""
admin = bool(gebruiker and int(gebruiker['Veiligheidsgroep']) == ADMIN)
session.clear()
session["auth"] = True
session["user"] = user
session["naam"] = naam
session["gebruiker_id"] = gebruiker['Gebruiker_ID'] if gebruiker else None
session["admin"] = admin or bool(superbeheerder)
session["superbeheerder"] = admin or bool(superbeheerder)
if groep is None and gebruiker:
groep = ADMIN if session["admin"] else GEBRUIKER
session["security_group"] = int(groep) if groep is not None else None
session["school"] = school.code if school else None
if gebruiker:
rechten.registreer_login(gebruiker['Gebruiker_ID'], request.remote_addr)
if school:
rechten.bewaar_laatste_school(gebruiker['Gebruiker_ID'], school.code)
def _onthoud_school(response, school):
if school:
response.set_cookie(SCHOOL_COOKIE, school.code, max_age=365 * 24 * 3600, samesite='Lax',
secure=request.is_secure, httponly=True)
return response
#----------------------------------------------
# BRUTE FORCE BEVEILIGING
#----------------------------------------------
attempts = {}
def client_ip():
return request.remote_addr
def is_locked():
ip = client_ip()
if ip in attempts and attempts[ip]['count'] >= 5:
elapsed = time.time() - attempts[ip]['last_time']
if elapsed < 60:
return True, int(60 - elapsed)
else:
attempts.pop(ip)
return False, 0
def record_failure():
ip = client_ip()
if ip not in attempts:
attempts[ip] = {'count': 1, 'last_time': time.time()}
else:
attempts[ip]['count'] += 1
attempts[ip]['last_time'] = time.time()
#----------------------------------------------
# --- GOOGLE SSO ROUTES ---
#----------------------------------------------
@auth_bp.route('/google-login')
def google_login():
"""Start het Google inlogproces. De school volgt uit de hostnaam, ?school=code of het e-maildomein."""
vast = school_voor_host(request.host) or school_op_code(request.args.get('school'))
# Zonder vaste school: de Google-client van de laatst gekozen school op dit toestel (of de standaardschool).
# Welke school na het aanmelden geladen wordt, beslist de callback.
school = vast or school_op_code(request.cookies.get(SCHOOL_COOKIE)) or standaard_school()
naam, client = _google_client(school)
if not client:
flash("Inloggen met Google is (nog) niet ingesteld.", "error")
return redirect(url_for('auth.login'))
session['sso'] = {'client': naam, 'school': vast.code if vast else ''}
extra = {'prompt': 'select_account'}
if vast and len(vast.domeinen) == 1:
extra['hd'] = school.domein # Google toont enkel accounts van dit domein
redirect_uri = url_for('auth.callback', _external=True)
return client.authorize_redirect(redirect_uri, **extra)
@auth_bp.route('/callback')
def callback():
"""Verwerkt het antwoord van Google en bepaalt de school op basis van het e-maildomein."""
sso = session.pop('sso', None) or {}
try:
client = oauth.create_client(sso.get('client') or 'google')
token = client.authorize_access_token()
user_info = token.get('userinfo') or {}
except Exception as e:
flash("Fout bij ophalen van Google gegevens.", "error")
return redirect(url_for('auth.login'))
email = (user_info.get('email') or '').lower()
if not email or user_info.get('email_verified') is False:
flash("Google gaf geen geverifieerd e-mailadres terug.", "error")
return redirect(url_for('auth.login'))
naam = user_info.get('name') or email
super_ = is_superbeheerder(email)
# Geregistreerde PIVOT-gebruiker (centrale tabel Gebruikers)?
try:
gebruiker = rechten.zoek_gebruiker(email)
except Exception:
flash("De centrale PIVOT-database is niet bereikbaar.", "error")
return redirect(url_for('auth.login'))
if gebruiker and not gebruiker['Actief']:
gebruiker = None
def heeft_toegang(s):
return bool(gebruiker) and (super_ or rechten.mag_in_school(gebruiker, s))
gekozen = school_op_code(sso.get('school')) # enkel als de school vastligt (hostnaam)
domeinscholen = _volgens_domein(email) # meerdere scholen kunnen hetzelfde domein hebben
volgens_domein = next((s for s in domeinscholen if heeft_toegang(s)), None) or \
(domeinscholen[0] if domeinscholen else None)
if gekozen:
school = gekozen
elif gebruiker and heeft_toegang_ergens(gebruiker, super_):
# Laatst actieve school → school van het e-maildomein → laatst gekozen op dit toestel → laagste nummer
school = kies_school(gebruiker, heeft_toegang, ook=domeinscholen)
elif super_:
school = kies_school(None, None, ook=domeinscholen)
else:
school = volgens_domein
if gekozen and gekozen not in domeinscholen and not super_ and not heeft_toegang(gekozen):
flash(f"{email} hoort niet bij {gekozen.naam}.", "error")
return redirect(url_for('auth.login'))
if school is None:
if super_ or (gebruiker and int(gebruiker['Veiligheidsgroep']) == ADMIN):
# Superbeheerder van wie het domein bij geen school hoort: meteen naar scholenbeheer
_login_ok(None, email, naam, None, superbeheerder=True, gebruiker=gebruiker)
flash("Ingelogd als superbeheerder. Kies of maak een school.", "success")
return redirect(url_for('scholen.overzicht'))
flash(f"Het domein van {email} hoort bij geen enkele school in PIVOT.", "error")
return redirect(url_for('auth.login'))
# 1) PIVOT-gebruiker met rechten in deze school
if heeft_toegang(school):
_login_ok(school, gebruiker['Gebruikersnaam'], naam, None, super_, gebruiker=gebruiker)
flash(f"Succesvol ingelogd via Google: {gebruiker['Gebruikersnaam']}", "success")
return _onthoud_school(redirect(url_for("main.dashboard")), school)
# 2) Superbeheerder van de scholengroep (config.ini) → beheerder in elke school
if super_:
_login_ok(school, email, naam, ADMIN, True)
return _onthoud_school(redirect(url_for("main.dashboard")), school)
# 3) Personeelslid van de school (personeels-OU's) → beperkte toegang tot de personeelsmodule.
# Delen meerdere scholen het domein, dan de eerste school waar hij personeelslid is.
from services.personeel_service import is_personeelslid, LEERKRACHT_GROEP
for kandidaat in [school] + [s for s in domeinscholen if s is not school and not gekozen]:
with in_school(kandidaat):
if is_personeelslid(email):
_login_ok(kandidaat, email, naam, LEERKRACHT_GROEP)
return _onthoud_school(redirect(url_for("personeel.mijn")), kandidaat)
flash(f"Toegang geweigerd: {email} is niet geregistreerd in PIVOT van {school.naam}.", "error")
return redirect(url_for('auth.login'))
def heeft_toegang_ergens(gebruiker, super_=False):
return bool(gebruiker) and (super_ or any(rechten.mag_in_school(gebruiker, s) for s in alle_scholen()))
#----------------------------------------------
# --- HANDMATIGE LOGIN/LOGOUT ---
#----------------------------------------------
def _login_pagina(school=None):
vast = school_voor_host(request.host)
return render_template("pin.html",
scholen=alle_scholen(),
gekozen=vast or school or gekozen_school(),
school_vast=bool(vast),
google_aan=bool(Config.SSO_CLIENT_ID) or any(
s.heeft('GOOGLE_SSO_CLIENTID') for s in alle_scholen()))
@auth_bp.route("/login", methods=["GET", "POST"])
def login():
locked, wait_time = is_locked()
if locked:
flash(f"Te veel pogingen. Probeer opnieuw over {wait_time} seconden.", "error")
return _login_pagina()
if request.method == "POST":
entered_username = request.form.get("username", "").strip()
entered_password = request.form.get("password", "")
school = school_voor_host(request.host) # ligt de school vast via de hostnaam?
if not entered_username or not entered_password:
record_failure()
flash("Vul zowel gebruikersnaam als wachtwoord in.", "error")
return _login_pagina(school)
try:
gebruiker = rechten.zoek_gebruiker(entered_username)
except Exception:
flash("De centrale PIVOT-database is niet bereikbaar.", "error")
return _login_pagina(school)
if (gebruiker and gebruiker['Actief'] and gebruiker['Passwoordhash']
and check_password_hash(gebruiker['Passwoordhash'], entered_password)):
attempts.pop(client_ip(), None)
if school and not rechten.mag_in_school(gebruiker, school):
flash(f"Je hebt geen rechten in {school.naam}. Vraag een administrator om toegang.", "error")
return _login_pagina(school)
# Geen school gevraagd: laatst actieve school, anders de school van het e-maildomein
school = school or kies_school(gebruiker, lambda s: rechten.mag_in_school(gebruiker, s))
if not school:
flash("Je hebt nog geen rechten in een school. Vraag een administrator om toegang.", "error")
return _login_pagina()
_login_ok(school, gebruiker['Gebruikersnaam'], gebruiker['Gebruikersnaam'], None, gebruiker=gebruiker)
flash(f"Welkom terug, {gebruiker['Gebruikersnaam']}!", "success")
return _onthoud_school(redirect(url_for("main.dashboard")), school)
record_failure()
flash("Ongeldige inloggegevens.", "error")
return _login_pagina(school)
return _login_pagina()
@auth_bp.route('/logout')
def logout():
session.clear()
flash("Je bent nu uitgelogd.", "success")
return redirect(url_for('auth.login'))
#----------------------------------------------
# --- DECORATORS ---
#----------------------------------------------
def login_required(view_func):
@functools.wraps(view_func)
def wrapped_view(*args, **kwargs):
if not session.get("auth") or not session.get("school"):
return redirect(url_for("auth.login"))
return view_func(*args, **kwargs)
return wrapped_view
# Rechten per school: zie rechten.py (recht_vereist, heeft_recht, admin_vereist)