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

516 lines
25 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
"""
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