Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

sources: add Kerberos #10815

Draft
wants to merge 50 commits into
base: main
Choose a base branch
from
Draft

sources: add Kerberos #10815

wants to merge 50 commits into from

Conversation

rissson
Copy link
Member

@rissson rissson commented Aug 7, 2024

Details

TODO:

  • sync user matching
  • sync group handling and matching
  • identifier always lowercase fix
  • tests for user/group matching
  • documentation
  • frontend
    • started, missing some config options in the form
    • form must be refactored to use new inputs
  • test property mappings

Checklist

  • Local tests pass (ak test authentik/)
  • The code has been formatted (make lint-fix)

If an API change has been made

  • The API schema has been updated (make gen-build)

If changes to the frontend have been made

  • The code has been formatted (make web)

If applicable

  • The documentation has been updated
  • The documentation has been formatted (make website)

Signed-off-by: Marc 'risson' Schmitt <marc.schmitt@risson.space>
Signed-off-by: Marc 'risson' Schmitt <marc.schmitt@risson.space>
Signed-off-by: Marc 'risson' Schmitt <marc.schmitt@risson.space>
Signed-off-by: Marc 'risson' Schmitt <marc.schmitt@risson.space>
Signed-off-by: Marc 'risson' Schmitt <marc.schmitt@risson.space>
Signed-off-by: Marc 'risson' Schmitt <marc.schmitt@risson.space>
Signed-off-by: Marc 'risson' Schmitt <marc.schmitt@risson.space>
Signed-off-by: Marc 'risson' Schmitt <marc.schmitt@risson.space>
Signed-off-by: Marc 'risson' Schmitt <marc.schmitt@risson.space>
Signed-off-by: Marc 'risson' Schmitt <marc.schmitt@risson.space>
Signed-off-by: Marc 'risson' Schmitt <marc.schmitt@risson.space>
Signed-off-by: Marc 'risson' Schmitt <marc.schmitt@risson.space>
Signed-off-by: Marc 'risson' Schmitt <marc.schmitt@risson.space>
Signed-off-by: Marc 'risson' Schmitt <marc.schmitt@risson.space>
Signed-off-by: Marc 'risson' Schmitt <marc.schmitt@risson.space>
Signed-off-by: Marc 'risson' Schmitt <marc.schmitt@risson.space>
Signed-off-by: Marc 'risson' Schmitt <marc.schmitt@risson.space>
Signed-off-by: Marc 'risson' Schmitt <marc.schmitt@risson.space>
Signed-off-by: Marc 'risson' Schmitt <marc.schmitt@risson.space>
Signed-off-by: Marc 'risson' Schmitt <marc.schmitt@risson.space>
Signed-off-by: Marc 'risson' Schmitt <marc.schmitt@risson.space>
Signed-off-by: Marc 'risson' Schmitt <marc.schmitt@risson.space>
Signed-off-by: Marc 'risson' Schmitt <marc.schmitt@risson.space>
Signed-off-by: Marc 'risson' Schmitt <marc.schmitt@risson.space>
Signed-off-by: Marc 'risson' Schmitt <marc.schmitt@risson.space>
Signed-off-by: Marc 'risson' Schmitt <marc.schmitt@risson.space>
Signed-off-by: Marc 'risson' Schmitt <marc.schmitt@risson.space>
@rissson rissson self-assigned this Aug 7, 2024
@rissson rissson requested review from a team as code owners August 7, 2024 22:31
@rissson rissson marked this pull request as draft August 7, 2024 22:31
Copy link

netlify bot commented Aug 7, 2024

Deploy Preview for authentik-storybook ready!

Name Link
🔨 Latest commit 38e7df9
🔍 Latest deploy log https://app.netlify.com/sites/authentik-storybook/deploys/66ed8b100a5caf00086dc230
😎 Deploy Preview https://deploy-preview-10815--authentik-storybook.netlify.app
📱 Preview on mobile
Toggle QR Code...

QR Code

Use your smartphone camera to open QR code link.

To edit notification comments on pull requests, go to your Netlify site configuration.

Copy link

