identite : une declaration de politique de mot de passe, deux executants
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 <noreply@anthropic.com>
This commit is contained in:
parent
775df924cb
commit
419ecaba63
12 changed files with 444 additions and 22 deletions
53
CHANGELOG.md
53
CHANGELOG.md
|
|
@ -1,5 +1,54 @@
|
||||||
# CHANGELOG — Set-OPS
|
# 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
|
## 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
|
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
|
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.
|
vérification n'est pas un message de succès.
|
||||||
|
|
||||||
|
|
||||||
### La rotation des comptes de secours devient possible — elle ne l'était pas
|
### 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* :
|
faire**. Les trois rôles ne posaient le mot de passe qu'à la *création* :
|
||||||
|
|
||||||
```
|
```
|
||||||
|
|
|
||||||
|
|
@ -218,7 +218,39 @@ ldapsearch -x -H ldapi:/// -D "cn=admin,$BASE" -W -b "uid=<uid>,ou=people,$BASE"
|
||||||
# Identity providers → LDAP → Sync all users
|
# 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
|
1. **Le mot de passe d'amorçage** — imposé dès la première connexion, tu n'as pas le
|
||||||
choix (§3).
|
choix (§3).
|
||||||
|
|
@ -226,7 +258,7 @@ ldapsearch -x -H ldapi:/// -D "cn=admin,$BASE" -W -b "uid=<uid>,ou=people,$BASE"
|
||||||
déploiement y a eu accès. Les régénérer transfère réellement le contrôle.
|
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.
|
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`,
|
**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
|
`vault_nextcloud_admin`) se régénèrent en voûte, puis un redéploiement les applique. Les rôles
|
||||||
|
|
|
||||||
|
|
@ -130,6 +130,10 @@
|
||||||
uid: "{{ amorcage_acces_uid }}"
|
uid: "{{ amorcage_acces_uid }}"
|
||||||
cn: "{{ amorcage_acces_nom }}"
|
cn: "{{ amorcage_acces_nom }}"
|
||||||
sn: "{{ amorcage_acces_nom.split(' ') | last }}"
|
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 }}"
|
userPassword: "{{ amorcage_acces_hache.stdout | trim }}"
|
||||||
pwdReset: "{{ 'TRUE' if amorcage_acces_reset_possible | bool else omit }}"
|
pwdReset: "{{ 'TRUE' if amorcage_acces_reset_possible | bool else omit }}"
|
||||||
mail: "{{ amorcage_acces_courriel | default(omit, true) }}"
|
mail: "{{ amorcage_acces_courriel | default(omit, true) }}"
|
||||||
|
|
|
||||||
34
roles/resoudre_politique_mdp/README.md
Normal file
34
roles/resoudre_politique_mdp/README.md
Normal file
|
|
@ -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.
|
||||||
34
roles/resoudre_politique_mdp/defaults/main.yml
Normal file
34
roles/resoudre_politique_mdp/defaults/main.yml
Normal file
|
|
@ -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
|
||||||
48
roles/resoudre_politique_mdp/tasks/main.yml
Normal file
48
roles/resoudre_politique_mdp/tasks/main.yml
Normal file
|
|
@ -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,
|
||||||
|
} }}
|
||||||
|
|
@ -142,3 +142,37 @@ serveur_keycloak_smtp_hote: ""
|
||||||
# « Mot de passe oublie » sur l'ecran de connexion. Exige le SMTP ci-dessus et une
|
# « 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.
|
# adresse `mail` sur le compte — sans elle, Keycloak n'a nulle part ou ecrire.
|
||||||
serveur_keycloak_reset_mot_de_passe: true
|
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"
|
||||||
|
|
|
||||||
39
roles/serveur_keycloak/tasks/attributs-ldap.yml
Normal file
39
roles/serveur_keycloak/tasks/attributs-ldap.yml
Normal file
|
|
@ -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]
|
||||||
|
|
@ -163,6 +163,10 @@
|
||||||
ansible.builtin.include_tasks: federation-ldap.yml
|
ansible.builtin.include_tasks: federation-ldap.yml
|
||||||
when: serveur_keycloak_ldap_federation | bool
|
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é »)
|
- name: Configurer le courriel du realm (« mot de passe oublié »)
|
||||||
ansible.builtin.include_tasks: courriel-realm.yml
|
ansible.builtin.include_tasks: courriel-realm.yml
|
||||||
when: serveur_keycloak_smtp_actif | bool
|
when: serveur_keycloak_smtp_actif | bool
|
||||||
|
|
|
||||||
103
roles/serveur_keycloak/tasks/politique-mdp.yml
Normal file
103
roles/serveur_keycloak/tasks/politique-mdp.yml
Normal file
|
|
@ -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
|
||||||
|
|
@ -45,22 +45,12 @@ serveur_openldap_ppolicy_actif: true
|
||||||
|
|
||||||
# `pwdMustChange` est ce qui donne son effet a `pwdReset` : sans lui, marquer une
|
# `pwdMustChange` est ce qui donne son effet a `pwdReset` : sans lui, marquer une
|
||||||
# entree n'oblige a rien. Les deux vont ensemble.
|
# entree n'oblige a rien. Les deux vont ensemble.
|
||||||
serveur_openldap_ppolicy:
|
# Les REGLES elles-memes ne sont plus declarees ici : elles vivent dans
|
||||||
pwdMustChange: "TRUE"
|
# `resoudre_politique_mdp`, qui les traduit AUSSI pour Keycloak. Tant qu'elles
|
||||||
# Qualite : 1 = verifier si possible, accepter sinon. `pwdMinLength` n'est
|
# n'existaient que de ce cote, Keycloak n'en validait aucune et le chemin que
|
||||||
# applique QUE si `pwdCheckQuality` > 0.
|
# prend un vrai utilisateur ne verifiait rien (mesure du 2026-08-08).
|
||||||
pwdCheckQuality: "1"
|
# Laisser vide ; ne surcharger que pour ajouter un attribut pwd* hors politique.
|
||||||
pwdMinLength: "12"
|
serveur_openldap_ppolicy_extra: {}
|
||||||
# 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"
|
|
||||||
|
|
||||||
# Reglages de l'OVERLAY lui-meme (pas de la politique). `HashCleartext` hache un
|
# 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
|
# mot de passe recu en clair au lieu de le stocker tel quel : filet de securite si
|
||||||
|
|
|
||||||
|
|
@ -101,6 +101,11 @@
|
||||||
- serveur_openldap_ppolicy_actif | bool
|
- serveur_openldap_ppolicy_actif | bool
|
||||||
- (serveur_openldap_base_mdb.results | default([]) | length) > 0
|
- (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
|
- name: Ppolicy — Creer l unite des politiques
|
||||||
community.general.ldap_entry:
|
community.general.ldap_entry:
|
||||||
dn: "ou=policies,{{ serveur_openldap_base_dn }}"
|
dn: "ou=policies,{{ serveur_openldap_base_dn }}"
|
||||||
|
|
@ -138,14 +143,60 @@
|
||||||
- person
|
- person
|
||||||
- top
|
- top
|
||||||
attributes: >-
|
attributes: >-
|
||||||
{{ {'cn': 'default', 'sn': 'default',
|
{{ {'cn': 'default', 'sn': 'default'}
|
||||||
'pwdAttribute': 'userPassword'} | combine(serveur_openldap_ppolicy) }}
|
| combine(resoudre_politique_mdp_ldap, serveur_openldap_ppolicy_extra) }}
|
||||||
server_uri: "ldapi:///"
|
server_uri: "ldapi:///"
|
||||||
bind_dn: "{{ serveur_openldap_admin_dn }}"
|
bind_dn: "{{ serveur_openldap_admin_dn }}"
|
||||||
bind_pw: "{{ serveur_openldap_admin_password }}"
|
bind_pw: "{{ serveur_openldap_admin_password }}"
|
||||||
no_log: true
|
no_log: true
|
||||||
when: serveur_openldap_ppolicy_actif | bool
|
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 ---
|
# --- TLS (LDAPS + STARTTLS) via le certificat step_ca depose par client_pki ---
|
||||||
- name: TLS — creer le repertoire pont lisible par openldap
|
- name: TLS — creer le repertoire pont lisible par openldap
|
||||||
ansible.builtin.file:
|
ansible.builtin.file:
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue