From b03cf9870fb7d6d711ee5f1e6ce2823c669b1e03 Mon Sep 17 00:00:00 2001 From: Benjamin Dauvergne Date: Thu, 6 Oct 2022 06:44:37 +0200 Subject: [PATCH 05/10] misc: split auth_saml tests (#69720) --- tests/auth_saml/conftest.py | 83 ++++++ tests/auth_saml/metadata.xml | 78 ++++++ tests/auth_saml/private_key.pem | 28 ++ tests/auth_saml/public_key.pem | 19 ++ tests/auth_saml/test_adapter.py | 98 +++++++ tests/auth_saml/test_login.py | 121 +++++++++ tests/auth_saml/test_manager.py | 33 +++ tests/auth_saml/test_migrations.py | 402 +++++++++++++++++++++++++++++ 8 files changed, 862 insertions(+) create mode 100644 tests/auth_saml/conftest.py create mode 100644 tests/auth_saml/metadata.xml create mode 100644 tests/auth_saml/private_key.pem create mode 100644 tests/auth_saml/public_key.pem create mode 100644 tests/auth_saml/test_adapter.py create mode 100644 tests/auth_saml/test_login.py create mode 100644 tests/auth_saml/test_manager.py create mode 100644 tests/auth_saml/test_migrations.py diff --git a/tests/auth_saml/conftest.py b/tests/auth_saml/conftest.py new file mode 100644 index 00000000..52632a28 --- /dev/null +++ b/tests/auth_saml/conftest.py @@ -0,0 +1,83 @@ +# authentic2 - versatile identity manager +# Copyright (C) 2010-2019 Entr'ouvert +# +# This program is free software: you can redistribute it and/or modify it +# under the terms of the GNU Affero General Public License as published +# by the Free Software Foundation, either version 3 of the License, or +# (at your option) any later version. +# +# This program is distributed in the hope that it will be useful, +# but WITHOUT ANY WARRANTY; without even the implied warranty of +# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +# GNU Affero General Public License for more details. +# +# You should have received a copy of the GNU Affero General Public License +# along with this program. If not, see . + +import pathlib + +import lasso +import pytest + +from authentic2.custom_user.models import User +from authentic2.models import Attribute +from authentic2_auth_saml.adapters import AuthenticAdapter +from authentic2_auth_saml.models import SAMLAuthenticator, SetAttributeAction + + +@pytest.fixture +def adapter(): + return AuthenticAdapter() + + +base_path = pathlib.Path(__file__).parent + + +@pytest.fixture +def idp(db, settings): + settings.MELLON_PRIVATE_KEY = str((base_path / './private_key.pem').resolve()) + settings.MELLON_PUBLIC_KEY = str((base_path / './public_key.pem').resolve()) + authenticator = SAMLAuthenticator.objects.create( + enabled=True, + metadata=(base_path / './metadata.xml').read_text(), + slug='idp1', + ) + SetAttributeAction.objects.create( + authenticator=authenticator, + user_field='email', + saml_attribute='mail', + mandatory=True, + ) + SetAttributeAction.objects.create( + authenticator=authenticator, + user_field='title', + saml_attribute='title', + ) + SetAttributeAction.objects.create( + authenticator=authenticator, + user_field='first_name', + saml_attribute='http://nice/attribute/givenName', + ) + return authenticator.settings + + +@pytest.fixture +def title_attribute(db): + return Attribute.objects.create(kind='title', name='title', label='title') + + +@pytest.fixture +def saml_attributes(): + return { + 'issuer': 'https://idp.com/', + 'name_id_content': 'xxx', + 'name_id_format': lasso.SAML2_NAME_IDENTIFIER_FORMAT_PERSISTENT, + 'mail': ['john.doe@example.com'], + 'title': ['Mr.'], + 'http://nice/attribute/givenName': ['John'], + } + + +@pytest.fixture +def user(db): + return User.objects.create() diff --git a/tests/auth_saml/metadata.xml b/tests/auth_saml/metadata.xml new file mode 100644 index 00000000..f59a7dd0 --- /dev/null +++ b/tests/auth_saml/metadata.xml @@ -0,0 +1,78 @@ + + + + + + +MIIC/TCCAeWgAwIBAgIUe/2RmSPWPz90rF3xm4q+jPPrGlcwDQYJKoZIhvcNAQEL +BQAwDjEMMAoGA1UEAwwDSURQMB4XDTE5MDQwMzEwMDEwM1oXDTQ2MDgxOTEwMDEw +M1owDjEMMAoGA1UEAwwDSURQMIIBIjANBgkqhkiG9w0BAQEFAAOCAQ8AMIIBCgKC +AQEA39a9HT0PFMAy5Tcdik+LEWuDqqEdt8UrZr7TH/GpfAneDC0skDeHi9ErsEet +ZYuBkk7YDpNvpaXprhG7EWwO9LnBN5oxN7Jp7PEOyD8+v4GSKjySbmTubaGcR5F3 +3EfPVp9yin79kN+iIi/VtoL6cacfzsIBjNmBBzs4RhIjoSce+0uTuV+EN73p5ZSt +mThamA/qnUeRDnVG5Y/hya3ldsg+rb6ObahnUYcAcP9sR/SKku3YQNVG0f4u1JYY +in7gKGZ8ty7YeVI6ulVNmG/fZXo8nw3OJ9VDG1Ye3yOz7tqhGCh9HjUsoVikPsoD +iDXQAgVynaEqo33SZGmgceGZowIDAQABo1MwUTAdBgNVHQ4EFgQUg0v91gd9cDen +b+C5YH/Kfj+1db4wHwYDVR0jBBgwFoAUg0v91gd9cDenb+C5YH/Kfj+1db4wDwYD +VR0TAQH/BAUwAwEB/zANBgkqhkiG9w0BAQsFAAOCAQEAiAvkSPyv5oCuJmETM/B/ +HKd252g90yzdKM38gs1fFXt6IErcI5t6UlFZFrIs6K1yE5dEjgxFZFKFbakO618C +xdh6MI8obfhAbqCDLVSkWtm9M0HX1I1HxJ/b+0BR6RtT9w8gDRL4ZRb/+y+82GRH +Sm+9A8VXgWaTKRsUnRXUQPVXrQ4mU0R+f5tXpa1CVpH3Z8krYbvZSzB086alim12 +5Kbe21CSN83wCZm0mjkKwFrrjCnKv3wSNqHHXQoYeGfON6B33d0rJRLwjIWJ7BDC +tkks8tLgCsYhKGNwprDy8Eo/lDCzQe03Ob1HPEh2XaENJoAx0XT6kJDyX41N8JPK +tA== + + + + + + + + + + + + + + + urn:oasis:names:tc:SAML:1.1:nameid-format:emailAddress + + + + + urn:oasis:names:tc:SAML:1.1:nameid-format:kerberos + + + + + urn:oasis:names:tc:SAML:1.1:nameid-format:X509SubjectName + + + Entr'ouvert + + + diff --git a/tests/auth_saml/private_key.pem b/tests/auth_saml/private_key.pem new file mode 100644 index 00000000..62f3cdea --- /dev/null +++ b/tests/auth_saml/private_key.pem @@ -0,0 +1,28 @@ +-----BEGIN PRIVATE KEY----- +MIIEvQIBADANBgkqhkiG9w0BAQEFAASCBKcwggSjAgEAAoIBAQDJ4MpsYAEt52vT +sbQzo15UE1ZyUNb5sz77iaZZXX3k6pS6z4qOF83p2y7CpRWyjfPsqxVTTbP70BVe +yI9G4cPw2BeCoyNxPLTjh4J/DUT3z6iCatJfKVUKZNIYKBGPb7wAA1OHGk4MOGw6 +gL0uC/m3yTN4Q8RhRD55GTHW0vb8PJOizWESr0ycvgIJyhmzhA++9DoAJj1jphj8 +AZWLYWDcBMVo4D/6TvcwWtaxfj8eXmmbIzB+igsvQSLbIyRxzXJaMj7cYocDoSS4 +eE8kRaL+ndUNd8gqNn6DpMbYolY24atjrT4+Zvqx3uDs0NMIN9rGagayNtTe+XTJ +FTx2nlL7AgMBAAECggEAVu2QvHHqkBWifJl8eu/R4mohQ0BEEWl5qV5wXvK/Dx9j +w70ycFUXuadDz1S+rxApBLP2jtRauAe17AZ4i5ETilXCaeJNlKkLSx5CturD0+F7 +Mg1FYOyvTbZ0MSqvxQ/b6DWGdhqBmQmCsP5Wd8l4Ugc1PogPu8JjFEohB9v3tkyr +jdJJB5hHB34YfpXfSqbo1tNuU1CLyzaL+D02BND7ompKuQRLG1MtTuvZ/7IS4l5v +t1CVfdOUfPZeF9DJmFYrTaDHSrsCSIJl/djNOl2dn2rZnd/TDnuxg0UdSz4myjhj +xdhlXpDK+VheDHqDwVYDul4F5xc3fg4AI2cm+Hy+YQKBgQDjYP0SG770n7lEpI5S +K8c8D2BtQPX1Qj9wyNr69T4dIphfCRNJvaQnUxBXDBD/Tup82zbXWr7jOaCBWJy+ +0Ik+nE+t85NY2sZcoBEd5dsYfriXYIjomhXzloNkwbR3HqN6DTbSqatLbwkdczw8 +K8ZR6mlGX5F7jRSf7sH+NU4+HQKBgQDjShH/Q0X97YOY0An6QPWVVIhSp5zbyB2M +KVfAV5bVF8AaJJccoaGgM5HBsQBd7IrgCJodt51E6TpZK+Im/VOzG8sUmNA5MeuF +8XFJ0TRwLsFFlFFh2KN2uWYIIe3RFbXY0Kxil2YSOxNiHG2m5qkBucTgWoKG4kzF +mY9+RHjp9wKBgQCo6/UW7uX+dmr9RAM3qK5rQEEy6X/QpVbcQ1vr9SYgHwN0Fxnt +PqYlUOBiyuQVyFsMRw+HDjOiO72yWlKYr/RGP3oykTJ2YJHdXk1ZGNqcaAha7azI +oTCNttQGlqGrnWd11TtVZheMAwGSj3nAegTr9mofjgBW+YjJCGe2o4NtQQKBgChQ +MvD7laZ8QiMQgzSH4Qcjfypp4EB7NgJuMspCvtX86G26n7LMWEZ53xhjtJT0J42k ++PDcaGCYnWjDh9EyjW3vOA3nLMd4OzX+pQFawdpD8LPOosCgFB4ytA4tNmknWKGk +IW87OzdwkveL40b/Emrj8C963jveV4+UtQbITknxAoGAAMRnKZiY5oCUdNF3b5CF +u/c7rBBvXvD6LoD/I+DciW+vzzLTpyHXe0O1LXAXbh6Vhbdl/JUEqQ9GY0WHUF2i +GGsC/A8cUZPBW/KRTp9Gvba/cLVVJJMg17Yeif1vLBipKtjhBOe2J32oi2sFBzU5 +ftuBuQmlGE+LRrRFtGu+fS8= +-----END PRIVATE KEY----- diff --git a/tests/auth_saml/public_key.pem b/tests/auth_saml/public_key.pem new file mode 100644 index 00000000..eb632d62 --- /dev/null +++ b/tests/auth_saml/public_key.pem @@ -0,0 +1,19 @@ +-----BEGIN CERTIFICATE----- +MIIC/TCCAeWgAwIBAgIUUpf0AbWHRr58otdjpctRwYP/uEYwDQYJKoZIhvcNAQEL +BQAwDjEMMAoGA1UEAwwDSURQMB4XDTE5MDQwMzEwMDExMloXDTQ2MDgxOTEwMDEx +MlowDjEMMAoGA1UEAwwDSURQMIIBIjANBgkqhkiG9w0BAQEFAAOCAQ8AMIIBCgKC +AQEAyeDKbGABLedr07G0M6NeVBNWclDW+bM++4mmWV195OqUus+KjhfN6dsuwqUV +so3z7KsVU02z+9AVXsiPRuHD8NgXgqMjcTy044eCfw1E98+ogmrSXylVCmTSGCgR +j2+8AANThxpODDhsOoC9Lgv5t8kzeEPEYUQ+eRkx1tL2/DyTos1hEq9MnL4CCcoZ +s4QPvvQ6ACY9Y6YY/AGVi2Fg3ATFaOA/+k73MFrWsX4/Hl5pmyMwfooLL0Ei2yMk +cc1yWjI+3GKHA6EkuHhPJEWi/p3VDXfIKjZ+g6TG2KJWNuGrY60+Pmb6sd7g7NDT +CDfaxmoGsjbU3vl0yRU8dp5S+wIDAQABo1MwUTAdBgNVHQ4EFgQUuJZGqJa7ljZZ +LWZ3AqbvbdipCBIwHwYDVR0jBBgwFoAUuJZGqJa7ljZZLWZ3AqbvbdipCBIwDwYD +VR0TAQH/BAUwAwEB/zANBgkqhkiG9w0BAQsFAAOCAQEAPaaW02Y17FAIJv9wk7tc +MURL364jlaGrNYcv/og2uJw2WxKsCKahE8fY3Yu3fceSt6eMannWkvDpAGC9COIX +Lr9VKK9c2eUbFyeCWu4eizQaUTKtrQIyxyL1geQdmJZPcJfvDfJM4lUxt0gTx1R5 +ouMwDAtIFfDpOKQyXthqeXoGrrraxHr+GzJcgdHeR9c4eiKXf7C1JEJhhv6a3zDz +v3uOwiLhlKIQ430623MK75jdEzo+2/aUzur8UttkRBdalumYR5SM+CKLhPYc9L6p +55pHYinL190yAjIDuY9WN+d+8C/2UrUI5iiHOc/D2kYCN8dJWDwhXlRKhRZ6f0jq +Lw== +-----END CERTIFICATE----- diff --git a/tests/auth_saml/test_adapter.py b/tests/auth_saml/test_adapter.py new file mode 100644 index 00000000..203d7aa5 --- /dev/null +++ b/tests/auth_saml/test_adapter.py @@ -0,0 +1,98 @@ +# authentic2 - versatile identity manager +# Copyright (C) 2010-2022 Entr'ouvert +# +# This program is free software: you can redistribute it and/or modify it +# under the terms of the GNU Affero General Public License as published +# by the Free Software Foundation, either version 3 of the License, or +# (at your option) any later version. +# +# This program is distributed in the hope that it will be useful, +# but WITHOUT ANY WARRANTY; without even the implied warranty of +# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +# GNU Affero General Public License for more details. +# +# You should have received a copy of the GNU Affero General Public License +# along with this program. If not, see . + +import re +from unittest import mock + +import lasso +import pytest +from mellon.adapters import UserCreationError + +from authentic2.custom_user.models import User +from authentic2_auth_saml.adapters import MappingError +from authentic2_auth_saml.models import AddRoleAction, SAMLAuthenticator, SetAttributeAction + + +def test_lookup_user_ok(adapter, idp, saml_attributes, title_attribute): + assert User.objects.count() == 0 + + user = adapter.lookup_user(idp, saml_attributes) + user.refresh_from_db() + assert user.email == 'john.doe@example.com' + assert user.attributes.title == 'Mr.' + assert user.first_name == 'John' + assert user.attributes.title == 'Mr.' + assert user.ou.default is True + + +def test_lookup_user_missing_mandatory_attribute(adapter, idp, saml_attributes, title_attribute): + del saml_attributes['mail'] + + assert User.objects.count() == 0 + assert adapter.lookup_user(idp, saml_attributes) is None + assert User.objects.count() == 0 + + +def test_apply_attribute_mapping_missing_attribute_logged( + caplog, adapter, idp, saml_attributes, title_attribute, user +): + caplog.set_level('WARNING') + saml_attributes['http://nice/attribute/givenName'] = [] + adapter.provision_a2_attributes(user, idp, saml_attributes) + assert re.match('.*no value.*first_name', caplog.records[-1].message) + + +@pytest.mark.parametrize('action_name', ['add-role', 'toggle-role']) +class TestAddRole: + @pytest.fixture + def idp(self, action_name, simple_role): + authenticator = SAMLAuthenticator.objects.create( + enabled=True, + metadata='meta1.xml', + slug='idp1', + ) + AddRoleAction.objects.create(authenticator=authenticator, role=simple_role) + return authenticator.settings + + @pytest.fixture + def saml_attributes(self): + return { + 'issuer': 'https://idp.com/', + 'name_id_content': 'xxx', + 'name_id_format': lasso.SAML2_NAME_IDENTIFIER_FORMAT_PERSISTENT, + } + + def test_lookup_user_success(self, adapter, simple_role, idp, saml_attributes): + user = adapter.lookup_user(idp, saml_attributes) + assert simple_role in user.roles.all() + + +def test_apply_attribute_mapping_missing_attribute_exception( + adapter, idp, saml_attributes, title_attribute, user, rf +): + saml_attributes['http://nice/attribute/givenName'] = [] + SetAttributeAction.objects.filter(user_field='first_name').update(mandatory=True) + with pytest.raises(MappingError, match='no value'): + adapter.provision_a2_attributes(user, idp, saml_attributes) + + request = rf.get('/') + request._messages = mock.Mock() + adapter.request = request + with pytest.raises(UserCreationError): + adapter.finish_create_user(idp, saml_attributes, user) + request._messages.add.assert_called_once_with( + 40, 'User creation failed: no value for attribute "first_name".', '' + ) diff --git a/tests/auth_saml/test_login.py b/tests/auth_saml/test_login.py new file mode 100644 index 00000000..adb03bd0 --- /dev/null +++ b/tests/auth_saml/test_login.py @@ -0,0 +1,121 @@ +# authentic2 - versatile identity manager +# Copyright (C) 2010-2022 Entr'ouvert +# +# This program is free software: you can redistribute it and/or modify it +# under the terms of the GNU Affero General Public License as published +# by the Free Software Foundation, either version 3 of the License, or +# (at your option) any later version. +# +# This program is distributed in the hope that it will be useful, +# but WITHOUT ANY WARRANTY; without even the implied warranty of +# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +# GNU Affero General Public License for more details. +# +# You should have received a copy of the GNU Affero General Public License +# along with this program. If not, see . + +import os +from unittest import mock + +import pytest + +from authentic2.apps.authenticators.models import LoginPasswordAuthenticator +from authentic2_auth_saml.adapters import AuthenticAdapter +from authentic2_auth_saml.models import SAMLAuthenticator + + +@pytest.fixture +def patched_adapter(monkeypatch): + def load_idp(self, settings, order): + settings['ENTITY_ID'] = 'idp1' + return settings + + monkeypatch.setattr(AuthenticAdapter, 'load_idp', load_idp) + + +def test_providers_on_login_page(db, app, settings): + SAMLAuthenticator.objects.create( + enabled=True, + metadata='meta1.xml', + slug='idp1', + button_label='Test label', + button_description='This is a test.', + ) + + response = app.get('/login/') + assert response.pyquery('button[name="login-saml-idp1"]') + assert not response.pyquery('button[name="login-saml-1"]') + assert 'SAML' in response.text + + SAMLAuthenticator.objects.create(enabled=True, metadata='meta1.xml', slug='idp2') + response = app.get('/login/') + # two frontends should be present on login page + assert response.pyquery('button[name="login-saml-idp1"]') + assert response.pyquery('button[name="login-saml-idp2"]') + assert 'Test label' in response.text + assert 'This is a test.' in response.text + + +def test_login_with_conditionnal_authenticators(db, app, settings, caplog): + authenticator = SAMLAuthenticator.objects.create( + enabled=True, metadata_path=os.path.join(os.path.dirname(__file__), 'metadata.xml'), slug='idp1' + ) + + response = app.get('/login/') + assert 'login-saml-idp1' in response + + authenticator.show_condition = 'remote_addr==\'0.0.0.0\'' + authenticator.save() + response = app.get('/login/') + assert 'login-saml-idp1' not in response + + authenticator2 = SAMLAuthenticator.objects.create( + enabled=True, metadata_path=os.path.join(os.path.dirname(__file__), 'metadata.xml'), slug='idp2' + ) + response = app.get('/login/') + assert 'login-saml-idp1' not in response + assert 'login-saml-idp2' in response + + authenticator2.show_condition = 'remote_addr==\'0.0.0.0\'' + authenticator2.save() + response = app.get('/login/') + assert 'login-saml-idp1' not in response + assert 'login-saml-idp2' not in response + + +def test_login_condition_dnsbl(db, app, settings, caplog): + SAMLAuthenticator.objects.create( + enabled=True, + metadata_path=os.path.join(os.path.dirname(__file__), 'metadata.xml'), + slug='idp1', + show_condition='remote_addr in dnsbl(\'dnswl.example.com\')', + ) + SAMLAuthenticator.objects.create( + enabled=True, + metadata_path=os.path.join(os.path.dirname(__file__), 'metadata.xml'), + slug='idp2', + show_condition='remote_addr not in dnsbl(\'dnswl.example.com\')', + ) + with mock.patch('authentic2.utils.evaluate.check_dnsbl', return_value=True): + response = app.get('/login/') + assert 'login-saml-idp1' in response + assert 'login-saml-idp2' not in response + + +def test_login_autorun(db, app, settings, patched_adapter): + response = app.get('/login/') + + authenticator = SAMLAuthenticator.objects.create( + enabled=True, metadata_path=os.path.join(os.path.dirname(__file__), 'metadata.xml'), slug='idp1' + ) + # hide password block + LoginPasswordAuthenticator.objects.update_or_create( + slug='password-authenticator', defaults={'enabled': False} + ) + response = app.get('/login/', status=302) + assert '/accounts/saml/login/?entityID=' in response['Location'] + + authenticator.slug = 'slug_with_underscore' + authenticator.save() + response = app.get('/login/', status=302) + assert '/accounts/saml/login/?entityID=' in response['Location'] diff --git a/tests/auth_saml/test_manager.py b/tests/auth_saml/test_manager.py new file mode 100644 index 00000000..4ccc1282 --- /dev/null +++ b/tests/auth_saml/test_manager.py @@ -0,0 +1,33 @@ +# authentic2 - versatile identity manager +# Copyright (C) 2010-2022 Entr'ouvert +# +# This program is free software: you can redistribute it and/or modify it +# under the terms of the GNU Affero General Public License as published +# by the Free Software Foundation, either version 3 of the License, or +# (at your option) any later version. +# +# This program is distributed in the hope that it will be useful, +# but WITHOUT ANY WARRANTY; without even the implied warranty of +# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +# GNU Affero General Public License for more details. +# +# You should have received a copy of the GNU Affero General Public License +# along with this program. If not, see . + +from mellon.models import Issuer, UserSAMLIdentifier + +from ..utils import login + + +def test_manager_user_sidebar(app, superuser, simple_user): + login(app, superuser, '/manage/') + response = app.get('/manage/users/%s/' % simple_user.id) + assert 'SAML' not in response + + issuer1, _ = Issuer.objects.get_or_create(entity_id='https://idp1.com/') + UserSAMLIdentifier.objects.create(user=simple_user, issuer=issuer1, name_id='1234') + + response = app.get('/manage/users/%s/' % simple_user.id) + assert 'SAML' in response + assert 'https://idp1.com/' in response + assert '1234' in response diff --git a/tests/auth_saml/test_migrations.py b/tests/auth_saml/test_migrations.py new file mode 100644 index 00000000..2272ab67 --- /dev/null +++ b/tests/auth_saml/test_migrations.py @@ -0,0 +1,402 @@ +# authentic2 - versatile identity manager +# Copyright (C) 2010-2022 Entr'ouvert +# +# This program is free software: you can redistribute it and/or modify it +# under the terms of the GNU Affero General Public License as published +# by the Free Software Foundation, either version 3 of the License, or +# (at your option) any later version. +# +# This program is distributed in the hope that it will be useful, +# but WITHOUT ANY WARRANTY; without even the implied warranty of +# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +# GNU Affero General Public License for more details. +# +# You should have received a copy of the GNU Affero General Public License +# along with this program. If not, see . + +import os + + +def test_saml_authenticator_data_migration(migration, settings): + app = 'authentic2_auth_saml' + migrate_from = [(app, '0001_initial')] + migrate_to = [(app, '0002_auto_20220608_1559')] + + old_apps = migration.before(migrate_from) + SAMLAuthenticator = old_apps.get_model(app, 'SAMLAuthenticator') + + settings.A2_AUTH_SAML_ENABLE = True + settings.MELLON_METADATA_CACHE_TIME = 42 + settings.MELLON_METADATA_HTTP_TIMEOUT = 42 + settings.MELLON_PROVISION = False + settings.MELLON_VERIFY_SSL_CERTIFICATE = True + settings.MELLON_TRANSIENT_FEDERATION_ATTRIBUTE = None + settings.MELLON_USERNAME_TEMPLATE = 'test' + settings.MELLON_NAME_ID_POLICY_ALLOW_CREATE = False + settings.MELLON_FORCE_AUTHN = True + settings.MELLON_ADD_AUTHNREQUEST_NEXT_URL_EXTENSION = False + settings.MELLON_GROUP_ATTRIBUTE = 'role' + settings.MELLON_CREATE_GROUP = True + settings.MELLON_ERROR_URL = 'https://example.com/error/' + settings.MELLON_AUTHN_CLASSREF = ('class1', 'class2') + settings.MELLON_LOGIN_HINTS = ['hint1', 'hint2'] + settings.AUTH_FRONTENDS_KWARGS = { + 'saml': { + 'priority': 1, + 'show_condition': { + '0': 'first condition', + '1': 'second condition', + }, + } + } + settings.MELLON_IDENTITY_PROVIDERS = [ + { + 'METADATA': os.path.join(os.path.dirname(__file__), 'metadata.xml'), + 'REALM': 'test', + 'METADATA_CACHE_TIME': 43, + 'METADATA_HTTP_TIMEOUT': 43, + 'PROVISION': True, + 'LOOKUP_BY_ATTRIBUTES': [], + }, + { + 'METADATA_PATH': os.path.join(os.path.dirname(__file__), 'metadata.xml'), + 'NAME_ID_POLICY_ALLOW_CREATE': True, + 'FORCE_AUTHN': False, + 'ADD_AUTHNREQUEST_NEXT_URL_EXTENSION': True, + 'A2_ATTRIBUTE_MAPPING': [ + { + 'attribute': 'email', + 'saml_attribute': 'mail', + }, + ], + 'LOOKUP_BY_ATTRIBUTES': [{'saml_attribute': 'email', 'user_field': 'email'}], + }, + { + 'METADATA_URL': 'https://example.com/metadata.xml', + 'SLUG': 'third', + 'ATTRIBUTE_MAPPING': {'email': 'attributes[mail][0]'}, + 'SUPERUSER_MAPPING': {'roles': 'Admin'}, + }, + ] + + new_apps = migration.apply(migrate_to) + SAMLAuthenticator = new_apps.get_model(app, 'SAMLAuthenticator') + first_authenticator, second_authenticator, third_authenticator = SAMLAuthenticator.objects.all() + assert first_authenticator.slug == '0' + assert first_authenticator.order == 1 + assert first_authenticator.show_condition == 'first condition' + assert first_authenticator.enabled is True + assert first_authenticator.metadata_path == os.path.join(os.path.dirname(__file__), 'metadata.xml') + assert first_authenticator.metadata_url == '' + assert first_authenticator.metadata_cache_time == 43 + assert first_authenticator.metadata_http_timeout == 43 + assert first_authenticator.provision is True + assert first_authenticator.verify_ssl_certificate is True + assert first_authenticator.transient_federation_attribute == '' + assert first_authenticator.realm == 'test' + assert first_authenticator.username_template == 'test' + assert first_authenticator.name_id_policy_format == '' + assert first_authenticator.name_id_policy_allow_create is False + assert first_authenticator.force_authn is True + assert first_authenticator.add_authnrequest_next_url_extension is False + assert first_authenticator.group_attribute == 'role' + assert first_authenticator.create_group is True + assert first_authenticator.error_url == 'https://example.com/error/' + assert first_authenticator.error_redirect_after_timeout == 120 + assert first_authenticator.authn_classref == 'class1, class2' + assert first_authenticator.login_hints == 'hint1, hint2' + assert first_authenticator.lookup_by_attributes == [] + assert first_authenticator.a2_attribute_mapping == [] + assert first_authenticator.attribute_mapping == {} + assert first_authenticator.superuser_mapping == {} + + assert second_authenticator.slug == '1' + assert second_authenticator.order == 1 + assert second_authenticator.show_condition == 'second condition' + assert second_authenticator.enabled is True + assert second_authenticator.metadata_path == os.path.join(os.path.dirname(__file__), 'metadata.xml') + assert second_authenticator.metadata_url == '' + assert second_authenticator.metadata_cache_time == 42 + assert second_authenticator.metadata_http_timeout == 42 + assert second_authenticator.provision is False + assert second_authenticator.verify_ssl_certificate is True + assert second_authenticator.transient_federation_attribute == '' + assert second_authenticator.realm == 'saml' + assert second_authenticator.username_template == 'test' + assert second_authenticator.name_id_policy_format == '' + assert second_authenticator.name_id_policy_allow_create is True + assert second_authenticator.force_authn is False + assert second_authenticator.add_authnrequest_next_url_extension is True + assert second_authenticator.group_attribute == 'role' + assert second_authenticator.create_group is True + assert second_authenticator.error_url == 'https://example.com/error/' + assert second_authenticator.error_redirect_after_timeout == 120 + assert second_authenticator.authn_classref == 'class1, class2' + assert second_authenticator.login_hints == 'hint1, hint2' + assert second_authenticator.lookup_by_attributes == [{'saml_attribute': 'email', 'user_field': 'email'}] + assert second_authenticator.a2_attribute_mapping == [ + { + 'attribute': 'email', + 'saml_attribute': 'mail', + }, + ] + assert first_authenticator.attribute_mapping == {} + assert first_authenticator.superuser_mapping == {} + + assert third_authenticator.slug == 'third' + assert third_authenticator.order == 1 + assert third_authenticator.show_condition == '' + assert third_authenticator.enabled is True + assert third_authenticator.metadata_path == '' + assert third_authenticator.metadata_url == 'https://example.com/metadata.xml' + assert third_authenticator.metadata_cache_time == 42 + assert third_authenticator.metadata_http_timeout == 42 + assert third_authenticator.provision is False + assert third_authenticator.verify_ssl_certificate is True + assert third_authenticator.transient_federation_attribute == '' + assert third_authenticator.realm == 'saml' + assert third_authenticator.username_template == 'test' + assert third_authenticator.name_id_policy_format == '' + assert third_authenticator.name_id_policy_format == '' + assert third_authenticator.name_id_policy_allow_create is False + assert third_authenticator.force_authn is True + assert third_authenticator.group_attribute == 'role' + assert third_authenticator.create_group is True + assert third_authenticator.error_url == 'https://example.com/error/' + assert third_authenticator.error_redirect_after_timeout == 120 + assert third_authenticator.authn_classref == 'class1, class2' + assert third_authenticator.login_hints == 'hint1, hint2' + assert third_authenticator.lookup_by_attributes == [ + {'saml_attribute': 'email', 'user_field': 'email', 'ignore-case': True}, + {'saml_attribute': 'username', 'user_field': 'username'}, + ] + assert third_authenticator.a2_attribute_mapping == [] + assert third_authenticator.attribute_mapping == {'email': 'attributes[mail][0]'} + assert third_authenticator.superuser_mapping == {'roles': 'Admin'} + + +def test_saml_authenticator_data_migration_empty_configuration(migration, settings): + app = 'authentic2_auth_saml' + migrate_from = [(app, '0001_initial')] + migrate_to = [(app, '0002_auto_20220608_1559')] + + old_apps = migration.before(migrate_from) + SAMLAuthenticator = old_apps.get_model(app, 'SAMLAuthenticator') + + new_apps = migration.apply(migrate_to) + SAMLAuthenticator = new_apps.get_model(app, 'SAMLAuthenticator') + assert not SAMLAuthenticator.objects.exists() + + +def test_saml_authenticator_data_migration_bad_settings(migration, settings): + app = 'authentic2_auth_saml' + migrate_from = [(app, '0001_initial')] + migrate_to = [(app, '0002_auto_20220608_1559')] + + old_apps = migration.before(migrate_from) + SAMLAuthenticator = old_apps.get_model(app, 'SAMLAuthenticator') + + settings.AUTH_FRONTENDS_KWARGS = {"saml": {"priority": None, "show_condition": None}} + settings.MELLON_METADATA_CACHE_TIME = 2**16 + settings.MELLON_METADATA_HTTP_TIMEOUT = -1 + settings.MELLON_PROVISION = None + settings.MELLON_USERNAME_TEMPLATE = 42 + settings.MELLON_GROUP_ATTRIBUTE = None + settings.MELLON_ERROR_URL = 'a' * 500 + settings.MELLON_AUTHN_CLASSREF = 'not-a-list' + settings.MELLON_IDENTITY_PROVIDERS = [ + { + 'METADATA': os.path.join(os.path.dirname(__file__), 'metadata.xml'), + 'ERROR_REDIRECT_AFTER_TIMEOUT': -1, + 'SUPERUSER_MAPPING': 'not-a-dict', + }, + ] + + new_apps = migration.apply(migrate_to) + SAMLAuthenticator = new_apps.get_model(app, 'SAMLAuthenticator') + authenticator = SAMLAuthenticator.objects.get() + assert authenticator.slug == '0' + assert authenticator.order == 3 + assert authenticator.show_condition == '' + assert authenticator.enabled is False + assert authenticator.metadata_cache_time == 3600 + assert authenticator.metadata_http_timeout == 10 + assert authenticator.provision is True + assert authenticator.username_template == '{attributes[name_id_content]}@{realm}' + assert authenticator.group_attribute == '' + assert authenticator.error_url == 'a' * 200 + assert authenticator.error_redirect_after_timeout == 120 + assert authenticator.authn_classref == '' + assert authenticator.superuser_mapping == {} + + +def test_saml_authenticator_data_migration_json_fields(migration, settings): + migrate_from = [ + ( + 'authentic2_auth_saml', + '0005_addroleaction_renameattributeaction_samlattributelookup_setattributeaction', + ), + ('a2_rbac', '0029_use_unique_constraints'), + ] + migrate_to = [ + ('authentic2_auth_saml', '0006_migrate_jsonfields'), + ('a2_rbac', '0029_use_unique_constraints'), + ] + + old_apps = migration.before(migrate_from) + SAMLAuthenticator = old_apps.get_model('authentic2_auth_saml', 'SAMLAuthenticator') + Role = old_apps.get_model('a2_rbac', 'Role') + OU = old_apps.get_model('a2_rbac', 'OrganizationalUnit') + + ou = OU.objects.create(name='Test OU', slug='test-ou') + role = Role.objects.create(name='Test role', slug='test-role', ou=ou) + + SAMLAuthenticator.objects.create( + metadata='meta1.xml', + slug='idp1', + lookup_by_attributes=[ + {'saml_attribute': 'email', 'user_field': 'email'}, + {'saml_attribute': 'saml_name', 'user_field': 'first_name', 'ignore-case': True}, + ], + a2_attribute_mapping=[ + { + 'attribute': 'email', + 'saml_attribute': 'mail', + 'mandatory': True, + }, + {'action': 'rename', 'from': 'a' * 1025, 'to': 'first_name'}, + { + 'attribute': 'first_name', + 'saml_attribute': 'first_name', + }, + { + 'attribute': 'invalid', + 'saml_attribute': '', + }, + { + 'attribute': 'invalid', + 'saml_attribute': None, + }, + { + 'attribute': 'invalid', + }, + { + 'action': 'add-role', + 'role': { + 'name': role.name, + 'ou': { + 'name': role.ou.name, + }, + }, + 'condition': "roles == 'A'", + }, + ], + ) + + new_apps = migration.apply(migrate_to) + SAMLAuthenticator = new_apps.get_model('authentic2_auth_saml', 'SAMLAuthenticator') + authenticator = SAMLAuthenticator.objects.get() + + attribute_lookup1, attribute_lookup2 = authenticator.attribute_lookups.all().order_by('pk') + assert attribute_lookup1.saml_attribute == 'email' + assert attribute_lookup1.user_field == 'email' + assert attribute_lookup1.ignore_case is False + assert attribute_lookup2.saml_attribute == 'saml_name' + assert attribute_lookup2.user_field == 'first_name' + assert attribute_lookup2.ignore_case is True + + set_attribute1, set_attribute2 = authenticator.set_attribute_actions.all().order_by('pk') + assert set_attribute1.attribute == 'email' + assert set_attribute1.saml_attribute == 'mail' + assert set_attribute1.mandatory is True + assert set_attribute2.attribute == 'first_name' + assert set_attribute2.saml_attribute == 'first_name' + assert set_attribute2.mandatory is False + + rename_attribute = authenticator.rename_attribute_actions.get() + assert rename_attribute.from_name == 'a' * 1024 + assert rename_attribute.to_name == 'first_name' + + add_role = authenticator.add_role_actions.get() + assert add_role.role.pk == role.pk + assert add_role.condition == "roles == 'A'" + assert add_role.mandatory is False + + +def test_saml_authenticator_data_migration_json_fields_log_errors(migration, settings, caplog): + migrate_from = [ + ( + 'authentic2_auth_saml', + '0005_addroleaction_renameattributeaction_samlattributelookup_setattributeaction', + ), + ('a2_rbac', '0029_use_unique_constraints'), + ] + migrate_to = [ + ('authentic2_auth_saml', '0006_migrate_jsonfields'), + ('a2_rbac', '0029_use_unique_constraints'), + ] + + old_apps = migration.before(migrate_from) + SAMLAuthenticator = old_apps.get_model('authentic2_auth_saml', 'SAMLAuthenticator') + + SAMLAuthenticator.objects.create( + metadata='meta1.xml', + slug='idp1', + lookup_by_attributes=[{'saml_attribute': 'email', 'user_field': 'email'}], + a2_attribute_mapping=['bad'], + ) + + new_apps = migration.apply(migrate_to) + SAMLAuthenticator = new_apps.get_model('authentic2_auth_saml', 'SAMLAuthenticator') + + authenticator = SAMLAuthenticator.objects.get() + assert not authenticator.attribute_lookups.exists() + + assert caplog.messages == [ + 'could not create related objects for authenticator SAMLAuthenticator object (%s)' % authenticator.pk, + 'attribute mapping for SAMLAuthenticator object (%s): ["bad"]' % authenticator.pk, + 'lookup by attributes for SAMLAuthenticator object (%s): [{"user_field": "email", "saml_attribute": "email"}]' + % authenticator.pk, + ] + + +def test_saml_authenticator_data_migration_rename_attributes(migration, settings): + migrate_from = [('authentic2_auth_saml', '0008_auto_20220913_1105')] + migrate_to = [('authentic2_auth_saml', '0009_statically_rename_attributes')] + + old_apps = migration.before(migrate_from) + SAMLAuthenticator = old_apps.get_model('authentic2_auth_saml', 'SAMLAuthenticator') + RenameAttributeAction = old_apps.get_model('authentic2_auth_saml', 'RenameAttributeAction') + SetAttributeAction = old_apps.get_model('authentic2_auth_saml', 'SetAttributeAction') + SAMLAttributeLookup = old_apps.get_model('authentic2_auth_saml', 'SAMLAttributeLookup') + + authenticator = SAMLAuthenticator.objects.create(slug='idp1') + RenameAttributeAction.objects.create( + authenticator=authenticator, from_name='http://nice/attribute/givenName', to_name='first_name' + ) + SAMLAttributeLookup.objects.create( + authenticator=authenticator, user_field='first_name', saml_attribute='first_name' + ) + SAMLAttributeLookup.objects.create( + authenticator=authenticator, user_field='title', saml_attribute='title' + ) + SetAttributeAction.objects.create( + authenticator=authenticator, user_field='first_name', saml_attribute='first_name' + ) + SetAttributeAction.objects.create(authenticator=authenticator, user_field='title', saml_attribute='title') + + new_apps = migration.apply(migrate_to) + SAMLAuthenticator = new_apps.get_model('authentic2_auth_saml', 'SAMLAuthenticator') + authenticator = SAMLAuthenticator.objects.get() + + attribute_lookup1, attribute_lookup2 = authenticator.attribute_lookups.all().order_by('pk') + assert attribute_lookup1.saml_attribute == 'http://nice/attribute/givenName' + assert attribute_lookup1.user_field == 'first_name' + assert attribute_lookup2.saml_attribute == 'title' + assert attribute_lookup2.user_field == 'title' + + set_attribute1, set_attribute2 = authenticator.set_attribute_actions.all().order_by('pk') + assert set_attribute1.saml_attribute == 'http://nice/attribute/givenName' + assert set_attribute1.user_field == 'first_name' + assert set_attribute2.saml_attribute == 'title' + assert set_attribute2.user_field == 'title' -- 2.37.2