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)