""" 2FA-hulp: back-upcodes, tijdelijke uitzonderingen en overzicht van wie 2FA nog niet heeft ingesteld. Vereist (eenmalig, Google Admin-console): 1. Beveiliging › API-beheer › Domeinbrede delegatie: aan het service-account toevoegen https://www.googleapis.com/auth/admin.directory.user.security (back-upcodes) https://www.googleapis.com/auth/admin.directory.group.member (uitzonderingsgroep) 2. Een groep aanmaken, bv. 2fa-uitzondering@school.be, en bij Beveiliging › Authenticatie › Verificatie in 2 stappen voor DIE GROEP 'Afdwingen' op UIT zetten (groepsinstellingen gaan boven de OU-instelling). 3. Het adres van die groep invullen in PIVOT › 🔌 Koppelingen van deze school › 🛡️ 2FA-hulp › Uitzonderingsgroep """ import sys import threading import time from datetime import datetime, timedelta from googleapiclient.discovery import build from googleapiclient.errors import HttpError from config import Config from database import execute_query from tenancy import google_credentials, school_singleton, alle_scholen, in_school SECURITY_SCOPE = 'https://www.googleapis.com/auth/admin.directory.user.security' GROUP_MEMBER_SCOPE = 'https://www.googleapis.com/auth/admin.directory.group.member' RESET_INTERVAL = 60 # wachtende 2FA-reset elke minuut opnieuw proberen CHECK_INTERVAL = 5 * 60 # elke 5 minuten verlopen uitzonderingen opruimen LIJST_CACHE = 10 * 60 def _is_afgedwongen_fout(e): """Google weigert turnOff zolang 2FA nog afgedwongen wordt (uitzonderingsgroep nog niet toegepast).""" msg = str(e).lower() return 'cannot be turned off' in msg or ('enforced' in msg and 'admin policy' in msg) def _is_netwerkfout(e): """Tijdelijke verbindingsfout (SSL, time-out, Google 5xx): later opnieuw proberen.""" import socket, ssl if isinstance(e, (ssl.SSLError, socket.timeout, ConnectionError, TimeoutError, OSError)): return True status = getattr(getattr(e, 'resp', None), 'status', None) try: return int(status) >= 500 except (TypeError, ValueError): return False class TweestapsError(Exception): pass class TweestapsService: _scheduler = None # één achtergrondtaak voor alle scholen def __new__(cls): # één instantie per school def init(inst): inst._clients = threading.local() # Google-clients (httplib2) zijn niet thread-safe: één per thread inst._directory = None inst._lock = threading.Lock() inst._lijst_cache = None return school_singleton(cls, init) # ---------------- clients ---------------- def _build(self, scope): creds = google_credentials([scope], subject=Config.GOOGLE_ADMIN_EMAIL) return build('admin', 'directory_v1', credentials=creds, static_discovery=False, cache_discovery=False) def _client(self, naam, scope): client = getattr(self._clients, naam, None) if client is None: client = self._build(scope) setattr(self._clients, naam, client) return client def _security_api(self): return self._client('security', SECURITY_SCOPE) def _group_api(self): return self._client('groups', GROUP_MEMBER_SCOPE) def _directory_api(self): if self._directory is None: from services.google_service import GoogleAdminService self._directory = GoogleAdminService().service return self._directory @staticmethod def _friendly(e, scope=None): msg = str(e) status = getattr(getattr(e, 'resp', None), 'status', None) if 'unauthorized_client' in msg or 'access_denied' in msg: return (f"Google weigert de toegang. Voeg de scope {scope} toe aan de domeinbrede delegatie " "van het service-account." if scope else "Google weigert de toegang (scope ontbreekt).") if _is_afgedwongen_fout(e): return ("Google dwingt 2FA nog af voor deze gebruiker. De uitzonderingsgroep is nog niet " "toegepast (dat kan enkele minuten duren).") if 'INVALID_OU_ID' in msg: return ("Een organisatie-eenheid bestaat niet in Google. Controleer de OU's bij 🔌 Koppelingen van " "deze school › 🛡️ 2FA-hulp.") if status == 404: return "Gebruiker of groep niet gevonden." if status == 403: return "Geen toestemming voor deze actie bij Google." return msg ontbrekende_ous = [] def ou_melding(self): if not self.ontbrekende_ous: return None return (f"Deze organisatie-eenheid bestaat niet in Google: {', '.join(self.ontbrekende_ous)}. " "Pas ze aan bij 🔌 Koppelingen van deze school › 🛡️ 2FA-hulp › \"OU's voor 'nog geen 2FA'\" " "(het volledige pad zoals in admin.google.com, bv. /Personeel/Leerkrachten; meerdere met komma's).") @property def exception_group(self): return (Config.TWEESTAPS_UITZONDERING_GROEP or '').strip() # ---------------- gebruiker opzoeken ---------------- def get_user(self, email): email = (email or '').strip().lower() if '@' not in email: raise TweestapsError("Geef een volledig e-mailadres op.") try: u = self._directory_api().users().get(userKey=email).execute() except HttpError as e: if getattr(e.resp, 'status', None) == 404: raise TweestapsError(f"{email} bestaat niet in jullie Google Workspace.") raise TweestapsError(self._friendly(e)) return { 'email': u['primaryEmail'].lower(), 'naam': (u.get('name') or {}).get('fullName', ''), 'ou': u.get('orgUnitPath', ''), 'ingesteld': bool(u.get('isEnrolledIn2Sv')), 'afgedwongen': bool(u.get('isEnforcedIn2Sv')), 'geschorst': bool(u.get('suspended')), 'laatste_login': (u.get('lastLoginTime') or '')[:16].replace('T', ' ').replace('1970-01-01 00:00', ''), } def search_users(self, query): try: resp = self._directory_api().users().list( customer='my_customer', query=query.strip(), maxResults=15, orderBy='email').execute() except Exception as e: sys.stderr.write(f"2FA zoeken: {e}\n") return [] return [{'email': u['primaryEmail'], 'naam': (u.get('name') or {}).get('fullName', ''), 'ou': u.get('orgUnitPath', '')} for u in resp.get('users', [])] # ---------------- back-upcodes ---------------- def list_codes(self, email): try: resp = self._security_api().verificationCodes().list(userKey=email).execute() except Exception as e: raise TweestapsError(self._friendly(e, SECURITY_SCOPE)) return [i['verificationCode'] for i in resp.get('items', []) if i.get('verificationCode')] def generate_codes(self, email): """Maakt een nieuwe set (de oude set wordt daardoor ongeldig) en geeft de codes terug.""" try: self._security_api().verificationCodes().generate(userKey=email).execute() except Exception as e: raise TweestapsError(self._friendly(e, SECURITY_SCOPE)) return self.list_codes(email) def invalidate_codes(self, email): try: self._security_api().verificationCodes().invalidate(userKey=email).execute() except Exception as e: raise TweestapsError(self._friendly(e, SECURITY_SCOPE)) # ---------------- uitzonderingsgroep ---------------- def _add_to_group(self, email): if not self.exception_group: raise TweestapsError("Er is nog geen uitzonderingsgroep ingesteld " "(🔌 Koppelingen van deze school › 🛡️ 2FA-hulp › Uitzonderingsgroep).") try: self._group_api().members().insert( groupKey=self.exception_group, body={'email': email, 'role': 'MEMBER'}).execute() except HttpError as e: if getattr(e.resp, 'status', None) == 409: # zit er al in → prima return raise TweestapsError(self._friendly(e, GROUP_MEMBER_SCOPE)) except Exception as e: raise TweestapsError(self._friendly(e, GROUP_MEMBER_SCOPE)) def _remove_from_group(self, email): try: self._group_api().members().delete(groupKey=self.exception_group, memberKey=email).execute() except HttpError as e: if getattr(e.resp, 'status', None) == 404: # zit er al niet meer in → prima return raise TweestapsError(self._friendly(e, GROUP_MEMBER_SCOPE)) except Exception as e: raise TweestapsError(self._friendly(e, GROUP_MEMBER_SCOPE)) # ---------------- database ---------------- @staticmethod def ensure_tables(): execute_query(""" CREATE TABLE IF NOT EXISTS tweestaps_uitzonderingen ( ID INT AUTO_INCREMENT PRIMARY KEY, Email VARCHAR(255) NOT NULL, Naam VARCHAR(255) NULL, Reden VARCHAR(255) NULL, Van DATETIME NOT NULL, Tot DATETIME NOT NULL, Door VARCHAR(100) NULL, Actief TINYINT(1) NOT NULL DEFAULT 1, Beeindigd_op DATETIME NULL, Beeindigd_door VARCHAR(100) NULL, INDEX idx_actief (Actief, Tot), INDEX idx_email (Email) )""", commit=True) execute_query(""" CREATE TABLE IF NOT EXISTS tweestaps_log ( ID INT AUTO_INCREMENT PRIMARY KEY, Tijdstip DATETIME NOT NULL, Door VARCHAR(100) NULL, Actie VARCHAR(40) NOT NULL, Email VARCHAR(255) NOT NULL, Details VARCHAR(255) NULL, INDEX idx_tijdstip (Tijdstip) )""", commit=True) execute_query(""" CREATE TABLE IF NOT EXISTS tweestaps_resets ( ID INT AUTO_INCREMENT PRIMARY KEY, Email VARCHAR(255) NOT NULL, Gevraagd_op DATETIME NOT NULL, Door VARCHAR(100) NULL, Reden VARCHAR(255) NULL, Tot DATETIME NOT NULL, Status VARCHAR(10) NOT NULL DEFAULT 'bezig', Pogingen INT NOT NULL DEFAULT 0, Laatste_fout VARCHAR(255) NULL, Klaar_op DATETIME NULL, INDEX idx_status (Status), INDEX idx_email (Email) )""", commit=True) @staticmethod def log(door, actie, email, details=None): try: execute_query("INSERT INTO tweestaps_log (Tijdstip, Door, Actie, Email, Details) VALUES (NOW(), %s, %s, %s, %s)", (door, actie, email, details), commit=True) except Exception as e: sys.stderr.write(f"2FA log: {e}\n") @staticmethod def recent_log(pagina=1, per_pagina=10): """Eén pagina van het logboek (nieuwste eerst). Geeft (rijen, totaal aantal).""" totaal = execute_query("SELECT COUNT(*) AS n FROM tweestaps_log")[0]['n'] pagina = max(1, int(pagina or 1)) rijen = execute_query("SELECT Tijdstip, Door, Actie, Email, Details FROM tweestaps_log " "ORDER BY Tijdstip DESC, ID DESC LIMIT %s OFFSET %s", (int(per_pagina), (pagina - 1) * int(per_pagina))) return rijen, totaal @staticmethod def active_exceptions(): return execute_query("SELECT ID, Email, Naam, Reden, Van, Tot, Door FROM tweestaps_uitzonderingen " "WHERE Actief = 1 ORDER BY Tot") @staticmethod def active_for(email): rows = execute_query("SELECT ID, Tot FROM tweestaps_uitzonderingen WHERE Actief = 1 AND Email = %s " "ORDER BY Tot DESC LIMIT 1", (email,)) return rows[0] if rows else None # ---------------- uitzonderingen ---------------- @staticmethod def einde_vandaag(nu=None): """Een uitzondering (of 2FA-reset) loopt altijd tot 23:59 dezelfde dag.""" return (nu or datetime.now()).replace(hour=23, minute=59, second=0, microsecond=0) def grant_exception(self, email, eindtijd, door, reden=None): user = self.get_user(email) if user['geschorst']: raise TweestapsError(f"{user['email']} is geschorst.") bestaand = self.active_for(user['email']) self._add_to_group(user['email']) if bestaand: execute_query("UPDATE tweestaps_uitzonderingen SET Tot = %s WHERE ID = %s", (eindtijd, bestaand['ID']), commit=True) else: execute_query( "INSERT INTO tweestaps_uitzonderingen (Email, Naam, Reden, Van, Tot, Door, Actief) " "VALUES (%s, %s, %s, NOW(), %s, %s, 1)", (user['email'], user['naam'], (reden or '')[:255], eindtijd, door), commit=True) self.log(door, 'uitzondering', user['email'], f"tot {eindtijd:%d-%m %H:%M}" + (f" – {reden}" if reden else '')) self._lijst_cache = None return user def reset_2fa(self, email, eindtijd, door, reden=None): """ 2FA resetten (nieuwe telefoon, toestel kwijt): 2FA uitzetten voor de gebruiker zodat hij het opnieuw kan instellen. Wordt 2FA afgedwongen, dan krijgt hij eerst een tijdelijke uitzondering; anders zou hij na het uitzetten niet meer kunnen inloggen om 2FA opnieuw in te stellen. Google: users.twoStepVerification.turnOff (scope admin.directory.user.security). """ user = self.get_user(email) if user['geschorst']: raise TweestapsError(f"{user['email']} is geschorst.") if not user['ingesteld']: raise TweestapsError(f"{user['email']} heeft nog geen 2FA ingesteld; er valt niets te resetten. " "Moet hij 2FA nog instellen maar kan hij niet inloggen, geef dan een tijdelijke uitzondering.") uitzondering = False if user['afgedwongen']: if not self.exception_group: raise TweestapsError("2FA wordt afgedwongen voor deze gebruiker. Stel eerst een uitzonderingsgroep in " "(🔌 Koppelingen van deze school › 🛡️ 2FA-hulp), anders kan hij na de reset niet " "meer inloggen om 2FA opnieuw in te stellen.") self.grant_exception(user['email'], eindtijd, door, reden or '2FA-reset') uitzondering = True try: self._turn_off(user['email']) except Exception as e: if uitzondering and _is_afgedwongen_fout(e): # Google past de uitzonderingsgroep pas na enkele minuten toe → op de achtergrond opnieuw proberen. self._plan_reset(user['email'], eindtijd, door, reden) user['uitzondering'] = True user['wacht'] = True return user raise TweestapsError(f"2FA uitzetten mislukt: {self._friendly(e, SECURITY_SCOPE)}" + (" De tijdelijke uitzondering blijft lopen tot ze afloopt." if uitzondering else "")) self._lijst_cache = None self.log(door, '2FA gereset', user['email'], reden or None) user['uitzondering'] = uitzondering user['wacht'] = False return user def _turn_off(self, email): self._security_api().twoStepVerification().turnOff(userKey=email).execute() # ---------------- 2FA-reset die op Google wacht ---------------- def _plan_reset(self, email, tot, door, reden): bestaand = self.reset_bezig(email) if bestaand: execute_query("UPDATE tweestaps_resets SET Tot = %s WHERE ID = %s", (tot, bestaand['ID']), commit=True) rid = bestaand['ID'] else: execute_query( "INSERT INTO tweestaps_resets (Email, Gevraagd_op, Door, Reden, Tot, Status, Pogingen) " "VALUES (%s, NOW(), %s, %s, %s, 'bezig', 1)", (email, door, (reden or '')[:255], tot), commit=True) rid = execute_query("SELECT ID FROM tweestaps_resets WHERE Email = %s AND Status = 'bezig' " "ORDER BY ID DESC LIMIT 1", (email,))[0]['ID'] self.log(door, '2FA reset wacht', email, 'Google past de uitzondering nog toe; PIVOT probeert opnieuw') self._volg_reset_op(rid) @staticmethod def reset_bezig(email=None): if email: rows = execute_query("SELECT * FROM tweestaps_resets WHERE Status = 'bezig' AND Email = %s " "ORDER BY ID DESC LIMIT 1", ((email or '').strip().lower(),)) return rows[0] if rows else None return execute_query("SELECT * FROM tweestaps_resets WHERE Status = 'bezig' ORDER BY Gevraagd_op") def probeer_reset(self, rij): """Eén poging voor een wachtende reset. Geeft 'klaar', 'mislukt' of 'bezig' terug.""" email = rij['Email'] try: self._turn_off(email) except Exception as e: nog_tijd = rij['Tot'] > datetime.now() tijdelijk = _is_afgedwongen_fout(e) or _is_netwerkfout(e) if _is_netwerkfout(e): self._clients.security = None # verbinding kapot → volgende poging met een nieuwe client if tijdelijk and nog_tijd: execute_query("UPDATE tweestaps_resets SET Pogingen = Pogingen + 1, Laatste_fout = %s WHERE ID = %s", (self._friendly(e)[:255], rij['ID']), commit=True) return 'bezig' fout = (self._friendly(e, SECURITY_SCOPE) if nog_tijd else "Google heeft de uitzondering niet toegepast voor ze afliep. Controleer of 'Afdwingen' op 'Uit' " "staat voor de uitzonderingsgroep in admin.google.com en probeer opnieuw.") execute_query("UPDATE tweestaps_resets SET Status = 'mislukt', Pogingen = Pogingen + 1, Laatste_fout = %s, " "Klaar_op = NOW() WHERE ID = %s", (fout[:255], rij['ID']), commit=True) self.log('automatisch', '2FA reset mislukt', email, fout[:255]) return 'mislukt' execute_query("UPDATE tweestaps_resets SET Status = 'klaar', Pogingen = Pogingen + 1, Laatste_fout = NULL, " "Klaar_op = NOW() WHERE ID = %s", (rij['ID'],), commit=True) self.log(rij['Door'] or 'automatisch', '2FA gereset', email, (rij['Reden'] or '') + f" (na {rij['Pogingen'] + 1} pogingen)") self._lijst_cache = None return 'klaar' def verwerk_resets(self): """Alle wachtende resets van deze school één keer proberen (vangnet van de 5-minutenplanner).""" n = 0 for rij in self.reset_bezig(): if self.probeer_reset(rij) == 'klaar': n += 1 return n def _volg_reset_op(self, rid): from tenancy import met_school svc = self def loop(): while True: time.sleep(RESET_INTERVAL) try: rows = execute_query("SELECT * FROM tweestaps_resets WHERE ID = %s", (rid,)) if not rows or rows[0]['Status'] != 'bezig': return if svc.probeer_reset(rows[0]) != 'bezig': return except Exception as e: sys.stderr.write(f"2FA-reset {rid}: {e}\n") threading.Thread(target=met_school(loop), daemon=True).start() def end_exception(self, uitz_id, door): rows = execute_query("SELECT Email FROM tweestaps_uitzonderingen WHERE ID = %s AND Actief = 1", (uitz_id,)) if not rows: raise TweestapsError("Deze uitzondering is al beëindigd.") email = rows[0]['Email'] self._remove_from_group(email) execute_query("UPDATE tweestaps_uitzonderingen SET Actief = 0, Beeindigd_op = NOW(), Beeindigd_door = %s " "WHERE ID = %s", (door, uitz_id), commit=True) self.log(door, 'uitzondering beëindigd', email) self._lijst_cache = None return email def expire_due(self): """Haalt iedereen met een verlopen uitzondering terug uit de groep. Geeft het aantal terug.""" if not self._lock.acquire(blocking=False): return 0 try: rows = execute_query("SELECT ID, Email FROM tweestaps_uitzonderingen WHERE Actief = 1 AND Tot <= NOW()") klaar = 0 for r in rows: try: self._remove_from_group(r['Email']) execute_query("UPDATE tweestaps_uitzonderingen SET Actief = 0, Beeindigd_op = NOW(), " "Beeindigd_door = 'automatisch' WHERE ID = %s", (r['ID'],), commit=True) self.log('automatisch', 'uitzondering verlopen', r['Email']) klaar += 1 except Exception as e: # blijft actief → volgende ronde opnieuw proberen sys.stderr.write(f"2FA: kon {r['Email']} niet uit de groep halen: {e}\n") if klaar: self._lijst_cache = None return klaar finally: self._lock.release() @classmethod def start_scheduler(cls): """Ruimt elke 5 minuten verlopen uitzonderingen op, voor elke school met een Google-sleutel.""" if TweestapsService._scheduler: return def loop(): time.sleep(20) while True: for school in alle_scholen(): if not school.google_sleutel: continue try: with in_school(school): svc = cls() svc.ensure_tables() svc.verwerk_resets() n = svc.expire_due() if n: print(f"[{school.code}] 2FA: {n} verlopen uitzondering(en) opgeruimd.", flush=True) except Exception as e: sys.stderr.write(f"[{school.code}] 2FA scheduler: {e}\n") time.sleep(CHECK_INTERVAL) TweestapsService._scheduler = threading.Thread(target=loop, daemon=True) TweestapsService._scheduler.start() # ---------------- wie heeft 2FA nog niet ingesteld ---------------- def niet_ingesteld(self, force=False): if not force and self._lijst_cache and time.time() - self._lijst_cache[0] < LIJST_CACHE: return self._lijst_cache[1] result = [] ous = [o.strip() for o in Config.TWEESTAPS_OUS.split(',') if o.strip()] self.ontbrekende_ous = [] for ou in ous: token = None while True: try: resp = self._directory_api().users().list( customer='my_customer', query=f"orgUnitPath='{ou}' isSuspended=false", maxResults=500, pageToken=token, projection='basic', fields='nextPageToken,users(primaryEmail,name/fullName,orgUnitPath,isEnrolledIn2Sv,' 'isEnforcedIn2Sv,archived,lastLoginTime,creationTime)').execute() except Exception as e: if 'INVALID_OU_ID' in str(e): self.ontbrekende_ous.append(ou) # deze OU bestaat niet in Google: overslaan break raise for u in resp.get('users', []): if u.get('archived') or u.get('isEnrolledIn2Sv'): continue result.append({ 'email': u['primaryEmail'].lower(), 'naam': (u.get('name') or {}).get('fullName', ''), 'ou': u.get('orgUnitPath', ''), 'afgedwongen': bool(u.get('isEnforcedIn2Sv')), 'laatste_login': (u.get('lastLoginTime') or '')[:10].replace('1970-01-01', ''), 'aangemaakt': (u.get('creationTime') or '')[:10], }) token = resp.get('nextPageToken') if not token: break if ous and len(self.ontbrekende_ous) == len(ous): raise TweestapsError(self.ou_melding()) uniek = {r['email']: r for r in result} lijst = sorted(uniek.values(), key=lambda r: (r['ou'], r['naam'])) self._lijst_cache = (time.time(), lijst) return lijst