From d01f4a8fb7da0cc8d4b6b4b410fd2c115b7172b4 Mon Sep 17 00:00:00 2001 From: Eric Meehan Date: Mon, 26 Oct 2026 18:06:22 -0400 Subject: [PATCH] TLS --- .env.example | 3 +++ README.md | 11 ++++++-- membership/config/settings.py | 2 ++ membership/members/directory.py | 5 ++-- membership/members/tests/test_membership.py | 28 ++++++++++++++++++++- 5 files changed, 44 insertions(+), 5 deletions(-) diff --git a/.env.example b/.env.example index eb6940a..9a48571 100755 --- a/.env.example +++ b/.env.example @@ -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= diff --git a/README.md b/README.md index e62a55e..50366b8 100644 --- a/README.md +++ b/README.md @@ -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). diff --git a/membership/config/settings.py b/membership/config/settings.py index eddc4fd..15a9d89 100755 --- a/membership/config/settings.py +++ b/membership/config/settings.py @@ -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 diff --git a/membership/members/directory.py b/membership/members/directory.py index bd1d711..a100f73 100755 --- a/membership/members/directory.py +++ b/membership/members/directory.py @@ -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.") diff --git a/membership/members/tests/test_membership.py b/membership/members/tests/test_membership.py index ba298b3..2ca7505 100755 --- a/membership/members/tests/test_membership.py +++ b/membership/members/tests/test_membership.py @@ -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)