516 lines
25 KiB
Python
516 lines
25 KiB
Python
"""
|
||
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
|