Skip to content

Commit d011404

Browse files
authored
Adding quickstarts for ppolicy for LDAP servers (#790)
Related to keycloak/keycloak#52400 Signed-off-by: Alexander Schwartz <alexander.schwartz@ibm.com>
1 parent d18fe88 commit d011404

10 files changed

Lines changed: 641 additions & 0 deletions

File tree

ldap/389ds-ppolicy/Dockerfile

Lines changed: 19 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,19 @@
1+
FROM fedora:42
2+
3+
RUN dnf install -y -q 389-ds-base && dnf clean all
4+
5+
RUN dscreate create-template /tmp/ds.inf \
6+
&& sed -i \
7+
-e 's/;root_password = .*/root_password = admin1234/' \
8+
-e 's/;port = .*/port = 3389/' \
9+
-e 's/;suffix = .*/suffix = dc=example,dc=org/' \
10+
-e 's/;self_sign_cert = .*/self_sign_cert = False/' \
11+
-e 's/;create_suffix_entry = .*/create_suffix_entry = True/' \
12+
/tmp/ds.inf \
13+
&& dscreate from-file /tmp/ds.inf \
14+
&& rm /tmp/ds.inf \
15+
&& dsctl localhost stop
16+
17+
EXPOSE 3389
18+
19+
CMD ["/usr/sbin/ns-slapd", "-D", "/etc/dirsrv/slapd-localhost", "-d", "0", "-i", "/run/dirsrv/slapd-localhost.pid"]

ldap/389ds-ppolicy/README.md

Lines changed: 120 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,120 @@
1+
# 389 Directory Server Password Policy Test Setup
2+
3+
> **This is a test setup for development and debugging only. Do not use in production.** Passwords are stored in plain text, TLS is not configured, and access controls are minimal.
4+
5+
Test environment for the LDAP password policy control ([`draft-behera-ldap-password-policy`](https://datatracker.ietf.org/doc/html/draft-behera-ldap-password-policy-11)).
6+
Exercises both elements of the `PasswordPolicyResponseValue`:
7+
8+
- **`warning [0]`**: `timeBeforeExpiration` — password expires in ~24 hours
9+
- **`error [1]`**: `changeAfterReset(2)` — user must change password
10+
11+
Having both elements present in the response exercises the full parsing path
12+
in Keycloak's `PasswordPolicyControl`: the warning's constructed `[0]` wrapper
13+
must be correctly skipped so the following `error [1]` element is found.
14+
15+
## Prerequisites
16+
17+
- Docker and Docker Compose
18+
- `ldap-utils` (Fedora/RHEL: `sudo dnf install openldap-clients`, Debian/Ubuntu: `sudo apt install ldap-utils`)
19+
20+
## Usage
21+
22+
### Start
23+
24+
```bash
25+
docker compose up -d --build
26+
./setup.sh
27+
```
28+
29+
### Re-arm password change
30+
31+
After testing, reset the test user's password back to `changeme` and re-enable the forced password change:
32+
33+
```bash
34+
ldapmodify -x -H ldap://localhost:3389 -D "cn=Directory Manager" -w admin1234 <<'EOF'
35+
dn: uid=testuser,ou=people,dc=example,dc=org
36+
changetype: modify
37+
replace: userPassword
38+
userPassword: changeme
39+
EOF
40+
```
41+
42+
### Stop
43+
44+
```bash
45+
docker compose down -v
46+
```
47+
48+
## What the setup does
49+
50+
1. Starts 389 Directory Server (Fedora 42 based image) on port 3389
51+
2. Creates a manager account with `write` access to `userPassword` and `read` access to the directory
52+
3. Enables the global password policy: `passwordMustChange: on`, `passwordExp: on`, `passwordMaxAge: 86400`, `passwordWarning: 86401`, `passwordSendExpiringTime: on`
53+
4. Creates a test user and sets their password to `changeme`
54+
55+
The manager account is created before the password policy is enabled so that its
56+
password is not flagged for change.
57+
58+
## Expected behavior
59+
60+
1. Log in as `testuser` with password `changeme`. Keycloak redirects to the **Update Password** page.
61+
2. Enter a new password. After the update, login completes normally.
62+
3. Log out and log in again with the new password. No password change is required — login succeeds directly.
63+
64+
To repeat the test, re-arm the forced password change (see above).
65+
66+
## Gotchas
67+
68+
### Directory Manager triggers passwordMustChange automatically
69+
70+
When Directory Manager changes a user's password, 389ds automatically marks it
71+
as requiring change on the next login. There is no need to manually set
72+
`pwdReset: TRUE`.
73+
74+
### passwordSendExpiringTime
75+
76+
Setting `passwordSendExpiringTime: on` makes 389ds always include the
77+
`timeBeforeExpiration` warning in the ppolicy response control, regardless of
78+
the warning period.
79+
80+
### ldappasswd does not work over plain LDAP
81+
82+
389ds rejects the LDAP Password Modify Extended Operation over unencrypted
83+
connections. Use `ldapmodify` to change `userPassword` directly instead
84+
of `ldappasswd`. This applies to the re-arm step as well.
85+
86+
### Secure binds disabled for testing
87+
88+
The setup disables `nsslapd-require-secure-binds` so that password operations
89+
work over plain LDAP. In production, use LDAPS or StartTLS instead.
90+
91+
### Manager account for Keycloak
92+
93+
The manager account is a regular user, not the Directory Manager. Using
94+
Directory Manager as a Keycloak bind DN is bad practice — it has unrestricted
95+
access to the entire directory. The manager has `write` access to `userPassword`
96+
and `read` access to the rest of the tree, configured via ACIs.
97+
98+
## Accounts
99+
100+
| Account | DN | Password |
101+
|---|---|---|
102+
| Directory Manager | `cn=Directory Manager` | `admin1234` |
103+
| Manager | `uid=manager,ou=people,dc=example,dc=org` | `manager` |
104+
| Test user | `uid=testuser,ou=people,dc=example,dc=org` | `changeme` |
105+
106+
## Keycloak LDAP federation settings
107+
108+
See the [Keycloak LDAP federation documentation](https://www.keycloak.org/docs/latest/server_admin/index.html#_ldap) for general setup and the [LDAP password policy section](https://www.keycloak.org/docs/latest/server_admin/index.html#_ldap_password_policy) for enabling password change after reset.
109+
110+
| Setting | Value |
111+
|---|---|
112+
| Edit Mode | `WRITABLE` |
113+
| Vendor | `Red Hat Directory Server` |
114+
| Connection URL | `ldap://localhost:3389` |
115+
| Bind DN | `uid=manager,ou=people,dc=example,dc=org` |
116+
| Bind Credential | `manager` |
117+
| Users DN | `ou=people,dc=example,dc=org` |
118+
| Username LDAP attribute | `uid` |
119+
| Import Users | `ON` |
120+
| Enable LDAP password policy | `ON` |
Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,7 @@
1+
services:
2+
389ds:
3+
build: .
4+
container_name: 389ds-ppolicy
5+
ports:
6+
# Different port than the openldap quickstart to avoid conflicts
7+
- "3389:3389"

ldap/389ds-ppolicy/setup.sh

Lines changed: 149 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,149 @@
1+
#!/bin/bash
2+
3+
set -euo pipefail
4+
5+
LDAP_URI="ldap://localhost:3389"
6+
DM_DN="cn=Directory Manager"
7+
DM_PW="admin1234"
8+
SUFFIX="dc=example,dc=org"
9+
CONTAINER="389ds-ppolicy"
10+
11+
wait_for_ldap() {
12+
echo "Waiting for 389ds to be ready..."
13+
for i in $(seq 1 30); do
14+
if ldapsearch -x -H "$LDAP_URI" -D "$DM_DN" -w "$DM_PW" -b "" -s base vendorVersion 2>/dev/null | grep -q vendorVersion; then
15+
echo "389ds is ready."
16+
return 0
17+
fi
18+
sleep 1
19+
done
20+
echo "389ds did not become ready in time."
21+
exit 1
22+
}
23+
24+
create_ou_people() {
25+
echo "Creating ou=people..."
26+
# The Dockerfile creates the suffix entry (dc=example,dc=org) via
27+
# dscreate, but we still need the ou=people container for users.
28+
ldapadd -x -H "$LDAP_URI" -D "$DM_DN" -w "$DM_PW" <<'EOF'
29+
dn: ou=people,dc=example,dc=org
30+
objectClass: organizationalUnit
31+
ou: people
32+
EOF
33+
}
34+
35+
configure_password_policy() {
36+
echo "Configuring password policy..."
37+
# passwordSendExpiringTime makes 389ds always include timeBeforeExpiration
38+
# in the ppolicy response, regardless of the warning period.
39+
# passwordWarning is set to passwordMaxAge + 1 so the timeBeforeExpiration
40+
# warning appears immediately after a password change.
41+
# nsslapd-require-secure-binds is disabled so password operations work
42+
# over plain LDAP (this is a test setup, not production).
43+
ldapmodify -x -H "$LDAP_URI" -D "$DM_DN" -w "$DM_PW" <<'EOF'
44+
dn: cn=config
45+
changetype: modify
46+
replace: nsslapd-require-secure-binds
47+
nsslapd-require-secure-binds: off
48+
-
49+
replace: passwordMustChange
50+
passwordMustChange: on
51+
-
52+
replace: passwordExp
53+
passwordExp: on
54+
-
55+
replace: passwordMaxAge
56+
passwordMaxAge: 86400
57+
-
58+
replace: passwordWarning
59+
passwordWarning: 86401
60+
-
61+
replace: passwordSendExpiringTime
62+
passwordSendExpiringTime: on
63+
EOF
64+
}
65+
66+
create_manager() {
67+
echo "Creating manager account..."
68+
# The manager account is used as Keycloak's bind DN. Using Directory
69+
# Manager as a Keycloak bind DN is bad practice — it has unrestricted
70+
# access to the entire directory.
71+
ldapadd -x -H "$LDAP_URI" -D "$DM_DN" -w "$DM_PW" <<'EOF'
72+
dn: uid=manager,ou=people,dc=example,dc=org
73+
objectClass: inetOrgPerson
74+
uid: manager
75+
cn: LDAP Manager
76+
sn: Manager
77+
userPassword: manager
78+
EOF
79+
}
80+
81+
add_manager_aci() {
82+
echo "Granting manager read/write access..."
83+
# ACIs grant the manager write access to userPassword and read access
84+
# to the rest of the tree.
85+
ldapmodify -x -H "$LDAP_URI" -D "$DM_DN" -w "$DM_PW" <<'EOF'
86+
dn: dc=example,dc=org
87+
changetype: modify
88+
add: aci
89+
aci: (targetattr="userPassword")(version 3.0; acl "manager write userPassword"; allow (write,search) userdn="ldap:///uid=manager,ou=people,dc=example,dc=org";)
90+
-
91+
add: aci
92+
aci: (targetattr="*")(version 3.0; acl "manager read all"; allow (read,search,compare) userdn="ldap:///uid=manager,ou=people,dc=example,dc=org";)
93+
EOF
94+
}
95+
96+
create_test_user() {
97+
echo "Creating test user..."
98+
ldapadd -x -H "$LDAP_URI" -D "$DM_DN" -w "$DM_PW" <<'EOF'
99+
dn: uid=testuser,ou=people,dc=example,dc=org
100+
objectClass: inetOrgPerson
101+
uid: testuser
102+
cn: Test User
103+
sn: User
104+
mail: testuser@example.org
105+
EOF
106+
}
107+
108+
reset_test_user_password() {
109+
echo "Setting testuser password (triggers passwordMustChange)..."
110+
# Directory Manager password resets trigger passwordMustChange
111+
# automatically — no need to manually set pwdReset: TRUE.
112+
# Use ldapmodify instead of ldappasswd — 389ds may reject the LDAP
113+
# Password Modify Extended Operation over plain LDAP.
114+
ldapmodify -x -H "$LDAP_URI" -D "$DM_DN" -w "$DM_PW" <<'EOF'
115+
dn: uid=testuser,ou=people,dc=example,dc=org
116+
changetype: modify
117+
replace: userPassword
118+
userPassword: changeme
119+
EOF
120+
}
121+
122+
verify() {
123+
echo ""
124+
echo "=== Verification ==="
125+
# Expect both ppolicy response elements:
126+
# warning [0]: timeBeforeExpiration (password expires in ~86400s)
127+
# error [1]: changeAfterReset(2) ("Password must be changed")
128+
echo ""
129+
ldapwhoami -x -H "$LDAP_URI" \
130+
-D "uid=testuser,ou=people,$SUFFIX" -w "changeme" \
131+
-e ppolicy -v 2>&1 || true
132+
echo ""
133+
}
134+
135+
wait_for_ldap
136+
create_ou_people
137+
create_manager
138+
add_manager_aci
139+
configure_password_policy
140+
create_test_user
141+
reset_test_user_password
142+
verify
143+
144+
echo ""
145+
echo "Done. 389ds is running on $LDAP_URI"
146+
echo " Directory Manager: $DM_DN (password: $DM_PW)"
147+
echo " Manager DN: uid=manager,ou=people,$SUFFIX (password: manager) — use as Keycloak bind DN"
148+
echo " Test user: uid=testuser,ou=people,$SUFFIX"
149+
echo " Test pass: changeme (must be changed on next login)"

ldap/README.md

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,10 @@
1+
# LDAP Quickstarts
2+
3+
Test setups for LDAP directory servers with password policy (`ppolicy`) enabled, used to exercise Keycloak's LDAP password policy integration.
4+
5+
| Directory Server | Quickstart | Host Port | Description |
6+
|------------------|-------------------------------------|-----------|----------------------------------------------------------------------------------|
7+
| OpenLDAP 2.6 | [ppolicy](openldap-ppolicy) | 389 | OpenLDAP with the `ppolicy` overlay, exercising `changeAfterReset` and `timeBeforeExpiration`. |
8+
| 389 Directory Server | [ppolicy](389ds-ppolicy) | 3389 | 389ds with native password policy, exercising `changeAfterReset` and `timeBeforeExpiration`. |
9+
10+
Both quickstarts use different host ports so they can run simultaneously.

ldap/openldap-ppolicy/Dockerfile

Lines changed: 23 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,23 @@
1+
FROM debian:trixie
2+
3+
RUN apt-get update -qq \
4+
&& echo "slapd slapd/internal/generated_adminpw password admin" | debconf-set-selections \
5+
&& echo "slapd slapd/internal/adminpw password admin" | debconf-set-selections \
6+
&& echo "slapd slapd/password1 password admin" | debconf-set-selections \
7+
&& echo "slapd slapd/password2 password admin" | debconf-set-selections \
8+
&& echo "slapd slapd/domain string example.org" | debconf-set-selections \
9+
&& echo "slapd shared/organization string Example" | debconf-set-selections \
10+
&& DEBIAN_FRONTEND=noninteractive apt-get install -y -qq slapd ldap-utils \
11+
&& apt-get clean && rm -rf /var/lib/apt/lists/* \
12+
&& mkdir -p /run/slapd && chown openldap:openldap /run/slapd
13+
14+
# Set a password for cn=admin,cn=config so cn=config can be managed via
15+
# simple bind over ldap:// (not just SASL EXTERNAL via ldapi:// socket).
16+
# Debian's slapd leaves cn=admin,cn=config without a password by default.
17+
COPY set-config-pw.ldif /tmp/set-config-pw.ldif
18+
RUN slapmodify -n0 -l /tmp/set-config-pw.ldif && rm /tmp/set-config-pw.ldif \
19+
&& chown -R openldap:openldap /etc/ldap/slapd.d
20+
21+
EXPOSE 389
22+
23+
CMD ["slapd", "-d", "256", "-h", "ldap:/// ldapi:///", "-F", "/etc/ldap/slapd.d", "-u", "openldap", "-g", "openldap"]

0 commit comments

Comments
 (0)