ldap: Implement external auth id auth+sync.

Fixes #24104.
This commit is contained in:
Mateusz Mandera
2025-06-10 01:47:28 +08:00
committed by Tim Abbott
parent d273059475
commit a61d849e37
11 changed files with 475 additions and 36 deletions

View File

@@ -187,10 +187,10 @@ All of these data synchronization options have the same model:
your configuration changes take effect.
- Logs are available in `/var/log/zulip/ldap.log`.
When using this feature, you may also want to
[prevent users from changing their display name in the Zulip UI][restrict-name-changes],
since any such changes would be automatically overwritten on the sync
run of `manage.py sync_ldap_user_data`.
When using this feature, you may also want to [prevent users from
changing their display name or email address in the Zulip
UI][restrict-name-changes], since any such changes would be
automatically overwritten.
[restrict-name-changes]: https://zulip.com/help/restrict-name-and-email-changes
@@ -269,40 +269,44 @@ groups. To configure this feature:
[zulip-groups]: https://zulip.com/help/user-groups
#### Synchronizing email addresses
### Synchronizing email addresses
User accounts in Zulip are uniquely identified by their email address,
and that's [currently](https://github.com/zulip/zulip/pull/16208) the
only way through which a Zulip account is associated with their LDAP
user account.
Zulip 11.0+ supports automatically handling changes in email address
for most LDAP installations. All you need to do is set the
`unique_account_id` field in `AUTH_LDAP_USER_ATTR_MAP` to a **stable
unique identifier** for the account, such as the LDAP Distinguished
Name (DN). The `unique_account_id` field defaults to the `dn` for new
installations.
In particular, whenever a user attempts to log in to Zulip using LDAP,
Zulip will use the LDAP information to authenticate the access, and
determine the user's email address. It will then log in the user to
the Zulip account with that email address (or if none exists,
potentially prompt the user to create one). This model is convenient,
because it works well with any LDAP provider (and handles migrations
between LDAP providers transparently).
:::{note}
However, when a user's email address is changed in your LDAP
directory, manual action needs to be taken to tell Zulip that the
email address Zulip account with the new email address.
While most LDAP data is synced in `sync_ldap_user_data`, email address
synchronization is only checked on login. The first time a user logs
in with `unique_account_id` enabled, the unique ID will be linked with
their Zulip account. After a change in their LDAP email address, Zulip
will update the linked Zulip account's Zulip email address the next
time the user logs in.
There are two ways to execute email address changes:
:::
- Users changing their email address in LDAP can [change their email
address in Zulip](https://zulip.com/help/change-your-email-address)
before logging out of Zulip. The user will need to be able to
receive email at the new email address in order to complete this
flow.
#### Manually handling LDAP email changes
If you don't have `unique_account_id` enabled, when a user's email
address is changed in your LDAP directory, it must be manually updated
in Zulip:
- A server administrator can use the `manage.py change_user_email`
[management command][management-commands] to adjust a Zulip
[management command][management-commands] to update a Zulip
account's email address directly.
If a user accidentally creates a duplicate account, the duplicate
account can be deactivated (and its email address changed) or deleted,
and then the real account adjusted using the management command above.
- Users can [change their email address in
Zulip](https://zulip.com/help/change-your-email-address). The user
must be already logged into Zulip and able to receive email at the
new email address.
Not doing so will often lead to a duplicate account when the user next
logs in. If that happens, you can delete the duplicate account and
then correct the user's email address using the management command.
[management-commands]: ../production/management-commands.md

View File

@@ -66,7 +66,7 @@ from zerver.models import (
)
from zerver.models.groups import SystemGroups
from zerver.models.realm_audit_logs import AuditLogEventType
from zerver.models.users import active_user_ids, bot_owner_user_ids, get_system_bot
from zerver.models.users import ExternalAuthID, active_user_ids, bot_owner_user_ids, get_system_bot
from zerver.tornado.django_api import send_event_on_commit
MAX_NUM_RECENT_MESSAGES = 1000
@@ -521,6 +521,7 @@ def do_create_user(
enable_marketing_emails: bool = True,
email_address_visibility: int | None = None,
add_initial_stream_subscriptions: bool = True,
external_auth_id_dict: dict[str, str] | None = None,
) -> UserProfile:
if settings.BILLING_ENABLED:
from corporate.lib.stripe import RealmBillingSession
@@ -615,6 +616,15 @@ def do_create_user(
"add_members", full_members_system_group, [user_profile.id]
)
if external_auth_id_dict:
for external_auth_method_name, external_auth_id in external_auth_id_dict.items():
ExternalAuthID.objects.create(
user=user_profile,
realm=user_profile.realm,
external_auth_method_name=external_auth_method_name,
external_auth_id=external_auth_id,
)
if prereg_realm is not None:
prereg_realm.created_user = user_profile
prereg_realm.save(update_fields=["created_user"])

View File

@@ -91,7 +91,7 @@ from zerver.models.presence import PresenceSequence
from zerver.models.realm_audit_logs import AuditLogEventType
from zerver.models.realms import get_fake_email_domain, get_realm
from zerver.models.saved_snippets import SavedSnippet
from zerver.models.users import get_system_bot, get_user_profile_by_id
from zerver.models.users import ExternalAuthID, get_system_bot, get_user_profile_by_id
if TYPE_CHECKING:
from mypy_boto3_s3.service_resource import Object
@@ -158,6 +158,7 @@ ALL_ZULIP_TABLES = {
"zerver_defaultstreamgroup_streams",
"zerver_draft",
"zerver_emailchangestatus",
"zerver_externalauthid",
"zerver_groupgroupmembership",
"zerver_huddle",
"zerver_imageattachment",
@@ -332,6 +333,7 @@ DATE_FIELDS: dict[TableName, list[Field]] = {
"analytics_usercount": ["end_time"],
"zerver_attachment": ["create_time"],
"zerver_channelfolder": ["date_created"],
"zerver_externalauthid": ["date_created"],
"zerver_message": ["last_edit_time", "date_sent"],
"zerver_muteduser": ["date_muted"],
"zerver_realmauditlog": ["event_time"],
@@ -1249,6 +1251,14 @@ def add_user_profile_child_configs(user_profile_config: Config) -> None:
limit_to_consenting_users=True,
)
Config(
table="zerver_externalauthid",
model=ExternalAuthID,
normal_parent=user_profile_config,
include_rows="user_id__in",
limit_to_consenting_users=True,
)
# We exclude these fields for the following reasons:
# * api_key is a secret.

View File

@@ -0,0 +1,49 @@
# Generated by Django 5.2.3 on 2025-07-08 00:35
import django.db.models.deletion
import django.utils.timezone
from django.conf import settings
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
("zerver", "0728_alter_stream_can_resolve_topics_group"),
]
operations = [
migrations.CreateModel(
name="ExternalAuthID",
fields=[
(
"id",
models.BigAutoField(
auto_created=True, primary_key=True, serialize=False, verbose_name="ID"
),
),
("date_created", models.DateTimeField(default=django.utils.timezone.now)),
("external_auth_method_name", models.TextField()),
("external_auth_id", models.TextField()),
(
"realm",
models.ForeignKey(
on_delete=django.db.models.deletion.CASCADE, to="zerver.realm"
),
),
(
"user",
models.ForeignKey(
on_delete=django.db.models.deletion.CASCADE, to=settings.AUTH_USER_MODEL
),
),
],
options={
"constraints": [
models.UniqueConstraint(
fields=("realm", "external_auth_method_name", "external_auth_id"),
name="zerver_externalauthid_uniq",
)
],
},
),
]

View File

@@ -1236,3 +1236,25 @@ def get_bot_dicts_in_realm(realm: "Realm") -> list[dict[str, Any]]:
def is_cross_realm_bot_email(email: str) -> bool:
return email.lower() in settings.CROSS_REALM_BOT_EMAILS
class ExternalAuthID(models.Model):
user = models.ForeignKey(UserProfile, on_delete=CASCADE)
realm = models.ForeignKey("zerver.Realm", on_delete=CASCADE)
date_created = models.DateTimeField(default=timezone_now)
# TODO: We might want to add is_active and date_deactivated fields in the future.
external_auth_method_name = models.TextField(db_index=False)
external_auth_id = models.TextField(db_index=False)
class Meta:
constraints = [
models.UniqueConstraint(
fields=[
"realm",
"external_auth_method_name",
"external_auth_id",
],
name="zerver_externalauthid_uniq",
),
]

View File

@@ -115,7 +115,7 @@ from zerver.models import (
)
from zerver.models.groups import SystemGroups, UserGroupMembership
from zerver.models.realms import clear_supported_auth_backends_cache, get_realm
from zerver.models.users import PasswordTooWeakError, get_user_by_delivery_email
from zerver.models.users import ExternalAuthID, PasswordTooWeakError, get_user_by_delivery_email
from zerver.signals import JUST_CREATED_THRESHOLD
from zerver.views.auth import log_into_subdomain, maybe_send_to_registration
from zproject.backends import (
@@ -6861,6 +6861,167 @@ class TestLDAP(ZulipLDAPTestCase):
assert user_profile is not None
self.assertEqual(user_profile, self.example_user("aaron"))
@override_settings(
AUTHENTICATION_BACKENDS=("zproject.backends.ZulipLDAPAuthBackend",),
LDAP_EMAIL_ATTR="mail",
AUTH_LDAP_USER_ATTR_MAP={"full_name": "cn", "unique_account_id": "dn"},
)
def test_external_auth_id_login(self) -> None:
realm = get_realm("zulip")
hamlet = self.example_user("hamlet")
username = self.ldap_username("hamlet")
self.assertEqual(ExternalAuthID.objects.filter(user=hamlet).count(), 0)
user_profile = self.backend.authenticate(
request=mock.MagicMock(),
username=username,
password=self.ldap_password("hamlet"),
realm=get_realm("zulip"),
)
self.assertEqual(hamlet.id, user_profile.id)
external_auth_ids = list(ExternalAuthID.objects.filter(user=hamlet))
self.assert_length(external_auth_ids, 1)
new_external_auth_id = external_auth_ids[0]
self.assertEqual(new_external_auth_id.realm_id, realm.id)
self.assertEqual(new_external_auth_id.external_auth_method_name, "ldap")
self.assertEqual(
new_external_auth_id.external_auth_id, "uid=hamlet,ou=users,dc=zulip,dc=com"
)
# The user's email changes in LDAP. The user should still be
# able to successfully log in with their ldap credential: The
# account will be found based on the ExternalAuthID.
# And the Zulip email is updated to match the new LDAP email.
self.change_ldap_user_attr(username, "mail", "new-hamlet-email@zulip.com")
with self.assertLogs("zulip.auth.ldap", level="INFO") as mock_log:
user_profile = self.backend.authenticate(
request=mock.MagicMock(),
username=username,
password=self.ldap_password("hamlet"),
realm=get_realm("zulip"),
)
self.assertEqual(hamlet.id, user_profile.id)
self.assertEqual(user_profile.delivery_email, "new-hamlet-email@zulip.com")
self.assertEqual(ExternalAuthID.objects.filter(user=hamlet).count(), 1)
self.assertIn(
f"INFO:zulip.auth.ldap:User {hamlet.id}, logged in via ExternalAuthId uid=hamlet,ou=users,dc=zulip,dc=com, has mismatched email. Syncing: hamlet@zulip.com => new-hamlet-email@zulip.com",
mock_log.output,
)
# Here the user's ldap email is changed to an email that
# matches another user already existing in LDAP.
#
# Expected outcome: The user can still log in to their Zulip
# account with their ldap credentials, but their account's
# email is not changed due to the conflict.
cordelia = self.example_user("cordelia")
self.change_ldap_user_attr(username, "mail", cordelia.delivery_email)
with self.assertLogs("zulip.auth.ldap", level="WARNING") as mock_log:
user_profile = self.backend.authenticate(
request=mock.MagicMock(),
username=username,
password=self.ldap_password("hamlet"),
realm=get_realm("zulip"),
)
self.assertEqual(hamlet.id, user_profile.id)
self.assertEqual(user_profile.delivery_email, "new-hamlet-email@zulip.com")
self.assertEqual(ExternalAuthID.objects.filter(user=hamlet).count(), 1)
self.assertEqual(
mock_log.output,
[
f"WARNING:zulip.auth.ldap:Can't sync email for user {hamlet.id}: another user exists with target email {cordelia.delivery_email}"
],
)
# If the capitalization of the current email changes in ldap,
# the capitalization of the Zulip email should be synced.
self.change_ldap_user_attr(username, "mail", "New-Hamlet-email@zulip.com")
with self.assertLogs("zulip.auth.ldap", level="INFO") as mock_log:
user_profile = self.backend.authenticate(
request=mock.MagicMock(),
username=username,
password=self.ldap_password("hamlet"),
realm=get_realm("zulip"),
)
self.assertEqual(hamlet.id, user_profile.id)
self.assertEqual(user_profile.delivery_email, "New-Hamlet-email@zulip.com")
self.assertEqual(ExternalAuthID.objects.filter(user=hamlet).count(), 1)
self.assertIn(
f"INFO:zulip.auth.ldap:User {hamlet.id}, logged in via ExternalAuthId uid=hamlet,ou=users,dc=zulip,dc=com, has mismatched email. Syncing: new-hamlet-email@zulip.com => New-Hamlet-email@zulip.com",
mock_log.output,
)
# Can't log into a deactivated account - make sure that authentication involving
# ExternalAuthID doesn't skip these kinds of checks.
do_deactivate_user(user_profile, acting_user=None)
user_profile = self.backend.authenticate(
request=mock.MagicMock(),
username=username,
password=self.ldap_password("hamlet"),
realm=get_realm("zulip"),
)
self.assertEqual(user_profile, None)
@override_settings(
AUTHENTICATION_BACKENDS=("zproject.backends.ZulipLDAPAuthBackend",),
LDAP_EMAIL_ATTR="mail",
AUTH_LDAP_USER_ATTR_MAP={"full_name": "cn", "unique_account_id": "homePhone"},
)
def test_external_auth_id_login_with_custom_unique_account_id_attribute(self) -> None:
"""
The default recommended value for the unique_account_id attribute is the DN, but we also
support using a different attr - as long as its values are unique and stable.
For this test we'll use the silly example of the phone number attribute.
"""
realm = get_realm("zulip")
hamlet = self.example_user("hamlet")
username = self.ldap_username("hamlet")
self.assertEqual(ExternalAuthID.objects.filter(user=hamlet).count(), 0)
user_profile = self.backend.authenticate(
request=mock.MagicMock(),
username=username,
password=self.ldap_password("hamlet"),
realm=get_realm("zulip"),
)
self.assertEqual(hamlet.id, user_profile.id)
external_auth_ids = list(ExternalAuthID.objects.filter(user=hamlet))
self.assert_length(external_auth_ids, 1)
new_external_auth_id = external_auth_ids[0]
self.assertEqual(new_external_auth_id.realm_id, realm.id)
self.assertEqual(new_external_auth_id.external_auth_method_name, "ldap")
self.assertEqual(new_external_auth_id.external_auth_id, "123456789")
@override_settings(
AUTHENTICATION_BACKENDS=("zproject.backends.ZulipLDAPAuthBackend",),
LDAP_EMAIL_ATTR="mail",
AUTH_LDAP_USER_ATTR_MAP={"full_name": "cn", "unique_account_id": "dn"},
)
def test_external_auth_id_user_creation(self) -> None:
realm = get_realm("zulip")
username = "newuser_with_email"
user_profile = self.backend.authenticate(
request=mock.MagicMock(),
username=username,
password=self.ldap_password("newuser_with_email"),
realm=get_realm("zulip"),
)
assert user_profile is not None
external_auth_ids = list(ExternalAuthID.objects.filter(user=user_profile))
self.assert_length(external_auth_ids, 1)
new_external_auth_id = external_auth_ids[0]
self.assertEqual(new_external_auth_id.realm_id, realm.id)
self.assertEqual(new_external_auth_id.external_auth_method_name, "ldap")
self.assertEqual(
new_external_auth_id.external_auth_id, "uid=newuser_with_email,ou=users,dc=zulip,dc=com"
)
@override_settings(
AUTHENTICATION_BACKENDS=(
"zproject.backends.EmailAuthBackend",

View File

@@ -115,7 +115,7 @@ from zerver.models.realm_audit_logs import AuditLogEventType
from zerver.models.realms import get_realm
from zerver.models.recipients import get_direct_message_group_hash
from zerver.models.streams import get_active_streams, get_stream
from zerver.models.users import get_system_bot, get_user_by_delivery_email
from zerver.models.users import ExternalAuthID, get_system_bot, get_user_by_delivery_email
def make_datetime(val: float) -> datetime:
@@ -3023,6 +3023,21 @@ class SingleUserExportTest(ExportFile):
def zerver_alertword(records: list[Record]) -> None:
self.assertEqual(records[-1]["word"], "pizza")
ExternalAuthID.objects.create(
user=cordelia,
realm=cordelia.realm,
external_auth_method_name="test-auth",
external_auth_id="test-value",
)
@checker
def zerver_externalauthid(records: list[Record]) -> None:
(rec,) = records
self.assertEqual(rec["user"], cordelia.id)
self.assertEqual(rec["realm"], cordelia.realm_id)
self.assertEqual(rec["external_auth_method_name"], "test-auth")
self.assertEqual(rec["external_auth_id"], "test-value")
favorite_city = try_add_realm_custom_profile_field(
realm,
"Favorite city",

View File

@@ -37,6 +37,7 @@ from django.shortcuts import render
from django.urls import reverse
from django.utils.translation import gettext as _
from django_auth_ldap.backend import LDAPBackend, _LDAPUser, _LDAPUserGroups, ldap_error
from django_auth_ldap.config import LDAPSettings
from lxml.etree import XMLSyntaxError
from onelogin.saml2 import compat as onelogin_saml2_compat
from onelogin.saml2.auth import OneLogin_Saml2_Auth
@@ -74,7 +75,7 @@ from zerver.actions.user_groups import (
bulk_add_members_to_user_groups,
bulk_remove_members_from_user_groups,
)
from zerver.actions.user_settings import do_regenerate_api_key
from zerver.actions.user_settings import do_change_user_delivery_email, do_regenerate_api_key
from zerver.actions.users import do_change_user_role, do_deactivate_user
from zerver.lib.avatar import avatar_url, is_avatar_new
from zerver.lib.avatar_hash import user_avatar_content_hash
@@ -108,6 +109,7 @@ from zerver.models.realms import (
supported_auth_backends,
)
from zerver.models.users import (
ExternalAuthID,
PasswordTooWeakError,
get_user_by_delivery_email,
get_user_profile_by_id,
@@ -687,6 +689,53 @@ class ZulipLDAPConfigurationError(Exception):
LDAP_USER_ACCOUNT_CONTROL_DISABLED_MASK = 2
def ldap_external_auth_id_sync_enabled() -> bool:
external_auth_id_attr = settings.AUTH_LDAP_USER_ATTR_MAP.get("unique_account_id")
if external_auth_id_attr is None:
return False
if not isinstance(external_auth_id_attr, str):
raise AssertionError(
"unique_account_id in AUTH_LDAP_USER_ATTR_MAP set to invalid value! must be a string."
)
return True
class ZulipLDAPSettings(LDAPSettings):
NON_SYNCABLE_ATTRS = [
"unique_account_id",
"avatar",
"userAccountControl",
"deactivated",
"org_membership",
]
def __init__(
self, prefix: str = "AUTH_LDAP_", defaults: dict[object, object] | None = None
) -> None:
"""
django-auth-ldap populate_user() codepath iterates through USER_ATTR_MAP dict, and calls
setattr(user, key, value) for each key, value pair. The challenge here is that Zulip adds some custom
mappings in AUTH_LDAP_USER_ATTR_MAP - for which the keys are special keywords (e.g. "unique_account_id")
with a meaning understood by Zulip's code for handling them - and aren't actual UserProfile attributes
that can be set in a valid way.
To avoid ending up with strange UserProfile objects, which have attributes set on them which shouldn't
exist at all, we have to patch this settings class - to drop such special keywords from USER_ATTR_MAP
for the purposes of django-auth-ldap's internal code.
"""
if defaults is None: # nocoverage
defaults = dict()
super().__init__(prefix, defaults)
self.USER_ATTR_MAP: dict[object, object] = {
key: value
for key, value in self.USER_ATTR_MAP.items()
if key not in self.NON_SYNCABLE_ATTRS
}
class ZulipLDAPAuthBackendBase(ZulipAuthMixin, LDAPBackend):
"""Common code between LDAP authentication (ZulipLDAPAuthBackend) and
using LDAP just to sync user data (ZulipLDAPUserPopulator).
@@ -708,6 +757,16 @@ class ZulipLDAPAuthBackendBase(ZulipAuthMixin, LDAPBackend):
check_ldap_config()
@property
def settings(self) -> ZulipLDAPSettings:
# See ZulipLDAPSettings for explanation of why we need to patch this
# to substitute our overridden LDAPSettings class.
self._settings: ZulipLDAPSettings | None
if self._settings is None:
self._settings = ZulipLDAPSettings(self.settings_prefix, self.default_settings)
return self._settings
# Disable django-auth-ldap's permissions functions -- we don't use
# the standard Django user/group permissions system because they
# are prone to performance issues.
@@ -1028,7 +1087,76 @@ class ZulipLDAPAuthBackend(ZulipLDAPAuthBackendBase):
return user
def get_or_build_user(self, username: str, ldap_user: _LDAPUser) -> tuple[UserProfile, bool]:
def get_and_sync_user_profile_by_external_auth_id(
self, external_auth_id: str, email: str, return_data: dict[str, Any]
) -> UserProfile | None:
# If we're using External Auth ID based auth, then the lookup by
# external_auth_id takes precedence over lookup by email.
try:
user_profile: UserProfile | None = ExternalAuthID.objects.get(
external_auth_method_name=self.name,
external_auth_id=external_auth_id,
realm=self._realm,
).user
except ExternalAuthID.DoesNotExist:
user_profile = common_get_active_user(email, self._realm, return_data)
if user_profile is not None:
# If we found an account with the matching email, but no ExternalAuthID, then this account hasn't
# yet had its external_auth_id stored - we need to do this now.
ExternalAuthID.objects.create(
external_auth_method_name=self.name,
external_auth_id=external_auth_id,
realm=self._realm,
user=user_profile,
)
return user_profile
assert user_profile is not None
# We found a user with the matching external_auth_id. Now we need to do a seemingly redundant
# re-fetching via common_get_active_user - as that's the core function for securely fetching
# a user for login, in authentication contexts. It's responsible for the relevant security
# checks (such as the account being active) - we don't attempt to duplicate these checks
# here independently.
user_profile = common_get_active_user(user_profile.delivery_email, self._realm, return_data)
if user_profile is None:
return None
# We need to ensure the email address of the Zulip account remains synced with what's in the ldap
# directory. That's the point of external_auth_id-based authentication.
if user_profile.delivery_email != email:
# We intentionally do a case-sensitive comparison, despite emails in Zulip being
# case-insensitive in auth contexts. We want to support the case of sync tweaking just
# capitalization of the email address.
self.logger.info(
"User %s, logged in via ExternalAuthId %s, has mismatched email. Syncing: %s => %s",
user_profile.id,
external_auth_id,
user_profile.delivery_email,
email,
)
if (
UserProfile.objects.filter(realm=self._realm, delivery_email__iexact=email)
# The EXISTS query has a somewhat strange shape because we need
# to again consider the edge case where we might be simply
# updating capitalization of the email for the user. In such a
# situation, a "conflict" on (realm, delivery_email__iexact) is
# not an actual conflict.
.exclude(id=user_profile.id)
.exists()
):
self.logger.warning(
"Can't sync email for user %s: another user exists with target email %s",
user_profile.id,
email,
)
else:
do_change_user_delivery_email(user_profile, email, acting_user=None)
return user_profile
def get_or_build_user(
self, username: str, ldap_user: "ZulipLDAPUser"
) -> tuple[UserProfile, bool]:
"""The main function of our authentication backend extension of
django-auth-ldap. When this is called (from `authenticate`),
django-auth-ldap will already have verified that the provided
@@ -1047,6 +1175,10 @@ class ZulipLDAPAuthBackend(ZulipLDAPAuthBackendBase):
email = self.user_email_from_ldapuser(username, ldap_user)
external_auth_id: str | None = None
if ldap_external_auth_id_sync_enabled():
external_auth_id = ldap_user.get_external_auth_id()
if self.is_account_realm_access_forbidden(ldap_user, self._realm):
raise ZulipLDAPError("User not allowed to access realm")
@@ -1057,7 +1189,13 @@ class ZulipLDAPAuthBackend(ZulipLDAPAuthBackendBase):
return_data["inactive_user"] = True
raise ZulipLDAPError("User has been deactivated")
if ldap_external_auth_id_sync_enabled() and external_auth_id:
user_profile = self.get_and_sync_user_profile_by_external_auth_id(
external_auth_id, email, return_data
)
else:
user_profile = common_get_active_user(email, self._realm, return_data)
if user_profile is not None:
# An existing user, successfully authed; return it.
return user_profile, False
@@ -1128,6 +1266,9 @@ class ZulipLDAPAuthBackend(ZulipLDAPAuthBackendBase):
opts["role"] = UserProfile.ROLE_REALM_OWNER
opts["default_stream_groups"] = []
if ldap_external_auth_id_sync_enabled():
opts["external_auth_id_dict"] = {self.name: external_auth_id}
user_profile = do_create_user(
email,
None,
@@ -1158,6 +1299,23 @@ class ZulipLDAPUser(_LDAPUser):
super().__init__(*args, **kwargs)
def get_external_auth_id(self) -> str:
"""
The user's ldap DN is usually the best choice for the external_auth_id in LDAP
authentication, as DNs are unique within an ldap directory.
However, we also support configuring a custom attribute to use instead.
"""
attr_name = settings.AUTH_LDAP_USER_ATTR_MAP.get("unique_account_id")
if attr_name is None:
raise AssertionError("unique_account_id is not configured in AUTH_LDAP_USER_ATTR_MAP")
if attr_name.lower() == "dn":
return self.dn
external_auth_id = self.attrs[attr_name][0]
assert isinstance(external_auth_id, str)
return external_auth_id
@transaction.atomic(savepoint=False)
def _get_or_create_user(self, force_populate: bool = False) -> UserProfile:
# This function is responsible for the core logic of syncing

View File

@@ -53,6 +53,7 @@ LDAP_APPEND_DOMAIN: str | None = None
LDAP_EMAIL_ATTR: str | None = None
AUTH_LDAP_REVERSE_EMAIL_SEARCH: Optional["LDAPSearch"] = None
AUTH_LDAP_USERNAME_ATTR: str | None = None
# AUTH_LDAP_USER_ATTR_MAP is uncommented in prod_settings_template.py,
# so the value here mainly serves to help document the default.
AUTH_LDAP_USER_ATTR_MAP: dict[str, str] = {

View File

@@ -240,6 +240,11 @@ AUTH_LDAP_USER_ATTR_MAP = {
"full_name": "cn",
# "first_name": "fn",
# "last_name": "ln",
#
## A stable unique identifier for a user allows Zulip to
## automatically handle email address changes.
## See https://zulip.readthedocs.io/en/latest/production/authentication-methods.html#identifying-user-accounts-via-a-unique-ldap-attribute
"unique_account_id": "dn",
##
## Profile pictures can be pulled from the LDAP "thumbnailPhoto"/"jpegPhoto" field.
# "avatar": "thumbnailPhoto",

View File

@@ -300,3 +300,7 @@ ROOT_DOMAIN_LANDING_PAGE = False
# Disable verifying webhook signatures in tests by default.
# Tests that intend to verify webhook signatures should override this setting.
VERIFY_WEBHOOK_SIGNATURES = False
AUTH_LDAP_USER_ATTR_MAP = {
"full_name": "cn",
}