mirror of
https://github.com/zulip/zulip.git
synced 2025-11-10 17:07:07 +00:00
committed by
Tim Abbott
parent
d273059475
commit
a61d849e37
@@ -187,10 +187,10 @@ All of these data synchronization options have the same model:
|
|||||||
your configuration changes take effect.
|
your configuration changes take effect.
|
||||||
- Logs are available in `/var/log/zulip/ldap.log`.
|
- Logs are available in `/var/log/zulip/ldap.log`.
|
||||||
|
|
||||||
When using this feature, you may also want to
|
When using this feature, you may also want to [prevent users from
|
||||||
[prevent users from changing their display name in the Zulip UI][restrict-name-changes],
|
changing their display name or email address in the Zulip
|
||||||
since any such changes would be automatically overwritten on the sync
|
UI][restrict-name-changes], since any such changes would be
|
||||||
run of `manage.py sync_ldap_user_data`.
|
automatically overwritten.
|
||||||
|
|
||||||
[restrict-name-changes]: https://zulip.com/help/restrict-name-and-email-changes
|
[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
|
[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,
|
Zulip 11.0+ supports automatically handling changes in email address
|
||||||
and that's [currently](https://github.com/zulip/zulip/pull/16208) the
|
for most LDAP installations. All you need to do is set the
|
||||||
only way through which a Zulip account is associated with their LDAP
|
`unique_account_id` field in `AUTH_LDAP_USER_ATTR_MAP` to a **stable
|
||||||
user account.
|
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,
|
:::{note}
|
||||||
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).
|
|
||||||
|
|
||||||
However, when a user's email address is changed in your LDAP
|
While most LDAP data is synced in `sync_ldap_user_data`, email address
|
||||||
directory, manual action needs to be taken to tell Zulip that the
|
synchronization is only checked on login. The first time a user logs
|
||||||
email address Zulip account with the new email address.
|
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
|
#### Manually handling LDAP email changes
|
||||||
address in Zulip](https://zulip.com/help/change-your-email-address)
|
|
||||||
before logging out of Zulip. The user will need to be able to
|
If you don't have `unique_account_id` enabled, when a user's email
|
||||||
receive email at the new email address in order to complete this
|
address is changed in your LDAP directory, it must be manually updated
|
||||||
flow.
|
in Zulip:
|
||||||
|
|
||||||
- A server administrator can use the `manage.py change_user_email`
|
- 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.
|
account's email address directly.
|
||||||
|
|
||||||
If a user accidentally creates a duplicate account, the duplicate
|
- Users can [change their email address in
|
||||||
account can be deactivated (and its email address changed) or deleted,
|
Zulip](https://zulip.com/help/change-your-email-address). The user
|
||||||
and then the real account adjusted using the management command above.
|
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
|
[management-commands]: ../production/management-commands.md
|
||||||
|
|
||||||
|
|||||||
@@ -66,7 +66,7 @@ from zerver.models import (
|
|||||||
)
|
)
|
||||||
from zerver.models.groups import SystemGroups
|
from zerver.models.groups import SystemGroups
|
||||||
from zerver.models.realm_audit_logs import AuditLogEventType
|
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
|
from zerver.tornado.django_api import send_event_on_commit
|
||||||
|
|
||||||
MAX_NUM_RECENT_MESSAGES = 1000
|
MAX_NUM_RECENT_MESSAGES = 1000
|
||||||
@@ -521,6 +521,7 @@ def do_create_user(
|
|||||||
enable_marketing_emails: bool = True,
|
enable_marketing_emails: bool = True,
|
||||||
email_address_visibility: int | None = None,
|
email_address_visibility: int | None = None,
|
||||||
add_initial_stream_subscriptions: bool = True,
|
add_initial_stream_subscriptions: bool = True,
|
||||||
|
external_auth_id_dict: dict[str, str] | None = None,
|
||||||
) -> UserProfile:
|
) -> UserProfile:
|
||||||
if settings.BILLING_ENABLED:
|
if settings.BILLING_ENABLED:
|
||||||
from corporate.lib.stripe import RealmBillingSession
|
from corporate.lib.stripe import RealmBillingSession
|
||||||
@@ -615,6 +616,15 @@ def do_create_user(
|
|||||||
"add_members", full_members_system_group, [user_profile.id]
|
"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:
|
if prereg_realm is not None:
|
||||||
prereg_realm.created_user = user_profile
|
prereg_realm.created_user = user_profile
|
||||||
prereg_realm.save(update_fields=["created_user"])
|
prereg_realm.save(update_fields=["created_user"])
|
||||||
|
|||||||
@@ -91,7 +91,7 @@ from zerver.models.presence import PresenceSequence
|
|||||||
from zerver.models.realm_audit_logs import AuditLogEventType
|
from zerver.models.realm_audit_logs import AuditLogEventType
|
||||||
from zerver.models.realms import get_fake_email_domain, get_realm
|
from zerver.models.realms import get_fake_email_domain, get_realm
|
||||||
from zerver.models.saved_snippets import SavedSnippet
|
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:
|
if TYPE_CHECKING:
|
||||||
from mypy_boto3_s3.service_resource import Object
|
from mypy_boto3_s3.service_resource import Object
|
||||||
@@ -158,6 +158,7 @@ ALL_ZULIP_TABLES = {
|
|||||||
"zerver_defaultstreamgroup_streams",
|
"zerver_defaultstreamgroup_streams",
|
||||||
"zerver_draft",
|
"zerver_draft",
|
||||||
"zerver_emailchangestatus",
|
"zerver_emailchangestatus",
|
||||||
|
"zerver_externalauthid",
|
||||||
"zerver_groupgroupmembership",
|
"zerver_groupgroupmembership",
|
||||||
"zerver_huddle",
|
"zerver_huddle",
|
||||||
"zerver_imageattachment",
|
"zerver_imageattachment",
|
||||||
@@ -332,6 +333,7 @@ DATE_FIELDS: dict[TableName, list[Field]] = {
|
|||||||
"analytics_usercount": ["end_time"],
|
"analytics_usercount": ["end_time"],
|
||||||
"zerver_attachment": ["create_time"],
|
"zerver_attachment": ["create_time"],
|
||||||
"zerver_channelfolder": ["date_created"],
|
"zerver_channelfolder": ["date_created"],
|
||||||
|
"zerver_externalauthid": ["date_created"],
|
||||||
"zerver_message": ["last_edit_time", "date_sent"],
|
"zerver_message": ["last_edit_time", "date_sent"],
|
||||||
"zerver_muteduser": ["date_muted"],
|
"zerver_muteduser": ["date_muted"],
|
||||||
"zerver_realmauditlog": ["event_time"],
|
"zerver_realmauditlog": ["event_time"],
|
||||||
@@ -1249,6 +1251,14 @@ def add_user_profile_child_configs(user_profile_config: Config) -> None:
|
|||||||
limit_to_consenting_users=True,
|
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:
|
# We exclude these fields for the following reasons:
|
||||||
# * api_key is a secret.
|
# * api_key is a secret.
|
||||||
|
|||||||
49
zerver/migrations/0729_externalauthid.py
Normal file
49
zerver/migrations/0729_externalauthid.py
Normal 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",
|
||||||
|
)
|
||||||
|
],
|
||||||
|
},
|
||||||
|
),
|
||||||
|
]
|
||||||
@@ -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:
|
def is_cross_realm_bot_email(email: str) -> bool:
|
||||||
return email.lower() in settings.CROSS_REALM_BOT_EMAILS
|
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",
|
||||||
|
),
|
||||||
|
]
|
||||||
|
|||||||
@@ -115,7 +115,7 @@ from zerver.models import (
|
|||||||
)
|
)
|
||||||
from zerver.models.groups import SystemGroups, UserGroupMembership
|
from zerver.models.groups import SystemGroups, UserGroupMembership
|
||||||
from zerver.models.realms import clear_supported_auth_backends_cache, get_realm
|
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.signals import JUST_CREATED_THRESHOLD
|
||||||
from zerver.views.auth import log_into_subdomain, maybe_send_to_registration
|
from zerver.views.auth import log_into_subdomain, maybe_send_to_registration
|
||||||
from zproject.backends import (
|
from zproject.backends import (
|
||||||
@@ -6861,6 +6861,167 @@ class TestLDAP(ZulipLDAPTestCase):
|
|||||||
assert user_profile is not None
|
assert user_profile is not None
|
||||||
self.assertEqual(user_profile, self.example_user("aaron"))
|
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(
|
@override_settings(
|
||||||
AUTHENTICATION_BACKENDS=(
|
AUTHENTICATION_BACKENDS=(
|
||||||
"zproject.backends.EmailAuthBackend",
|
"zproject.backends.EmailAuthBackend",
|
||||||
|
|||||||
@@ -115,7 +115,7 @@ from zerver.models.realm_audit_logs import AuditLogEventType
|
|||||||
from zerver.models.realms import get_realm
|
from zerver.models.realms import get_realm
|
||||||
from zerver.models.recipients import get_direct_message_group_hash
|
from zerver.models.recipients import get_direct_message_group_hash
|
||||||
from zerver.models.streams import get_active_streams, get_stream
|
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:
|
def make_datetime(val: float) -> datetime:
|
||||||
@@ -3023,6 +3023,21 @@ class SingleUserExportTest(ExportFile):
|
|||||||
def zerver_alertword(records: list[Record]) -> None:
|
def zerver_alertword(records: list[Record]) -> None:
|
||||||
self.assertEqual(records[-1]["word"], "pizza")
|
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(
|
favorite_city = try_add_realm_custom_profile_field(
|
||||||
realm,
|
realm,
|
||||||
"Favorite city",
|
"Favorite city",
|
||||||
|
|||||||
@@ -37,6 +37,7 @@ from django.shortcuts import render
|
|||||||
from django.urls import reverse
|
from django.urls import reverse
|
||||||
from django.utils.translation import gettext as _
|
from django.utils.translation import gettext as _
|
||||||
from django_auth_ldap.backend import LDAPBackend, _LDAPUser, _LDAPUserGroups, ldap_error
|
from django_auth_ldap.backend import LDAPBackend, _LDAPUser, _LDAPUserGroups, ldap_error
|
||||||
|
from django_auth_ldap.config import LDAPSettings
|
||||||
from lxml.etree import XMLSyntaxError
|
from lxml.etree import XMLSyntaxError
|
||||||
from onelogin.saml2 import compat as onelogin_saml2_compat
|
from onelogin.saml2 import compat as onelogin_saml2_compat
|
||||||
from onelogin.saml2.auth import OneLogin_Saml2_Auth
|
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_add_members_to_user_groups,
|
||||||
bulk_remove_members_from_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.actions.users import do_change_user_role, do_deactivate_user
|
||||||
from zerver.lib.avatar import avatar_url, is_avatar_new
|
from zerver.lib.avatar import avatar_url, is_avatar_new
|
||||||
from zerver.lib.avatar_hash import user_avatar_content_hash
|
from zerver.lib.avatar_hash import user_avatar_content_hash
|
||||||
@@ -108,6 +109,7 @@ from zerver.models.realms import (
|
|||||||
supported_auth_backends,
|
supported_auth_backends,
|
||||||
)
|
)
|
||||||
from zerver.models.users import (
|
from zerver.models.users import (
|
||||||
|
ExternalAuthID,
|
||||||
PasswordTooWeakError,
|
PasswordTooWeakError,
|
||||||
get_user_by_delivery_email,
|
get_user_by_delivery_email,
|
||||||
get_user_profile_by_id,
|
get_user_profile_by_id,
|
||||||
@@ -687,6 +689,53 @@ class ZulipLDAPConfigurationError(Exception):
|
|||||||
LDAP_USER_ACCOUNT_CONTROL_DISABLED_MASK = 2
|
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):
|
class ZulipLDAPAuthBackendBase(ZulipAuthMixin, LDAPBackend):
|
||||||
"""Common code between LDAP authentication (ZulipLDAPAuthBackend) and
|
"""Common code between LDAP authentication (ZulipLDAPAuthBackend) and
|
||||||
using LDAP just to sync user data (ZulipLDAPUserPopulator).
|
using LDAP just to sync user data (ZulipLDAPUserPopulator).
|
||||||
@@ -708,6 +757,16 @@ class ZulipLDAPAuthBackendBase(ZulipAuthMixin, LDAPBackend):
|
|||||||
|
|
||||||
check_ldap_config()
|
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
|
# Disable django-auth-ldap's permissions functions -- we don't use
|
||||||
# the standard Django user/group permissions system because they
|
# the standard Django user/group permissions system because they
|
||||||
# are prone to performance issues.
|
# are prone to performance issues.
|
||||||
@@ -1028,7 +1087,76 @@ class ZulipLDAPAuthBackend(ZulipLDAPAuthBackendBase):
|
|||||||
|
|
||||||
return user
|
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
|
"""The main function of our authentication backend extension of
|
||||||
django-auth-ldap. When this is called (from `authenticate`),
|
django-auth-ldap. When this is called (from `authenticate`),
|
||||||
django-auth-ldap will already have verified that the provided
|
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)
|
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):
|
if self.is_account_realm_access_forbidden(ldap_user, self._realm):
|
||||||
raise ZulipLDAPError("User not allowed to access realm")
|
raise ZulipLDAPError("User not allowed to access realm")
|
||||||
|
|
||||||
@@ -1057,7 +1189,13 @@ class ZulipLDAPAuthBackend(ZulipLDAPAuthBackendBase):
|
|||||||
return_data["inactive_user"] = True
|
return_data["inactive_user"] = True
|
||||||
raise ZulipLDAPError("User has been deactivated")
|
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)
|
user_profile = common_get_active_user(email, self._realm, return_data)
|
||||||
|
|
||||||
if user_profile is not None:
|
if user_profile is not None:
|
||||||
# An existing user, successfully authed; return it.
|
# An existing user, successfully authed; return it.
|
||||||
return user_profile, False
|
return user_profile, False
|
||||||
@@ -1128,6 +1266,9 @@ class ZulipLDAPAuthBackend(ZulipLDAPAuthBackendBase):
|
|||||||
opts["role"] = UserProfile.ROLE_REALM_OWNER
|
opts["role"] = UserProfile.ROLE_REALM_OWNER
|
||||||
opts["default_stream_groups"] = []
|
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(
|
user_profile = do_create_user(
|
||||||
email,
|
email,
|
||||||
None,
|
None,
|
||||||
@@ -1158,6 +1299,23 @@ class ZulipLDAPUser(_LDAPUser):
|
|||||||
|
|
||||||
super().__init__(*args, **kwargs)
|
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)
|
@transaction.atomic(savepoint=False)
|
||||||
def _get_or_create_user(self, force_populate: bool = False) -> UserProfile:
|
def _get_or_create_user(self, force_populate: bool = False) -> UserProfile:
|
||||||
# This function is responsible for the core logic of syncing
|
# This function is responsible for the core logic of syncing
|
||||||
|
|||||||
@@ -53,6 +53,7 @@ LDAP_APPEND_DOMAIN: str | None = None
|
|||||||
LDAP_EMAIL_ATTR: str | None = None
|
LDAP_EMAIL_ATTR: str | None = None
|
||||||
AUTH_LDAP_REVERSE_EMAIL_SEARCH: Optional["LDAPSearch"] = None
|
AUTH_LDAP_REVERSE_EMAIL_SEARCH: Optional["LDAPSearch"] = None
|
||||||
AUTH_LDAP_USERNAME_ATTR: str | None = None
|
AUTH_LDAP_USERNAME_ATTR: str | None = None
|
||||||
|
|
||||||
# AUTH_LDAP_USER_ATTR_MAP is uncommented in prod_settings_template.py,
|
# AUTH_LDAP_USER_ATTR_MAP is uncommented in prod_settings_template.py,
|
||||||
# so the value here mainly serves to help document the default.
|
# so the value here mainly serves to help document the default.
|
||||||
AUTH_LDAP_USER_ATTR_MAP: dict[str, str] = {
|
AUTH_LDAP_USER_ATTR_MAP: dict[str, str] = {
|
||||||
|
|||||||
@@ -240,6 +240,11 @@ AUTH_LDAP_USER_ATTR_MAP = {
|
|||||||
"full_name": "cn",
|
"full_name": "cn",
|
||||||
# "first_name": "fn",
|
# "first_name": "fn",
|
||||||
# "last_name": "ln",
|
# "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.
|
## Profile pictures can be pulled from the LDAP "thumbnailPhoto"/"jpegPhoto" field.
|
||||||
# "avatar": "thumbnailPhoto",
|
# "avatar": "thumbnailPhoto",
|
||||||
|
|||||||
@@ -300,3 +300,7 @@ ROOT_DOMAIN_LANDING_PAGE = False
|
|||||||
# Disable verifying webhook signatures in tests by default.
|
# Disable verifying webhook signatures in tests by default.
|
||||||
# Tests that intend to verify webhook signatures should override this setting.
|
# Tests that intend to verify webhook signatures should override this setting.
|
||||||
VERIFY_WEBHOOK_SIGNATURES = False
|
VERIFY_WEBHOOK_SIGNATURES = False
|
||||||
|
|
||||||
|
AUTH_LDAP_USER_ATTR_MAP = {
|
||||||
|
"full_name": "cn",
|
||||||
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user