From 419ecaba631ec0d675e3840a856a79e8319befda Mon Sep 17 00:00:00 2001 From: Daniel Allaire Date: Sat, 8 Aug 2026 06:28:10 -0400 Subject: [PATCH] identite : une declaration de politique de mot de passe, deux executants MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Quatre defauts mesures dans l'integration Keycloak/LDAP, meme famille : une valeur declaree d'un cote, consommee de l'autre, rien qui verifie la jonction. 1. Aucune regle ne s'appliquait sur le chemin d'un vrai utilisateur. Sonde : « abcd » refuse par l'operation etendue LDAP, accepte par Keycloak (204), puis actif pour l'authentification. Keycloak ecrivait userPassword en direct (ppolicy aveugle) et le realm n'avait aucune passwordPolicy. 2. ldap_entry ne fait que CREER : la politique etait figee a sa creation. Le depot disait pwdMustChange TRUE, le serveur FALSE — une reconstruction from-zero aurait ressuscite la boucle du 2026-08-07. ldap_attrs state=exact reconcilie la politique et l'overlay (DN lu, pas devine). 3. syncRegistrations absent : un compte cree dans Keycloak n'atteignait jamais ou=people — acces web, aucune boite, invisible du modele de groupes. 4. Le prenom pointait sur cn (nom complet) : « Administrateur systeme systeme ». Ajoute roles/resoudre_politique_mdp : LA declaration, traduite en pwdPolicy, passwordPolicy et anti-force-brute. Les deux roles la consomment sans la redeclarer. usePasswordModifyExtendedOp ET validatePasswordPolicy : la seconde est porteuse, Keycloak se liant en rootDN et slapd n'appliquant pas ses controles de qualite au rootDN. La premiere seule aurait paru juste sans tenir. Verification : abcd -> 400 « minimum length 12 » et absent de LDAP ; mot de passe conforme -> 204 puis ldapwhoami accepte ; POST users -> 201 ET present dans ou=people. Second passage des deux playbooks : changed=0. Co-Authored-By: Claude Opus 5 --- CHANGELOG.md | 53 ++++++++- docs/autorisation.md | 36 +++++- roles/amorcage_acces/tasks/main.yml | 4 + roles/resoudre_politique_mdp/README.md | 34 ++++++ .../resoudre_politique_mdp/defaults/main.yml | 34 ++++++ roles/resoudre_politique_mdp/tasks/main.yml | 48 ++++++++ roles/serveur_keycloak/defaults/main.yml | 34 ++++++ .../serveur_keycloak/tasks/attributs-ldap.yml | 39 +++++++ roles/serveur_keycloak/tasks/main.yml | 4 + .../serveur_keycloak/tasks/politique-mdp.yml | 103 ++++++++++++++++++ roles/serveur_openldap/defaults/main.yml | 22 +--- roles/serveur_openldap/tasks/main.yml | 55 +++++++++- 12 files changed, 444 insertions(+), 22 deletions(-) create mode 100644 roles/resoudre_politique_mdp/README.md create mode 100644 roles/resoudre_politique_mdp/defaults/main.yml create mode 100644 roles/resoudre_politique_mdp/tasks/main.yml create mode 100644 roles/serveur_keycloak/tasks/attributs-ldap.yml create mode 100644 roles/serveur_keycloak/tasks/politique-mdp.yml diff --git a/CHANGELOG.md b/CHANGELOG.md index 60f1c6a..d8e9c9b 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,54 @@ # CHANGELOG — Set-OPS +## 2026-08-08 — La politique de mot de passe existait des deux côtés et ne s'appliquait d'aucun + +Question de l'exploitant : « l'intégration Keycloak/LDAP est incomplète, non ? » Elle +l'était, et pas cosmétiquement. Quatre défauts mesurés, tous de la même famille — une +valeur déclarée d'un côté, consommée de l'autre, sans que rien ne vérifie qu'elles se +rejoignent. + +**1. Aucune règle de mot de passe ne s'appliquait sur le chemin d'un vrai utilisateur.** +Compte sonde, même mot de passe `abcd` : l'opération étendue LDAP le refuse +(`Constraint violation (19) — Password fails quality checking policy`), Keycloak l'accepte +(`204`), et `ldapwhoami` avec `abcd` réussit ensuite. Deux causes empilées : Keycloak +écrivait `userPassword` **directement**, donc l'overlay `ppolicy` n'interceptait rien ; et +le realm n'avait aucune `passwordPolicy`. Chacun déléguait la vérification à l'autre. + +**2. `ldap_entry` ne fait que créer.** L'entrée `cn=default,ou=policies` était figée à ce +qu'elle valait le jour de sa création : toute modification ultérieure de la déclaration +était ignorée en silence. Le dépôt annonçait `pwdMustChange: TRUE`, le serveur portait +`FALSE` (corrigé à la main après la boucle de changement de mot de passe du 2026-08-07). +Une reconstruction from-zero aurait donc **ressuscité** le défaut. `ldap_attrs state: exact` +réconcilie désormais la politique **et** les réglages de l'overlay — dont le DN, qui porte +un index attribué par slapd, est lu et non deviné. + +**3. Un compte créé dans Keycloak n'atteignait jamais l'annuaire.** `POST users` → `201`, +rien dans `ou=people` : `syncRegistrations` était absent. Ce compte aurait eu un accès web, +aucune boîte aux lettres, et serait resté invisible du modèle de groupes — Postfix et +Dovecot lisent LDAP, pas Keycloak. C'est la divergence nettoyée le matin même sur l'adresse +du sysadmin, réintroduite par une autre porte. + +**4. Le prénom était mappé sur `cn`.** Dans `inetOrgPerson`, `cn` porte le nom *complet* : +Keycloak affichait « Administrateur systeme systeme ». Le mappeur pointe désormais sur +`givenName`, que `amorcage_acces` écrit, dérivé par le même découpage que `sn`. + +**Ce qui est ajouté.** `roles/resoudre_politique_mdp/` porte **la** déclaration, en termes +neutres, et la traduit dans les trois dialectes qui doivent l'appliquer : `pwdPolicy`, +`passwordPolicy` du realm, protection anti-force-brute. `serveur_openldap` et +`serveur_keycloak` la consomment ; aucun des deux ne la redéclare. + +La fédération est durcie de six clés, dont deux portent la correction et se complètent : +`usePasswordModifyExtendedOp` (slapd voit passer le changement) et `validatePasswordPolicy` +(Keycloak valide avant d'écrire). La seconde est la porteuse — Keycloak se lie en rootDN, et +slapd n'applique pas ses contrôles de qualité au rootDN. S'en remettre à la première seule +aurait donné une correction qui *paraît* juste et ne tient pas ; c'est le test qui a tranché, +pas le raisonnement. + +**Vérification, mêmes sondes qu'au diagnostic** — `abcd` → `400 Invalid password: minimum +length 12`, absent de LDAP ; mot de passe conforme → `204` puis `ldapwhoami` accepté (l'écriture +traversante reste intacte) ; `POST users` → `201` **et** `dn: uid=setops-sonde2,ou=people,…`. +Sondes supprimées des deux côtés. Second passage des deux playbooks : `changed=0`. + ## 2026-08-08 — « Mot de passe oublié » : Keycloak sait enfin envoyer Le realm affichait une politique d'accès complète et **aucun moyen d'écrire à qui que ce @@ -114,13 +163,13 @@ pu s'authentifier pour poser la nouvelle valeur. ``` C'est une **procédure**, pas un redéploiement — et la même contrainte vaut pour -`vault_openldap_admin`, qui reste à faire. Consigné au runbook (§6.8), avec le rappel qu'une +`vault_openldap_admin`, qui reste à faire. Consigné au runbook (§6.9), avec le rappel qu'une vérification n'est pas un message de succès. ### La rotation des comptes de secours devient possible — elle ne l'était pas -Le runbook §6.7 promettait de régénérer les comptes de secours ; **le code ne savait pas le +Le runbook §6.8 promettait de régénérer les comptes de secours ; **le code ne savait pas le faire**. Les trois rôles ne posaient le mot de passe qu'à la *création* : ``` diff --git a/docs/autorisation.md b/docs/autorisation.md index 92e1703..4408092 100644 --- a/docs/autorisation.md +++ b/docs/autorisation.md @@ -218,7 +218,39 @@ ldapsearch -x -H ldapi:/// -D "cn=admin,$BASE" -W -b "uid=,ou=people,$BASE" # Identity providers → LDAP → Sync all users ``` -### 6.7 Ce que tu dois changer en priorité +### 6.7 La politique de mot de passe : une déclaration, deux exécutants + +Les règles sont déclarées **une seule fois**, dans `resoudre_politique_mdp`, en termes +neutres. Le rôle les traduit dans les deux dialectes qui doivent les appliquer : +`pwdPolicy` pour OpenLDAP, `passwordPolicy` pour le realm. Changer la longueur minimale +se fait à un seul endroit. + +Il a fallu en arriver là parce que les deux se renvoyaient la balle. LDAP portait bien les +règles — mais il ne sait que **refuser**, sans jamais dire pourquoi à l'écran. Keycloak, +lui, ne validait rien, et écrivait `userPassword` directement : l'overlay `ppolicy` ne +voyait même pas passer le changement. Mesuré le 2026-08-08 avec un compte sonde, le même +`abcd` était refusé par l'opération étendue LDAP et **accepté** par Keycloak, puis actif +pour l'authentification. + +Deux réglages de la fédération tiennent la correction, et il faut les deux : + +| Réglage | Ce qu'il répare | +|---|---| +| `usePasswordModifyExtendedOp` | slapd voit le changement passer, donc `ppolicy` s'applique | +| `validatePasswordPolicy` | Keycloak valide la politique du realm **avant** d'écrire | + +Le second est le contrôle porteur : Keycloak se lie en rootDN, et slapd n'applique pas ses +contrôles de qualité au rootDN. L'utilisateur voit désormais *« Invalid password: minimum +length 12 »* au lieu d'un refus muet. + +**`pwdMustChange` reste délibérément à `FALSE`.** Il signifie « quand un *administrateur* +pose un mot de passe, l'utilisateur devra le changer ». Comme Keycloak se lie en rootDN, +tout changement qui passe par lui *est* un changement administrateur : l'utilisateur +choisissait un nouveau mot de passe, `ppolicy` reposait aussitôt `pwdReset`, et l'écran +redemandait un changement — sans fin. Le changement forcé à la première connexion est +obtenu par l'action requise `UPDATE_PASSWORD` de Keycloak, qui, elle, se consomme. + +### 6.8 Ce que tu dois changer en priorité 1. **Le mot de passe d'amorçage** — imposé dès la première connexion, tu n'as pas le choix (§3). @@ -226,7 +258,7 @@ ldapsearch -x -H ldapi:/// -D "cn=admin,$BASE" -W -b "uid=,ou=people,$BASE" déploiement y a eu accès. Les régénérer transfère réellement le contrôle. 3. **Le mot de passe de la voûte** elle-même, si le dépôt change de mains. -### 6.8 Faire tourner un secret : l'ordre n'est pas indifférent +### 6.9 Faire tourner un secret : l'ordre n'est pas indifférent **Les comptes de secours des services** (`vault_grafana_admin`, `vault_forgejo_admin`, `vault_nextcloud_admin`) se régénèrent en voûte, puis un redéploiement les applique. Les rôles diff --git a/roles/amorcage_acces/tasks/main.yml b/roles/amorcage_acces/tasks/main.yml index 8d64bd5..bdf055b 100644 --- a/roles/amorcage_acces/tasks/main.yml +++ b/roles/amorcage_acces/tasks/main.yml @@ -130,6 +130,10 @@ uid: "{{ amorcage_acces_uid }}" cn: "{{ amorcage_acces_nom }}" sn: "{{ amorcage_acces_nom.split(' ') | last }}" + # `cn` porte le nom complet ; le prenom a son propre attribut, et c'est lui + # que Keycloak doit lire — sinon il affiche le nom entier suivi du nom de + # famille. Derive comme `sn`, par le meme decoupage. + givenName: "{{ (amorcage_acces_nom.split(' ')[:-1] | join(' ')) | default(amorcage_acces_nom, true) }}" userPassword: "{{ amorcage_acces_hache.stdout | trim }}" pwdReset: "{{ 'TRUE' if amorcage_acces_reset_possible | bool else omit }}" mail: "{{ amorcage_acces_courriel | default(omit, true) }}" diff --git a/roles/resoudre_politique_mdp/README.md b/roles/resoudre_politique_mdp/README.md new file mode 100644 index 0000000..6270092 --- /dev/null +++ b/roles/resoudre_politique_mdp/README.md @@ -0,0 +1,34 @@ +# resoudre_politique_mdp + +Rôle **utilitaire** (non déployable) : porte *la* déclaration de la politique de mot de +passe et la traduit dans les deux dialectes qui doivent l'appliquer. + +| Dialecte | Consommateur | Sortie | +|---|---|---| +| `pwdPolicy` (ppolicy) | `serveur_openldap` | `resoudre_politique_mdp_ldap` | +| `passwordPolicy` du realm | `serveur_keycloak` | `resoudre_politique_mdp_keycloak` | +| anti-force-brute du realm | `serveur_keycloak` | `resoudre_politique_mdp_brute` | + +## Pourquoi + +Les règles n'existaient que côté LDAP. LDAP ne sait que **refuser** — il ne dit jamais à +l'écran *pourquoi*. Keycloak, lui, ne validait rien : `passwordPolicy` était vide. Chacun +déléguait la vérification à l'autre. + +Mesuré le 2026-08-08 avec un compte sonde, le même mot de passe `abcd` : + +``` +opération étendue LDAP → Constraint violation (19) — "Password fails quality checking policy" +Keycloak reset-password → 204, puis ldapwhoami avec "abcd" : accepté +``` + +Deux causes empilées : Keycloak écrivait `userPassword` **directement** en tant que rootDN, +donc l'overlay `ppolicy` n'interceptait rien ; et le realm n'avait aucune politique propre. +La correction tient aux deux bouts — `usePasswordModifyExtendedOp` côté fédération, et la +politique projetée ici. + +## Ce que ce rôle ne fait pas + +Il ne compare pas les deux dialectes après coup. Ils ne sont pas équivalents : `pwdCheckQuality` +d'OpenLDAP délègue à un module externe, `notUsername`/`notEmail` de Keycloak sont des règles +précises. La garantie porte sur la **source**, pas sur une équivalence terme à terme. diff --git a/roles/resoudre_politique_mdp/defaults/main.yml b/roles/resoudre_politique_mdp/defaults/main.yml new file mode 100644 index 0000000..1313587 --- /dev/null +++ b/roles/resoudre_politique_mdp/defaults/main.yml @@ -0,0 +1,34 @@ +--- +# LA declaration de la politique de mot de passe — une seule, en termes neutres. +# Les deux dialectes (ppolicy OpenLDAP, passwordPolicy Keycloak) en sont DERIVES. +# +# Pourquoi un role a part : les regles vivaient dans `serveur_openldap_ppolicy` et +# nulle part ailleurs. LDAP ne sait que refuser, sans jamais dire pourquoi a l'ecran ; +# Keycloak, lui, ne validait rien du tout (`passwordPolicy` vide). Chacun des deux +# deleguait la verification a l'autre, et personne ne la faisait : mesure du +# 2026-08-08, `abcd` refuse par l'operation etendue LDAP et accepte par Keycloak, +# puis actif pour l'authentification. + +resoudre_politique_mdp_longueur_min: 12 +# Interdit de reprendre un mot de passe recent (0 = pas d'historique). +resoudre_politique_mdp_historique: 3 +# Refuse un mot de passe qui contient l'identifiant ou l'adresse. +resoudre_politique_mdp_interdire_identifiant: true + +# Verrouillage apres echecs repetes. Cote LDAP c'est `ppolicy`, cote Keycloak c'est +# la protection anti-force-brute du realm — deux mecanismes distincts, memes chiffres. +resoudre_politique_mdp_verrouillage_actif: true +resoudre_politique_mdp_verrouillage_echecs: 5 +resoudre_politique_mdp_verrouillage_duree: 900 # secondes +resoudre_politique_mdp_verrouillage_fenetre: 900 # secondes + +# `pwdMustChange` — DELIBEREMENT a false, et ce n'est pas un oubli. +# +# Il signifie « quand un ADMINISTRATEUR pose un mot de passe, l'utilisateur devra le +# changer ». Or Keycloak se lie en rootDN : tout changement passe par lui EST un +# changement administrateur. L'utilisateur choisissait donc un nouveau mot de passe, +# ppolicy reposait aussitot `pwdReset`, et l'ecran redemandait un changement — sans +# fin. Vecu le 2026-08-07. Le mecanisme est incompatible par construction avec un IdP +# qui relaie le changement ; le changement force a la premiere connexion est obtenu +# par l'action requise UPDATE_PASSWORD de Keycloak, qui, elle, se consomme. +resoudre_politique_mdp_changement_impose: false diff --git a/roles/resoudre_politique_mdp/tasks/main.yml b/roles/resoudre_politique_mdp/tasks/main.yml new file mode 100644 index 0000000..aace623 --- /dev/null +++ b/roles/resoudre_politique_mdp/tasks/main.yml @@ -0,0 +1,48 @@ +--- +# Role utilitaire (pas un groupe deployable) : traduit LA declaration unique dans les +# deux dialectes. Voir defaults/main.yml. +# +# Sorties (facts) : +# resoudre_politique_mdp_ldap — attributs pwd* de l'entree pwdPolicy +# resoudre_politique_mdp_keycloak — chaine `passwordPolicy` du realm +# resoudre_politique_mdp_brute — protection anti-force-brute du realm + +- name: Exiger une politique coherente + ansible.builtin.assert: + that: + - resoudre_politique_mdp_longueur_min | int >= 8 + - resoudre_politique_mdp_historique | int >= 0 + fail_msg: >- + Politique de mot de passe incoherente : une longueur minimale sous 8 caracteres + ne protege rien, et un historique negatif n'a pas de sens. + +- name: Dériver la politique dans les deux dialectes + ansible.builtin.set_fact: + resoudre_politique_mdp_ldap: >- + {{ { + 'pwdAttribute': 'userPassword', + 'pwdCheckQuality': '1', + 'pwdMinLength': resoudre_politique_mdp_longueur_min | string, + 'pwdInHistory': resoudre_politique_mdp_historique | string, + 'pwdMustChange': 'TRUE' if resoudre_politique_mdp_changement_impose | bool else 'FALSE', + 'pwdAllowUserChange': 'TRUE', + 'pwdLockout': 'TRUE' if resoudre_politique_mdp_verrouillage_actif | bool else 'FALSE', + 'pwdMaxFailure': resoudre_politique_mdp_verrouillage_echecs | string, + 'pwdLockoutDuration': resoudre_politique_mdp_verrouillage_duree | string, + 'pwdFailureCountInterval': resoudre_politique_mdp_verrouillage_fenetre | string, + } }} + resoudre_politique_mdp_keycloak: >- + {{ ( + ['length(' ~ resoudre_politique_mdp_longueur_min | int ~ ')'] + + (['passwordHistory(' ~ resoudre_politique_mdp_historique | int ~ ')'] + if resoudre_politique_mdp_historique | int > 0 else []) + + (['notUsername', 'notEmail'] if resoudre_politique_mdp_interdire_identifiant | bool else []) + ) | join(' and ') }} + resoudre_politique_mdp_brute: >- + {{ { + 'bruteForceProtected': resoudre_politique_mdp_verrouillage_actif | bool, + 'failureFactor': resoudre_politique_mdp_verrouillage_echecs | int, + 'waitIncrementSeconds': resoudre_politique_mdp_verrouillage_duree | int, + 'maxFailureWaitSeconds': resoudre_politique_mdp_verrouillage_duree | int, + 'maxDeltaTimeSeconds': resoudre_politique_mdp_verrouillage_fenetre | int, + } }} diff --git a/roles/serveur_keycloak/defaults/main.yml b/roles/serveur_keycloak/defaults/main.yml index b48d713..50979ac 100644 --- a/roles/serveur_keycloak/defaults/main.yml +++ b/roles/serveur_keycloak/defaults/main.yml @@ -142,3 +142,37 @@ serveur_keycloak_smtp_hote: "" # « Mot de passe oublie » sur l'ecran de connexion. Exige le SMTP ci-dessus et une # adresse `mail` sur le compte — sans elle, Keycloak n'a nulle part ou ecrire. serveur_keycloak_reset_mot_de_passe: true + +# --- Durcissement de la federation LDAP --------------------------------------- +# Forme de l'API (chaque valeur est une LISTE de chaines) : on ecrit le dialecte +# reel plutot que de le fabriquer en Jinja. +# +# `usePasswordModifyExtendedOp` — sans lui, Keycloak ecrit l'attribut `userPassword` +# a la main et l'overlay `ppolicy` de slapd ne voit rien passer. +# `validatePasswordPolicy` — fait valider la politique DU REALM avant l'ecriture. +# C'est le controle porteur : Keycloak se lie en rootDN, et slapd n'applique pas +# ses controles de qualite au rootDN. Les deux cles ensemble, pas l'une ou l'autre. +# `syncRegistrations` — sans lui, un compte cree dans Keycloak n'atteint JAMAIS +# l'annuaire (mesure du 2026-08-08 : `POST users` -> 201, rien dans `ou=people`). +# Il aurait un acces web, aucune boite aux lettres, et serait invisible du modele +# de groupes : Postfix et Dovecot lisent LDAP, pas Keycloak. +# `trustEmail` — l'adresse vient de l'annuaire, qui est la source de verite ; la +# faire re-verifier par courriel demanderait a l'utilisateur de confirmer ce que +# l'exploitant a deja etabli. +# Les delais — un annuaire qui PEND, plutot qu'il refuse, bloque sinon les fils de +# Keycloak sans limite. +serveur_keycloak_ldap_durcissement: + usePasswordModifyExtendedOp: ["true"] + validatePasswordPolicy: ["true"] + syncRegistrations: ["true"] + trustEmail: ["true"] + connectionTimeout: ["5000"] + readTimeout: ["10000"] + +# Mappeurs d'attributs a reconcilier : nom du mappeur -> attribut LDAP. Voir +# tasks/attributs-ldap.yml pour ce que le defaut de Keycloak faisait de travers. +serveur_keycloak_ldap_attributs: + first name: "givenName" + last name: "sn" + email: "mail" + username: "uid" diff --git a/roles/serveur_keycloak/tasks/attributs-ldap.yml b/roles/serveur_keycloak/tasks/attributs-ldap.yml new file mode 100644 index 0000000..7729a02 --- /dev/null +++ b/roles/serveur_keycloak/tasks/attributs-ldap.yml @@ -0,0 +1,39 @@ +--- +# Reconcilie les mappeurs d'attributs de la federation. Keycloak en cree un jeu par +# defaut a la creation du fournisseur, et pour un annuaire « other » il fait pointer +# le prenom sur `cn` — qui, dans `inetOrgPerson`, porte le nom COMPLET. Resultat +# affiche au 2026-08-08 : « Administrateur systeme systeme », le nom entier suivi du +# nom de famille. Le prenom a son attribut, c'est `givenName`. + +- name: Lire les mappeurs de la fédération + ansible.builtin.uri: + url: >- + http://localhost:8080/admin/realms/{{ serveur_keycloak_realm }}/components?parent={{ + serveur_keycloak_pol_fed.json[0].id }}&type=org.keycloak.storage.ldap.mappers.LDAPStorageMapper + headers: + Authorization: "Bearer {{ serveur_keycloak_pol_jeton.json.access_token }}" + register: serveur_keycloak_mappeurs + no_log: true + +- name: Corriger l'attribut LDAP des mappeurs qui divergent + ansible.builtin.uri: + url: >- + http://localhost:8080/admin/realms/{{ serveur_keycloak_realm + }}/components/{{ item.id }} + method: PUT + status_code: [204] + headers: + Authorization: "Bearer {{ serveur_keycloak_pol_jeton.json.access_token }}" + body_format: json + body: >- + {{ item | combine({'config': item.config | combine({ + 'ldap.attribute': [serveur_keycloak_ldap_attributs[item.name]] + })}) }} + loop: "{{ serveur_keycloak_mappeurs.json }}" + loop_control: + label: "{{ item.name }}" + no_log: true + changed_when: true + when: + - item.name in serveur_keycloak_ldap_attributs + - (item.config['ldap.attribute'] | default(['']) | first) != serveur_keycloak_ldap_attributs[item.name] diff --git a/roles/serveur_keycloak/tasks/main.yml b/roles/serveur_keycloak/tasks/main.yml index 815c01e..9acbbfb 100644 --- a/roles/serveur_keycloak/tasks/main.yml +++ b/roles/serveur_keycloak/tasks/main.yml @@ -163,6 +163,10 @@ ansible.builtin.include_tasks: federation-ldap.yml when: serveur_keycloak_ldap_federation | bool +- name: Appliquer la politique de mot de passe et durcir la fédération + ansible.builtin.include_tasks: politique-mdp.yml + when: not ansible_check_mode + - name: Configurer le courriel du realm (« mot de passe oublié ») ansible.builtin.include_tasks: courriel-realm.yml when: serveur_keycloak_smtp_actif | bool diff --git a/roles/serveur_keycloak/tasks/politique-mdp.yml b/roles/serveur_keycloak/tasks/politique-mdp.yml new file mode 100644 index 0000000..cb063d9 --- /dev/null +++ b/roles/serveur_keycloak/tasks/politique-mdp.yml @@ -0,0 +1,103 @@ +--- +# Projette LA declaration unique (`resoudre_politique_mdp`) dans le realm, et durcit +# la federation pour que l'annuaire soit consulte au lieu d'etre contourne. +# +# Sans cela, les regles n'existaient QUE cote LDAP — et LDAP ne sait que refuser, +# sans jamais dire pourquoi a l'ecran. Pire : Keycloak ecrivait `userPassword` +# directement, donc l'overlay `ppolicy` n'interceptait meme pas. Mesure du +# 2026-08-08 : `abcd` refuse par l'operation etendue LDAP, accepte par Keycloak, +# puis actif pour l'authentification. +# +# La conformite se teste partout ici de la meme facon : fusionner l'attendu dans le +# reel ne change rien => c'est deja conforme. Ca evite d'enumerer les champs deux fois. + +- name: Résoudre la politique de mot de passe (déclaration unique) + ansible.builtin.include_role: + name: resoudre_politique_mdp + +- name: Obtenir un jeton d'administration + ansible.builtin.uri: + url: "http://localhost:8080/realms/master/protocol/openid-connect/token" + method: POST + body_format: form-urlencoded + body: + grant_type: password + client_id: admin-cli + username: "{{ serveur_keycloak_admin_user }}" + password: "{{ serveur_keycloak_admin_password }}" + register: serveur_keycloak_pol_jeton + no_log: true + +- name: Lire le realm + ansible.builtin.uri: + url: "http://localhost:8080/admin/realms/{{ serveur_keycloak_realm }}" + headers: + Authorization: "Bearer {{ serveur_keycloak_pol_jeton.json.access_token }}" + register: serveur_keycloak_pol_realm + no_log: true + +- name: Composer l'état attendu du realm + ansible.builtin.set_fact: + serveur_keycloak_realm_attendu: >- + {{ {'passwordPolicy': resoudre_politique_mdp_keycloak} + | combine(resoudre_politique_mdp_brute) }} + +- name: Appliquer la politique de mot de passe au realm + ansible.builtin.uri: + url: "http://localhost:8080/admin/realms/{{ serveur_keycloak_realm }}" + method: PUT + status_code: [204] + headers: + Authorization: "Bearer {{ serveur_keycloak_pol_jeton.json.access_token }}" + body_format: json + body: "{{ serveur_keycloak_realm_attendu }}" + no_log: true + changed_when: true + when: >- + (serveur_keycloak_pol_realm.json | combine(serveur_keycloak_realm_attendu)) + != serveur_keycloak_pol_realm.json + +# --- Durcissement de la federation -------------------------------------------- +# Voir `serveur_keycloak_ldap_durcissement` (defaults) pour ce que chaque cle repare. +- name: Lire la fédération LDAP + ansible.builtin.uri: + url: >- + http://localhost:8080/admin/realms/{{ serveur_keycloak_realm + }}/components?type=org.keycloak.storage.UserStorageProvider + headers: + Authorization: "Bearer {{ serveur_keycloak_pol_jeton.json.access_token }}" + register: serveur_keycloak_pol_fed + no_log: true + +- name: Exiger une fédération LDAP en place + ansible.builtin.assert: + that: + - (serveur_keycloak_pol_fed.json | length) == 1 + fail_msg: >- + {{ serveur_keycloak_pol_fed.json | length }} fournisseur(s) de stockage + d'utilisateurs dans le realme « {{ serveur_keycloak_realm }} » — il en faut + exactement un. Durcir le mauvais serait pire que ne rien durcir. + +- name: Durcir la fédération LDAP + ansible.builtin.uri: + url: >- + http://localhost:8080/admin/realms/{{ serveur_keycloak_realm + }}/components/{{ serveur_keycloak_pol_fed.json[0].id }} + method: PUT + status_code: [204] + headers: + Authorization: "Bearer {{ serveur_keycloak_pol_jeton.json.access_token }}" + body_format: json + body: >- + {{ serveur_keycloak_pol_fed.json[0] | combine({ + 'config': serveur_keycloak_pol_fed.json[0].config + | combine(serveur_keycloak_ldap_durcissement) + }) }} + no_log: true + changed_when: true + when: >- + (serveur_keycloak_pol_fed.json[0].config | combine(serveur_keycloak_ldap_durcissement)) + != serveur_keycloak_pol_fed.json[0].config + +- name: Reconcilier les mappeurs d'attributs + ansible.builtin.include_tasks: attributs-ldap.yml diff --git a/roles/serveur_openldap/defaults/main.yml b/roles/serveur_openldap/defaults/main.yml index 9823950..208684c 100644 --- a/roles/serveur_openldap/defaults/main.yml +++ b/roles/serveur_openldap/defaults/main.yml @@ -45,22 +45,12 @@ serveur_openldap_ppolicy_actif: true # `pwdMustChange` est ce qui donne son effet a `pwdReset` : sans lui, marquer une # entree n'oblige a rien. Les deux vont ensemble. -serveur_openldap_ppolicy: - pwdMustChange: "TRUE" - # Qualite : 1 = verifier si possible, accepter sinon. `pwdMinLength` n'est - # applique QUE si `pwdCheckQuality` > 0. - pwdCheckQuality: "1" - pwdMinLength: "12" - # Verrouillage apres echecs repetes. `pwdLockoutDuration` en secondes. - pwdLockout: "TRUE" - pwdMaxFailure: "5" - pwdFailureCountInterval: "900" - pwdLockoutDuration: "900" - # Historique : interdit de reprendre un mot de passe recent. - pwdInHistory: "3" - # L'utilisateur peut changer son propre mot de passe — indispensable, puisque - # `pwdMustChange` l'y oblige. - pwdAllowUserChange: "TRUE" +# Les REGLES elles-memes ne sont plus declarees ici : elles vivent dans +# `resoudre_politique_mdp`, qui les traduit AUSSI pour Keycloak. Tant qu'elles +# n'existaient que de ce cote, Keycloak n'en validait aucune et le chemin que +# prend un vrai utilisateur ne verifiait rien (mesure du 2026-08-08). +# Laisser vide ; ne surcharger que pour ajouter un attribut pwd* hors politique. +serveur_openldap_ppolicy_extra: {} # Reglages de l'OVERLAY lui-meme (pas de la politique). `HashCleartext` hache un # mot de passe recu en clair au lieu de le stocker tel quel : filet de securite si diff --git a/roles/serveur_openldap/tasks/main.yml b/roles/serveur_openldap/tasks/main.yml index 9f41e51..e77b655 100644 --- a/roles/serveur_openldap/tasks/main.yml +++ b/roles/serveur_openldap/tasks/main.yml @@ -101,6 +101,11 @@ - serveur_openldap_ppolicy_actif | bool - (serveur_openldap_base_mdb.results | default([]) | length) > 0 +- name: Ppolicy — Résoudre la politique (déclaration unique) + ansible.builtin.include_role: + name: resoudre_politique_mdp + when: serveur_openldap_ppolicy_actif | bool + - name: Ppolicy — Creer l unite des politiques community.general.ldap_entry: dn: "ou=policies,{{ serveur_openldap_base_dn }}" @@ -138,14 +143,60 @@ - person - top attributes: >- - {{ {'cn': 'default', 'sn': 'default', - 'pwdAttribute': 'userPassword'} | combine(serveur_openldap_ppolicy) }} + {{ {'cn': 'default', 'sn': 'default'} + | combine(resoudre_politique_mdp_ldap, serveur_openldap_ppolicy_extra) }} server_uri: "ldapi:///" bind_dn: "{{ serveur_openldap_admin_dn }}" bind_pw: "{{ serveur_openldap_admin_password }}" no_log: true when: serveur_openldap_ppolicy_actif | bool +# `ldap_entry` ne fait que CREER : l'entree restait figee a ce qu'elle valait le +# jour de sa creation, et toute modification ulterieure de la declaration etait +# ignoree en silence. Constate le 2026-08-08 — le depot annoncait +# `pwdMustChange: TRUE`, le serveur portait `FALSE` (corrige a la main apres la +# boucle de changement de mot de passe du 2026-08-07), et une reconstruction +# from-zero aurait ressuscite le defaut. Il faut donc RECONCILIER en plus. +- name: Ppolicy — Reconcilier la politique par defaut + community.general.ldap_attrs: + dn: "cn=default,ou=policies,{{ serveur_openldap_base_dn }}" + attributes: "{{ resoudre_politique_mdp_ldap | combine(serveur_openldap_ppolicy_extra) }}" + state: exact + server_uri: "ldapi:///" + bind_dn: "{{ serveur_openldap_admin_dn }}" + bind_pw: "{{ serveur_openldap_admin_password }}" + no_log: true + when: serveur_openldap_ppolicy_actif | bool + +# Meme raison : l'overlay non plus n'etait pose qu'a la creation. Son DN porte un +# index d'ordre (`{0}`, `{1}`…) attribue par slapd — on le LIT, on ne le devine pas. +- name: Ppolicy — Trouver le DN de l overlay + community.general.ldap_search: + dn: "{{ serveur_openldap_mdb_dn }}" + scope: onelevel + filter: "(olcOverlay=*ppolicy)" + attrs: + - dn + server_uri: "ldapi:///" + register: serveur_openldap_overlay_ppolicy + changed_when: false + when: + - serveur_openldap_ppolicy_actif | bool + - serveur_openldap_mdb_dn is defined + +- name: Ppolicy — Reconcilier les reglages de l overlay + community.general.ldap_attrs: + dn: "{{ (serveur_openldap_overlay_ppolicy.results | first).dn }}" + attributes: + olcPPolicyDefault: "cn=default,ou=policies,{{ serveur_openldap_base_dn }}" + olcPPolicyHashCleartext: "{{ 'TRUE' if serveur_openldap_ppolicy_hash_cleartext | bool else 'FALSE' }}" + olcPPolicyUseLockout: "{{ 'TRUE' if serveur_openldap_ppolicy_use_lockout | bool else 'FALSE' }}" + state: exact + server_uri: "ldapi:///" + when: + - serveur_openldap_ppolicy_actif | bool + - (serveur_openldap_overlay_ppolicy.results | default([]) | length) > 0 + # --- TLS (LDAPS + STARTTLS) via le certificat step_ca depose par client_pki --- - name: TLS — creer le repertoire pont lisible par openldap ansible.builtin.file: