This commit is contained in:
2026-10-26 18:06:22 -04:00
parent caf8839edd
commit d01f4a8fb7
5 changed files with 44 additions and 5 deletions
+3
View File
@@ -19,6 +19,9 @@ DEX_CLIENT_ID=www-membership
DEX_CLIENT_SECRET=
DEX_CONNECTOR_ID=www-ldap
LDAP_URL=ldaps://ldap.eom.dev
# Set to 1 ONLY with ldap:// on a trusted network to skip StartTLS.
# Bind and registration passwords will be sent unencrypted. LDAPS stays verified.
LDAP_ALLOW_INSECURE=0
LDAP_BIND_DN=
LDAP_BIND_PASSWORD=
LDAP_CA_CERT_FILE=
+9 -2
View File
@@ -11,7 +11,8 @@ Both payment methods feed the same LDAP group reconciliation and local expiry.
1. Anyone can request a free account. A one-hour, single-use email link allows
them to choose a password. The account is created as an `inetOrgPerson` in
OpenLDAP; the password goes only through RFC 3062 Password Modify over
certificate-verified LDAPS/StartTLS. This app never stores a password/hash.
certificate-verified LDAPS/StartTLS by default (see the explicit plaintext
option below). This app never stores a password/hash.
2. Sign-in uses Dex Authorization Code + PKCE, state and nonce. Authlib verifies
the RS256 ID token, issuer, audience, signature and lifetime. A signed
`federated_claims` connector/user ID must match the configured LDAP connector
@@ -89,7 +90,13 @@ of these five prices when inspected.
tree**, including other account registrars. Only then set
`LDAP_PASSWORD_SCHEME={ARGON2}`. The app does not alter slapd configuration.
If a private CA is used, mount its certificate read-only into both membership
and worker services and set `LDAP_CA_CERT_FILE`. TLS verification is mandatory.
and worker services and set `LDAP_CA_CERT_FILE`. TLS verification is mandatory
when TLS is used. To deliberately use plaintext on a trusted internal network,
set `LDAP_URL=ldap://openldap.openldap.svc.cluster.local:389` and
`LDAP_ALLOW_INSECURE=1` in both web and worker environments. This skips StartTLS
and exposes bind passwords, registration passwords and directory data to the
network. The default is `0` (StartTLS required for `ldap://`); TLS failures never
trigger a plaintext fallback, and `ldaps://` always verifies certificates.
4. Add a dedicated Dex client/LDAP connector using
[membership/deploy/dex-membership.example.yaml](membership/deploy/dex-membership.example.yaml).
The exact callback is `https://eom.dev/members/oidc/callback/` (trailing slash).
+2
View File
@@ -87,6 +87,8 @@ DEX_CLIENT_SECRET = os.environ.get("DEX_CLIENT_SECRET", "")
DEX_CONNECTOR_ID = os.environ.get("DEX_CONNECTOR_ID", "ldap")
LDAP_URL = os.environ.get("LDAP_URL", "")
# Explicit opt-in for trusted internal networks; never a TLS-error fallback.
LDAP_ALLOW_INSECURE = os.environ.get("LDAP_ALLOW_INSECURE", "0") == "1"
LDAP_BIND_DN = os.environ.get("LDAP_BIND_DN", "")
LDAP_BIND_PASSWORD = os.environ.get("LDAP_BIND_PASSWORD", "")
LDAP_CA_CERT_FILE = os.environ.get("LDAP_CA_CERT_FILE") or None
+3 -2
View File
@@ -1,4 +1,4 @@
"""TLS-only LDAP; paid groups are never granted during registration or login."""
"""TLS by default; paid groups are never granted during registration or login."""
import re
import ssl
@@ -22,7 +22,8 @@ def connection():
conn = Connection(server, user=settings.LDAP_BIND_DN, password=settings.LDAP_BIND_PASSWORD, raise_exceptions=True, receive_timeout=8, auto_referrals=False)
try:
conn.open()
if url.scheme == "ldap" and not conn.start_tls():
# Plaintext requires explicit operator consent; LDAPS is never downgraded.
if url.scheme == "ldap" and not settings.LDAP_ALLOW_INSECURE and not conn.start_tls():
raise LDAPException("StartTLS failed; refusing to bind.")
if not conn.bind():
raise LDAPException("LDAP bind failed.")
+27 -1
View File
@@ -471,7 +471,7 @@ class DirectoryTests(SimpleTestCase):
with self.assertRaises(ValueError):
directory.identity_for_login("*)(uid=*)")
@override_settings(LDAP_URL="ldap://ldap.example.org", LDAP_BIND_DN="cn=service", LDAP_BIND_PASSWORD="test-only", LDAP_ACCOUNT_BASE_DN=BASE)
@override_settings(LDAP_URL="ldap://ldap.example.org", LDAP_ALLOW_INSECURE=False, LDAP_BIND_DN="cn=service", LDAP_BIND_PASSWORD="test-only", LDAP_ACCOUNT_BASE_DN=BASE)
@patch("members.directory.Connection")
def test_starttls_failure_never_binds_credentials(self, conn_factory):
conn_factory.return_value.start_tls.return_value = False
@@ -480,3 +480,29 @@ class DirectoryTests(SimpleTestCase):
pass
conn_factory.return_value.bind.assert_not_called()
conn_factory.return_value.unbind.assert_called_once()
@override_settings(LDAP_BIND_DN="cn=service", LDAP_BIND_PASSWORD="test-only", LDAP_ACCOUNT_BASE_DN=BASE, LDAP_CA_CERT_FILE=None)
@patch("members.directory.Connection")
@patch("members.directory.Server")
def test_ldap_transport_modes(self, server_factory, conn_factory):
import ssl
for scheme, insecure, starttls, port in (
("ldap", False, True, 389),
("ldap", True, False, 389),
("ldaps", False, False, 636),
("ldaps", True, False, 636),
):
with self.subTest(scheme=scheme, insecure=insecure):
conn_factory.reset_mock()
with override_settings(LDAP_URL=f"{scheme}://ldap.example.org", LDAP_ALLOW_INSECURE=insecure):
with directory.connection() as conn:
self.assertIs(conn, conn_factory.return_value)
self.assertEqual(conn_factory.return_value.start_tls.call_count, int(starttls))
conn_factory.return_value.open.assert_called_once()
conn_factory.return_value.bind.assert_called_once()
conn_factory.return_value.unbind.assert_called_once()
options = server_factory.call_args.kwargs
self.assertEqual(options["use_ssl"], scheme == "ldaps")
self.assertEqual(options["port"], port)
self.assertEqual(options["tls"].validate, ssl.CERT_REQUIRED)