diff --git a/.github/workflows/ci-main.yml b/.github/workflows/ci-main.yml index bb23379f0..4569cea36 100644 --- a/.github/workflows/ci-main.yml +++ b/.github/workflows/ci-main.yml @@ -124,7 +124,7 @@ jobs: - name: saml glob: tests/e2e/test_provider_saml* tests/e2e/test_source_saml* - name: ldap - glob: tests/e2e/test_provider_ldap* + glob: tests/e2e/test_provider_ldap* tests/e2e/test_source_ldap* - name: flows glob: tests/e2e/test_flows* steps: diff --git a/authentik/events/models.py b/authentik/events/models.py index a218e78ef..4b48da521 100644 --- a/authentik/events/models.py +++ b/authentik/events/models.py @@ -448,7 +448,7 @@ class NotificationTransport(SerializerModel): # pyright: reportGeneralTypeIssues=false return send_mail(mail.__dict__) # pylint: disable=no-value-for-parameter except (SMTPException, ConnectionError, OSError) as exc: - raise NotificationTransportError from exc + raise NotificationTransportError(exc) from exc @property def serializer(self) -> "Serializer": diff --git a/authentik/sources/ldap/sync/groups.py b/authentik/sources/ldap/sync/groups.py index 983feb9f5..df747ec5c 100644 --- a/authentik/sources/ldap/sync/groups.py +++ b/authentik/sources/ldap/sync/groups.py @@ -38,7 +38,6 @@ class GroupLDAPSynchronizer(BaseLDAPSynchronizer): try: defaults = self.build_group_properties(group_dn, **attributes) defaults["parent"] = self._source.sync_parent_group - self._logger.debug("Creating group with attributes", **defaults) if "name" not in defaults: raise IntegrityError("Name was not set by propertymappings") # Special check for `users` field, as this is an M2M relation, and cannot be sync'd @@ -51,6 +50,7 @@ class GroupLDAPSynchronizer(BaseLDAPSynchronizer): }, defaults, ) + self._logger.debug("Created group with attributes", **defaults) except (IntegrityError, FieldError, TypeError, AttributeError) as exc: Event.new( EventAction.CONFIGURATION_ERROR, diff --git a/authentik/sources/ldap/tests/test_auth.py b/authentik/sources/ldap/tests/test_auth.py index 396c78468..39fcfca43 100644 --- a/authentik/sources/ldap/tests/test_auth.py +++ b/authentik/sources/ldap/tests/test_auth.py @@ -33,11 +33,10 @@ class LDAPSyncTests(TestCase): """Test Cached auth""" self.source.property_mappings.set( LDAPPropertyMapping.objects.filter( - Q(name__startswith="authentik default LDAP Mapping") - | Q(name__startswith="authentik default Active Directory Mapping") + Q(managed__startswith="goauthentik.io/sources/ldap/default-") + | Q(managed__startswith="goauthentik.io/sources/ldap/ms-") ) ) - self.source.save() connection = PropertyMock(return_value=mock_ad_connection(LDAP_PASSWORD)) with patch("authentik.sources.ldap.models.LDAPSource.connection", connection): user_sync = UserLDAPSynchronizer(self.source) diff --git a/tests/e2e/test_source_ldap_samba.py b/tests/e2e/test_source_ldap_samba.py new file mode 100644 index 000000000..cff8feae3 --- /dev/null +++ b/tests/e2e/test_source_ldap_samba.py @@ -0,0 +1,165 @@ +"""test LDAP Source""" +from typing import Any, Optional + +from django.db.models import Q + +from authentik.blueprints.tests import apply_blueprint +from authentik.core.models import Group, User +from authentik.lib.generators import generate_id, generate_key +from authentik.sources.ldap.auth import LDAPBackend +from authentik.sources.ldap.models import LDAPPropertyMapping, LDAPSource +from authentik.sources.ldap.sync.groups import GroupLDAPSynchronizer +from authentik.sources.ldap.sync.membership import MembershipLDAPSynchronizer +from authentik.sources.ldap.sync.users import UserLDAPSynchronizer +from tests.e2e.utils import SeleniumTestCase, retry + + +class TestSourceLDAPSamba(SeleniumTestCase): + """test LDAP Source""" + + def setUp(self): + self.admin_password = generate_key() + super().setUp() + + def get_container_specs(self) -> Optional[dict[str, Any]]: + return { + "image": "ghcr.io/beryju/test-samba-dc:latest", + "detach": True, + "cap_add": ["SYS_ADMIN"], + "ports": { + "389": "389/tcp", + }, + "auto_remove": True, + "environment": { + "SMB_DOMAIN": "test.goauthentik.io", + "SMB_NETBIOS": "goauthentik", + "SMB_ADMIN_PASSWORD": self.admin_password, + }, + } + + @retry() + @apply_blueprint( + "system/sources-ldap.yaml", + ) + def test_source_sync(self): + """Test Sync""" + source = LDAPSource.objects.create( + name=generate_id(), + slug=generate_id(), + server_uri="ldap://localhost", + bind_cn="administrator@test.goauthentik.io", + bind_password=self.admin_password, + base_dn="dc=test,dc=goauthentik,dc=io", + additional_user_dn="ou=users", + additional_group_dn="ou=groups", + ) + source.property_mappings.set( + LDAPPropertyMapping.objects.filter( + Q(managed__startswith="goauthentik.io/sources/ldap/default-") + | Q(managed__startswith="goauthentik.io/sources/ldap/ms-") + ) + ) + source.property_mappings_group.set( + LDAPPropertyMapping.objects.filter(name="goauthentik.io/sources/ldap/default-name") + ) + UserLDAPSynchronizer(source).sync() + self.assertTrue(User.objects.filter(username="bob").exists()) + self.assertTrue(User.objects.filter(username="james").exists()) + self.assertTrue(User.objects.filter(username="john").exists()) + self.assertTrue(User.objects.filter(username="harry").exists()) + + @retry() + @apply_blueprint( + "system/sources-ldap.yaml", + ) + def test_source_sync_group(self): + """Test Sync""" + source = LDAPSource.objects.create( + name=generate_id(), + slug=generate_id(), + server_uri="ldap://localhost", + bind_cn="administrator@test.goauthentik.io", + bind_password=self.admin_password, + base_dn="dc=test,dc=goauthentik,dc=io", + additional_user_dn="ou=users", + additional_group_dn="ou=groups", + ) + source.property_mappings.set( + LDAPPropertyMapping.objects.filter( + Q(managed__startswith="goauthentik.io/sources/ldap/default-") + | Q(managed__startswith="goauthentik.io/sources/ldap/ms-") + ) + ) + source.property_mappings_group.set( + LDAPPropertyMapping.objects.filter(managed="goauthentik.io/sources/ldap/default-name") + ) + GroupLDAPSynchronizer(source).sync() + UserLDAPSynchronizer(source).sync() + MembershipLDAPSynchronizer(source).sync() + self.assertIsNotNone(User.objects.get(username="bob")) + self.assertIsNotNone(User.objects.get(username="james")) + self.assertIsNotNone(User.objects.get(username="john")) + self.assertIsNotNone(User.objects.get(username="harry")) + self.assertIsNotNone(Group.objects.get(name="dev")) + self.assertEqual( + list(User.objects.get(username="bob").ak_groups.all()), [Group.objects.get(name="dev")] + ) + self.assertEqual(list(User.objects.get(username="james").ak_groups.all()), []) + self.assertEqual( + list(User.objects.get(username="john").ak_groups.all().order_by("name")), + [Group.objects.get(name="admins"), Group.objects.get(name="dev")], + ) + self.assertEqual(list(User.objects.get(username="harry").ak_groups.all()), []) + + @retry() + @apply_blueprint( + "system/sources-ldap.yaml", + ) + def test_sync_password(self): + """Test Sync""" + source = LDAPSource.objects.create( + name=generate_id(), + slug=generate_id(), + server_uri="ldap://localhost", + bind_cn="administrator@test.goauthentik.io", + bind_password=self.admin_password, + base_dn="dc=test,dc=goauthentik,dc=io", + additional_user_dn="ou=users", + additional_group_dn="ou=groups", + ) + source.property_mappings.set( + LDAPPropertyMapping.objects.filter( + Q(managed__startswith="goauthentik.io/sources/ldap/default-") + | Q(managed__startswith="goauthentik.io/sources/ldap/ms-") + ) + ) + source.property_mappings_group.set( + LDAPPropertyMapping.objects.filter(name="goauthentik.io/sources/ldap/default-name") + ) + UserLDAPSynchronizer(source).sync() + username = "bob" + password = generate_id() + result = self.container.exec_run( + ["samba-tool", "user", "setpassword", username, "--newpassword", password] + ) + self.assertEqual(result.exit_code, 0) + user: User = User.objects.get(username=username) + # Ensure user has an unusable password directly after sync + self.assertFalse(user.has_usable_password()) + # Auth (which will fallback to bind) + LDAPBackend().auth_user(source, password, username=username) + user.refresh_from_db() + # User should now have a usable password in the database + self.assertTrue(user.has_usable_password()) + self.assertTrue(user.check_password(password)) + # Set new password + new_password = generate_id() + result = self.container.exec_run( + ["samba-tool", "user", "setpassword", username, "--newpassword", new_password] + ) + self.assertEqual(result.exit_code, 0) + # Sync again + UserLDAPSynchronizer(source).sync() + user.refresh_from_db() + # Since password in samba was checked, it should be invalidated here too + self.assertFalse(user.has_usable_password()) diff --git a/tests/e2e/utils.py b/tests/e2e/utils.py index ad1a14f35..e8b71b436 100644 --- a/tests/e2e/utils.py +++ b/tests/e2e/utils.py @@ -54,7 +54,6 @@ class SeleniumTestCase(StaticLiveServerTestCase): self.maxDiff = None self.wait_timeout = 60 self.driver = self._get_driver() - self.driver.maximize_window() self.driver.implicitly_wait(30) self.wait = WebDriverWait(self.driver, self.wait_timeout) self.logger = get_logger() @@ -77,7 +76,9 @@ class SeleniumTestCase(StaticLiveServerTestCase): def _start_container(self, specs: dict[str, Any]) -> Container: client: DockerClient = from_env() container = client.containers.run(**specs) - if "healthcheck" not in specs: + container.reload() + state = container.attrs.get("State", {}) + if "Health" not in state: return container while True: container.reload() @@ -100,12 +101,18 @@ class SeleniumTestCase(StaticLiveServerTestCase): def _get_driver(self) -> WebDriver: count = 0 + try: + return webdriver.Chrome() + except WebDriverException: + pass while count < RETRIES: try: - return webdriver.Remote( + driver = webdriver.Remote( command_executor="http://localhost:4444/wd/hub", options=webdriver.ChromeOptions(), ) + driver.maximize_window() + return driver except WebDriverException: count += 1 raise ValueError(f"Webdriver failed after {RETRIES}.") diff --git a/web/src/common/ui/locale.ts b/web/src/common/ui/locale.ts index 2faf515fb..de3733de5 100644 --- a/web/src/common/ui/locale.ts +++ b/web/src/common/ui/locale.ts @@ -1,5 +1,6 @@ import { EVENT_LOCALE_CHANGE } from "@goauthentik/common/constants"; import { globalAK } from "@goauthentik/common/global"; +import { PluralCategory } from "make-plural"; import { Messages, i18n } from "@lingui/core"; import { detect, fromNavigator, fromUrl } from "@lingui/detect-locale"; @@ -7,7 +8,7 @@ import { t } from "@lingui/macro"; interface Locale { locale: Messages; - plurals: (n: string | number, ord?: boolean | undefined) => string; + plurals: (n: string | number, ord?: boolean | undefined) => PluralCategory; } export const LOCALES: {