schulsync 0.1.0__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,392 @@
1
+ """LDAP-Anbindung an Active Directory (Windows AD oder Samba AD DC).
2
+
3
+ Designentscheidungen (im README ausführlicher begründet):
4
+
5
+ * **CN = Benutzername**, der Anzeigename steht in ``displayName``.
6
+ Zwei »Ben Yılmaz« in derselben Klasse wären sonst eine RDN-Kollision –
7
+ der Benutzername ist dagegen domänenweit eindeutig, OU-Umzüge können
8
+ nie kollidieren.
9
+ * Die Quell-ID der Schulverwaltung liegt in ``employeeNumber``,
10
+ die Rolle in ``employeeType`` – beides Standard-AD-Attribute,
11
+ kein Schema-Gefrickel nötig.
12
+ * Passwörter (``unicodePwd``) verlangen eine **verschlüsselte Verbindung**:
13
+ LDAPS oder STARTTLS. Unverschlüsselt verweigert SchulSync den Dienst.
14
+ * Deaktivierte Konten bekommen einen maschinenlesbaren Stempel in
15
+ ``description`` – daraus liest ``cleanup`` die Aufbewahrungsfrist.
16
+ """
17
+
18
+ from __future__ import annotations
19
+
20
+ import re
21
+ import ssl
22
+ from datetime import date
23
+
24
+ from ldap3 import (
25
+ ALL,
26
+ MODIFY_ADD,
27
+ MODIFY_DELETE,
28
+ MODIFY_REPLACE,
29
+ SIMPLE,
30
+ SUBTREE,
31
+ Connection,
32
+ Server,
33
+ Tls,
34
+ )
35
+ from ldap3.core.exceptions import (
36
+ LDAPAttributeOrValueExistsResult,
37
+ LDAPEntryAlreadyExistsResult,
38
+ LDAPException,
39
+ LDAPNoSuchAttributeResult,
40
+ LDAPNoSuchObjectResult,
41
+ )
42
+ from ldap3.utils.conv import escape_filter_chars
43
+ from ldap3.utils.dn import escape_rdn
44
+
45
+ from .config import Config
46
+ from .models import IstKonto, Rolle
47
+
48
+ UAC_NORMAL = 0x0200 # 512
49
+ UAC_DISABLED = 0x0202 # 514 (ACCOUNTDISABLE-Bit gesetzt)
50
+ GRUPPE_GLOBAL_SECURITY = -2147483646
51
+
52
+ _STEMPEL_RE = re.compile(r"deaktiviert am (\d{4}-\d{2}-\d{2})", re.IGNORECASE)
53
+
54
+
55
+ def deaktivierungs_stempel(am: date, frist_tage: int) -> str:
56
+ return f"SchulSync: deaktiviert am {am.isoformat()} – Löschung nach {frist_tage} Tagen Frist"
57
+
58
+
59
+ def parse_stempel(description: str | None) -> date | None:
60
+ if not description:
61
+ return None
62
+ m = _STEMPEL_RE.search(description)
63
+ return date.fromisoformat(m.group(1)) if m else None
64
+
65
+
66
+ class LdapFehler(Exception):
67
+ """LDAP-Fehler mit verständlicher Meldung für die CLI."""
68
+
69
+
70
+ class AdVerbindung:
71
+ """Dünne, gut testbare Schicht über ldap3 – jede Methode ist eine
72
+ fachliche Operation, kein generischer LDAP-Wrapper."""
73
+
74
+ def __init__(self, config: Config):
75
+ self.config = config
76
+ self._conn: Connection | None = None
77
+
78
+ # ------------------------------------------------------------- Aufbau
79
+ def verbinden(self) -> None:
80
+ c = self.config.ldap
81
+ url = c.server
82
+ use_ssl = url.startswith("ldaps://")
83
+
84
+ tls = Tls(
85
+ validate=ssl.CERT_REQUIRED if c.tls_verifizieren else ssl.CERT_NONE,
86
+ version=ssl.PROTOCOL_TLS_CLIENT if c.tls_verifizieren else ssl.PROTOCOL_TLS,
87
+ )
88
+ # ldap3 versteht vollständige LDAP-URLs inkl. Port (ldaps://host:636).
89
+ server = Server(
90
+ url,
91
+ tls=tls,
92
+ get_info=ALL,
93
+ connect_timeout=c.zeitlimit_sekunden,
94
+ )
95
+ try:
96
+ conn = Connection(
97
+ server,
98
+ user=c.bind_benutzer,
99
+ password=c.passwort,
100
+ authentication=SIMPLE,
101
+ raise_exceptions=True,
102
+ receive_timeout=c.zeitlimit_sekunden * 3,
103
+ auto_bind=False,
104
+ )
105
+ conn.open()
106
+ if not use_ssl:
107
+ # Ohne Verschlüsselung keine Passwörter: STARTTLS erzwingen.
108
+ conn.start_tls()
109
+ conn.bind()
110
+ except LDAPException as e:
111
+ raise LdapFehler(
112
+ f"Verbindung zu {url} als '{c.bind_benutzer}' fehlgeschlagen: {e}"
113
+ ) from e
114
+ self._conn = conn
115
+
116
+ def trennen(self) -> None:
117
+ if self._conn is not None:
118
+ self._conn.unbind()
119
+ self._conn = None
120
+
121
+ def __enter__(self) -> AdVerbindung:
122
+ self.verbinden()
123
+ return self
124
+
125
+ def __exit__(self, *exc) -> None:
126
+ self.trennen()
127
+
128
+ @property
129
+ def conn(self) -> Connection:
130
+ if self._conn is None:
131
+ raise LdapFehler("Nicht verbunden – verbinden() zuerst aufrufen.")
132
+ return self._conn
133
+
134
+ # -------------------------------------------------------------- Lesen
135
+ def basis_existiert(self) -> bool:
136
+ try:
137
+ return self.conn.search(
138
+ self.config.basis_dn, "(objectClass=organizationalUnit)", search_scope="BASE"
139
+ )
140
+ except LDAPNoSuchObjectResult:
141
+ return False
142
+
143
+ def alle_benutzernamen_der_domaene(self) -> set[str]:
144
+ """Alle sAMAccountNames der gesamten Domäne – neue Benutzernamen
145
+ dürfen auch mit nicht von SchulSync verwalteten Konten (Sekretariat,
146
+ Hausmeister, Dienstkonten) nicht kollidieren."""
147
+ self.conn.search(
148
+ self.config.ldap.base_dn,
149
+ "(sAMAccountName=*)",
150
+ search_scope=SUBTREE,
151
+ attributes=["sAMAccountName"],
152
+ )
153
+ return {str(e["sAMAccountName"].value) for e in self.conn.entries}
154
+
155
+ def lade_ist_konten(self) -> list[IstKonto]:
156
+ """Liest alle von SchulSync verwalteten Konten (unterhalb der Basis-OU)."""
157
+ if not self.basis_existiert():
158
+ return []
159
+ self.conn.search(
160
+ self.config.basis_dn,
161
+ "(&(objectCategory=person)(objectClass=user))",
162
+ search_scope=SUBTREE,
163
+ attributes=[
164
+ "sAMAccountName",
165
+ "employeeNumber",
166
+ "employeeType",
167
+ "givenName",
168
+ "sn",
169
+ "userAccountControl",
170
+ "description",
171
+ ],
172
+ )
173
+ konten: list[IstKonto] = []
174
+ for e in self.conn.entries:
175
+ dn = str(e.entry_dn)
176
+ uac = int(str(e["userAccountControl"].value or UAC_NORMAL))
177
+ beschreibung = str(e["description"].value) if e["description"] else None
178
+ rolle_roh = str(e["employeeType"].value) if e["employeeType"] else ""
179
+ konten.append(
180
+ IstKonto(
181
+ dn=dn,
182
+ benutzername=str(e["sAMAccountName"].value),
183
+ quell_id=str(e["employeeNumber"].value) if e["employeeNumber"] else None,
184
+ vorname=str(e["givenName"].value) if e["givenName"] else "",
185
+ nachname=str(e["sn"].value) if e["sn"] else "",
186
+ rolle=Rolle(rolle_roh) if rolle_roh in ("schueler", "lehrkraft")
187
+ else Rolle.SCHUELER,
188
+ klasse=self._klasse_aus_dn(dn),
189
+ aktiv=not (uac & 0x2),
190
+ deaktiviert_am=parse_stempel(beschreibung),
191
+ )
192
+ )
193
+ konten.sort(key=lambda k: (k.klasse or "", k.nachname, k.vorname))
194
+ return konten
195
+
196
+ def _klasse_aus_dn(self, dn: str) -> str | None:
197
+ """CN=maja.weber,OU=5A,OU=Schueler,… → ``5A``."""
198
+ suffix = "," + self.config.schueler_dn.lower()
199
+ if not dn.lower().endswith(suffix):
200
+ return None # Lehrkraft oder Abgänger
201
+ mitte = dn[: -len(suffix)]
202
+ teile = mitte.split(",")
203
+ if len(teile) >= 2 and teile[-1].upper().startswith("OU="):
204
+ return teile[-1][3:]
205
+ return None
206
+
207
+ # ---------------------------------------------------------- Struktur
208
+ def stelle_struktur_sicher(self, klassen: set[str]) -> list[str]:
209
+ """Legt Basis-OUs, Klassen-OUs und Gruppen an, falls sie fehlen.
210
+ Idempotent. Gibt die neu angelegten Objekte zurück."""
211
+ neu: list[str] = []
212
+ s = self.config
213
+ ous = [
214
+ s.basis_dn,
215
+ s.schueler_dn,
216
+ s.lehrkraefte_dn,
217
+ s.abgaenger_dn,
218
+ *(s.klassen_dn(k) for k in sorted(klassen)),
219
+ ]
220
+ for dn in ous:
221
+ if self._lege_ou_an(dn):
222
+ neu.append(dn)
223
+ gruppen = [
224
+ s.struktur.gruppe_alle_schueler,
225
+ s.struktur.gruppe_alle_lehrkraefte,
226
+ *(s.klassen_gruppe(k) for k in sorted(klassen)),
227
+ ]
228
+ for name in gruppen:
229
+ if self._lege_gruppe_an(name):
230
+ neu.append(f"Gruppe {name}")
231
+ return neu
232
+
233
+ def _lege_ou_an(self, dn: str) -> bool:
234
+ try:
235
+ self.conn.add(dn, attributes={"objectClass": ["top", "organizationalUnit"]})
236
+ return True
237
+ except LDAPEntryAlreadyExistsResult:
238
+ return False
239
+ except LDAPException as e:
240
+ raise LdapFehler(f"OU '{dn}' konnte nicht angelegt werden: {e}") from e
241
+
242
+ def _gruppen_dn(self, name: str) -> str:
243
+ return f"CN={escape_rdn(name)},{self.config.basis_dn}"
244
+
245
+ def _lege_gruppe_an(self, name: str) -> bool:
246
+ try:
247
+ self.conn.add(
248
+ self._gruppen_dn(name),
249
+ attributes={
250
+ "objectClass": ["top", "group"],
251
+ "sAMAccountName": name,
252
+ "groupType": GRUPPE_GLOBAL_SECURITY,
253
+ },
254
+ )
255
+ return True
256
+ except LDAPEntryAlreadyExistsResult:
257
+ return False
258
+ except LDAPException as e:
259
+ raise LdapFehler(f"Gruppe '{name}' konnte nicht angelegt werden: {e}") from e
260
+
261
+ # ------------------------------------------------------------ Konten
262
+ def benutzer_dn(self, benutzername: str, eltern_dn: str) -> str:
263
+ return f"CN={escape_rdn(benutzername)},{eltern_dn}"
264
+
265
+ def lege_benutzer_an(
266
+ self,
267
+ benutzername: str,
268
+ vorname: str,
269
+ nachname: str,
270
+ quell_id: str,
271
+ rolle: Rolle,
272
+ eltern_dn: str,
273
+ passwort: str,
274
+ ) -> str:
275
+ """Dreischritt, wie AD ihn verlangt: anlegen (deaktiviert) →
276
+ Passwort setzen (nur über TLS möglich) → aktivieren + Passwortwechsel
277
+ bei erster Anmeldung erzwingen."""
278
+ dn = self.benutzer_dn(benutzername, eltern_dn)
279
+ attribute = {
280
+ "objectClass": ["top", "person", "organizationalPerson", "user"],
281
+ "sAMAccountName": benutzername,
282
+ "userPrincipalName": f"{benutzername}{self.config.konten.upn_suffix}",
283
+ "givenName": vorname,
284
+ "sn": nachname,
285
+ "displayName": f"{vorname} {nachname}",
286
+ "employeeNumber": quell_id,
287
+ "employeeType": rolle.value,
288
+ }
289
+ k = self.config.konten
290
+ if k.home_laufwerk and k.home_pfad:
291
+ attribute["homeDrive"] = k.home_laufwerk
292
+ attribute["homeDirectory"] = k.home_pfad.format(benutzername=benutzername)
293
+ try:
294
+ self.conn.add(dn, attributes=attribute)
295
+ self.setze_passwort(dn, passwort)
296
+ self.conn.modify(
297
+ dn,
298
+ {
299
+ "userAccountControl": [(MODIFY_REPLACE, [UAC_NORMAL])],
300
+ "pwdLastSet": [(MODIFY_REPLACE, [0])], # Änderung bei 1. Anmeldung
301
+ },
302
+ )
303
+ except LDAPException as e:
304
+ raise LdapFehler(f"Konto '{benutzername}' konnte nicht angelegt werden: {e}") from e
305
+ return dn
306
+
307
+ def setze_passwort(self, dn: str, passwort: str) -> None:
308
+ self.conn.modify(
309
+ dn, {"unicodePwd": [(MODIFY_REPLACE, [f'"{passwort}"'.encode("utf-16-le")])]}
310
+ )
311
+
312
+ def verschiebe(self, dn: str, ziel_ou: str) -> str:
313
+ rdn = dn.split(",", 1)[0]
314
+ try:
315
+ self.conn.modify_dn(dn, rdn, new_superior=ziel_ou)
316
+ except LDAPException as e:
317
+ raise LdapFehler(f"'{dn}' → '{ziel_ou}' fehlgeschlagen: {e}") from e
318
+ return f"{rdn},{ziel_ou}"
319
+
320
+ def benenne_um(self, dn: str, vorname: str, nachname: str) -> None:
321
+ try:
322
+ self.conn.modify(
323
+ dn,
324
+ {
325
+ "givenName": [(MODIFY_REPLACE, [vorname])],
326
+ "sn": [(MODIFY_REPLACE, [nachname])],
327
+ "displayName": [(MODIFY_REPLACE, [f"{vorname} {nachname}"])],
328
+ },
329
+ )
330
+ except LDAPException as e:
331
+ raise LdapFehler(f"Umbenennen von '{dn}' fehlgeschlagen: {e}") from e
332
+
333
+ def deaktiviere(self, dn: str, am: date, frist_tage: int) -> None:
334
+ try:
335
+ self.conn.modify(
336
+ dn,
337
+ {
338
+ "userAccountControl": [(MODIFY_REPLACE, [UAC_DISABLED])],
339
+ "description": [(MODIFY_REPLACE, [deaktivierungs_stempel(am, frist_tage)])],
340
+ },
341
+ )
342
+ except LDAPException as e:
343
+ raise LdapFehler(f"Deaktivieren von '{dn}' fehlgeschlagen: {e}") from e
344
+
345
+ def aktiviere(self, dn: str) -> None:
346
+ try:
347
+ self.conn.modify(
348
+ dn,
349
+ {
350
+ "userAccountControl": [(MODIFY_REPLACE, [UAC_NORMAL])],
351
+ "description": [(MODIFY_REPLACE, [])],
352
+ },
353
+ )
354
+ except LDAPException as e:
355
+ raise LdapFehler(f"Aktivieren von '{dn}' fehlgeschlagen: {e}") from e
356
+
357
+ def loesche(self, dn: str) -> None:
358
+ try:
359
+ self.conn.delete(dn)
360
+ except LDAPException as e:
361
+ raise LdapFehler(f"Löschen von '{dn}' fehlgeschlagen: {e}") from e
362
+
363
+ # ----------------------------------------------------------- Gruppen
364
+ def fuege_zu_gruppe_hinzu(self, benutzer_dn: str, gruppe: str) -> None:
365
+ try:
366
+ self.conn.modify(
367
+ self._gruppen_dn(gruppe), {"member": [(MODIFY_ADD, [benutzer_dn])]}
368
+ )
369
+ except (LDAPAttributeOrValueExistsResult, LDAPEntryAlreadyExistsResult):
370
+ pass # schon Mitglied – idempotent
371
+ except LDAPException as e:
372
+ raise LdapFehler(f"'{benutzer_dn}' → Gruppe '{gruppe}' fehlgeschlagen: {e}") from e
373
+
374
+ def entferne_aus_gruppe(self, benutzer_dn: str, gruppe: str) -> None:
375
+ try:
376
+ self.conn.modify(
377
+ self._gruppen_dn(gruppe), {"member": [(MODIFY_DELETE, [benutzer_dn])]}
378
+ )
379
+ except (LDAPNoSuchAttributeResult, LDAPNoSuchObjectResult):
380
+ pass # war kein Mitglied / Gruppe existiert nicht – idempotent
381
+ except LDAPException as e:
382
+ raise LdapFehler(f"'{benutzer_dn}' aus '{gruppe}' entfernen fehlgeschlagen: {e}") from e
383
+
384
+ def gruppen_von(self, benutzer_dn: str) -> list[str]:
385
+ """Alle SchulSync-Gruppen, in denen der Benutzer Mitglied ist."""
386
+ self.conn.search(
387
+ self.config.basis_dn,
388
+ f"(&(objectClass=group)(member={escape_filter_chars(benutzer_dn)}))",
389
+ search_scope=SUBTREE,
390
+ attributes=["sAMAccountName"],
391
+ )
392
+ return [str(e["sAMAccountName"].value) for e in self.conn.entries]
schulsync/models.py ADDED
@@ -0,0 +1,167 @@
1
+ """Domänenmodell von SchulSync.
2
+
3
+ Zentrale Idee (Terraform-Prinzip):
4
+ * Der CSV-Export der Schulverwaltung beschreibt den **Soll-Zustand**.
5
+ * Das Active Directory enthält den **Ist-Zustand**.
6
+ * Der Planner berechnet daraus einen nachvollziehbaren Plan,
7
+ der erst nach expliziter Bestätigung angewendet wird.
8
+
9
+ Als stabiler Schlüssel zwischen beiden Welten dient die ID aus der
10
+ Schulverwaltung (SchILD: "Interne ID-Nummer", ASV: "Schülernummer").
11
+ Sie wird im AD-Attribut ``employeeNumber`` gespeichert – Namen und
12
+ Klassen dürfen sich ändern, die ID nicht.
13
+ """
14
+
15
+ from __future__ import annotations
16
+
17
+ import re
18
+ from dataclasses import dataclass, field
19
+ from datetime import date
20
+ from enum import StrEnum
21
+
22
+
23
+ class Rolle(StrEnum):
24
+ SCHUELER = "schueler"
25
+ LEHRKRAFT = "lehrkraft"
26
+
27
+
28
+ _KLASSE_RE = re.compile(r"^([0-9]{1,2})\s*([A-Za-z]{0,3})$")
29
+
30
+
31
+ def normalisiere_klasse(roh: str) -> str:
32
+ """Normalisiert Klassenbezeichnungen: ``5a`` → ``5A``, `` 10 b`` → ``10B``.
33
+
34
+ Bezeichnungen ohne Jahrgangsmuster (z. B. Kursstufen wie ``JG1``)
35
+ werden nur getrimmt und großgeschrieben.
36
+ """
37
+ roh = roh.strip()
38
+ m = _KLASSE_RE.match(roh)
39
+ if m:
40
+ return f"{int(m.group(1))}{m.group(2).upper()}"
41
+ return roh.upper().replace(" ", "-")
42
+
43
+
44
+ @dataclass(frozen=True, slots=True)
45
+ class SollPerson:
46
+ """Eine Person, wie die Schulverwaltung sie exportiert (Soll-Zustand)."""
47
+
48
+ quell_id: str
49
+ vorname: str
50
+ nachname: str
51
+ rolle: Rolle
52
+ klasse: str | None = None # nur für Schüler:innen
53
+
54
+ @property
55
+ def anzeigename(self) -> str:
56
+ return f"{self.vorname} {self.nachname}"
57
+
58
+
59
+ @dataclass(slots=True)
60
+ class IstKonto:
61
+ """Ein von SchulSync verwaltetes Konto, wie es im AD existiert (Ist-Zustand)."""
62
+
63
+ dn: str
64
+ benutzername: str # sAMAccountName
65
+ quell_id: str | None
66
+ vorname: str
67
+ nachname: str
68
+ rolle: Rolle
69
+ klasse: str | None
70
+ aktiv: bool
71
+ deaktiviert_am: date | None = None # aus dem description-Stempel geparst
72
+
73
+ @property
74
+ def anzeigename(self) -> str:
75
+ return f"{self.vorname} {self.nachname}"
76
+
77
+
78
+ # ---------------------------------------------------------------------------
79
+ # Plan-Aktionen
80
+ # ---------------------------------------------------------------------------
81
+
82
+
83
+ @dataclass(frozen=True, slots=True)
84
+ class Anlegen:
85
+ person: SollPerson
86
+ benutzername: str
87
+
88
+
89
+ @dataclass(frozen=True, slots=True)
90
+ class Verschieben:
91
+ """Klassenwechsel: OU-Umzug + Gruppen-Update (z. B. 5A → 6A)."""
92
+
93
+ konto: IstKonto
94
+ von_klasse: str | None
95
+ nach_klasse: str
96
+
97
+
98
+ @dataclass(frozen=True, slots=True)
99
+ class Umbenennen:
100
+ """Namensänderung in der Schulverwaltung (Heirat, Korrektur …)."""
101
+
102
+ konto: IstKonto
103
+ neuer_vorname: str
104
+ neuer_nachname: str
105
+
106
+
107
+ @dataclass(frozen=True, slots=True)
108
+ class Deaktivieren:
109
+ """Abgänger: Konto wird deaktiviert und in die Abgänger-OU verschoben.
110
+
111
+ Gelöscht wird erst nach Ablauf der Aufbewahrungsfrist durch
112
+ ``schulsync cleanup`` – niemals sofort (Löschkonzept, Art. 17 DSGVO).
113
+ """
114
+
115
+ konto: IstKonto
116
+
117
+
118
+ @dataclass(frozen=True, slots=True)
119
+ class Reaktivieren:
120
+ """Rückkehrer: war Abgänger, taucht im Export wieder auf."""
121
+
122
+ konto: IstKonto
123
+ nach_klasse: str | None
124
+
125
+
126
+ @dataclass(frozen=True, slots=True)
127
+ class Loeschen:
128
+ """Endgültige Löschung nach Ablauf der Aufbewahrungsfrist (nur `cleanup`)."""
129
+
130
+ konto: IstKonto
131
+ deaktiviert_am: date
132
+ frist_tage: int
133
+
134
+
135
+ @dataclass(frozen=True, slots=True)
136
+ class Konflikt:
137
+ """Ein Datensatz, der nicht automatisch verarbeitet werden kann."""
138
+
139
+ betrifft: str
140
+ grund: str
141
+
142
+
143
+ @dataclass(slots=True)
144
+ class Plan:
145
+ """Ergebnis des Soll/Ist-Vergleichs. Reihenfolge der Listen ist deterministisch."""
146
+
147
+ anlegen: list[Anlegen] = field(default_factory=list)
148
+ verschieben: list[Verschieben] = field(default_factory=list)
149
+ umbenennen: list[Umbenennen] = field(default_factory=list)
150
+ deaktivieren: list[Deaktivieren] = field(default_factory=list)
151
+ reaktivieren: list[Reaktivieren] = field(default_factory=list)
152
+ konflikte: list[Konflikt] = field(default_factory=list)
153
+ unveraendert: int = 0
154
+
155
+ @property
156
+ def anzahl_aenderungen(self) -> int:
157
+ return (
158
+ len(self.anlegen)
159
+ + len(self.verschieben)
160
+ + len(self.umbenennen)
161
+ + len(self.deaktivieren)
162
+ + len(self.reaktivieren)
163
+ )
164
+
165
+ @property
166
+ def leer(self) -> bool:
167
+ return self.anzahl_aenderungen == 0
schulsync/passwords.py ADDED
@@ -0,0 +1,42 @@
1
+ """Initialpasswörter.
2
+
3
+ Anforderungen aus der Praxis:
4
+
5
+ * Ein Fünftklässler muss es **abtippen** können → Wortpaare statt Zeichensalat.
6
+ * AD-Komplexitätsrichtlinie (3 von 4 Zeichenklassen) muss erfüllt sein
7
+ → Großbuchstabe + Kleinbuchstaben + Ziffern sind immer enthalten.
8
+ * Es ist ein **Einmalpasswort**: ``pwdLastSet = 0`` erzwingt die Änderung
9
+ bei der ersten Anmeldung. Die Stärke muss den Schulflur bis zur ersten
10
+ Anmeldung überleben, keinen Offline-Angriff über Jahre.
11
+ * Kryptografisch sauberer Zufall via ``secrets``.
12
+ """
13
+
14
+ from __future__ import annotations
15
+
16
+ import secrets
17
+
18
+ # Kindgerechte, eindeutig tippbare Wörter (keine Umlaute, kein ß):
19
+ WOERTER = (
20
+ "Adler", "Ampel", "Anker", "Apfel", "Banane", "Baum", "Berg", "Biber",
21
+ "Blitz", "Brille", "Delfin", "Drache", "Eiche", "Ente", "Erdbeere",
22
+ "Falke", "Feder", "Fichte", "Flamme", "Garten", "Gipfel", "Hafen",
23
+ "Herbst", "Himmel", "Honig", "Igel", "Insel", "Kaktus", "Kirsche",
24
+ "Koala", "Komet", "Kompass", "Kranich", "Lampe", "Leuchtturm", "Linde",
25
+ "Melone", "Mond", "Morgen", "Nebel", "Orange", "Panda", "Pilot",
26
+ "Pinguin", "Rakete", "Regen", "Robbe", "Salat", "Sommer", "Sonne",
27
+ "Stern", "Sturm", "Tiger", "Tomate", "Traktor", "Wal", "Wiese",
28
+ "Winter", "Wolke", "Zebra",
29
+ )
30
+
31
+ MIN_LAENGE = 12
32
+
33
+
34
+ def erzeuge_passwort(anzahl_woerter: int = 2) -> str:
35
+ """Erzeugt z. B. ``Tiger-Wolke-83`` – merkbar, tippbar, AD-konform."""
36
+ anzahl_woerter = max(2, anzahl_woerter)
37
+ while True:
38
+ woerter = [secrets.choice(WOERTER) for _ in range(anzahl_woerter)]
39
+ ziffern = f"{secrets.randbelow(90) + 10}" # zweistellig, keine führende 0
40
+ pw = "-".join([*woerter, ziffern])
41
+ if len(pw) >= MIN_LAENGE:
42
+ return pw