feat(auth): LDAP and SSO users keep their source; directory TLS is real (#5036)
#5036 "SSO and LDAP: users keep their auth source, no merging into local accounts, is_active checked, LDAP TLS real and filter escaped". - users gain auth_source (local/ldap/oidc) and external_id (migration 0027). A provider account is found by (source, subject) only: the LDAP entry DN or the OIDC sub. A first sign-in whose username or email another account holds is refused with a message naming why; a disabled user stays out. - Local passwords sign in local accounts only. - LDAP: port 636 is LDAPS, otherwise StartTLS before any bind; the server certificate is checked against the system CAs or a CA pasted in Settings -> Auth. The login name is escaped into the filter (RFC 4515), and an empty password is refused before the directory sees it. - OIDC: the ID token must carry this sign-in's nonce and the userinfo sub. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
@@ -18,6 +18,34 @@ def _ldap_available() -> bool:
|
||||
return False
|
||||
|
||||
|
||||
def filter_value(value: str) -> str:
|
||||
"""Escape a value for an LDAP search filter (RFC 4515), so a login name
|
||||
like `*)(uid=*` matches only itself."""
|
||||
out = []
|
||||
for ch in value:
|
||||
if ch in "\\*()\x00":
|
||||
out.append("\\%02x" % ord(ch))
|
||||
else:
|
||||
out.append(ch)
|
||||
return "".join(out)
|
||||
|
||||
|
||||
def transport(cfg: dict) -> tuple[bool, bool]:
|
||||
"""(use_ssl, start_tls) for the configured server: port 636 is LDAPS,
|
||||
otherwise the TLS setting means StartTLS before any bind."""
|
||||
if int(cfg.get("port", 389)) == 636:
|
||||
return True, False
|
||||
return False, bool(cfg.get("tls", False))
|
||||
|
||||
|
||||
def _tls(ldap3, cfg: dict):
|
||||
"""Certificate-checked TLS: against the CA the operator pasted in, else the
|
||||
system's CAs. A server whose certificate does not check is refused."""
|
||||
import ssl
|
||||
ca = (cfg.get("ca_cert") or "").strip()
|
||||
return ldap3.Tls(validate=ssl.CERT_REQUIRED, ca_certs_data=ca or None)
|
||||
|
||||
|
||||
def _ldap_authenticate_sync(cfg: dict, username: str, password: str) -> dict | None:
|
||||
"""Synchronous LDAP auth. Returns user info dict or None on failure.
|
||||
|
||||
@@ -27,22 +55,23 @@ def _ldap_authenticate_sync(cfg: dict, username: str, password: str) -> dict | N
|
||||
|
||||
host = cfg.get("host", "")
|
||||
port = int(cfg.get("port", 389))
|
||||
use_tls = cfg.get("tls", False)
|
||||
bind_dn = cfg.get("bind_dn", "")
|
||||
bind_password = cfg.get("bind_password", "")
|
||||
base_dn = cfg.get("base_dn", "")
|
||||
user_filter = cfg.get("user_filter", "(uid={username})").format(username=username)
|
||||
user_filter = cfg.get("user_filter", "(uid={username})").replace("{username}", filter_value(username))
|
||||
admin_group_dn = cfg.get("admin_group_dn", "")
|
||||
operator_group_dn = cfg.get("operator_group_dn", "")
|
||||
attr_username = cfg.get("attr_username", "uid")
|
||||
attr_email = cfg.get("attr_email", "mail")
|
||||
|
||||
tls = ldap3.Tls() if use_tls else None
|
||||
server = ldap3.Server(host, port=port, tls=tls, get_info=ldap3.ALL)
|
||||
use_ssl, start_tls = transport(cfg)
|
||||
tls = _tls(ldap3, cfg) if (use_ssl or start_tls) else None
|
||||
server = ldap3.Server(host, port=port, use_ssl=use_ssl, tls=tls, get_info=ldap3.ALL)
|
||||
auto_bind = ldap3.AUTO_BIND_TLS_BEFORE_BIND if start_tls else ldap3.AUTO_BIND_NO_TLS
|
||||
|
||||
# Service account bind to search for user DN
|
||||
try:
|
||||
svc_conn = ldap3.Connection(server, user=bind_dn, password=bind_password, auto_bind=True)
|
||||
svc_conn = ldap3.Connection(server, user=bind_dn, password=bind_password, auto_bind=auto_bind)
|
||||
except Exception as exc:
|
||||
logger.error("LDAP service bind failed: %s", exc)
|
||||
return None
|
||||
@@ -69,7 +98,7 @@ def _ldap_authenticate_sync(cfg: dict, username: str, password: str) -> dict | N
|
||||
|
||||
# Bind as the user to verify password
|
||||
try:
|
||||
user_conn = ldap3.Connection(server, user=user_dn, password=password, auto_bind=True)
|
||||
user_conn = ldap3.Connection(server, user=user_dn, password=password, auto_bind=auto_bind)
|
||||
user_conn.unbind()
|
||||
except Exception:
|
||||
logger.debug("LDAP password verify failed for %s", username)
|
||||
@@ -83,6 +112,7 @@ def _ldap_authenticate_sync(cfg: dict, username: str, password: str) -> dict | N
|
||||
role = "operator"
|
||||
|
||||
return {
|
||||
"subject": user_dn,
|
||||
"username": ldap_username,
|
||||
"email": ldap_email,
|
||||
"role": role,
|
||||
@@ -90,7 +120,12 @@ def _ldap_authenticate_sync(cfg: dict, username: str, password: str) -> dict | N
|
||||
|
||||
|
||||
async def ldap_authenticate(cfg: dict, username: str, password: str) -> dict | None:
|
||||
"""Async wrapper around synchronous LDAP auth. Returns user info or None."""
|
||||
"""Async wrapper around synchronous LDAP auth. Returns user info or None.
|
||||
|
||||
An empty password is refused here: LDAP treats a simple bind with a DN
|
||||
and no password as an anonymous bind, which succeeds."""
|
||||
if not username or not password:
|
||||
return None
|
||||
if not _ldap_available():
|
||||
logger.warning("ldap3 not installed — LDAP auth unavailable")
|
||||
return None
|
||||
|
||||
Reference in New Issue
Block a user