netlify bot commented Aug 7, 2024

Deploy Preview for authentik-docs canceled.

Name Link
🔨 Latest commit 38e7df9
🔍 Latest deploy log https://app.netlify.com/sites/authentik-docs/deploys/66ed8b10e96ad100082a0783

@rissson rissson changed the title Kerberos source reworked sources: add Kerberos Aug 7, 2024
Signed-off-by: Marc 'risson' Schmitt <marc.schmitt@risson.space>
Signed-off-by: Marc 'risson' Schmitt <marc.schmitt@risson.space>
Comment on lines 38 to 55
def challenge(self, request, token: str | None = None) -> HttpResponse:
"""Get SNPEGO challenge response"""
response = render(
request,
"if/error.html",
context={
"title": _("SPNEGO authentication required"),
"message": _("""
Make sure you have valid tickets (obtainable via kinit) and configured the browser correctly.
You can find out more at https://goauthentik.io/integrations/sources/spnego/browser
"""),
},
status=401,
)
response[WWW_AUTHENTICATE] = (
NEGOTIATE if token is None else f"{NEGOTIATE} {b64encode(token.encode()).decode('ascii')}"
)
return response
Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

We may want to handle this differently

@rissson rissson mentioned this pull request Aug 7, 2024
10 tasks
Signed-off-by: Marc 'risson' Schmitt <marc.schmitt@risson.space>
Signed-off-by: Marc 'risson' Schmitt <marc.schmitt@risson.space>
Signed-off-by: Marc 'risson' Schmitt <marc.schmitt@risson.space>
Copy link

codecov bot commented Aug 7, 2024

❌ 2 Tests Failed:

Tests completed Failed Passed Skipped
65 2 63 1
View the top 2 failed tests by shortest run time
 authentik.sources.kerberos.tests.test_sync
Stack Traces | 0s run time
No failure message available
tests.e2e.test_source_ldap_samba.TestSourceLDAPSamba test_sync_password
Stack Traces | 21.4s run time
self = &lt;authentik.sources.ldap.password.LDAPPasswordChanger object at 0x7fa154411670&gt;
user = &lt;User: bob&gt;, password = 'YMqjM0Q31zxW6SlQeJwyRykHzFFlzxUQOVVXhU4x'

    def change_password(self, user: User, password: str):
        """Change user's password"""
        user_dn = user.attributes.get(LDAP_DISTINGUISHED_NAME, None)
        if not user_dn:
            LOGGER.info(f"User has no {LDAP_DISTINGUISHED_NAME} set.")
            return
        try:
&gt;           self._connection.extend.microsoft.modify_password(user_dn, password)

.../sources/ldap/password.py:104: 
_ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ 

self =   add_members_to_groups
  dir_sync
  modify_password
  persistent_search
  remove_members_from_groups
  unlock_account
user = 'CN=bob,OU=Users,DC=test,DC=goauthentik,DC=io'
new_password = 'YMqjM0Q31zxW6SlQeJwyRykHzFFlzxUQOVVXhU4x', old_password = None
controls = None

    def modify_password(self, user, new_password, old_password=None, controls=None):
&gt;       return ad_modify_password(self._connection,
                                  user,
                                  new_password,
                                  old_password,
                                  controls)

../../../..../pypoetry/virtualenvs/authentik-xvtLQ9eE-py3.12/lib/python3.12.../ldap3/extend/__init__.py:289: 
_ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ 

