feat(ppolicy): add progressive account lockouts

This commit is contained in:
Bertrand Gouny
2026-10-19 20:06:16 +02:00
parent 553632f663
commit c10e2054f2
11 changed files with 181 additions and 1 deletions
+2
View File
@@ -6,6 +6,8 @@ Project-specific changes only. See upstream [OpenLDAP release notes](https://www
### Added
- Add `OPENLDAP_BOOTSTRAP_PPOLICY_DEFAULT_MIN_DELAY` and `OPENLDAP_BOOTSTRAP_PPOLICY_DEFAULT_MAX_DELAY`
for progressive per-account lockouts after failed authentications (disabled by default).
- Add `openldap-ctl password hash --file` and `--stdin` to hash secrets without
passing their contents as command arguments.
- Add `OPENLDAP_BOOTSTRAP_PPOLICY_DEFAULT_MAX_IDLE` to reject logins after a
+2
View File
@@ -414,6 +414,8 @@ docker run --rm --user $UID -v $(pwd)/schema:/data \
| `OPENLDAP_BOOTSTRAP_PPOLICY_DEFAULT_FAILURE_COUNT_INTERVAL` | Value for `pwdFailureCountInterval` | `300` |
| `OPENLDAP_BOOTSTRAP_PPOLICY_DEFAULT_LOCKOUT` | Value for `pwdLockout` | `true` |
| `OPENLDAP_BOOTSTRAP_PPOLICY_DEFAULT_LOCKOUT_DURATION` | Value for `pwdLockoutDuration` | `900` |
| `OPENLDAP_BOOTSTRAP_PPOLICY_DEFAULT_MIN_DELAY` | Initial per-account lockout after a failed bind, in seconds (`0`: disabled) | `0` |
| `OPENLDAP_BOOTSTRAP_PPOLICY_DEFAULT_MAX_DELAY` | Maximum progressive lockout in seconds; requires `0 < MIN_DELAY <= MAX_DELAY` and policy lockout enabled | `0` |
| `OPENLDAP_BOOTSTRAP_PPOLICY_DEFAULT_MAX_AGE` | Value for `pwdMaxAge` | `7776000` |
| `OPENLDAP_BOOTSTRAP_PPOLICY_DEFAULT_MAX_IDLE` | Maximum inactivity in seconds (`0`: disabled); requires last bind tracking and policy lockout | `0` |
| `OPENLDAP_BOOTSTRAP_PPOLICY_DEFAULT_EXPIRE_WARNING` | Value for `pwdExpireWarning` | `1209600` |
+2
View File
@@ -33,6 +33,8 @@ After the shared version check, `tests/` runs integration scenarios on each buil
and compatibility with ppolicy;
- password policy inactivity: disabled by default, active and idle accounts,
first logins and missing prerequisites;
- progressive password policy lockouts: disabled by default, doubling and cap,
recovery, independent accounts and compatibility with the fixed lockout threshold;
- refint empty-group placeholders: default behavior, automatic role creation
and deletion of the last real group member with memberof enabled;
- all features with 3 replicated nodes: all compatible modules and schemas, TLS required,
+59
View File
@@ -62,3 +62,62 @@ func PasswordPolicyIdle(ctx context.Context, client *dagger.Client, image *dagge
//go:embed testdata/ppolicy-idle.sh
var ppolicyIdleCheck string
// PasswordPolicyDelay checks progressive lockouts and the existing failure threshold.
func PasswordPolicyDelay(ctx context.Context, client *dagger.Client, image *dagger.Container) error {
passwords, err := rootPasswords(ctx, image)
if err != nil {
return err
}
base := merge(passwords, env{"OPENLDAP_BOOTSTRAP_PPOLICY": "true"})
for _, tc := range []struct {
settings env
message string
}{
{env{"OPENLDAP_BOOTSTRAP_PPOLICY_DEFAULT_MIN_DELAY": "2"}, "Password policy delays must satisfy"},
{env{"OPENLDAP_BOOTSTRAP_PPOLICY_DEFAULT_MAX_DELAY": "4"}, "Password policy delays must satisfy"},
{env{
"OPENLDAP_BOOTSTRAP_PPOLICY_DEFAULT_MIN_DELAY": "4",
"OPENLDAP_BOOTSTRAP_PPOLICY_DEFAULT_MAX_DELAY": "2",
}, "Password policy delays must satisfy"},
{env{
"OPENLDAP_BOOTSTRAP_PPOLICY_DEFAULT_MIN_DELAY": "2",
"OPENLDAP_BOOTSTRAP_PPOLICY_DEFAULT_MAX_DELAY": "4",
"OPENLDAP_BOOTSTRAP_PPOLICY_DEFAULT_LOCKOUT": "false",
}, "Password policy delays require OPENLDAP_BOOTSTRAP_PPOLICY_DEFAULT_LOCKOUT=true"},
} {
if _, err := expect(ctx, withEnv(image, merge(base, tc.settings)), false,
[]string{tc.message}, "container", "--skip-process"); err != nil {
return fmt.Errorf("password policy delay prerequisites: %w", err)
}
}
for _, mode := range []string{"default", "enabled", "threshold"} {
settings := env{}
if mode != "default" {
settings["OPENLDAP_BOOTSTRAP_PPOLICY_DEFAULT_MIN_DELAY"] = "2"
settings["OPENLDAP_BOOTSTRAP_PPOLICY_DEFAULT_MAX_DELAY"] = "4"
}
if mode == "threshold" {
settings["OPENLDAP_BOOTSTRAP_PPOLICY_DEFAULT_MAX_FAILURE"] = "2"
}
if err := func() error {
node := "ppolicy-delay-" + mode
service, err := startNode(ctx, image, node, merge(base, settings), nil, nil)
if err != nil {
return err
}
defer stopNodes(ctx, []*dagger.Service{service})
return check(ctx, image, nil, env{
"TEST_NODES": node,
"TEST_PPOLICY_DELAY_MODE": mode,
}, ppolicyDelayCheck)
}(); err != nil {
return fmt.Errorf("password policy delay %s: %w", mode, err)
}
}
return nil
}
//go:embed testdata/ppolicy-delay.sh
var ppolicyDelayCheck string
+1
View File
@@ -26,6 +26,7 @@ var scenarios = []scenario{
{"audit logging", AuditLog},
{"last successful binds", LastBind},
{"password policy inactivity", PasswordPolicyIdle},
{"progressive password policy lockouts", PasswordPolicyDelay},
{"password helpers", PasswordHelpers},
{"refint empty-group placeholders", RefintNothing},
{"all features with 3 replicated nodes", AllFeatures},
+97
View File
@@ -0,0 +1,97 @@
#!/bin/bash
set -euo pipefail
# if container log level is trace:
# print commands and their arguments as they are executed
container log level eq trace && set -x
# shellcheck disable=SC1091
source /tmp/openldap-test-lib.sh
URI="ldap://${TEST_NODES}:3890"
POLICY_DN="cn=default,ou=policies,${TEST_SUFFIX}"
USER_DN="uid=delay-user,${TEST_SUFFIX}"
OTHER_DN="uid=other-user,${TEST_SUFFIX}"
eventually has_entry "${URI}" "${POLICY_DN}"
expect_rc 0 bind_search "${URI}" "${ADMIN_DN}" -b "${POLICY_DN}" -s base pwdMinDelay pwdMaxDelay
if [ "${TEST_PPOLICY_DELAY_MODE}" = default ]; then
expect_output '^pwdMinDelay: 0$'
expect_output '^pwdMaxDelay: 0$'
else
expect_output '^pwdMinDelay: 2$'
expect_output '^pwdMaxDelay: 4$'
fi
for user in delay-user other-user; do
expect_rc 0 bind_modify "${URI}" "${ADMIN_DN}" -a <<LDIF
dn: uid=${user},${TEST_SUFFIX}
objectClass: inetOrgPerson
uid: ${user}
cn: ${user}
sn: User
userPassword: ${TEST_PASSWORD}
LDIF
done
timestamp_epoch() {
local stamp="$1"
date -u -d "${stamp:0:4}-${stamp:4:2}-${stamp:6:2} ${stamp:8:2}:${stamp:10:2}:${stamp:12:2}" +%s
}
check_delay() {
local expected="$1"
local failure_time lockout_end
expect_rc 49 ldapwhoami -x -H "${URI}" -D "${USER_DN}" -w incorrect-password
expect_rc 0 bind_search "${URI}" "${ADMIN_DN}" -b "${USER_DN}" -s base pwdFailureTime pwdAccountTmpLockoutEnd
failure_time=$(sed -n 's/^pwdFailureTime: //p' "${OUTPUT_FILE}" | sort | tail -1)
lockout_end=$(sed -n 's/^pwdAccountTmpLockoutEnd: //p' "${OUTPUT_FILE}")
[ -n "${failure_time}" ] && [ -n "${lockout_end}" ] || fail 'missing failure or lockout timestamp'
[ "$(( $(timestamp_epoch "${lockout_end}") - $(timestamp_epoch "${failure_time}") ))" -eq "${expected}" ] || fail "expected ${expected}-second lockout"
expect_rc 49 ldapwhoami -x -H "${URI}" -D "${USER_DN}" -w "${TEST_PASSWORD}"
expect_rc 0 ldapwhoami -x -H "${URI}" -D "${OTHER_DN}" -w "${TEST_PASSWORD}"
}
expire_delay() {
# Expire only the temporary lockout, preserving the failure history for progression.
expect_rc 0 bind_modify "${URI}" "${ADMIN_DN}" -e relax <<LDIF
dn: ${USER_DN}
changetype: modify
replace: pwdAccountTmpLockoutEnd
pwdAccountTmpLockoutEnd: 20000101000000Z
LDIF
}
if [ "${TEST_PPOLICY_DELAY_MODE}" = default ]; then
expect_rc 49 ldapwhoami -x -H "${URI}" -D "${USER_DN}" -w incorrect-password
expect_rc 0 bind_search "${URI}" "${ADMIN_DN}" -b "${USER_DN}" -s base pwdAccountTmpLockoutEnd
expect_no_output '^pwdAccountTmpLockoutEnd:'
expect_rc 0 ldapwhoami -x -H "${URI}" -D "${USER_DN}" -w "${TEST_PASSWORD}"
ok 'progressive lockouts are disabled by default'
exit 0
fi
check_delay 2
expire_delay
if [ "${TEST_PPOLICY_DELAY_MODE}" = threshold ]; then
expect_rc 49 ldapwhoami -x -H "${URI}" -D "${USER_DN}" -w incorrect-password
expect_rc 0 bind_search "${URI}" "${ADMIN_DN}" -b "${USER_DN}" -s base pwdAccountLockedTime
expect_output '^pwdAccountLockedTime: [0-9]{14}Z$'
expect_rc 49 ldapwhoami -x -H "${URI}" -D "${USER_DN}" -w "${TEST_PASSWORD}"
ok 'the fixed lockout still applies when pwdMaxFailure is reached'
exit 0
fi
check_delay 4
expire_delay
check_delay 4
expire_delay
ok 'per-account lockouts double and stop at the configured maximum'
expect_rc 0 ldapwhoami -x -H "${URI}" -D "${USER_DN}" -w "${TEST_PASSWORD}"
expect_rc 0 bind_search "${URI}" "${ADMIN_DN}" -b "${USER_DN}" -s base pwdFailureTime
expect_no_output '^pwdFailureTime:'
check_delay 2
ok 'successful authentication clears failures and resets the progression'
+2
View File
@@ -64,6 +64,8 @@ OPENLDAP_BOOTSTRAP_PPOLICY_DEFAULT_MAX_FAILURE=5
OPENLDAP_BOOTSTRAP_PPOLICY_DEFAULT_FAILURE_COUNT_INTERVAL=300
OPENLDAP_BOOTSTRAP_PPOLICY_DEFAULT_LOCKOUT=true
OPENLDAP_BOOTSTRAP_PPOLICY_DEFAULT_LOCKOUT_DURATION=900
OPENLDAP_BOOTSTRAP_PPOLICY_DEFAULT_MIN_DELAY=0
OPENLDAP_BOOTSTRAP_PPOLICY_DEFAULT_MAX_DELAY=0
OPENLDAP_BOOTSTRAP_PPOLICY_DEFAULT_MAX_AGE=7776000
OPENLDAP_BOOTSTRAP_PPOLICY_DEFAULT_MAX_IDLE=0
OPENLDAP_BOOTSTRAP_PPOLICY_DEFAULT_EXPIRE_WARNING=1209600
@@ -81,6 +81,10 @@ ACL files ending in `-close.ldif` append a final `access to * by * none` rule to
`pwdMaxIdle` in seconds (`0`: disabled). Positive values require
`OPENLDAP_BOOTSTRAP_DATA_LAST_BIND=true` and `OPENLDAP_BOOTSTRAP_PPOLICY_DEFAULT_LOCKOUT=true`.
`pwdMinDelay` and `pwdMaxDelay` configure progressive per-account lockouts after
failed binds (`0`/`0`: disabled). Successful authentication resets the progression;
`pwdMaxFailure` and `pwdLockoutDuration` still apply when the failure threshold is reached.
## config/replication
Syncrepl-based multi-provider replication. Applied when `OPENLDAP_BOOTSTRAP_REPLICATION=true`.
@@ -16,6 +16,8 @@ pwdMaxFailure: ${OPENLDAP_BOOTSTRAP_PPOLICY_DEFAULT_MAX_FAILURE}
pwdFailureCountInterval: ${OPENLDAP_BOOTSTRAP_PPOLICY_DEFAULT_FAILURE_COUNT_INTERVAL}
pwdLockout: ${OPENLDAP_BOOTSTRAP_PPOLICY_DEFAULT_LOCKOUT}
pwdLockoutDuration: ${OPENLDAP_BOOTSTRAP_PPOLICY_DEFAULT_LOCKOUT_DURATION}
pwdMinDelay: ${OPENLDAP_BOOTSTRAP_PPOLICY_DEFAULT_MIN_DELAY}
pwdMaxDelay: ${OPENLDAP_BOOTSTRAP_PPOLICY_DEFAULT_MAX_DELAY}
pwdMaxAge: ${OPENLDAP_BOOTSTRAP_PPOLICY_DEFAULT_MAX_AGE}
pwdMaxIdle: ${OPENLDAP_BOOTSTRAP_PPOLICY_DEFAULT_MAX_IDLE}
pwdExpireWarning: ${OPENLDAP_BOOTSTRAP_PPOLICY_DEFAULT_EXPIRE_WARNING}
@@ -20,6 +20,15 @@ if [ "${OPENLDAP_BOOTSTRAP_PPOLICY}" = "true" ]; then
if [ "${TYPE}" = "env" ]; then
ENV_FILE="$2"
if [ "${OPENLDAP_BOOTSTRAP_PPOLICY_DEFAULT_MIN_DELAY}" -ne 0 ] || [ "${OPENLDAP_BOOTSTRAP_PPOLICY_DEFAULT_MAX_DELAY}" -ne 0 ]; then
if [ "${OPENLDAP_BOOTSTRAP_PPOLICY_DEFAULT_MIN_DELAY}" -le 0 ] || [ "${OPENLDAP_BOOTSTRAP_PPOLICY_DEFAULT_MAX_DELAY}" -lt "${OPENLDAP_BOOTSTRAP_PPOLICY_DEFAULT_MIN_DELAY}" ]; then
container log fatal "Password policy delays must satisfy 0 < OPENLDAP_BOOTSTRAP_PPOLICY_DEFAULT_MIN_DELAY <= OPENLDAP_BOOTSTRAP_PPOLICY_DEFAULT_MAX_DELAY, or both must be 0"
fi
if [ "${OPENLDAP_BOOTSTRAP_PPOLICY_DEFAULT_LOCKOUT^^}" != "TRUE" ]; then
container log fatal "Password policy delays require OPENLDAP_BOOTSTRAP_PPOLICY_DEFAULT_LOCKOUT=true"
fi
fi
if [ "${OPENLDAP_BOOTSTRAP_PPOLICY_DEFAULT_MAX_IDLE}" -gt 0 ]; then
if [ "${OPENLDAP_BOOTSTRAP_DATA_LAST_BIND^^}" != "TRUE" ]; then
container log fatal "OPENLDAP_BOOTSTRAP_PPOLICY_DEFAULT_MAX_IDLE requires OPENLDAP_BOOTSTRAP_DATA_LAST_BIND=true"
@@ -19,7 +19,7 @@ Scripts are executed in numeric order. Most are feature-gated by an environment
| `35-auditlog.sh` | `OPENLDAP_BOOTSTRAP_AUDITLOG_ENABLED=true` | Configures file-based auditing on the data database |
| `40-refint.sh` | `OPENLDAP_BOOTSTRAP_REFINT=true` | Configures the referential integrity overlay and optional replacement DN |
| `45-refint-nothing.sh` | `OPENLDAP_BOOTSTRAP_REFINT=true` and `OPENLDAP_BOOTSTRAP_REFINT_NOTHING_DN` non-empty | Creates the passwordless replacement role |
| `50-ppolicy.sh` | `OPENLDAP_BOOTSTRAP_PPOLICY=true` | Configures the password policy and checks that a positive max idle has last bind tracking and policy lockout enabled |
| `50-ppolicy.sh` | `OPENLDAP_BOOTSTRAP_PPOLICY=true` | Configures the password policy and validates inactivity and progressive lockout prerequisites |
| `60-unique.sh` | `OPENLDAP_BOOTSTRAP_UNIQUE=true` | Configures the unique attributes overlay |
| `70-memberof.sh` | `OPENLDAP_BOOTSTRAP_MEMBEROF=true` | Configures the memberof overlay |
| `80-replication.sh` | `OPENLDAP_BOOTSTRAP_REPLICATION=true` | Sets up multi-provider syncrepl replication (requires 2 to 9 hosts) |