connection = Connection(server=ServerPool(servers=[Server(host='localhost', port=389, use_ssl=False, allowed_referral_hosts=[('*', ...e=True, receive_timeout=15, return_empty_attributes=True, auto_encode=True, auto_escape=True, use_referral_cache=False)
user_dn = 'CN=bob,OU=Users,DC=test,DC=goauthentik,DC=io'
new_password = 'YMqjM0Q31zxW6SlQeJwyRykHzFFlzxUQOVVXhU4x', old_password = None
controls = None

    def ad_modify_password(connection, user_dn, new_password, old_password, controls=None):
        # old password must be None to reset password with sufficient privileges
        if connection.check_names:
            user_dn = safe_dn(user_dn)
        if str is bytes:  # python2, converts to unicode
            new_password = to_unicode(new_password)
            if old_password:
                old_password = to_unicode(old_password)
    
        encoded_new_password = ('"%s"' % new_password).encode('utf-16-le')
    
        if old_password:  # normal users must specify old and new password
            encoded_old_password = ('"%s"' % old_password).encode('utf-16-le')
            result = connection.modify(user_dn,
                                       {'unicodePwd': [(MODIFY_DELETE, [encoded_old_password]),
                                                       (MODIFY_ADD, [encoded_new_password])]},
                                       controls)
        else:  # admin users can reset password without sending the old one
&gt;           result = connection.modify(user_dn,
                                       {'unicodePwd': [(MODIFY_REPLACE, [encoded_new_password])]},
                                       controls)

../../../..../pypoetry/virtualenvs/authentik-xvtLQ9eE-py3.12/lib/python3.12.../extend/microsoft/modifyPassword.py:52: 
_ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ 

self = Connection(server=ServerPool(servers=[Server(host='localhost', port=389, use_ssl=False, allowed_referral_hosts=[('*', ...e=True, receive_timeout=15, return_empty_attributes=True, auto_encode=True, auto_escape=True, use_referral_cache=False)
dn = 'CN=bob,OU=Users,DC=test,DC=goauthentik,DC=io'
changes = {'unicodePwd': [('MODIFY_REPLACE', [b'"\x00Y\x00M\x00q\x00j\x00M\x000\x00Q\x003\x001\x00z\x00x\x00W\x006\x00S\x00l\x00...\x00y\x00R\x00y\x00k\x00H\x00z\x00F\x00F\x00l\x00z\x00x\x00U\x00Q\x00O\x00V\x00V\x00X\x00h\x00U\x004\x00x\x00"\x00'])]}
controls = None

    def modify(self,
               dn,
               changes,
               controls=None):
        """
        Modify attributes of entry
    
        - changes is a dictionary in the form {'attribute1': change), 'attribute2': [change, change, ...], ...}
        - change is (operation, [value1, value2, ...])
        - operation is 0 (MODIFY_ADD), 1 (MODIFY_DELETE), 2 (MODIFY_REPLACE), 3 (MODIFY_INCREMENT)
        """
        conf_attributes_excluded_from_check = [v.lower() for v in get_config_parameter('ATTRIBUTES_EXCLUDED_FROM_CHECK')]
    
        if log_enabled(BASIC):
            log(BASIC, 'start MODIFY operation via &lt;%s&gt;', self)
        self.last_error = None
        if self.check_names:
            dn = safe_dn(dn)
            if log_enabled(EXTENDED):
                log(EXTENDED, 'dn sanitized to &lt;%s&gt; for MODIFY operation via &lt;%s&gt;', dn, self)
    
        with self.connection_lock:
            self._fire_deferred()
            if self.read_only:
                self.last_error = 'connection is read-only'
                if log_enabled(ERROR):
                    log(ERROR, '%s for &lt;%s&gt;', self.last_error, self)
                raise LDAPConnectionIsReadOnlyError(self.last_error)
    
            if not isinstance(changes, dict):
                self.last_error = 'changes must be a dictionary'
                if log_enabled(ERROR):
                    log(ERROR, '%s for &lt;%s&gt;', self.last_error, self)
                raise LDAPChangeError(self.last_error)
    
            if not changes:
                self.last_error = 'no changes in modify request'
                if log_enabled(ERROR):
                    log(ERROR, '%s for &lt;%s&gt;', self.last_error, self)
                raise LDAPChangeError(self.last_error)
    
            changelist = dict()
            for attribute_name in changes:
                if self.server and self.server.schema and self.check_names:
                    if ';' in attribute_name:  # remove tags for checking
                        attribute_name_to_check = attribute_name.split(';')[0]
                    else:
                        attribute_name_to_check = attribute_name
    
                    if self.server.schema.attribute_types and attribute_name_to_check.lower() not in conf_attributes_excluded_from_check and attribute_name_to_check not in self.server.schema.attribute_types:
                        self.last_error = 'invalid attribute type ' + attribute_name_to_check
                        if log_enabled(ERROR):
                            log(ERROR, '%s for &lt;%s&gt;', self.last_error, self)
                        raise LDAPAttributeError(self.last_error)
                change = changes[attribute_name]
                if isinstance(change, SEQUENCE_TYPES) and change[0] in [MODIFY_ADD, MODIFY_DELETE, MODIFY_REPLACE, MODIFY_INCREMENT, 0, 1, 2, 3]:
                    if len(change) != 2:
                        self.last_error = 'malformed change'
                        if log_enabled(ERROR):
                            log(ERROR, '%s for &lt;%s&gt;', self.last_error, self)
                        raise LDAPChangeError(self.last_error)
    
                    changelist[attribute_name] = [change]  # insert change in a list
                else:
                    for change_operation in change:
                        if len(change_operation) != 2 or change_operation[0] not in [MODIFY_ADD, MODIFY_DELETE, MODIFY_REPLACE, MODIFY_INCREMENT, 0, 1, 2, 3]:
                            self.last_error = 'invalid change list'
                            if log_enabled(ERROR):
                                log(ERROR, '%s for &lt;%s&gt;', self.last_error, self)
                            raise LDAPChangeError(self.last_error)
                    changelist[attribute_name] = change
            request = modify_operation(dn, changelist, self.auto_encode, self.server.schema if self.server else None, validator=self.server.custom_validator if self.server else None, check_names=self.check_names)
            if log_enabled(PROTOCOL):
                log(PROTOCOL, 'MODIFY request &lt;%s&gt; sent via &lt;%s&gt;', modify_request_to_dict(request), self)
&gt;           response = self.post_send_single_response(self.send('modifyRequest', request, controls))

../../../..../pypoetry/virtualenvs/authentik-xvtLQ9eE-py3.12/lib/python3.12.../ldap3/core/connection.py:1150: 
_ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ 

self = &lt;ldap3.strategy.sync.SyncStrategy object at 0x7fa150faf080&gt;
message_id = 71

    def post_send_single_response(self, message_id):
        """
        Executed after an Operation Request (except Search)
        Returns the result message or None
        """
&gt;       responses, result = self.get_response(message_id)

../../../..../pypoetry/virtualenvs/authentik-xvtLQ9eE-py3.12/lib/python3.12.../ldap3/strategy/sync.py:160: 
_ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ 

self = &lt;ldap3.strategy.sync.SyncStrategy object at 0x7fa150faf080&gt;
message_id = 71, timeout = 20, get_request = False

    def get_response(self, message_id, timeout=None, get_request=False):
        """
        Get response LDAP messages
        Responses are returned by the underlying connection strategy
        Check if message_id LDAP message is still outstanding and wait for timeout to see if it appears in _get_response
        Result is stored in connection.result
        Responses without result is stored in connection.response
        A tuple (responses, result) is returned
        """
        if timeout is None:
            timeout = get_config_parameter('RESPONSE_WAITING_TIMEOUT')
        response = None
        result = None
        # request = None
        if self._outstanding and message_id in self._outstanding:
            responses = self._get_response(message_id, timeout)
    
            if not responses:
                if log_enabled(ERROR):
                    log(ERROR, 'socket timeout, no response from server for &lt;%s&gt;', self.connection)
                raise LDAPResponseTimeoutError('no response from server')
    
            if responses == SESSION_TERMINATED_BY_SERVER:
                try:  # try to close the session but don't raise any error if server has already closed the session
                    self.close()
                except (socket.error, LDAPExceptionError):
                    pass
                self.connection.last_error = 'session terminated by server'
                if log_enabled(ERROR):
                    log(ERROR, '&lt;%s&gt; for &lt;%s&gt;', self.connection.last_error, self.connection)
                raise LDAPSessionTerminatedByServerError(self.connection.last_error)
            elif responses == TRANSACTION_ERROR:  # Novell LDAP Transaction unsolicited notification
                self.connection.last_error = 'transaction error'
                if log_enabled(ERROR):
                    log(ERROR, '&lt;%s&gt; for &lt;%s&gt;', self.connection.last_error, self.connection)
                raise LDAPTransactionError(self.connection.last_error)
    
            # if referral in response opens a new connection to resolve referrals if requested
    
            if responses[-2]['result'] == RESULT_REFERRAL:
                if self.connection.usage:
                    self.connection._usage.referrals_received += 1
                if self.connection.auto_referrals:
                    ref_response, ref_result = self.do_operation_on_referral(self._outstanding[message_id], responses[-2]['referrals'])
                    if ref_response is not None:
                        responses = ref_response + [ref_result]
                        responses.append(RESPONSE_COMPLETE)
                    elif ref_result is not None:
                        responses = [ref_result, RESPONSE_COMPLETE]
    
                    self._referrals = []
    
            if responses:
                result = responses[-2]
                response = responses[:-2]
                self.connection.result = None
                self.connection.response = None
    
            if self.connection.raise_exceptions and result and result['result'] not in DO_NOT_RAISE_EXCEPTIONS:
                if log_enabled(PROTOCOL):
                    log(PROTOCOL, 'operation result &lt;%s&gt; for &lt;%s&gt;', result, self.connection)
                self._outstanding.pop(message_id)
                self.connection.result = result.copy()
&gt;               raise LDAPOperationResult(result=result['result'], description=result['description'], dn=result['dn'], message=result['message'], response_type=result['type'])
E               ldap3.core.exceptions.LDAPUnwillingToPerformResult: LDAPUnwillingToPerformResult - 53 - unwillingToPerform - None - 0000001F: SvcErr: DSID-031A126C, problem 5003 (WILL_NOT_PERFORM), data 0
E               Password modification over LDAP must be over an encrypted connection - modifyResponse - None

../../../..../pypoetry/virtualenvs/authentik-xvtLQ9eE-py3.12/lib/python3.12.../ldap3/strategy/base.py:403: LDAPUnwillingToPerformResult

During handling of the above exception, another exception occurred:

self = &lt;unittest.case._Outcome object at 0x7fa154a192b0&gt;
test_case = &lt;tests.e2e.test_source_ldap_samba.TestSourceLDAPSamba testMethod=test_sync_password&gt;
subTest = False

    @contextlib.contextmanager
    def testPartExecutor(self, test_case, subTest=False):
        old_success = self.success
        self.success = True
        try:
&gt;           yield

.../hostedtoolcache/Python/3.12.6........./x64/lib/python3.12/unittest/case.py:58: 
_ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ 

self = &lt;tests.e2e.test_source_ldap_samba.TestSourceLDAPSamba testMethod=test_sync_password&gt;
result = &lt;TestCaseFunction test_sync_password&gt;

    def run(self, result=None):
        if result is None:
            result = self.defaultTestResult()
            startTestRun = getattr(result, 'startTestRun', None)
            stopTestRun = getattr(result, 'stopTestRun', None)
            if startTestRun is not None:
                startTestRun()
        else:
            stopTestRun = None
    
        result.startTest(self)
        try:
            testMethod = getattr(self, self._testMethodName)
            if (getattr(self.__class__, "__unittest_skip__", False) or
                getattr(testMethod, "__unittest_skip__", False)):
                # If the class or method was skipped.
                skip_why = (getattr(self.__class__, '__unittest_skip_why__', '')
                            or getattr(testMethod, '__unittest_skip_why__', ''))
                _addSkip(result, self, skip_why)
                return result
    
            expecting_failure = (
                getattr(self, "__unittest_expecting_failure__", False) or
                getattr(testMethod, "__unittest_expecting_failure__", False)
            )
            outcome = _Outcome(result)
            start_time = time.perf_counter()
            try:
                self._outcome = outcome
    
                with outcome.testPartExecutor(self):
                    self._callSetUp()
                if outcome.success:
                    outcome.expecting_failure = expecting_failure
                    with outcome.testPartExecutor(self):
&gt;                       self._callTestMethod(testMethod)

.../hostedtoolcache/Python/3.12.6........./x64/lib/python3.12/unittest/case.py:634: 
_ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ 

self = &lt;tests.e2e.test_source_ldap_samba.TestSourceLDAPSamba testMethod=test_sync_password&gt;
method = &lt;bound method TestSourceLDAPSamba.test_sync_password of &lt;tests.e2e.test_source_ldap_samba.TestSourceLDAPSamba testMethod=test_sync_password&gt;&gt;

    def _callTestMethod(self, method):
&gt;       if method() is not None:

.../hostedtoolcache/Python/3.12.6........./x64/lib/python3.12/unittest/case.py:589: 
_ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ 

self = &lt;tests.e2e.test_source_ldap_samba.TestSourceLDAPSamba testMethod=test_sync_password&gt;
args = (), kwargs = {}

    @wraps(func)
    def wrapper(self: TransactionTestCase, *args, **kwargs):
        """Run test again if we're below max_retries, including tearDown and
        setUp. Otherwise raise the error"""
        nonlocal count
        try:
&gt;           return func(self, *args, **kwargs)

tests/e2e/utils.py:253: 
_ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ 

args = (&lt;tests.e2e.test_source_ldap_samba.TestSourceLDAPSamba testMethod=test_sync_password&gt;,)
kwargs = {}, file = 'system/sources-ldap.yaml'
content = 'version: 1\nmetadata:\n  labels:\n    blueprints.goauthentik.io/system: "true"\n  name: System - LDAP Source - Mappin...default OpenLDAP Mapping: cn"\n      expression: |\n        return {\n            "name": ldap.get("cn"),\n        }\n'

    @wraps(func)
    def wrapper(*args, **kwargs):
        for file in files:
            content = BlueprintInstance(path=file).retrieve()
            Importer.from_string(content).apply()
&gt;       return func(*args, **kwargs)

.../blueprints/tests/__init__.py:25: 
_ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ 

self = &lt;tests.e2e.test_source_ldap_samba.TestSourceLDAPSamba testMethod=test_sync_password&gt;

    @retry(exceptions=[LDAPSessionTerminatedByServerError])
    @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",
            password_login_update_internal_password=True,
        )
        source.user_property_mappings.set(
            LDAPSourcePropertyMapping.objects.filter(
                Q(managed__startswith="goauthentik..../sources/ldap/default-")
                | Q(managed__startswith="goauthentik..../sources/ldap/ms-")
            )
        )
        source.group_property_mappings.set(
            LDAPSourcePropertyMapping.objects.filter(
                name="goauthentik..../sources/ldap/default-name"
            )
        )
        UserLDAPSynchronizer(source).sync_full()
        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)
&gt;       LDAPBackend().auth_user(source, password, username=username)

tests/e2e/test_source_ldap_samba.py:159: 
_ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ 

self = &lt;authentik.sources.ldap.auth.LDAPBackend object at 0x7fa154410d10&gt;
source = &lt;LDAPSource: rYxtjMJL1ZUHvJVB2PEC6JxagHNkMKm9VsQtK1P6&gt;
password = 'YMqjM0Q31zxW6SlQeJwyRykHzFFlzxUQOVVXhU4x'
filters = {'username': 'bob'}, users = &lt;UserQuerySet []&gt;, user = &lt;User: bob&gt;

    def auth_user(self, source: LDAPSource, password: str, **filters: str) -&gt; User | None:
        """Try to bind as either user_dn or mail with password.
        Returns True on success, otherwise False"""
        users = User.objects.filter(**filters)
        if not users.exists():
            return None
        user: User = users.first()
        if LDAP_DISTINGUISHED_NAME not in user.attributes:
            LOGGER.debug("User doesn't have DN set, assuming not LDAP imported.", user=user)
            return None
        # Either has unusable password,
        # or has a password, but couldn't be authenticated by ModelBackend.
        # This means we check with a bind to see if the LDAP password has changed
        if self.auth_user_by_bind(source, user, password):
            if source.password_login_update_internal_password:
                # Password given successfully binds to LDAP, so we save it in our Database
                LOGGER.debug("Updating user's password in DB", user=user)
&gt;               user.set_password(password)

.../sources/ldap/auth.py:46: 
_ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ 

self = &lt;User: bob&gt;, raw_password = 'YMqjM0Q31zxW6SlQeJwyRykHzFFlzxUQOVVXhU4x'
signal = True

    def set_password(self, raw_password, signal=True):
        if self.pk and signal:
            from authentik.core.signals import password_changed
    
&gt;           password_changed.send(sender=self, user=self, password=raw_password)

authentik/core/models.py:337: 
_ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ 

self = &lt;django.dispatch.dispatcher.Signal object at 0x7fa15862c9e0&gt;
sender = &lt;User: bob&gt;
named = {'password': 'YMqjM0Q31zxW6SlQeJwyRykHzFFlzxUQOVVXhU4x', 'user': &lt;User: bob&gt;}
responses = [(&lt;function on_password_changed at 0x7fa15107cea0&gt;, None), (&lt;function kerberos_sync_password at 0x7fa15107cf40&gt;, None)]
sync_receivers = [&lt;function on_password_changed at 0x7fa15107cea0&gt;, &lt;function kerberos_sync_password at 0x7fa15107cf40&gt;, &lt;function ldap_sync_password at 0x7fa15107cfe0&gt;]
receiver = &lt;function ldap_sync_password at 0x7fa15107cfe0&gt;, response = None

    def send(self, sender, **named):
        """
        Send signal from sender to all connected receivers.
    
        If any receiver raises an error, the error propagates back through send,
        terminating the dispatch loop. So it's possible that all receivers
        won't be called if an error is raised.
    
        If any receivers are asynchronous, they are called after all the
        synchronous receivers via a single call to async_to_sync(). They are
        also executed concurrently with asyncio.gather().
    
        Arguments:
    
            sender
                The sender of the signal. Either a specific object or None.
    
            named
                Named arguments which will be passed to receivers.
    
        Return a list of tuple pairs [(receiver, response), ... ].
        """
        if (
            not self.receivers
            or self.sender_receivers_cache.get(sender) is NO_RECEIVERS
        ):
            return []
        responses = []
        sync_receivers, async_receivers = self._live_receivers(sender)
        for receiver in sync_receivers:
&gt;           response = receiver(signal=self, sender=sender, **named)

../../../..../pypoetry/virtualenvs/authentik-xvtLQ9eE-py3.12/lib/python3.12.../django/dispatch/dispatcher.py:189: 
_ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ 

args = ()
kwargs = {'password': 'YMqjM0Q31zxW6SlQeJwyRykHzFFlzxUQOVVXhU4x', 'sender': &lt;User: bob&gt;, 'signal': &lt;django.dispatch.dispatcher.Signal object at 0x7fa15862c9e0&gt;, 'user': &lt;User: bob&gt;}
signal_name = 'authentik.sources.ldap.signals.ldap_sync_password'
span = &lt;Span(op='event.django', description:'authentik.sources.ldap.signals.ldap_sync_password', trace_id='af807d070295420c8221dd61300437bc', span_id='8b631441441b4000', parent_span_id=None, sampled=None, origin='auto.http.django')&gt;

    @wraps(receiver)
    def wrapper(*args, **kwargs):
        # type: (Any, Any) -&gt; Any
        signal_name = _get_receiver_name(receiver)
        with sentry_sdk.start_span(
            op=OP.EVENT_DJANGO,
            description=signal_name,
            origin=DjangoIntegration.origin,
        ) as span:
            span.set_data("signal", signal_name)
&gt;           return receiver(*args, **kwargs)

../../../..../pypoetry/virtualenvs/authentik-xvtLQ9eE-py3.12/lib/python3.12.../integrations/django/signals_handlers.py:73: 
_ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ 

sender = &lt;User: bob&gt;, user = &lt;User: bob&gt;
password = 'YMqjM0Q31zxW6SlQeJwyRykHzFFlzxUQOVVXhU4x'
_ = {'signal': &lt;django.dispatch.dispatcher.Signal object at 0x7fa15862c9e0&gt;}
sources = &lt;InheritanceQuerySet []&gt;
source = &lt;LDAPSource: rYxtjMJL1ZUHvJVB2PEC6JxagHNkMKm9VsQtK1P6&gt;
changer = &lt;authentik.sources.ldap.password.LDAPPasswordChanger object at 0x7fa154411670&gt;

    @receiver(password_changed)
    def ldap_sync_password(sender, user: User, password: str, **_):
        """Connect to ldap and update password."""
        sources = LDAPSource.objects.filter(sync_users_password=True, enabled=True)
        if not sources.exists():
            return
        source = sources.first()
        if not LDAPPasswordChanger.should_check_user(user):
            return
        try:
            changer = LDAPPasswordChanger(source)
&gt;           changer.change_password(user, password)

.../sources/ldap/signals.py:70: 
_ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ 

self = &lt;authentik.sources.ldap.password.LDAPPasswordChanger object at 0x7fa154411670&gt;
user = &lt;User: bob&gt;, password = 'YMqjM0Q31zxW6SlQeJwyRykHzFFlzxUQOVVXhU4x'

    def change_password(self, user: User, password: str):
        """Change user's password"""
        user_dn = user.attributes.get(LDAP_DISTINGUISHED_NAME, None)
        if not user_dn:
            LOGGER.info(f"User has no {LDAP_DISTINGUISHED_NAME} set.")
            return
        try:
            self._connection.extend.microsoft.modify_password(user_dn, password)
        except (LDAPAttributeError, LDAPUnwillingToPerformResult, LDAPNoSuchAttributeResult):
&gt;           self._connection.extend.standard.modify_password(user_dn, new_password=password)

.../sources/ldap/password.py:106: 
_ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ 

self =   funnel_search
  modify_password
  paged_search
  persistent_search
  who_am_i
user = 'CN=bob,OU=Users,DC=test,DC=goauthentik,DC=io', old_password = None
new_password = 'YMqjM0Q31zxW6SlQeJwyRykHzFFlzxUQOVVXhU4x', hash_algorithm = None
salt = None, controls = None

    def modify_password(self,
                        user=None,
                        old_password=None,
                        new_password=None,
                        hash_algorithm=None,
                        salt=None,
                        controls=None):
    
        return ModifyPassword(self._connection,
                              user,
                              old_password,
                              new_password,
                              hash_algorithm,
                              salt,
&gt;                             controls).send()

../../../..../pypoetry/virtualenvs/authentik-xvtLQ9eE-py3.12/lib/python3.12.../ldap3/extend/__init__.py:82: 
_ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ 

self = &lt;ldap3.extend.standard.modifyPassword.ModifyPassword object at 0x7fa15395d0d0&gt;

    def send(self):
        if self.connection.check_names and self.connection.server.info is not None and self.connection.server.info.supported_extensions is not None:  # checks if extension is supported
            for request_name in self.connection.server.info.supported_extensions:
                if request_name[0] == self.request_name:
                    break
            else:
&gt;               raise LDAPExtensionError('extension not in DSA list of supported extensions')
E               ldap3.core.exceptions.LDAPExtensionError: extension not in DSA list of supported extensions

../../../..../pypoetry/virtualenvs/authentik-xvtLQ9eE-py3.12/lib/python3.12.../ldap3/extend/operation.py:51: LDAPExtensionError

To view individual test run time comparison to the main branch, go to the Test Analytics Dashboard

Signed-off-by: Marc 'risson' Schmitt <marc.schmitt@risson.space>
Signed-off-by: Marc 'risson' Schmitt <marc.schmitt@risson.space>
Signed-off-by: Marc 'risson' Schmitt <marc.schmitt@risson.space>
Signed-off-by: Marc 'risson' Schmitt <marc.schmitt@risson.space>
Signed-off-by: Marc 'risson' Schmitt <marc.schmitt@risson.space>
Signed-off-by: Marc 'risson' Schmitt <marc.schmitt@risson.space>
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment
Labels
None yet
Projects
None yet
Development

Successfully merging this pull request may close these issues.

1 participant